Merge remote-tracking branch 'origin/feature/FullStack' into feature/app-icon-switcher
# Conflicts: # pubspec.lock
This commit is contained in:
+6
-1
@@ -133,4 +133,9 @@ agents.md
|
|||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
# Локальные дампы трафика и скрипты анализа (содержат секреты)
|
||||||
|
komet.txt
|
||||||
|
original_app.txt
|
||||||
|
fingerprint.py
|
||||||
|
PCAPdroid_*.txt
|
||||||
|
|||||||
+16
-7
@@ -39,6 +39,12 @@ class Api {
|
|||||||
|
|
||||||
Map<dynamic, dynamic>? get userAgent => _userAgent;
|
Map<dynamic, dynamic>? get userAgent => _userAgent;
|
||||||
|
|
||||||
|
int? _callsSeed;
|
||||||
|
String? _deviceId;
|
||||||
|
|
||||||
|
int? get callsSeed => _callsSeed;
|
||||||
|
String? get deviceId => _deviceId;
|
||||||
|
|
||||||
List<CountryName>? _registrationCountries;
|
List<CountryName>? _registrationCountries;
|
||||||
|
|
||||||
List<CountryName> get registrationCountries =>
|
List<CountryName> get registrationCountries =>
|
||||||
@@ -111,6 +117,7 @@ class Api {
|
|||||||
try {
|
try {
|
||||||
final response = await sendHandshake();
|
final response = await sendHandshake();
|
||||||
if (response.isOk) {
|
if (response.isOk) {
|
||||||
|
_callsSeed = response.payload['callsSeed'] as int?;
|
||||||
_registrationCountries = _parseRegistrationCountries(response.payload);
|
_registrationCountries = _parseRegistrationCountries(response.payload);
|
||||||
_setSessionState(SessionState.online);
|
_setSessionState(SessionState.online);
|
||||||
_startPinging();
|
_startPinging();
|
||||||
@@ -164,7 +171,7 @@ class Api {
|
|||||||
String architecture = 'arm64';
|
String architecture = 'arm64';
|
||||||
String appVersion = SpoofingService.hardcodedAppVersion;
|
String appVersion = SpoofingService.hardcodedAppVersion;
|
||||||
int buildNumber = SpoofingService.hardcodedBuildNumber;
|
int buildNumber = SpoofingService.hardcodedBuildNumber;
|
||||||
String screen = '1920x1080';
|
String screen = '420dpi 420dpi 1080x2340';
|
||||||
|
|
||||||
tz.initializeTimeZones();
|
tz.initializeTimeZones();
|
||||||
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
||||||
@@ -230,23 +237,25 @@ class Api {
|
|||||||
|
|
||||||
_userAgent = {
|
_userAgent = {
|
||||||
'deviceType': deviceType,
|
'deviceType': deviceType,
|
||||||
'locale': locale,
|
|
||||||
'deviceLocale': deviceLocale,
|
|
||||||
'osVersion': osVersion,
|
|
||||||
'deviceName': deviceName,
|
|
||||||
'appVersion': appVersion,
|
'appVersion': appVersion,
|
||||||
'screen': screen,
|
'osVersion': osVersion,
|
||||||
'timezone': timezone,
|
'timezone': timezone,
|
||||||
|
'screen': screen,
|
||||||
'pushDeviceType': 'GCM',
|
'pushDeviceType': 'GCM',
|
||||||
'arch': architecture,
|
'arch': architecture,
|
||||||
|
'locale': locale,
|
||||||
'buildNumber': buildNumber,
|
'buildNumber': buildNumber,
|
||||||
|
'deviceName': deviceName,
|
||||||
|
'deviceLocale': deviceLocale,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_deviceId = deviceId;
|
||||||
|
|
||||||
final payload = <dynamic, dynamic>{
|
final payload = <dynamic, dynamic>{
|
||||||
'mt_instanceid': await DeviceIdentity.instanceId(),
|
'mt_instanceid': await DeviceIdentity.instanceId(),
|
||||||
|
'userAgent': _userAgent,
|
||||||
'clientSessionId': DeviceIdentity.clientSessionId,
|
'clientSessionId': DeviceIdentity.clientSessionId,
|
||||||
'deviceId': deviceId,
|
'deviceId': deviceId,
|
||||||
'userAgent': _userAgent,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return sendRequest(Opcode.sessionInit, payload);
|
return sendRequest(Opcode.sessionInit, payload);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
|
import '../../core/protocol/chat_cache_fingerprint.dart';
|
||||||
import '../../core/protocol/opcode_map.dart';
|
import '../../core/protocol/opcode_map.dart';
|
||||||
import '../../core/protocol/packet.dart';
|
import '../../core/protocol/packet.dart';
|
||||||
import '../../core/storage/app_database.dart';
|
import '../../core/storage/app_database.dart';
|
||||||
@@ -223,6 +225,20 @@ class RequestCodeResult {
|
|||||||
const RequestCodeResult({required this.token});
|
const RequestCodeResult({required this.token});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PresetAvatar {
|
||||||
|
final int id;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
const PresetAvatar({required this.id, required this.url});
|
||||||
|
}
|
||||||
|
|
||||||
|
class PresetAvatarCategory {
|
||||||
|
final String name;
|
||||||
|
final List<PresetAvatar> avatars;
|
||||||
|
|
||||||
|
const PresetAvatarCategory({required this.name, required this.avatars});
|
||||||
|
}
|
||||||
|
|
||||||
class VerifyCodeResult {
|
class VerifyCodeResult {
|
||||||
final Map<dynamic, dynamic> payload;
|
final Map<dynamic, dynamic> payload;
|
||||||
|
|
||||||
@@ -232,6 +248,37 @@ class VerifyCodeResult {
|
|||||||
|
|
||||||
String? get registerToken => _nestedToken('REGISTER');
|
String? get registerToken => _nestedToken('REGISTER');
|
||||||
|
|
||||||
|
bool get isRegistration => registerToken != null && loginToken == null;
|
||||||
|
|
||||||
|
List<PresetAvatarCategory> get presetAvatars {
|
||||||
|
final raw = payload['presetAvatars'];
|
||||||
|
if (raw is! List) return const [];
|
||||||
|
final categories = <PresetAvatarCategory>[];
|
||||||
|
for (final cat in raw) {
|
||||||
|
if (cat is! Map) continue;
|
||||||
|
final avatarsRaw = cat['avatars'];
|
||||||
|
if (avatarsRaw is! List) continue;
|
||||||
|
final avatars = <PresetAvatar>[];
|
||||||
|
for (final a in avatarsRaw) {
|
||||||
|
if (a is! Map) continue;
|
||||||
|
final id = a['id'];
|
||||||
|
final url = a['url'];
|
||||||
|
if (id is int && url is String && url.isNotEmpty) {
|
||||||
|
avatars.add(PresetAvatar(id: id, url: url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (avatars.isNotEmpty) {
|
||||||
|
categories.add(
|
||||||
|
PresetAvatarCategory(
|
||||||
|
name: cat['name']?.toString() ?? '',
|
||||||
|
avatars: avatars,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
bool get requiresPassword => payload['passwordChallenge'] != null;
|
bool get requiresPassword => payload['passwordChallenge'] != null;
|
||||||
|
|
||||||
Map<dynamic, dynamic>? get passwordChallenge {
|
Map<dynamic, dynamic>? get passwordChallenge {
|
||||||
@@ -484,7 +531,7 @@ class AccountModule {
|
|||||||
return newProfile;
|
return newProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ProfileData> updateProfileAvatar(String photoToken, String avatarType) async {
|
Future<ProfileData> updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async {
|
||||||
_ensureOnline();
|
_ensureOnline();
|
||||||
final packet = await _api.sendRequest(Opcode.profile, {
|
final packet = await _api.sendRequest(Opcode.profile, {
|
||||||
'photoToken': photoToken,
|
'photoToken': photoToken,
|
||||||
@@ -621,15 +668,17 @@ class AccountModule {
|
|||||||
String? hint,
|
String? hint,
|
||||||
}) async {
|
}) async {
|
||||||
_ensureOnline();
|
_ensureOnline();
|
||||||
|
final capabilities = <int>[0, if (hint != null) 3, 4];
|
||||||
final payload = <dynamic, dynamic>{
|
final payload = <dynamic, dynamic>{
|
||||||
'expectedCapabilities': [0, 3, 4],
|
'expectedCapabilities': capabilities,
|
||||||
'trackId': trackId,
|
'trackId': trackId,
|
||||||
'password': password,
|
'password': password,
|
||||||
};
|
};
|
||||||
if (hint != null) payload['hint'] = hint;
|
if (hint != null) payload['hint'] = hint;
|
||||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
return _processProfileUpdate(
|
||||||
_checkPacketError(packet, 'confirm2fa');
|
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||||
return _processProfileUpdate(packet);
|
'confirm2fa',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2FA Management (when already set)
|
// 2FA Management (when already set)
|
||||||
@@ -670,6 +719,11 @@ class AccountModule {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<TwoFactorDetails> get2faStatus() async {
|
||||||
|
final trackId = await enter2faPanel();
|
||||||
|
return get2faDetails(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> check2faPassword(String trackId, String password) async {
|
Future<void> check2faPassword(String trackId, String password) async {
|
||||||
_ensureOnline();
|
_ensureOnline();
|
||||||
final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, {
|
final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, {
|
||||||
@@ -707,15 +761,16 @@ class AccountModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final payload = <dynamic, dynamic>{
|
final payload = <dynamic, dynamic>{
|
||||||
'expectedCapabilities': [1, 3],
|
'expectedCapabilities': <int>[1, if (hint != null) 3],
|
||||||
'trackId': trackId,
|
'trackId': trackId,
|
||||||
'password': newPassword,
|
'password': newPassword,
|
||||||
};
|
};
|
||||||
if (hint != null) payload['hint'] = hint;
|
if (hint != null) payload['hint'] = hint;
|
||||||
|
|
||||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
return _processProfileUpdate(
|
||||||
_checkPacketError(packet, 'update2faPassword');
|
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||||
return _processProfileUpdate(packet);
|
'update2faPassword',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ProfileData> update2faEmail({
|
Future<ProfileData> update2faEmail({
|
||||||
@@ -740,9 +795,10 @@ class AccountModule {
|
|||||||
'expectedCapabilities': [4],
|
'expectedCapabilities': [4],
|
||||||
'trackId': trackId,
|
'trackId': trackId,
|
||||||
};
|
};
|
||||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
return _processProfileUpdate(
|
||||||
_checkPacketError(packet, 'update2faEmail');
|
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||||
return _processProfileUpdate(packet);
|
'update2faEmail',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ProfileData> remove2fa(String trackId) async {
|
Future<ProfileData> remove2fa(String trackId) async {
|
||||||
@@ -752,34 +808,46 @@ class AccountModule {
|
|||||||
'trackId': trackId,
|
'trackId': trackId,
|
||||||
'remove2fa': true,
|
'remove2fa': true,
|
||||||
};
|
};
|
||||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
return _processProfileUpdate(
|
||||||
_checkPacketError(packet, 'remove2fa');
|
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||||
return _processProfileUpdate(packet);
|
'remove2fa',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ProfileData> _processProfileUpdate(Packet packet) async {
|
Future<ProfileData> _processProfileUpdate(
|
||||||
_api.registerPushHandler(Opcode.notifProfile, (p) {});
|
Future<Packet> requestFuture,
|
||||||
try {
|
String tag,
|
||||||
await for (final push in _api.pushStream
|
) async {
|
||||||
.where((p) => p.opcode == Opcode.notifProfile)
|
final completer = Completer<ProfileData>();
|
||||||
.timeout(const Duration(seconds: 15))) {
|
final sub = _api.pushStream
|
||||||
final payload = push.payload;
|
.where((p) => p.opcode == Opcode.notifProfile)
|
||||||
if (payload is Map) {
|
.listen((push) {
|
||||||
final profile = payload['profile'];
|
if (completer.isCompleted) return;
|
||||||
if (profile is Map) {
|
final payload = push.payload;
|
||||||
final contact = profile['contact'];
|
if (payload is! Map) return;
|
||||||
if (contact is Map) {
|
final profile = payload['profile'];
|
||||||
return ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
if (profile is! Map) return;
|
||||||
}
|
final contact = profile['contact'];
|
||||||
}
|
if (contact is! Map) return;
|
||||||
}
|
completer.complete(
|
||||||
|
ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final timer = Timer(const Duration(seconds: 15), () {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(
|
||||||
|
Exception('Таймаут ожидания обновления профиля'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} on TimeoutException {
|
});
|
||||||
throw Exception('Таймаут ожидания обновления профиля');
|
try {
|
||||||
|
final packet = await requestFuture;
|
||||||
|
_checkPacketError(packet, tag);
|
||||||
|
return await completer.future;
|
||||||
} finally {
|
} finally {
|
||||||
_api.unregisterPushHandler(Opcode.notifProfile);
|
timer.cancel();
|
||||||
|
await sub.cancel();
|
||||||
}
|
}
|
||||||
throw Exception('Не удалось получить обновлённый профиль');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RequestCodeResult> requestCode(
|
Future<RequestCodeResult> requestCode(
|
||||||
@@ -827,6 +895,61 @@ class AccountModule {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> completeRegistration({
|
||||||
|
required String token,
|
||||||
|
required String firstName,
|
||||||
|
String? lastName,
|
||||||
|
int? photoId,
|
||||||
|
}) async {
|
||||||
|
_ensureOnline();
|
||||||
|
|
||||||
|
final payload = <dynamic, dynamic>{
|
||||||
|
'token': token,
|
||||||
|
'tokenType': AuthRequestType.register.value,
|
||||||
|
'firstName': firstName,
|
||||||
|
};
|
||||||
|
if (lastName != null && lastName.isNotEmpty) {
|
||||||
|
payload['lastName'] = lastName;
|
||||||
|
}
|
||||||
|
if (photoId != null) {
|
||||||
|
payload['photoId'] = photoId;
|
||||||
|
payload['avatarType'] = 'PRESET_AVATAR';
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})');
|
||||||
|
|
||||||
|
final packet = await _api.sendRequest(Opcode.authConfirm, payload);
|
||||||
|
|
||||||
|
_checkPacketError(packet, 'completeRegistration');
|
||||||
|
|
||||||
|
final data = packet.payload;
|
||||||
|
if (data is! Map) {
|
||||||
|
throw Exception(
|
||||||
|
'completeRegistration: неожиданный тип payload: ${data.runtimeType}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final profileMap = data['profile'];
|
||||||
|
if (profileMap is! Map) {
|
||||||
|
throw Exception('completeRegistration: отсутствует profile в ответе');
|
||||||
|
}
|
||||||
|
final contact = profileMap['contact'];
|
||||||
|
if (contact is! Map) {
|
||||||
|
throw Exception('completeRegistration: отсутствует profile.contact');
|
||||||
|
}
|
||||||
|
final accountId = contact['id'] as int?;
|
||||||
|
if (accountId == null) {
|
||||||
|
throw Exception('completeRegistration: отсутствует id аккаунта');
|
||||||
|
}
|
||||||
|
|
||||||
|
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||||
|
await AppDatabase.saveProfile(profile, isActive: true);
|
||||||
|
await TokenStorage.setActiveAccount(accountId);
|
||||||
|
|
||||||
|
logger.i('Регистрация завершена, accountId=$accountId');
|
||||||
|
return accountId;
|
||||||
|
}
|
||||||
|
|
||||||
Future<LoginResult> login({
|
Future<LoginResult> login({
|
||||||
int? accountId,
|
int? accountId,
|
||||||
String? token,
|
String? token,
|
||||||
@@ -921,6 +1044,20 @@ class AccountModule {
|
|||||||
_checkPacketError(packet, 'authorizeWebQrLogin');
|
_checkPacketError(packet, 'authorizeWebQrLogin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> beginAddAccount() async {
|
||||||
|
try {
|
||||||
|
await _api.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
await TokenStorage.clearActiveAccount();
|
||||||
|
|
||||||
|
ContactCache.clear();
|
||||||
|
TranscriptionCache.clear();
|
||||||
|
ChatsModule.resetForAccountSwitch();
|
||||||
|
|
||||||
|
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
|
||||||
|
}
|
||||||
|
|
||||||
Future<ProfileData> switchAccount(int accountId) async {
|
Future<ProfileData> switchAccount(int accountId) async {
|
||||||
final profile = await AppDatabase.loadProfile(accountId);
|
final profile = await AppDatabase.loadProfile(accountId);
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
@@ -940,6 +1077,7 @@ class AccountModule {
|
|||||||
|
|
||||||
ContactCache.clear();
|
ContactCache.clear();
|
||||||
TranscriptionCache.clear();
|
TranscriptionCache.clear();
|
||||||
|
ChatsModule.resetForAccountSwitch();
|
||||||
await ContactsModule.primeCacheFromDb(accountId);
|
await ContactsModule.primeCacheFromDb(accountId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1017,9 +1155,18 @@ class AccountModule {
|
|||||||
final payload = <dynamic, dynamic>{
|
final payload = <dynamic, dynamic>{
|
||||||
'token': token,
|
'token': token,
|
||||||
'interactive': true,
|
'interactive': true,
|
||||||
if (_api.userAgent != null) 'userAgent': _api.userAgent,
|
'exp': {
|
||||||
|
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final callsSeed = _api.callsSeed;
|
||||||
|
final deviceId = _api.deviceId;
|
||||||
|
if (callsSeed != null && deviceId != null) {
|
||||||
|
payload['chatCacheFingerprint'] =
|
||||||
|
ChatCacheFingerprint.compute(callsSeed, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
if (sync != null) {
|
if (sync != null) {
|
||||||
payload['presenceSync'] = sync.presenceSync;
|
payload['presenceSync'] = sync.presenceSync;
|
||||||
payload['chatsSync'] = sync.chatsSync;
|
payload['chatsSync'] = sync.chatsSync;
|
||||||
@@ -1029,9 +1176,6 @@ class AccountModule {
|
|||||||
payload['bannersSync'] = sync.bannersSync;
|
payload['bannersSync'] = sync.bannersSync;
|
||||||
payload['lastLogin'] = sync.lastLogin;
|
payload['lastLogin'] = sync.lastLogin;
|
||||||
if (sync.configHash != null) payload['configHash'] = sync.configHash;
|
if (sync.configHash != null) payload['configHash'] = sync.configHash;
|
||||||
if (sync.chatCacheFingerprint != null) {
|
|
||||||
payload['chatCacheFingerprint'] = sync.chatCacheFingerprint;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
payload['presenceSync'] = 0;
|
payload['presenceSync'] = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../../core/protocol/opcode_map.dart';
|
import '../../core/protocol/opcode_map.dart';
|
||||||
import '../../core/protocol/packet.dart';
|
import '../../core/protocol/packet.dart';
|
||||||
|
import '../../core/cache/info_cache.dart';
|
||||||
import '../../core/storage/app_database.dart';
|
import '../../core/storage/app_database.dart';
|
||||||
import '../../core/storage/token_storage.dart';
|
import '../../core/storage/token_storage.dart';
|
||||||
import '../../core/utils/logger.dart';
|
import '../../core/utils/logger.dart';
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
import 'folders.dart';
|
import 'folders.dart';
|
||||||
import 'messages.dart' show ContactCache;
|
import 'messages.dart' show ContactCache, CachedMessage;
|
||||||
|
|
||||||
Map<int, int> _parseParticipants(dynamic raw) {
|
Map<int, int> _parseParticipants(dynamic raw) {
|
||||||
try {
|
try {
|
||||||
@@ -146,27 +147,317 @@ class CachedChat {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class MessageEvent {
|
||||||
|
final int chatId;
|
||||||
|
const MessageEvent(this.chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageAddedEvent extends MessageEvent {
|
||||||
|
final CachedMessage message;
|
||||||
|
const MessageAddedEvent(super.chatId, this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageEditedEvent extends MessageEvent {
|
||||||
|
final CachedMessage message;
|
||||||
|
const MessageEditedEvent(super.chatId, this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageRemovedEvent extends MessageEvent {
|
||||||
|
final String messageId;
|
||||||
|
const MessageRemovedEvent(super.chatId, this.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageReactionsChangedEvent extends MessageEvent {
|
||||||
|
final String messageId;
|
||||||
|
final Map<String, dynamic>? reactionInfo;
|
||||||
|
const MessageReactionsChangedEvent(super.chatId, this.messageId, this.reactionInfo);
|
||||||
|
}
|
||||||
|
|
||||||
class ChatsModule {
|
class ChatsModule {
|
||||||
static const int muteOff = 0;
|
static const int muteOff = 0;
|
||||||
static const int muteForever = -1;
|
static const int muteForever = -1;
|
||||||
|
|
||||||
|
/// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено,
|
||||||
|
/// а кеша истории нет — UI должен отрисовать курсивную плашку.
|
||||||
|
static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__';
|
||||||
|
|
||||||
|
static final _messageEventsController =
|
||||||
|
StreamController<MessageEvent>.broadcast();
|
||||||
|
static Stream<MessageEvent> get messageEvents =>
|
||||||
|
_messageEventsController.stream;
|
||||||
|
|
||||||
static final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
static final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
||||||
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||||
|
|
||||||
static StreamSubscription<Packet>? _globalPushSub;
|
static StreamSubscription<Packet>? _globalPushSub;
|
||||||
|
static StreamSubscription<SessionState>? _globalStateSub;
|
||||||
|
|
||||||
|
static final Set<int> _dirtyChats = {};
|
||||||
|
static final Set<int> _knownChats = {};
|
||||||
|
|
||||||
|
static bool isChatDirty(int chatId) => _dirtyChats.contains(chatId);
|
||||||
|
static void markChatClean(int chatId) => _dirtyChats.remove(chatId);
|
||||||
|
static void markChatDirty(int chatId) => _dirtyChats.add(chatId);
|
||||||
|
static void registerKnownChat(int chatId) => _knownChats.add(chatId);
|
||||||
|
|
||||||
static void attachGlobalPushHandlers(Api api) {
|
static void attachGlobalPushHandlers(Api api) {
|
||||||
_globalPushSub?.cancel();
|
_globalPushSub?.cancel();
|
||||||
|
_globalStateSub?.cancel();
|
||||||
_globalPushSub = api.pushStream.listen(_handleGlobalPush);
|
_globalPushSub = api.pushStream.listen(_handleGlobalPush);
|
||||||
|
_globalStateSub = api.stateStream.listen(_handleSessionState);
|
||||||
|
if (api.state != SessionState.online) {
|
||||||
|
_markAllKnownChatsDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _handleSessionState(SessionState state) async {
|
||||||
|
if (state == SessionState.disconnected) {
|
||||||
|
ContactInfoFetch.clear();
|
||||||
|
PresenceFetch.clear();
|
||||||
|
ChatInfoFetch.clear();
|
||||||
|
await _markAllKnownChatsDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void resetForAccountSwitch() {
|
||||||
|
_dirtyChats.clear();
|
||||||
|
_knownChats.clear();
|
||||||
|
ContactInfoFetch.clear();
|
||||||
|
PresenceFetch.clear();
|
||||||
|
ChatInfoFetch.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _markAllKnownChatsDirty() async {
|
||||||
|
if (_knownChats.isEmpty) {
|
||||||
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
|
if (accountId == null) return;
|
||||||
|
final rows = await AppDatabase.loadChats(accountId);
|
||||||
|
for (final row in rows) {
|
||||||
|
final id = row['id'];
|
||||||
|
if (id is int) _knownChats.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_dirtyChats.addAll(_knownChats);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _handleGlobalPush(Packet packet) async {
|
static Future<void> _handleGlobalPush(Packet packet) async {
|
||||||
switch (packet.opcode) {
|
switch (packet.opcode) {
|
||||||
|
case Opcode.notifMessage:
|
||||||
|
await _handleNotifMessage(packet);
|
||||||
case Opcode.notifMark:
|
case Opcode.notifMark:
|
||||||
await _handleNotifMark(packet);
|
await _handleNotifMark(packet);
|
||||||
|
case Opcode.notifMsgReactionsChanged:
|
||||||
|
await _handleNotifMsgReactionsChanged(packet);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> _handleNotifMessage(Packet packet) async {
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is! Map) return;
|
||||||
|
final chatId = payload['chatId'];
|
||||||
|
if (chatId is! int) return;
|
||||||
|
final msg = payload['message'];
|
||||||
|
if (msg is! Map) return;
|
||||||
|
|
||||||
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
|
if (accountId == null) return;
|
||||||
|
|
||||||
|
final senderId = msg['sender'] as int?;
|
||||||
|
final msgIdStr = msg['id']?.toString();
|
||||||
|
final msgIdInt = (msg['id'] is int)
|
||||||
|
? msg['id'] as int
|
||||||
|
: int.tryParse(msgIdStr ?? '');
|
||||||
|
final msgTime = msg['time'] as int?;
|
||||||
|
final msgText = msg['text'] as String?;
|
||||||
|
final status = msg['status'] as String?;
|
||||||
|
final unread = payload['unread'] as int?;
|
||||||
|
|
||||||
|
var rows = await AppDatabase.loadChat(accountId, chatId);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
try {
|
||||||
|
final chatInfo = await ChatInfoFetch.get(chatId);
|
||||||
|
if (chatInfo != null) {
|
||||||
|
await cacheServerChat(chatInfo, accountId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('notifMessage: fetch info for unknown chat $chatId failed: $e');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows = await AppDatabase.loadChat(accountId, chatId);
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == 'REMOVED' && msgIdStr != null) {
|
||||||
|
await AppDatabase.deleteMessage(accountId, chatId, msgIdStr);
|
||||||
|
final cachedChat = CachedChat.fromDbRow(rows.first);
|
||||||
|
if (cachedChat.lastMsgId == msgIdInt) {
|
||||||
|
await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread);
|
||||||
|
} else if (unread != null) {
|
||||||
|
final newRow = Map<String, dynamic>.from(rows.first);
|
||||||
|
newRow['unread_count'] = unread;
|
||||||
|
await AppDatabase.saveChats([newRow]);
|
||||||
|
}
|
||||||
|
_messageEventsController.add(MessageRemovedEvent(chatId, msgIdStr));
|
||||||
|
_bump();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CachedMessage? emittedMessage;
|
||||||
|
if (status == 'EDITED' && msgIdStr != null) {
|
||||||
|
final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr);
|
||||||
|
if (existing != null) {
|
||||||
|
Map<String, dynamic> mergedPayload;
|
||||||
|
final existingPayloadRaw = existing['payload'];
|
||||||
|
if (existingPayloadRaw is String && existingPayloadRaw.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
mergedPayload = Map<String, dynamic>.from(
|
||||||
|
jsonDecode(existingPayloadRaw) as Map,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
mergedPayload = Map<String, dynamic>.from(msg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mergedPayload = Map<String, dynamic>.from(msg);
|
||||||
|
}
|
||||||
|
for (final entry in msg.entries) {
|
||||||
|
if (entry.key == 'reactionInfo') continue;
|
||||||
|
mergedPayload[entry.key.toString()] = entry.value;
|
||||||
|
}
|
||||||
|
final newRow = Map<String, dynamic>.from(existing);
|
||||||
|
newRow['text'] = msgText;
|
||||||
|
newRow['status'] = status;
|
||||||
|
newRow['payload'] = jsonEncode(mergedPayload);
|
||||||
|
await AppDatabase.saveMessages([newRow]);
|
||||||
|
emittedMessage = CachedMessage.fromDbRow(newRow);
|
||||||
|
_messageEventsController.add(MessageEditedEvent(chatId, emittedMessage));
|
||||||
|
}
|
||||||
|
} else if (msgIdStr != null) {
|
||||||
|
final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr);
|
||||||
|
if (existing == null) {
|
||||||
|
final cached = CachedMessage.fromPushPayload(accountId, chatId, msg);
|
||||||
|
await AppDatabase.saveMessages([cached.toDbRow()]);
|
||||||
|
emittedMessage = cached;
|
||||||
|
_messageEventsController.add(MessageAddedEvent(chatId, cached));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = CachedChat.fromDbRow(rows.first);
|
||||||
|
final isStaleLast = status != 'REMOVED' &&
|
||||||
|
msgIdInt != null &&
|
||||||
|
cached.lastMsgId == msgIdInt &&
|
||||||
|
status != 'EDITED';
|
||||||
|
if (isStaleLast) {
|
||||||
|
_bump();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newRow = Map<String, dynamic>.from(rows.first);
|
||||||
|
if (status != 'REMOVED') {
|
||||||
|
if (msgIdInt != null) newRow['last_msg_id'] = msgIdInt;
|
||||||
|
if (msgTime != null) {
|
||||||
|
newRow['last_msg_time'] = msgTime;
|
||||||
|
if (status != 'EDITED') {
|
||||||
|
newRow['last_event_time'] = msgTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newRow['last_msg_text'] = msgText;
|
||||||
|
if (senderId != null) newRow['last_msg_sender'] = senderId;
|
||||||
|
}
|
||||||
|
if (unread != null) newRow['unread_count'] = unread;
|
||||||
|
|
||||||
|
await AppDatabase.saveChats([newRow]);
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _reconcileLastMessage(
|
||||||
|
int accountId,
|
||||||
|
int chatId,
|
||||||
|
Map<String, dynamic> chatRow, {
|
||||||
|
int? unread,
|
||||||
|
}) async {
|
||||||
|
final latest = await AppDatabase.loadMessages(accountId, chatId, limit: 1);
|
||||||
|
final newRow = Map<String, dynamic>.from(chatRow);
|
||||||
|
if (latest.isNotEmpty) {
|
||||||
|
final m = latest.first;
|
||||||
|
newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? '');
|
||||||
|
newRow['last_msg_text'] = m['text'];
|
||||||
|
newRow['last_msg_time'] = m['time'];
|
||||||
|
newRow['last_msg_sender'] = m['sender_id'];
|
||||||
|
} else {
|
||||||
|
newRow['last_msg_id'] = null;
|
||||||
|
newRow['last_msg_text'] = lastMsgPlaceholder;
|
||||||
|
newRow['last_msg_sender'] = null;
|
||||||
|
}
|
||||||
|
if (unread != null) newRow['unread_count'] = unread;
|
||||||
|
await AppDatabase.saveChats([newRow]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вызывается после успешного фетча истории чата —
|
||||||
|
/// если в превью был placeholder, заменяем его на актуальное
|
||||||
|
/// последнее сообщение из кеша.
|
||||||
|
static Future<void> reconcileLastMessageIfPlaceholder(
|
||||||
|
int accountId,
|
||||||
|
int chatId,
|
||||||
|
) async {
|
||||||
|
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
final chat = CachedChat.fromDbRow(rows.first);
|
||||||
|
if (chat.lastMsgText != lastMsgPlaceholder) return;
|
||||||
|
await _reconcileLastMessage(accountId, chatId, rows.first);
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _handleNotifMsgReactionsChanged(Packet packet) async {
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is! Map) return;
|
||||||
|
final chatId = payload['chatId'];
|
||||||
|
if (chatId is! int) return;
|
||||||
|
final messageId = payload['messageId']?.toString();
|
||||||
|
if (messageId == null || messageId.isEmpty) return;
|
||||||
|
|
||||||
|
final accountId = await TokenStorage.getActiveAccountId();
|
||||||
|
if (accountId == null) return;
|
||||||
|
|
||||||
|
final existing = await AppDatabase.loadMessage(accountId, chatId, messageId);
|
||||||
|
if (existing == null) return;
|
||||||
|
|
||||||
|
Map<String, dynamic> payloadMap;
|
||||||
|
final raw = existing['payload'];
|
||||||
|
if (raw is String && raw.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
payloadMap = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||||
|
} catch (_) {
|
||||||
|
payloadMap = {};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payloadMap = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final counters = payload['counters'];
|
||||||
|
final totalCount = payload['totalCount'];
|
||||||
|
final reactionInfo = <String, dynamic>{};
|
||||||
|
final prev = payloadMap['reactionInfo'];
|
||||||
|
if (prev is Map && prev['yourReaction'] != null) {
|
||||||
|
reactionInfo['yourReaction'] = prev['yourReaction'];
|
||||||
|
}
|
||||||
|
if (counters is List) reactionInfo['counters'] = counters;
|
||||||
|
if (totalCount is int) reactionInfo['totalCount'] = totalCount;
|
||||||
|
if (reactionInfo['counters'] == null || (counters is List && counters.isEmpty)) {
|
||||||
|
payloadMap.remove('reactionInfo');
|
||||||
|
} else {
|
||||||
|
payloadMap['reactionInfo'] = reactionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newRow = Map<String, dynamic>.from(existing);
|
||||||
|
newRow['payload'] = jsonEncode(payloadMap);
|
||||||
|
await AppDatabase.saveMessages([newRow]);
|
||||||
|
final emitted = payloadMap['reactionInfo'] as Map<String, dynamic>?;
|
||||||
|
_messageEventsController.add(
|
||||||
|
MessageReactionsChangedEvent(chatId, messageId, emitted),
|
||||||
|
);
|
||||||
|
_bump();
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> _handleNotifMark(Packet packet) async {
|
static Future<void> _handleNotifMark(Packet packet) async {
|
||||||
final payload = packet.payload;
|
final payload = packet.payload;
|
||||||
if (payload is! Map) return;
|
if (payload is! Map) return;
|
||||||
@@ -221,12 +512,15 @@ class ChatsModule {
|
|||||||
if (accountId == null) return;
|
if (accountId == null) return;
|
||||||
|
|
||||||
final dialogRows = await AppDatabase.loadDialogChats(accountId);
|
final dialogRows = await AppDatabase.loadDialogChats(accountId);
|
||||||
final byParticipant = <int, List<Map<String, dynamic>>>{};
|
final byParticipant =
|
||||||
|
<int, List<({Map<String, dynamic> row, CachedChat cached})>>{};
|
||||||
for (final row in dialogRows) {
|
for (final row in dialogRows) {
|
||||||
final cached = CachedChat.fromDbRow(row);
|
final cached = CachedChat.fromDbRow(row);
|
||||||
for (final pid in cached.participants.keys) {
|
for (final pid in cached.participants.keys) {
|
||||||
if (pid == accountId) continue;
|
if (pid == accountId) continue;
|
||||||
byParticipant.putIfAbsent(pid, () => []).add(row);
|
byParticipant
|
||||||
|
.putIfAbsent(pid, () => [])
|
||||||
|
.add((row: row, cached: cached));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,8 +532,9 @@ class ChatsModule {
|
|||||||
final options = ContactCache.getOptions(contactId) ?? const <String>{};
|
final options = ContactCache.getOptions(contactId) ?? const <String>{};
|
||||||
final affected = byParticipant[contactId];
|
final affected = byParticipant[contactId];
|
||||||
if (affected == null) continue;
|
if (affected == null) continue;
|
||||||
for (final row in affected) {
|
for (final entry in affected) {
|
||||||
final cached = CachedChat.fromDbRow(row);
|
final row = entry.row;
|
||||||
|
final cached = entry.cached;
|
||||||
final sameTitle = cached.title == name;
|
final sameTitle = cached.title == name;
|
||||||
final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
|
final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
|
||||||
final sameOptions = cached.options.length == options.length &&
|
final sameOptions = cached.options.length == options.length &&
|
||||||
@@ -288,6 +583,7 @@ class ChatsModule {
|
|||||||
logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
|
logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
_knownChats.add(parsed.id);
|
||||||
final ex = existing[parsed.id];
|
final ex = existing[parsed.id];
|
||||||
if (ex != null && _sameContent(ex, parsed)) {
|
if (ex != null && _sameContent(ex, parsed)) {
|
||||||
return parsed;
|
return parsed;
|
||||||
@@ -383,7 +679,11 @@ class ChatsModule {
|
|||||||
static Future<List<CachedChat>> getChats(int accountId) async {
|
static Future<List<CachedChat>> getChats(int accountId) async {
|
||||||
try {
|
try {
|
||||||
final rows = await AppDatabase.loadChats(accountId);
|
final rows = await AppDatabase.loadChats(accountId);
|
||||||
return rows.map(CachedChat.fromDbRow).toList();
|
final chats = rows.map(CachedChat.fromDbRow).toList();
|
||||||
|
for (final c in chats) {
|
||||||
|
_knownChats.add(c.id);
|
||||||
|
}
|
||||||
|
return chats;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e("Ошибка при получении чатов: $e");
|
logger.e("Ошибка при получении чатов: $e");
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -190,13 +190,23 @@ class FileUploader {
|
|||||||
Socket? socket;
|
Socket? socket;
|
||||||
try {
|
try {
|
||||||
socket = await _openSocket(uri);
|
socket = await _openSocket(uri);
|
||||||
|
final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||||
|
final preamble = utf8.encode(
|
||||||
|
'--$boundary\r\n'
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||||
|
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
|
||||||
|
'\r\n',
|
||||||
|
);
|
||||||
|
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||||
_writeImageHeaders(
|
_writeImageHeaders(
|
||||||
socket,
|
socket,
|
||||||
uri,
|
uri,
|
||||||
bytes.length,
|
preamble.length + bytes.length + epilogue.length,
|
||||||
contentType: _contentTypeForFilename(filename),
|
boundary: boundary,
|
||||||
);
|
);
|
||||||
|
socket.add(preamble);
|
||||||
socket.add(bytes);
|
socket.add(bytes);
|
||||||
|
socket.add(epilogue);
|
||||||
await socket.flush();
|
await socket.flush();
|
||||||
|
|
||||||
final response = await _readFullResponse(
|
final response = await _readFullResponse(
|
||||||
@@ -208,7 +218,6 @@ class FileUploader {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
logger.w('uploadImage: empty/timed-out response');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final (status, body) = response;
|
final (status, body) = response;
|
||||||
@@ -230,12 +239,12 @@ class FileUploader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) {
|
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
|
||||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||||
final headers = StringBuffer()
|
final headers = StringBuffer()
|
||||||
..write('POST $path HTTP/1.1\r\n')
|
..write('POST $path HTTP/1.1\r\n')
|
||||||
..write('Host: ${uri.host}\r\n')
|
..write('Host: ${uri.host}\r\n')
|
||||||
..write('Content-Type: $contentType\r\n')
|
..write('Content-Type: multipart/form-data; boundary=$boundary\r\n')
|
||||||
..write('Content-Length: $total\r\n')
|
..write('Content-Length: $total\r\n')
|
||||||
..write('Connection: keep-alive\r\n')
|
..write('Connection: keep-alive\r\n')
|
||||||
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
||||||
@@ -273,36 +282,63 @@ class FileUploader {
|
|||||||
Timer? timer;
|
Timer? timer;
|
||||||
StreamSubscription<List<int>>? sub;
|
StreamSubscription<List<int>>? sub;
|
||||||
|
|
||||||
void finish() {
|
void finishWith((int, String)? value) {
|
||||||
timer?.cancel();
|
timer?.cancel();
|
||||||
sub?.cancel();
|
sub?.cancel();
|
||||||
if (completer.isCompleted) return;
|
if (!completer.isCompleted) completer.complete(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
(int, String)? tryParse({required bool atClose}) {
|
||||||
final headerEnd = _findHeaderEnd(bytes);
|
final headerEnd = _findHeaderEnd(bytes);
|
||||||
if (headerEnd == -1) {
|
if (headerEnd == -1) return null;
|
||||||
completer.complete(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||||
final lines = headerStr.split('\r\n');
|
final lines = headerStr.split('\r\n');
|
||||||
final parts = lines.first.split(' ');
|
final parts = lines.first.split(' ');
|
||||||
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
||||||
final chunked = lines.skip(1).any(
|
final headerLines = lines.skip(1);
|
||||||
|
final chunked = headerLines.any(
|
||||||
(l) => l.toLowerCase().startsWith('transfer-encoding:') &&
|
(l) => l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||||
l.toLowerCase().contains('chunked'),
|
l.toLowerCase().contains('chunked'),
|
||||||
);
|
);
|
||||||
|
int? contentLength;
|
||||||
|
for (final l in headerLines) {
|
||||||
|
if (l.toLowerCase().startsWith('content-length:')) {
|
||||||
|
contentLength = int.tryParse(l.split(':').last.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
||||||
final body = chunked ? _decodeChunked(rawBody) : rawBody;
|
if (chunked) {
|
||||||
completer.complete((status, body));
|
if (!atClose && !rawBody.contains('\r\n0\r\n')) return null;
|
||||||
|
return (status, _decodeChunked(rawBody));
|
||||||
|
}
|
||||||
|
if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (status, rawBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
void fail() {
|
sub = socket.listen(
|
||||||
timer?.cancel();
|
(chunk) {
|
||||||
sub?.cancel();
|
bytes.addAll(chunk);
|
||||||
if (!completer.isCompleted) completer.complete(null);
|
final parsed = tryParse(atClose: false);
|
||||||
}
|
if (parsed != null) finishWith(parsed);
|
||||||
|
},
|
||||||
sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish);
|
onError: (e) {
|
||||||
timer = Timer(timeout, fail);
|
logger.w('uploadImage: socket error after ${bytes.length} bytes: $e');
|
||||||
|
finishWith(tryParse(atClose: true));
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
final parsed = tryParse(atClose: true);
|
||||||
|
if (parsed == null) {
|
||||||
|
logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)');
|
||||||
|
}
|
||||||
|
finishWith(parsed);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
timer = Timer(timeout, () {
|
||||||
|
logger.w('uploadImage: response timeout after ${bytes.length} bytes');
|
||||||
|
finishWith(tryParse(atClose: true));
|
||||||
|
});
|
||||||
return completer.future;
|
return completer.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,10 +44,13 @@ class FoldersModule {
|
|||||||
List<dynamic>? foldersOrder,
|
List<dynamic>? foldersOrder,
|
||||||
) {
|
) {
|
||||||
if (foldersOrder == null || foldersOrder.isEmpty) return;
|
if (foldersOrder == null || foldersOrder.isEmpty) return;
|
||||||
final orderedIds = foldersOrder.map((id) => id.toString()).toList();
|
final orderIndex = <String, int>{};
|
||||||
|
for (var i = 0; i < foldersOrder.length; i++) {
|
||||||
|
orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i);
|
||||||
|
}
|
||||||
folders.sort((a, b) {
|
folders.sort((a, b) {
|
||||||
final aIndex = orderedIds.indexOf(a.id);
|
final aIndex = orderIndex[a.id] ?? -1;
|
||||||
final bIndex = orderedIds.indexOf(b.id);
|
final bIndex = orderIndex[b.id] ?? -1;
|
||||||
if (aIndex == -1 && bIndex == -1) return 0;
|
if (aIndex == -1 && bIndex == -1) return 0;
|
||||||
if (aIndex == -1) return 1;
|
if (aIndex == -1) return 1;
|
||||||
if (bIndex == -1) return -1;
|
if (bIndex == -1) return -1;
|
||||||
|
|||||||
@@ -245,6 +245,29 @@ class CachedMessage {
|
|||||||
'status': status,
|
'status': status,
|
||||||
'payload': payload != null ? jsonEncode(payload) : null,
|
'payload': payload != null ? jsonEncode(payload) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) {
|
||||||
|
List<MessageAttachment>? attachments;
|
||||||
|
final attaches = msg['attaches'];
|
||||||
|
if (attaches is List && attaches.isNotEmpty) {
|
||||||
|
attachments = attaches
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((a) =>
|
||||||
|
MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return CachedMessage(
|
||||||
|
id: msg['id']?.toString() ?? '',
|
||||||
|
accountId: accountId,
|
||||||
|
chatId: chatId,
|
||||||
|
senderId: msg['sender'] as int? ?? 0,
|
||||||
|
text: msg['text'] as String?,
|
||||||
|
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
|
status: (msg['status'] as String?) ?? 'sent',
|
||||||
|
payload: Map<String, dynamic>.from(msg),
|
||||||
|
attachments: attachments,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MessagesModule {
|
class MessagesModule {
|
||||||
@@ -545,6 +568,43 @@ class MessagesModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83).
|
||||||
|
///
|
||||||
|
/// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`,
|
||||||
|
/// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`.
|
||||||
|
/// Возвращает лучший доступный progressive-MP4 (или HLS как запасной).
|
||||||
|
Future<String?> getVideoUrl({
|
||||||
|
required String messageId,
|
||||||
|
required int chatId,
|
||||||
|
required String token,
|
||||||
|
required int videoId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _api.sendRequest(Opcode.videoPlay, {
|
||||||
|
'messageId': int.tryParse(messageId) ?? 0,
|
||||||
|
'chatId': chatId,
|
||||||
|
'token': token,
|
||||||
|
'videoId': videoId,
|
||||||
|
});
|
||||||
|
if (!response.isOk) return null;
|
||||||
|
final data = response.payload;
|
||||||
|
if (data is! Map) return null;
|
||||||
|
|
||||||
|
const mp4Keys = ['MP4_1080', 'MP4_720', 'MP4_480', 'MP4_360', 'MP4_240'];
|
||||||
|
for (final key in mp4Keys) {
|
||||||
|
final url = data[key];
|
||||||
|
if (url is String && url.isNotEmpty) return url;
|
||||||
|
}
|
||||||
|
final hls = data['HLS'];
|
||||||
|
if (hls is String && hls.isNotEmpty) return hls;
|
||||||
|
final external = data['EXTERNAL'];
|
||||||
|
if (external is String && external.isNotEmpty) return external;
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
|
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
|
||||||
try {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||||
@@ -565,23 +625,6 @@ class MessagesModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> getVideoUrl(String baseUrl, String videoToken) async {
|
|
||||||
try {
|
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
|
||||||
'url': baseUrl,
|
|
||||||
'token': videoToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.isOk) return null;
|
|
||||||
final data = response.payload;
|
|
||||||
if (data is! Map) return null;
|
|
||||||
|
|
||||||
return data['content'] as String?;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
|
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
|
||||||
try {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||||
@@ -602,18 +645,27 @@ class MessagesModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> getFileUrl(String baseUrl, String fileToken) async {
|
/// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88).
|
||||||
|
///
|
||||||
|
/// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`,
|
||||||
|
/// ответ `{url: "https://fd.oneme.ru/getfile?..."}`.
|
||||||
|
Future<String?> getFileUrl({
|
||||||
|
required String messageId,
|
||||||
|
required int chatId,
|
||||||
|
required int fileId,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||||
'url': baseUrl,
|
'messageId': int.tryParse(messageId) ?? 0,
|
||||||
'token': fileToken,
|
'chatId': chatId,
|
||||||
|
'fileId': fileId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.isOk) return null;
|
if (!response.isOk) return null;
|
||||||
final data = response.payload;
|
final data = response.payload;
|
||||||
if (data is! Map) return null;
|
if (data is! Map) return null;
|
||||||
|
|
||||||
return data['content'] as String?;
|
return data['url'] as String?;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../api.dart';
|
||||||
|
import '../../core/protocol/opcode_map.dart';
|
||||||
|
import '../../models/poll.dart';
|
||||||
|
|
||||||
|
class PollsModule extends ChangeNotifier {
|
||||||
|
final Api _api;
|
||||||
|
|
||||||
|
PollsModule(this._api);
|
||||||
|
|
||||||
|
final Map<int, Poll> _cache = {};
|
||||||
|
final Set<int> _inFlight = {};
|
||||||
|
|
||||||
|
Poll? get(int pollId) => _cache[pollId];
|
||||||
|
|
||||||
|
Future<void> fetch(
|
||||||
|
int chatId,
|
||||||
|
String messageId,
|
||||||
|
int pollId, {
|
||||||
|
bool force = false,
|
||||||
|
}) async {
|
||||||
|
if (pollId == 0) return;
|
||||||
|
if (!force && (_cache.containsKey(pollId) || _inFlight.contains(pollId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_inFlight.add(pollId);
|
||||||
|
try {
|
||||||
|
final mid = int.tryParse(messageId) ?? 0;
|
||||||
|
final response = await _api.sendRequest(Opcode.getPollUpdates, {
|
||||||
|
'chatId': chatId,
|
||||||
|
'polls': [
|
||||||
|
{'messageId': mid, 'pollId': pollId},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!response.isOk) return;
|
||||||
|
|
||||||
|
final data = response.payload;
|
||||||
|
if (data is! Map) return;
|
||||||
|
|
||||||
|
final polls = data['polls'];
|
||||||
|
if (polls is! List) return;
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
for (final p in polls) {
|
||||||
|
if (p is Map) {
|
||||||
|
final poll = Poll.fromServerMap(p);
|
||||||
|
if (poll.pollId != 0) {
|
||||||
|
_cache[poll.pollId] = poll;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) notifyListeners();
|
||||||
|
} catch (_) {
|
||||||
|
// тихо игнорируем — опрос просто не отобразится
|
||||||
|
} finally {
|
||||||
|
_inFlight.remove(pollId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+226
@@ -0,0 +1,226 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../../backend/api.dart';
|
||||||
|
import '../protocol/opcode_map.dart';
|
||||||
|
|
||||||
|
Api? _api;
|
||||||
|
|
||||||
|
void attachInfoCacheApi(Api api) {
|
||||||
|
_api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Entry<T> {
|
||||||
|
T? value;
|
||||||
|
DateTime? fetchedAt;
|
||||||
|
DateTime? failedAt;
|
||||||
|
Future<T?>? inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InfoCache<T> {
|
||||||
|
final Duration ttl;
|
||||||
|
final Duration failureBackoff;
|
||||||
|
final Future<T?> Function(int id) fetcher;
|
||||||
|
final Map<int, _Entry<T>> _entries = {};
|
||||||
|
|
||||||
|
InfoCache({
|
||||||
|
required this.ttl,
|
||||||
|
required this.fetcher,
|
||||||
|
this.failureBackoff = const Duration(seconds: 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
bool _isFresh(_Entry<T> e) {
|
||||||
|
if (e.fetchedAt == null) return false;
|
||||||
|
return DateTime.now().difference(e.fetchedAt!) < ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isInFailureBackoff(_Entry<T> e) {
|
||||||
|
if (e.failedAt == null) return false;
|
||||||
|
return DateTime.now().difference(e.failedAt!) < failureBackoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T?> get(int id, {bool forceRefresh = false}) {
|
||||||
|
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||||
|
|
||||||
|
if (!forceRefresh && _isFresh(entry)) {
|
||||||
|
return Future.value(entry.value);
|
||||||
|
}
|
||||||
|
if (!forceRefresh && _isInFailureBackoff(entry)) {
|
||||||
|
return Future.value(null);
|
||||||
|
}
|
||||||
|
if (entry.inFlight != null) return entry.inFlight!;
|
||||||
|
|
||||||
|
final future = _runFetch(entry, id);
|
||||||
|
entry.inFlight = future;
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T?> _runFetch(_Entry<T> entry, int id) async {
|
||||||
|
try {
|
||||||
|
final result = await fetcher(id);
|
||||||
|
entry.value = result;
|
||||||
|
entry.fetchedAt = DateTime.now();
|
||||||
|
entry.failedAt = null;
|
||||||
|
return result;
|
||||||
|
} catch (_) {
|
||||||
|
entry.failedAt = DateTime.now();
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
entry.inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
T? peek(int id) {
|
||||||
|
final entry = _entries[id];
|
||||||
|
if (entry == null || !_isFresh(entry)) return null;
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidate(int id) => _entries.remove(id);
|
||||||
|
void clear() => _entries.clear();
|
||||||
|
|
||||||
|
void putValue(int id, T value, {DateTime? at}) {
|
||||||
|
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||||
|
entry.value = value;
|
||||||
|
entry.fetchedAt = at ?? DateTime.now();
|
||||||
|
entry.failedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void markFailed(int id, {DateTime? at}) {
|
||||||
|
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||||
|
entry.failedAt = at ?? DateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactInfoFetch {
|
||||||
|
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||||
|
ttl: const Duration(minutes: 5),
|
||||||
|
fetcher: _fetch,
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||||
|
_cache.get(id, forceRefresh: forceRefresh);
|
||||||
|
|
||||||
|
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||||
|
|
||||||
|
static void invalidate(int id) => _cache.invalidate(id);
|
||||||
|
static void clear() => _cache.clear();
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||||
|
final api = _api;
|
||||||
|
if (api == null || api.state != SessionState.online) return null;
|
||||||
|
final resp = await api.sendRequest(Opcode.contactInfo, {
|
||||||
|
'contactIds': [id],
|
||||||
|
});
|
||||||
|
final data = resp.payload;
|
||||||
|
if (data is! Map) return null;
|
||||||
|
final contacts = data['contacts'];
|
||||||
|
if (contacts is! List || contacts.isEmpty) return null;
|
||||||
|
final first = contacts.first;
|
||||||
|
if (first is! Map) return null;
|
||||||
|
return Map<String, dynamic>.from(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PresenceFetch {
|
||||||
|
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||||
|
ttl: const Duration(seconds: 60),
|
||||||
|
fetcher: _fetch,
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||||
|
_cache.get(id, forceRefresh: forceRefresh);
|
||||||
|
|
||||||
|
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||||
|
|
||||||
|
static void invalidate(int id) => _cache.invalidate(id);
|
||||||
|
static void clear() => _cache.clear();
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||||
|
final results = await _fetchBatch([id]);
|
||||||
|
return results[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<int, Map<String, dynamic>>> getMany(
|
||||||
|
List<int> ids, {
|
||||||
|
bool forceRefresh = false,
|
||||||
|
}) async {
|
||||||
|
final result = <int, Map<String, dynamic>>{};
|
||||||
|
final missing = <int>[];
|
||||||
|
for (final id in ids) {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
final cached = _cache.peek(id);
|
||||||
|
if (cached != null) {
|
||||||
|
result[id] = cached;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing.add(id);
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
final fetched = await _fetchBatch(missing);
|
||||||
|
final now = DateTime.now();
|
||||||
|
for (final id in missing) {
|
||||||
|
final value = fetched[id];
|
||||||
|
if (value != null) {
|
||||||
|
_cache.putValue(id, value, at: now);
|
||||||
|
result[id] = value;
|
||||||
|
} else {
|
||||||
|
_cache.markFailed(id, at: now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<int, Map<String, dynamic>>> _fetchBatch(List<int> ids) async {
|
||||||
|
final api = _api;
|
||||||
|
if (api == null || api.state != SessionState.online || ids.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
final resp = await api.sendRequest(Opcode.contactPresence, {
|
||||||
|
'contactIds': ids,
|
||||||
|
});
|
||||||
|
final data = resp.payload;
|
||||||
|
if (data is! Map) return const {};
|
||||||
|
final presence = data['presence'];
|
||||||
|
if (presence is! Map) return const {};
|
||||||
|
final out = <int, Map<String, dynamic>>{};
|
||||||
|
for (final id in ids) {
|
||||||
|
final entry = presence[id.toString()] ?? presence[id];
|
||||||
|
if (entry is Map) {
|
||||||
|
out[id] = Map<String, dynamic>.from(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatInfoFetch {
|
||||||
|
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||||
|
ttl: const Duration(minutes: 5),
|
||||||
|
fetcher: _fetch,
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||||
|
_cache.get(id, forceRefresh: forceRefresh);
|
||||||
|
|
||||||
|
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||||
|
|
||||||
|
static void invalidate(int id) => _cache.invalidate(id);
|
||||||
|
static void clear() => _cache.clear();
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||||
|
final api = _api;
|
||||||
|
if (api == null || api.state != SessionState.online) return null;
|
||||||
|
final resp = await api.sendRequest(Opcode.chatInfo, {
|
||||||
|
'chatIds': [id],
|
||||||
|
});
|
||||||
|
final data = resp.payload;
|
||||||
|
if (data is! Map) return null;
|
||||||
|
final chats = data['chats'];
|
||||||
|
if (chats is! List || chats.isEmpty) return null;
|
||||||
|
final first = chats.first;
|
||||||
|
if (first is! Map) return null;
|
||||||
|
return Map<String, dynamic>.from(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ class AppFonts {
|
|||||||
static const String customPrefKey = 'app_custom_fonts';
|
static const String customPrefKey = 'app_custom_fonts';
|
||||||
static const String customPrefix = 'g:';
|
static const String customPrefix = 'g:';
|
||||||
|
|
||||||
static const double minScale = 0.85;
|
static const double minScale = 0.60;
|
||||||
static const double maxScale = 1.35;
|
static const double maxScale = 1.35;
|
||||||
static const double defaultScale = 1.0;
|
static const double defaultScale = 1.0;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class AppMediaCacheLimit {
|
||||||
|
static const prefKey = 'media_cache_limit_bytes';
|
||||||
|
static const int defaultValue = 500 * 1024 * 1024; // 500 МБ
|
||||||
|
|
||||||
|
/// Значение «без лимита» — вытеснение из кэша отключено.
|
||||||
|
static const int unlimited = 0;
|
||||||
|
|
||||||
|
/// Доступные пресеты лимита, байты (0 — без лимита).
|
||||||
|
static const List<int> presets = [
|
||||||
|
100 * 1024 * 1024,
|
||||||
|
250 * 1024 * 1024,
|
||||||
|
500 * 1024 * 1024,
|
||||||
|
1024 * 1024 * 1024,
|
||||||
|
2 * 1024 * 1024 * 1024,
|
||||||
|
unlimited,
|
||||||
|
];
|
||||||
|
|
||||||
|
static final ValueNotifier<int> current = ValueNotifier(defaultValue);
|
||||||
|
|
||||||
|
static Future<int> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getInt(prefKey) ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(int value) async {
|
||||||
|
current.value = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(prefKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class AppPranks {
|
||||||
|
static const prefKey = 'dev_pranks';
|
||||||
|
static const bool defaultValue = false;
|
||||||
|
|
||||||
|
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||||
|
|
||||||
|
static Future<bool> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(prefKey) ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(bool value) async {
|
||||||
|
current.value = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(prefKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class AppStories {
|
||||||
|
static const prefKey = 'dev_stories';
|
||||||
|
static const bool defaultValue = false;
|
||||||
|
|
||||||
|
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||||
|
|
||||||
|
static Future<bool> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(prefKey) ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(bool value) async {
|
||||||
|
current.value = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(prefKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
class ChatCacheFingerprint {
|
||||||
|
static final Uint8List _signatureDigest = _hex(
|
||||||
|
'1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93',
|
||||||
|
);
|
||||||
|
static final Uint8List _soDigest = _hex(
|
||||||
|
'c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111',
|
||||||
|
);
|
||||||
|
static final Uint8List _dexDigest = _hex(
|
||||||
|
'490a2746c7ebbff050353c575a186ca65bc708f9b6e0c1329b59a3bfab6c3924',
|
||||||
|
);
|
||||||
|
|
||||||
|
static Uint8List compute(int callsSeed, String deviceId) {
|
||||||
|
final seed = _int64BigEndian(callsSeed);
|
||||||
|
final device = Uint8List.fromList(utf8.encode(deviceId));
|
||||||
|
final result = BytesBuilder();
|
||||||
|
result.add(_sha256(_signatureDigest, seed, device));
|
||||||
|
result.add(_sha256(_soDigest, seed, device));
|
||||||
|
result.add(_sha256(_dexDigest, seed, device));
|
||||||
|
return result.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> _sha256(Uint8List a, Uint8List b, Uint8List c) {
|
||||||
|
final builder = BytesBuilder()
|
||||||
|
..add(a)
|
||||||
|
..add(b)
|
||||||
|
..add(c);
|
||||||
|
return sha256.convert(builder.toBytes()).bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List _int64BigEndian(int value) {
|
||||||
|
final data = ByteData(8)..setInt64(0, value, Endian.big);
|
||||||
|
return data.buffer.asUint8List();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List _hex(String hex) {
|
||||||
|
final out = Uint8List(hex.length ~/ 2);
|
||||||
|
for (var i = 0; i < out.length; i++) {
|
||||||
|
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,19 +82,41 @@ String messageFromErrorPayload(dynamic payload) {
|
|||||||
return s.isNotEmpty ? s : 'Неизвестная ошибка';
|
return s.isNotEmpty ? s : 'Неизвестная ошибка';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Упаковка пакета для отправки на сервер
|
/// Payload меньше этого размера отправляется без сжатия (как в оригинале).
|
||||||
|
const int _compressionThreshold = 32;
|
||||||
|
|
||||||
|
/// Упаковка пакета для отправки на сервер.
|
||||||
|
///
|
||||||
|
/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold]
|
||||||
|
/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия:
|
||||||
|
/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по
|
||||||
|
/// которому получатель выделяет буфер под распаковку).
|
||||||
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
|
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
|
||||||
final header = ByteData(headerSize);
|
final Uint8List raw = msgpack.serialize(payload);
|
||||||
|
|
||||||
|
final List<int> body;
|
||||||
|
final int flag;
|
||||||
|
if (raw.length < _compressionThreshold) {
|
||||||
|
body = raw;
|
||||||
|
flag = 0;
|
||||||
|
} else {
|
||||||
|
body = lz4Compress(raw);
|
||||||
|
flag = (raw.length ~/ body.length) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final out = Uint8List(headerSize + body.length);
|
||||||
|
final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize);
|
||||||
header.setUint8(0, 10);
|
header.setUint8(0, 10);
|
||||||
header.setUint8(1, CmdType.request);
|
header.setUint8(1, CmdType.request);
|
||||||
header.setUint16(2, seq, Endian.big);
|
header.setUint16(2, seq, Endian.big);
|
||||||
header.setUint16(4, opcode, Endian.big);
|
header.setUint16(4, opcode, Endian.big);
|
||||||
|
header.setUint32(
|
||||||
final payloadBytes = msgpack.serialize(payload);
|
6,
|
||||||
final payloadLen = payloadBytes.length & 0xFFFFFF;
|
((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
|
||||||
header.setUint32(6, payloadLen, Endian.big);
|
Endian.big,
|
||||||
|
);
|
||||||
return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes);
|
out.setRange(headerSize, out.length, body);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Распаковка пакета от сервера
|
/// Распаковка пакета от сервера
|
||||||
|
|||||||
@@ -435,17 +435,20 @@ class AppDatabase {
|
|||||||
// Chats cache
|
// Chats cache
|
||||||
|
|
||||||
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
||||||
|
if (rows.isEmpty) return;
|
||||||
try {
|
try {
|
||||||
final db = await _instance;
|
final db = await _instance;
|
||||||
final batch = db.batch();
|
await db.transaction((txn) async {
|
||||||
for (final row in rows) {
|
final batch = txn.batch();
|
||||||
batch.insert(
|
for (final row in rows) {
|
||||||
'chats_cache',
|
batch.insert(
|
||||||
row,
|
'chats_cache',
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
row,
|
||||||
);
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
}
|
);
|
||||||
await batch.commit(noResult: true);
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e("Ошибка при сохранении чата: $e");
|
logger.e("Ошибка при сохранении чата: $e");
|
||||||
}
|
}
|
||||||
@@ -587,4 +590,33 @@ class AppDatabase {
|
|||||||
whereArgs: [accountId, chatId],
|
whereArgs: [accountId, chatId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> loadMessage(
|
||||||
|
int accountId,
|
||||||
|
int chatId,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final db = await _instance;
|
||||||
|
final rows = await db.query(
|
||||||
|
'messages',
|
||||||
|
where: 'account_id = ? AND chat_id = ? AND id = ?',
|
||||||
|
whereArgs: [accountId, chatId, messageId],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
if (rows.isEmpty) return null;
|
||||||
|
return rows.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteMessage(
|
||||||
|
int accountId,
|
||||||
|
int chatId,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final db = await _instance;
|
||||||
|
await db.delete(
|
||||||
|
'messages',
|
||||||
|
where: 'account_id = ? AND chat_id = ? AND id = ?',
|
||||||
|
whereArgs: [accountId, chatId, messageId],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class SpoofingService {
|
class SpoofingService {
|
||||||
static const String hardcodedAppVersion = '26.14.1';
|
static const String hardcodedAppVersion = '26.17.1';
|
||||||
static const int hardcodedBuildNumber = 6606;
|
static const int hardcodedBuildNumber = 6712;
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
|
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class TokenStorage {
|
|||||||
await prefs.setString(_activeAccountKey, accountId.toString());
|
await prefs.setString(_activeAccountKey, accountId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> clearActiveAccount() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_activeAccountKey);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<int?> getActiveAccountId() async {
|
static Future<int?> getActiveAccountId() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final val = prefs.getString(_activeAccountKey);
|
final val = prefs.getString(_activeAccountKey);
|
||||||
|
|||||||
@@ -7,46 +7,73 @@ import '../utils/logger.dart';
|
|||||||
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
|
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
|
||||||
class PacketReceiver {
|
class PacketReceiver {
|
||||||
Uint8List _buffer = Uint8List(0);
|
Uint8List _buffer = Uint8List(0);
|
||||||
|
int _start = 0;
|
||||||
|
int _end = 0;
|
||||||
|
|
||||||
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
||||||
|
|
||||||
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
|
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
|
||||||
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
|
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
|
||||||
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
|
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
|
||||||
|
///
|
||||||
|
/// Накопление идёт без перекопирования всего буфера на каждый чанк: целые
|
||||||
|
/// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается
|
||||||
|
/// сдвигом указателя `_start`, а не пересборкой буфера.
|
||||||
List<Uint8List> feed(Uint8List data) {
|
List<Uint8List> feed(Uint8List data) {
|
||||||
final newBuffer = Uint8List(_buffer.length + data.length);
|
_append(data);
|
||||||
newBuffer.setAll(0, _buffer);
|
|
||||||
newBuffer.setAll(_buffer.length, data);
|
|
||||||
_buffer = newBuffer;
|
|
||||||
|
|
||||||
if (_buffer.length > _maxBufferSize) {
|
if (_end - _start > _maxBufferSize) {
|
||||||
logger.e(
|
logger.e(
|
||||||
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
|
'PacketReceiver: переполнение буфера (${_end - _start} B), сброс',
|
||||||
);
|
);
|
||||||
reset();
|
reset();
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
final packets = <Uint8List>[];
|
final packets = <Uint8List>[];
|
||||||
while (_buffer.length >= headerSize) {
|
while (_end - _start >= headerSize) {
|
||||||
final bd = ByteData.view(
|
final bd = ByteData.view(
|
||||||
_buffer.buffer,
|
_buffer.buffer,
|
||||||
_buffer.offsetInBytes,
|
_buffer.offsetInBytes + _start,
|
||||||
headerSize,
|
headerSize,
|
||||||
);
|
);
|
||||||
final packedLen = bd.getUint32(6, Endian.big);
|
final packedLen = bd.getUint32(6, Endian.big);
|
||||||
final payloadLength = packedLen & 0xFFFFFF;
|
final payloadLength = packedLen & 0xFFFFFF;
|
||||||
final totalLength = headerSize + payloadLength;
|
final totalLength = headerSize + payloadLength;
|
||||||
|
|
||||||
if (_buffer.length < totalLength) break;
|
if (_end - _start < totalLength) break;
|
||||||
|
|
||||||
packets.add(Uint8List.sublistView(_buffer, 0, totalLength));
|
packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength));
|
||||||
_buffer = _buffer.sublist(totalLength);
|
_start += totalLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_start == _end) {
|
||||||
|
_start = 0;
|
||||||
|
_end = 0;
|
||||||
}
|
}
|
||||||
return packets;
|
return packets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _append(Uint8List data) {
|
||||||
|
final pending = _end - _start;
|
||||||
|
if (pending == 0) {
|
||||||
|
_buffer = Uint8List.fromList(data);
|
||||||
|
_start = 0;
|
||||||
|
_end = data.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final total = pending + data.length;
|
||||||
|
final newBuffer = Uint8List(total);
|
||||||
|
newBuffer.setRange(0, pending, _buffer, _start);
|
||||||
|
newBuffer.setRange(pending, total, data);
|
||||||
|
_buffer = newBuffer;
|
||||||
|
_start = 0;
|
||||||
|
_end = total;
|
||||||
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
_buffer = Uint8List(0);
|
_buffer = Uint8List(0);
|
||||||
|
_start = 0;
|
||||||
|
_end = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Прогресс активных загрузок вложений, ключ — имя в кэше.
|
||||||
|
///
|
||||||
|
/// Значение: `null` — не загружается; `0..1` — доля загруженного.
|
||||||
|
class MediaDownloadProgress {
|
||||||
|
static final Map<String, ValueNotifier<double?>> _notifiers = {};
|
||||||
|
|
||||||
|
static ValueNotifier<double?> notifier(String key) =>
|
||||||
|
_notifiers.putIfAbsent(key, () => ValueNotifier<double?>(null));
|
||||||
|
|
||||||
|
static void set(String key, double? value) {
|
||||||
|
notifier(key).value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
|
||||||
|
import 'media_cache.dart';
|
||||||
|
|
||||||
|
class FileDownloadResult {
|
||||||
|
final bool ok;
|
||||||
|
final String? path;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const FileDownloadResult({required this.ok, this.path, this.error});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает файл из кэша, скачивая его при отсутствии.
|
||||||
|
///
|
||||||
|
/// [cacheName] — стабильное имя в кэше (например, `<fileId>_имя.ext`).
|
||||||
|
/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше,
|
||||||
|
/// чтобы не дёргать сервер за временной ссылкой повторно.
|
||||||
|
Future<FileDownloadResult> openCachedFile(
|
||||||
|
String cacheName,
|
||||||
|
Future<String?> Function() resolveUrl, {
|
||||||
|
void Function(double progress)? onProgress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
var file = await MediaCache.existing(cacheName);
|
||||||
|
|
||||||
|
if (file == null) {
|
||||||
|
final url = await resolveUrl();
|
||||||
|
if (url == null || url.isEmpty) {
|
||||||
|
return const FileDownloadResult(ok: false, error: 'нет ссылки');
|
||||||
|
}
|
||||||
|
file = await MediaCache.getOrDownload(
|
||||||
|
cacheName,
|
||||||
|
url,
|
||||||
|
onProgress: onProgress,
|
||||||
|
);
|
||||||
|
if (file == null) {
|
||||||
|
return const FileDownloadResult(ok: false, error: 'ошибка загрузки');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final opened = await OpenFilex.open(file.path);
|
||||||
|
return FileDownloadResult(
|
||||||
|
ok: opened.type == ResultType.done,
|
||||||
|
path: file.path,
|
||||||
|
error: opened.type == ResultType.done ? null : opened.message,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return FileDownloadResult(ok: false, error: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
const int _avatarMaxDimension = 1024;
|
||||||
|
const int _avatarTargetBytes = 900 * 1024;
|
||||||
|
|
||||||
|
Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input);
|
||||||
|
|
||||||
|
Uint8List? _encodeAvatar(Uint8List input) {
|
||||||
|
final decoded = img.decodeImage(input);
|
||||||
|
if (decoded == null) return null;
|
||||||
|
final oriented = img.bakeOrientation(decoded);
|
||||||
|
final image = oriented.width > _avatarMaxDimension || oriented.height > _avatarMaxDimension
|
||||||
|
? img.copyResize(
|
||||||
|
oriented,
|
||||||
|
width: oriented.width >= oriented.height ? _avatarMaxDimension : null,
|
||||||
|
height: oriented.height > oriented.width ? _avatarMaxDimension : null,
|
||||||
|
interpolation: img.Interpolation.average,
|
||||||
|
)
|
||||||
|
: oriented;
|
||||||
|
var quality = 88;
|
||||||
|
var out = img.encodeJpg(image, quality: quality);
|
||||||
|
while (out.lengthInBytes > _avatarTargetBytes && quality > 35) {
|
||||||
|
quality -= 12;
|
||||||
|
out = img.encodeJpg(image, quality: quality);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../config/app_media_cache.dart';
|
||||||
|
|
||||||
|
/// Постоянный дисковый кэш скачанных медиа (файлы, видео).
|
||||||
|
///
|
||||||
|
/// Хранит файлы в `<appSupport>/media_cache/` под детерминированным именем
|
||||||
|
/// (обычно по id вложения), чтобы повторные открытия не качали заново.
|
||||||
|
class MediaCache {
|
||||||
|
/// Максимальный размер кэша (настраивается в дев-меню); при превышении
|
||||||
|
/// вытесняются старые файлы (LRU).
|
||||||
|
static int get maxBytes => AppMediaCacheLimit.current.value;
|
||||||
|
|
||||||
|
static Directory? _dir;
|
||||||
|
static int? _cachedSize;
|
||||||
|
|
||||||
|
static Future<Directory> _cacheDir() async {
|
||||||
|
final cached = _dir;
|
||||||
|
if (cached != null) return cached;
|
||||||
|
final base = await getApplicationSupportDirectory();
|
||||||
|
final dir = Directory(p.join(base.path, 'media_cache'));
|
||||||
|
if (!await dir.exists()) {
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
}
|
||||||
|
_dir = dir;
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Путь к кэш-файлу с именем [name] (файл может ещё не существовать).
|
||||||
|
static Future<File> fileFor(String name) async {
|
||||||
|
final dir = await _cacheDir();
|
||||||
|
return File(p.join(dir.path, _sanitize(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Существует ли непустой кэш-файл [name].
|
||||||
|
///
|
||||||
|
/// При попадании обновляет mtime файла — это делает вытеснение LRU
|
||||||
|
/// (часто используемые файлы переживают очистку).
|
||||||
|
static Future<File?> existing(String name) async {
|
||||||
|
final file = await fileFor(name);
|
||||||
|
if (await file.exists() && await file.length() > 0) {
|
||||||
|
try {
|
||||||
|
await file.setLastModified(DateTime.now());
|
||||||
|
} catch (_) {}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает кэш-файл [name], скачивая [url] при отсутствии.
|
||||||
|
///
|
||||||
|
/// Загрузка идёт во временный `.part` и переименовывается атомарно —
|
||||||
|
/// прерванная закачка не считается валидным кэшем.
|
||||||
|
static Future<File?> getOrDownload(
|
||||||
|
String name,
|
||||||
|
String url, {
|
||||||
|
void Function(double progress)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final existingFile = await existing(name);
|
||||||
|
if (existingFile != null) return existingFile;
|
||||||
|
|
||||||
|
final file = await fileFor(name);
|
||||||
|
final part = File('${file.path}.part');
|
||||||
|
final client = HttpClient();
|
||||||
|
try {
|
||||||
|
final request = await client.getUrl(Uri.parse(url));
|
||||||
|
final response = await request.close();
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
|
|
||||||
|
final total = response.contentLength;
|
||||||
|
var received = 0;
|
||||||
|
final sink = part.openWrite();
|
||||||
|
await for (final chunk in response) {
|
||||||
|
received += chunk.length;
|
||||||
|
sink.add(chunk);
|
||||||
|
if (onProgress != null && total > 0) {
|
||||||
|
onProgress(received / total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sink.close();
|
||||||
|
await part.rename(file.path);
|
||||||
|
final known = _cachedSize;
|
||||||
|
if (known != null) {
|
||||||
|
try {
|
||||||
|
_cachedSize = known + await file.length();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await _enforceLimit();
|
||||||
|
return file;
|
||||||
|
} catch (_) {
|
||||||
|
if (await part.exists()) {
|
||||||
|
try {
|
||||||
|
await part.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Суммарный размер кэша в байтах.
|
||||||
|
///
|
||||||
|
/// Результат держится в памяти и поддерживается инкрементально при
|
||||||
|
/// загрузке/очистке/вытеснении — повторные вызовы не пересканируют каталог.
|
||||||
|
static Future<int> currentSize() async {
|
||||||
|
final cached = _cachedSize;
|
||||||
|
if (cached != null) return cached;
|
||||||
|
final total = await _scanSize();
|
||||||
|
_cachedSize = total;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<int> _scanSize() async {
|
||||||
|
final dir = await _cacheDir();
|
||||||
|
var total = 0;
|
||||||
|
await for (final entity in dir.list()) {
|
||||||
|
if (entity is File) {
|
||||||
|
try {
|
||||||
|
total += await entity.length();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Полностью очищает кэш. Возвращает число удалённых байт.
|
||||||
|
static Future<int> clear() async {
|
||||||
|
final dir = await _cacheDir();
|
||||||
|
var freed = 0;
|
||||||
|
await for (final entity in dir.list()) {
|
||||||
|
if (entity is File) {
|
||||||
|
try {
|
||||||
|
freed += await entity.length();
|
||||||
|
await entity.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_cachedSize = 0;
|
||||||
|
return freed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes].
|
||||||
|
///
|
||||||
|
/// Под лимитом — ранний выход без сканирования каталога (частый случай).
|
||||||
|
/// Каталог обходится только когда лимит реально превышен.
|
||||||
|
static Future<void> _enforceLimit() async {
|
||||||
|
final limit = maxBytes;
|
||||||
|
if (limit <= 0) return;
|
||||||
|
|
||||||
|
var total = _cachedSize ?? await _scanSize();
|
||||||
|
if (total <= limit) {
|
||||||
|
_cachedSize = total;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dir = await _cacheDir();
|
||||||
|
final files = <File>[];
|
||||||
|
await for (final entity in dir.list()) {
|
||||||
|
if (entity is File && !entity.path.endsWith('.part')) {
|
||||||
|
files.add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort((a, b) =>
|
||||||
|
a.statSync().modified.compareTo(b.statSync().modified));
|
||||||
|
|
||||||
|
for (final file in files) {
|
||||||
|
if (total <= limit) break;
|
||||||
|
try {
|
||||||
|
total -= await file.length();
|
||||||
|
await file.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
_cachedSize = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _sanitize(String name) {
|
||||||
|
final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
||||||
|
return cleaned.isEmpty ? 'file' : cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:komet/l10n/app_localizations.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'password_2fa_screen.dart';
|
import 'password_2fa_screen.dart';
|
||||||
|
import 'registration_screen.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/login_success_screen.dart';
|
import '../../widgets/login_success_screen.dart';
|
||||||
@@ -167,6 +168,20 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.isRegistration) {
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => RegistrationScreen(
|
||||||
|
phoneNumber: widget.phoneNumber,
|
||||||
|
registerToken: result.registerToken!,
|
||||||
|
presetAvatars: result.presetAvatars,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final loginResult = await accountModule.login();
|
final loginResult = await accountModule.login();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -183,10 +198,8 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
|||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 240),
|
transitionDuration: const Duration(milliseconds: 240),
|
||||||
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
||||||
transitionsBuilder: (_, animation, __, child) => FadeTransition(
|
transitionsBuilder: (_, animation, __, child) =>
|
||||||
opacity: animation,
|
FadeTransition(opacity: animation, child: child),
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(route) => false,
|
(route) => false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ import 'select_country_screen.dart';
|
|||||||
import 'proxy_settings_sheet.dart';
|
import 'proxy_settings_sheet.dart';
|
||||||
import 'server_settings_sheet.dart';
|
import 'server_settings_sheet.dart';
|
||||||
import '../profile/spoof_screen.dart';
|
import '../profile/spoof_screen.dart';
|
||||||
|
import '../profile/debug_menu_screen.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
|
import '../../widgets/adaptive_shell.dart';
|
||||||
|
import '../../../backend/api.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
final int? returnToAccountId;
|
||||||
|
|
||||||
|
const LoginScreen({super.key, this.returnToAccountId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LoginScreen> createState() => _LoginScreenState();
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
@@ -30,15 +35,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
bool _isTOSRead = false;
|
bool _isTOSRead = false;
|
||||||
String? _phoneError;
|
String? _phoneError;
|
||||||
Timer? _phoneErrorTimer;
|
Timer? _phoneErrorTimer;
|
||||||
|
int _logoTapCount = 0;
|
||||||
|
Timer? _logoTapTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
if (api.state == SessionState.disconnected) {
|
||||||
|
unawaited(api.connect());
|
||||||
|
}
|
||||||
_selectedCountry = countriesByCode['RU'] ?? allCountries.first;
|
_selectedCountry = countriesByCode['RU'] ?? allCountries.first;
|
||||||
_clampCountryToAllowed();
|
_clampCountryToAllowed();
|
||||||
_checkTOS();
|
_checkTOS();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onBackPressed() async {
|
||||||
|
final returnId = widget.returnToAccountId;
|
||||||
|
if (returnId != null) {
|
||||||
|
try {
|
||||||
|
await accountModule.switchAccount(returnId);
|
||||||
|
} catch (_) {}
|
||||||
|
if (!mounted) return;
|
||||||
|
await Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const AdaptiveShell()),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Navigator.canPop(context)) Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
void _clampCountryToAllowed() {
|
void _clampCountryToAllowed() {
|
||||||
final allowed = api.registrationCountries;
|
final allowed = api.registrationCountries;
|
||||||
if (allowed.any((c) => c.code == _selectedCountry.code)) return;
|
if (allowed.any((c) => c.code == _selectedCountry.code)) return;
|
||||||
@@ -51,6 +77,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_phoneErrorTimer?.cancel();
|
_phoneErrorTimer?.cancel();
|
||||||
|
_logoTapTimer?.cancel();
|
||||||
_phoneController.dispose();
|
_phoneController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -64,6 +91,22 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onLogoTap() {
|
||||||
|
_logoTapTimer?.cancel();
|
||||||
|
_logoTapTimer = Timer(const Duration(milliseconds: 600), () {
|
||||||
|
_logoTapCount = 0;
|
||||||
|
});
|
||||||
|
_logoTapCount++;
|
||||||
|
if (_logoTapCount >= 7) {
|
||||||
|
_logoTapTimer?.cancel();
|
||||||
|
_logoTapCount = 0;
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const DebugMenuScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _markTOSRead() async {
|
Future<void> _markTOSRead() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('IsReadeTOS', true);
|
await prefs.setBool('IsReadeTOS', true);
|
||||||
@@ -666,9 +709,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
if (Navigator.canPop(context))
|
if (Navigator.canPop(context) ||
|
||||||
|
widget.returnToAccountId != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: _onBackPressed,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Symbols.arrow_back,
|
Symbols.arrow_back,
|
||||||
color: cs.onSurfaceVariant,
|
color: cs.onSurfaceVariant,
|
||||||
@@ -704,10 +748,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
Center(
|
Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Image.asset(
|
GestureDetector(
|
||||||
'assets/komet.png',
|
behavior: HitTestBehavior.opaque,
|
||||||
height: 80,
|
onTap: _onLogoTap,
|
||||||
color: cs.onSurface,
|
child: Image.asset(
|
||||||
|
'assets/komet.png',
|
||||||
|
height: 80,
|
||||||
|
color: cs.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -56,10 +56,8 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
|||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 240),
|
transitionDuration: const Duration(milliseconds: 240),
|
||||||
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
||||||
transitionsBuilder: (_, animation, __, child) => FadeTransition(
|
transitionsBuilder: (_, animation, __, child) =>
|
||||||
opacity: animation,
|
FadeTransition(opacity: animation, child: child),
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(route) => false,
|
(route) => false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:komet/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
import '../../../backend/modules/account.dart';
|
||||||
|
import '../../../main.dart';
|
||||||
|
import '../../widgets/custom_notification.dart';
|
||||||
|
import '../../widgets/login_success_screen.dart';
|
||||||
|
|
||||||
|
class RegistrationScreen extends StatefulWidget {
|
||||||
|
final String phoneNumber;
|
||||||
|
final String registerToken;
|
||||||
|
final List<PresetAvatarCategory> presetAvatars;
|
||||||
|
|
||||||
|
const RegistrationScreen({
|
||||||
|
super.key,
|
||||||
|
required this.phoneNumber,
|
||||||
|
required this.registerToken,
|
||||||
|
required this.presetAvatars,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RegistrationScreen> createState() => _RegistrationScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegistrationScreenState extends State<RegistrationScreen> {
|
||||||
|
final TextEditingController _firstNameController = TextEditingController();
|
||||||
|
final TextEditingController _lastNameController = TextEditingController();
|
||||||
|
|
||||||
|
int? _selectedPhotoId;
|
||||||
|
String? _selectedAvatarUrl;
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_firstNameController.dispose();
|
||||||
|
_lastNameController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canSubmit =>
|
||||||
|
!_isSubmitting && _firstNameController.text.trim().isNotEmpty;
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final firstName = _firstNameController.text.trim();
|
||||||
|
if (firstName.isEmpty) return;
|
||||||
|
final lastName = _lastNameController.text.trim();
|
||||||
|
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
try {
|
||||||
|
final accountId = await accountModule.completeRegistration(
|
||||||
|
token: widget.registerToken,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName.isEmpty ? null : lastName,
|
||||||
|
photoId: _selectedPhotoId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final loginResult = await accountModule.login(
|
||||||
|
accountId: accountId,
|
||||||
|
token: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final avatar = await precacheLoginAvatar(
|
||||||
|
context,
|
||||||
|
loginResult.profile.baseUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
Navigator.pushAndRemoveUntil(
|
||||||
|
context,
|
||||||
|
PageRouteBuilder(
|
||||||
|
transitionDuration: const Duration(milliseconds: 240),
|
||||||
|
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
||||||
|
transitionsBuilder: (_, animation, __, child) =>
|
||||||
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
showCustomNotification(context, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final firstName = _firstNameController.text.trim();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: cs.surface,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant),
|
||||||
|
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _canSubmit ? _submit : null,
|
||||||
|
backgroundColor: _canSubmit
|
||||||
|
? cs.primaryContainer
|
||||||
|
: cs.surfaceContainerHighest,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
child: _isSubmitting
|
||||||
|
? SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.arrow_forward,
|
||||||
|
color: _canSubmit
|
||||||
|
? cs.onPrimaryContainer
|
||||||
|
: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 96),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.registrationTitle,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.registrationSubtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: cs.primary.withValues(alpha: 0.5),
|
||||||
|
width: 2.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: _selectedAvatarUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: _selectedAvatarUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: cs.primaryContainer,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
firstName.isNotEmpty
|
||||||
|
? firstName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onPrimaryContainer,
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_buildTextField(
|
||||||
|
cs,
|
||||||
|
label: l10n.editProfileFirstName,
|
||||||
|
controller: _firstNameController,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_buildTextField(
|
||||||
|
cs,
|
||||||
|
label: l10n.editProfileLastName,
|
||||||
|
controller: _lastNameController,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
),
|
||||||
|
if (widget.presetAvatars.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
Text(
|
||||||
|
l10n.registrationChooseAvatar,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
for (final category in widget.presetAvatars)
|
||||||
|
_buildAvatarCategory(cs, category),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField(
|
||||||
|
ColorScheme cs, {
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required TextInputAction textInputAction,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
enabled: !_isSubmitting,
|
||||||
|
textInputAction: textInputAction,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: cs.surfaceContainerHigh,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatarCategory(ColorScheme cs, PresetAvatarCategory category) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (category.name.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
||||||
|
child: Text(
|
||||||
|
category.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 64,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: category.avatars.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final avatar = category.avatars[index];
|
||||||
|
final selected = _selectedPhotoId == avatar.id;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () => setState(() {
|
||||||
|
_selectedPhotoId = avatar.id;
|
||||||
|
_selectedAvatarUrl = avatar.url;
|
||||||
|
}),
|
||||||
|
child: Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? cs.primary : Colors.transparent,
|
||||||
|
width: 2.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: ClipOval(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: avatar.url,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
enum CallScreenState { incoming, outgoing, active }
|
||||||
|
|
||||||
|
class CallScreen extends StatefulWidget {
|
||||||
|
final String name;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final CallScreenState initialState;
|
||||||
|
|
||||||
|
const CallScreen({
|
||||||
|
super.key,
|
||||||
|
required this.name,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.initialState = CallScreenState.incoming,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CallScreen> createState() => _CallScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallScreenState extends State<CallScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late CallScreenState _state;
|
||||||
|
Timer? _timer;
|
||||||
|
int _seconds = 0;
|
||||||
|
bool _isMuted = false;
|
||||||
|
bool _isSpeaker = false;
|
||||||
|
late AnimationController _pulseController;
|
||||||
|
late Animation<double> _pulseAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_state = widget.initialState;
|
||||||
|
_pulseController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1500),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
|
||||||
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
|
if (_state == CallScreenState.outgoing) {
|
||||||
|
_startOutgoingTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startOutgoingTimer() {
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _seconds++);
|
||||||
|
if (_seconds >= 3 && _state == CallScreenState.outgoing) {
|
||||||
|
_timer?.cancel();
|
||||||
|
setState(() => _state = CallScreenState.active);
|
||||||
|
_startActiveTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startActiveTimer() {
|
||||||
|
_seconds = 0;
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _seconds++);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _timerText {
|
||||||
|
final m = (_seconds ~/ 60).toString().padLeft(2, '0');
|
||||||
|
final s = (_seconds % 60).toString().padLeft(2, '0');
|
||||||
|
return '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _accept() {
|
||||||
|
setState(() {
|
||||||
|
_state = CallScreenState.active;
|
||||||
|
_seconds = 0;
|
||||||
|
});
|
||||||
|
_startActiveTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _endCall() {
|
||||||
|
_timer?.cancel();
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_pulseController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenH = MediaQuery.of(context).size.height;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0E0E14),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Spacer(flex: 3),
|
||||||
|
_buildAvatar(screenH),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildName(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildStatus(),
|
||||||
|
const Spacer(flex: 2),
|
||||||
|
_buildActions(),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar(double screenH) {
|
||||||
|
final size = screenH * 0.18;
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final isRinging = _state == CallScreenState.incoming;
|
||||||
|
final isOutgoing = _state == CallScreenState.outgoing;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _pulseAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final scale = (isRinging || isOutgoing)
|
||||||
|
? _pulseAnimation.value
|
||||||
|
: 1.0;
|
||||||
|
return Transform.scale(
|
||||||
|
scale: scale,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: cs.primaryContainer.withValues(alpha: 0.2),
|
||||||
|
border: Border.all(
|
||||||
|
color: cs.primary.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: widget.avatarUrl != null && widget.avatarUrl!.isNotEmpty
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: widget.avatarUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 360,
|
||||||
|
memCacheHeight: 360,
|
||||||
|
errorWidget: (_, _, _) => _fallbackAvatar(size),
|
||||||
|
)
|
||||||
|
: _fallbackAvatar(size),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _fallbackAvatar(double size) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: cs.primaryContainer,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onPrimaryContainer,
|
||||||
|
fontSize: size * 0.4,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildName() {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Text(
|
||||||
|
widget.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatus() {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
String text;
|
||||||
|
switch (_state) {
|
||||||
|
case CallScreenState.incoming:
|
||||||
|
text = 'Входящий звонок';
|
||||||
|
case CallScreenState.outgoing:
|
||||||
|
text = 'Вызов...';
|
||||||
|
case CallScreenState.active:
|
||||||
|
text = _timerText;
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActions() {
|
||||||
|
switch (_state) {
|
||||||
|
case CallScreenState.incoming:
|
||||||
|
return _buildIncomingActions();
|
||||||
|
case CallScreenState.outgoing:
|
||||||
|
return _buildOutgoingActions();
|
||||||
|
case CallScreenState.active:
|
||||||
|
return _buildActiveActions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIncomingActions() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_ActionButton(
|
||||||
|
icon: Symbols.phone_disabled,
|
||||||
|
label: 'Отклонить',
|
||||||
|
color: const Color(0xFFBA1A1A),
|
||||||
|
onTap: _endCall,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 48),
|
||||||
|
_ActionButton(
|
||||||
|
icon: Symbols.phone,
|
||||||
|
label: 'Принять',
|
||||||
|
color: const Color(0xFF3A691E),
|
||||||
|
onTap: _accept,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOutgoingActions() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_ActionButton(
|
||||||
|
icon: Symbols.phone_disabled,
|
||||||
|
label: 'Отмена',
|
||||||
|
color: const Color(0xFFBA1A1A),
|
||||||
|
onTap: _endCall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActiveActions() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_CircleActionButton(
|
||||||
|
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
|
||||||
|
active: _isMuted,
|
||||||
|
onTap: () => setState(() => _isMuted = !_isMuted),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 32),
|
||||||
|
_CircleActionButton(
|
||||||
|
icon: _isMuted ? Symbols.volume_off : Symbols.volume_up,
|
||||||
|
active: _isSpeaker,
|
||||||
|
onTap: () => setState(() => _isSpeaker = !_isSpeaker),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 32),
|
||||||
|
_CircleActionButton(
|
||||||
|
icon: Symbols.bluetooth_audio,
|
||||||
|
active: false,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
_ActionButton(
|
||||||
|
icon: Symbols.phone_disabled,
|
||||||
|
label: 'Завершить',
|
||||||
|
color: const Color(0xFFBA1A1A),
|
||||||
|
onTap: _endCall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ActionButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(icon, color: Colors.white, size: 28, fill: 1),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CircleActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final bool active;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _CircleActionButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.active,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: active
|
||||||
|
? Colors.white.withValues(alpha: 0.2)
|
||||||
|
: Colors.white.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: active ? Colors.white : Colors.white70,
|
||||||
|
size: 24,
|
||||||
|
fill: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,8 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||||
import '../../../core/protocol/opcode_map.dart';
|
import '../../../core/cache/info_cache.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../main.dart' as main;
|
|
||||||
|
|
||||||
class _MemberInfo {
|
class _MemberInfo {
|
||||||
final int id;
|
final int id;
|
||||||
@@ -96,21 +95,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
final profile = await AppDatabase.loadActiveProfile();
|
final profile = await AppDatabase.loadActiveProfile();
|
||||||
_myId = profile?.id ?? 0;
|
_myId = profile?.id ?? 0;
|
||||||
|
|
||||||
final packet = await main.api.sendRequest(
|
final info = await ChatInfoFetch.get(widget.chatId);
|
||||||
Opcode.chatInfo,
|
if (!mounted) return;
|
||||||
{'chatIds': [widget.chatId]},
|
if (info == null) {
|
||||||
);
|
setState(() => _isLoading = false);
|
||||||
if (!packet.isOk || !mounted) {
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_chatData = info;
|
||||||
final chats = (packet.payload as Map?)?['chats'] as List?;
|
|
||||||
if (chats == null || chats.isEmpty) {
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_chatData = Map<String, dynamic>.from(chats.first as Map);
|
|
||||||
|
|
||||||
if (widget.chatType == 'DIALOG') {
|
if (widget.chatType == 'DIALOG') {
|
||||||
final parts = _chatData!['participants'] as Map? ?? {};
|
final parts = _chatData!['participants'] as Map? ?? {};
|
||||||
@@ -123,32 +114,19 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_otherId != null) {
|
if (_otherId != null) {
|
||||||
final cp = await main.api.sendRequest(
|
final contact = await ContactInfoFetch.get(_otherId!);
|
||||||
Opcode.contactInfo,
|
if (contact != null) {
|
||||||
{'contactIds': [_otherId]},
|
_contactData = contact;
|
||||||
);
|
final opts = _contactData!['options'];
|
||||||
if (cp.isOk) {
|
_isBot = (opts is List) && opts.contains('BOT');
|
||||||
final contacts = (cp.payload as Map?)?['contacts'] as List?;
|
|
||||||
if (contacts != null && contacts.isNotEmpty) {
|
|
||||||
_contactData = Map<String, dynamic>.from(contacts.first as Map);
|
|
||||||
final opts = _contactData!['options'];
|
|
||||||
_isBot = (opts is List) && opts.contains('BOT');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final pp = await main.api.sendRequest(
|
final presence = await PresenceFetch.get(_otherId!);
|
||||||
Opcode.contactPresence,
|
if (presence != null) {
|
||||||
{'contactIds': [_otherId]},
|
_seenTime = presence['seen'] as int?;
|
||||||
);
|
final st = (presence['status'] as int?) ?? 0;
|
||||||
if (pp.isOk) {
|
_presenceStatus = st;
|
||||||
final presence = (pp.payload as Map?)?['presence'] as Map?;
|
_isOnline = st == 1;
|
||||||
final p = presence?[_otherId.toString()] ?? presence?[_otherId];
|
|
||||||
if (p is Map) {
|
|
||||||
_seenTime = p['seen'] as int?;
|
|
||||||
final st = (p['status'] as int?) ?? 0;
|
|
||||||
_presenceStatus = st;
|
|
||||||
_isOnline = st == 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (widget.chatType == 'CHAT') {
|
} else if (widget.chatType == 'CHAT') {
|
||||||
@@ -162,25 +140,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
|||||||
if (id != null) memberIds.add(id);
|
if (id != null) memberIds.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<int, Map> presenceMap = {};
|
Map<int, Map<String, dynamic>> presenceMap = {};
|
||||||
if (memberIds.isNotEmpty) {
|
if (memberIds.isNotEmpty) {
|
||||||
final pp = await main.api.sendRequest(
|
presenceMap = await PresenceFetch.getMany(memberIds);
|
||||||
Opcode.contactPresence,
|
|
||||||
{'contactIds': memberIds},
|
|
||||||
);
|
|
||||||
if (pp.isOk) {
|
|
||||||
final presence = (pp.payload as Map?)?['presence'] as Map?;
|
|
||||||
if (presence != null) {
|
|
||||||
for (final e in presence.entries) {
|
|
||||||
final id = e.key is int
|
|
||||||
? e.key as int
|
|
||||||
: int.tryParse(e.key.toString());
|
|
||||||
if (id != null && e.value is Map) {
|
|
||||||
presenceMap[id] = e.value as Map;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onlineCount = 0;
|
_onlineCount = 0;
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ import '../auth/login_screen.dart';
|
|||||||
import '../../widgets/account_switcher_overlay.dart';
|
import '../../widgets/account_switcher_overlay.dart';
|
||||||
import '../../../backend/api.dart';
|
import '../../../backend/api.dart';
|
||||||
import '../../../core/utils/haptics.dart';
|
import '../../../core/utils/haptics.dart';
|
||||||
|
import '../../../core/config/app_stories.dart';
|
||||||
import '../../../backend/models/chat_folder.dart';
|
import '../../../backend/models/chat_folder.dart';
|
||||||
import '../../../backend/modules/account.dart';
|
import '../../../backend/modules/account.dart';
|
||||||
import '../../../backend/modules/chats.dart';
|
import '../../../backend/modules/chats.dart';
|
||||||
import '../../../backend/modules/cloud_storage.dart';
|
import '../../../backend/modules/cloud_storage.dart';
|
||||||
import '../../../backend/modules/folders.dart';
|
import '../../../backend/modules/folders.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../main.dart' show accountModule, api, messagesModule;
|
import '../../../core/storage/token_storage.dart';
|
||||||
|
import '../../../main.dart'
|
||||||
|
show accountModule, api, messagesModule, appRouteObserver;
|
||||||
|
|
||||||
class _StoriesScrollPhysics extends BouncingScrollPhysics {
|
class _StoriesScrollPhysics extends BouncingScrollPhysics {
|
||||||
final bool Function() blockPositive;
|
final bool Function() blockPositive;
|
||||||
@@ -72,7 +75,7 @@ class ChatListScreen extends StatefulWidget {
|
|||||||
enum _DeleteKind { personalLike, ownerGroup, blocked }
|
enum _DeleteKind { personalLike, ownerGroup, blocked }
|
||||||
|
|
||||||
class _ChatListScreenState extends State<ChatListScreen>
|
class _ChatListScreenState extends State<ChatListScreen>
|
||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin, RouteAware {
|
||||||
String? _selectedFolderId;
|
String? _selectedFolderId;
|
||||||
|
|
||||||
List<ChatFolder> _folders = [];
|
List<ChatFolder> _folders = [];
|
||||||
@@ -81,7 +84,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
double _navPageAnimStart = 0;
|
double _navPageAnimStart = 0;
|
||||||
double _navPageAnimEnd = 0;
|
double _navPageAnimEnd = 0;
|
||||||
double _navDragDx = 0;
|
final ValueNotifier<double> _navDragDx = ValueNotifier(0);
|
||||||
double _navDragBaseLeft = 0;
|
double _navDragBaseLeft = 0;
|
||||||
double _revealAnimBegin = 0.0;
|
double _revealAnimBegin = 0.0;
|
||||||
double _closeAnimBegin = 0.0;
|
double _closeAnimBegin = 0.0;
|
||||||
@@ -100,9 +103,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
bool _navDragging = false;
|
bool _navDragging = false;
|
||||||
bool _isFabOpen = false;
|
bool _isFabOpen = false;
|
||||||
bool _showCacheWarning = false;
|
|
||||||
bool _storiesAnimClosing = false;
|
bool _storiesAnimClosing = false;
|
||||||
Timer? _contactRebuildTimer;
|
Timer? _contactRebuildTimer;
|
||||||
|
bool _deferReloads = false;
|
||||||
|
bool _reloadQueued = false;
|
||||||
|
Timer? _settleTimer;
|
||||||
bool get _isSelectionMode => _selectedChats.isNotEmpty;
|
bool get _isSelectionMode => _selectedChats.isNotEmpty;
|
||||||
bool? _foldersListKnown;
|
bool? _foldersListKnown;
|
||||||
|
|
||||||
@@ -140,11 +145,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
_selectedFolderId,
|
_selectedFolderId,
|
||||||
_isInitialLoading,
|
_isInitialLoading,
|
||||||
_foldersListKnown,
|
_foldersListKnown,
|
||||||
_showCacheWarning,
|
|
||||||
_isSelectionMode,
|
_isSelectionMode,
|
||||||
_shouldCollapseSearch,
|
_shouldCollapseSearch,
|
||||||
_selectedChats.length,
|
_selectedChats.length,
|
||||||
_pullRatio,
|
|
||||||
_storiesDockedOpen,
|
_storiesDockedOpen,
|
||||||
_storiesAnimClosing,
|
_storiesAnimClosing,
|
||||||
_storiesOverscrollRevealArmed,
|
_storiesOverscrollRevealArmed,
|
||||||
@@ -180,8 +183,12 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
List<CachedChat> _selectedChatObjects() {
|
List<CachedChat> _selectedChatObjects() {
|
||||||
if (_selectedChats.isEmpty) return const [];
|
if (_selectedChats.isEmpty) return const [];
|
||||||
final ids = _selectedChats;
|
final ids = <int>{};
|
||||||
return _chats.where((c) => ids.contains(c.id.toString())).toList();
|
for (final s in _selectedChats) {
|
||||||
|
final v = int.tryParse(s);
|
||||||
|
if (v != null) ids.add(v);
|
||||||
|
}
|
||||||
|
return _chats.where((c) => ids.contains(c.id)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
_DeleteKind _categorizeChat(CachedChat c, int myId) {
|
_DeleteKind _categorizeChat(CachedChat c, int myId) {
|
||||||
@@ -404,6 +411,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _allowStoriesPullOverscrollTop() {
|
bool _allowStoriesPullOverscrollTop() {
|
||||||
|
if (!AppStories.current.value) return false;
|
||||||
if (_storiesDockedOpen ||
|
if (_storiesDockedOpen ||
|
||||||
_storiesRevealController.isAnimating ||
|
_storiesRevealController.isAnimating ||
|
||||||
_pullRatio > 0) {
|
_pullRatio > 0) {
|
||||||
@@ -447,30 +455,73 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sessionState = state;
|
_sessionState = state;
|
||||||
if (state == SessionState.disconnected && _chats.isNotEmpty) {
|
|
||||||
_showCacheWarning = true;
|
|
||||||
}
|
|
||||||
if (state == SessionState.online) {
|
|
||||||
_showCacheWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (state == SessionState.online) {
|
if (state == SessionState.online) {
|
||||||
_reloadChatsAndFolders();
|
_requestReload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_loginSub = accountModule.loginStatusStream.listen((status) {
|
_loginSub = accountModule.loginStatusStream.listen((status) {
|
||||||
if (status == LoginStatus.success) {
|
if (status == LoginStatus.success) {
|
||||||
_reloadChatsAndFolders();
|
_requestReload();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ChatsModule.chatsChanged.addListener(_onChatsChanged);
|
ChatsModule.chatsChanged.addListener(_onChatsChanged);
|
||||||
|
AppStories.current.addListener(_onStoriesEnabledChanged);
|
||||||
|
_reloadChatsAndFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStoriesEnabledChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!AppStories.current.value) {
|
||||||
|
_storiesRevealController.stop();
|
||||||
|
_pullRatio = 0;
|
||||||
|
_storiesDockedOpen = false;
|
||||||
|
_storiesAnimClosing = false;
|
||||||
|
_storiesOverscrollRevealArmed = false;
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
final route = ModalRoute.of(context);
|
||||||
|
if (route is PageRoute) {
|
||||||
|
appRouteObserver.subscribe(this, route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPushNext() {
|
||||||
|
_deferReloads = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPopNext() {
|
||||||
|
_settleTimer?.cancel();
|
||||||
|
_settleTimer = Timer(const Duration(milliseconds: 420), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
_deferReloads = false;
|
||||||
|
if (_reloadQueued) {
|
||||||
|
_reloadQueued = false;
|
||||||
|
_reloadChatsAndFolders();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _requestReload() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_deferReloads) {
|
||||||
|
_reloadQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
_reloadChatsAndFolders();
|
_reloadChatsAndFolders();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onChatsChanged() {
|
void _onChatsChanged() {
|
||||||
if (mounted) _reloadChatsAndFolders();
|
_requestReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reloadChatsAndFolders() async {
|
Future<void> _reloadChatsAndFolders() async {
|
||||||
@@ -619,7 +670,19 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _pageChatsBaseKey;
|
||||||
|
final Map<int, List<CachedChat>> _pageChatsCache = {};
|
||||||
|
|
||||||
List<CachedChat> _chatsForPageIndex(int pageIndex) {
|
List<CachedChat> _chatsForPageIndex(int pageIndex) {
|
||||||
|
final baseKey =
|
||||||
|
Object.hash(identityHashCode(_chats), identityHashCode(_folders));
|
||||||
|
if (_pageChatsBaseKey != baseKey) {
|
||||||
|
_pageChatsBaseKey = baseKey;
|
||||||
|
_pageChatsCache.clear();
|
||||||
|
}
|
||||||
|
final cached = _pageChatsCache[pageIndex];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
List<CachedChat> base;
|
List<CachedChat> base;
|
||||||
if (_folders.isEmpty) {
|
if (_folders.isEmpty) {
|
||||||
base = _chats;
|
base = _chats;
|
||||||
@@ -634,7 +697,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList()
|
final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList()
|
||||||
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
|
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
|
||||||
final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList();
|
final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList();
|
||||||
return [...pinned, ...regular];
|
final result = [...pinned, ...regular];
|
||||||
|
_pageChatsCache[pageIndex] = result;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncFolderChatScrollControllers() {
|
void _syncFolderChatScrollControllers() {
|
||||||
@@ -928,7 +993,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
appRouteObserver.unsubscribe(this);
|
||||||
|
_settleTimer?.cancel();
|
||||||
ChatsModule.chatsChanged.removeListener(_onChatsChanged);
|
ChatsModule.chatsChanged.removeListener(_onChatsChanged);
|
||||||
|
AppStories.current.removeListener(_onStoriesEnabledChanged);
|
||||||
_loginSub?.cancel();
|
_loginSub?.cancel();
|
||||||
_stateSub?.cancel();
|
_stateSub?.cancel();
|
||||||
_fabController.dispose();
|
_fabController.dispose();
|
||||||
@@ -947,6 +1015,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
}
|
}
|
||||||
_contactRebuildTimer?.cancel();
|
_contactRebuildTimer?.cancel();
|
||||||
_storiesUi.dispose();
|
_storiesUi.dispose();
|
||||||
|
_navDragDx.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,7 +1024,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
required double Function(int index) bubbleLeftForIndex,
|
required double Function(int index) bubbleLeftForIndex,
|
||||||
}) {
|
}) {
|
||||||
if (_navDragging) {
|
if (_navDragging) {
|
||||||
final left = (_navDragBaseLeft + _navDragDx).clamp(
|
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||||
bubbleLeftForIndex(0),
|
bubbleLeftForIndex(0),
|
||||||
bubbleLeftForIndex(3),
|
bubbleLeftForIndex(3),
|
||||||
);
|
);
|
||||||
@@ -1028,7 +1097,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (_pullRatio < 0.8)
|
if (AppStories.current.value &&
|
||||||
|
_pullRatio < 0.8)
|
||||||
Opacity(
|
Opacity(
|
||||||
opacity: 1.0 - _pullRatio,
|
opacity: 1.0 - _pullRatio,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1107,68 +1177,33 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
if (AppStories.current.value)
|
||||||
height: 96 * _pullRatio,
|
SizedBox(
|
||||||
child: Opacity(
|
height: 96 * _pullRatio,
|
||||||
opacity: _pullRatio,
|
child: Opacity(
|
||||||
child: ListView(
|
opacity: _pullRatio,
|
||||||
scrollDirection: Axis.horizontal,
|
child: ListView(
|
||||||
padding: const EdgeInsets.symmetric(
|
scrollDirection: Axis.horizontal,
|
||||||
horizontal: 20,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 20,
|
||||||
children: [
|
|
||||||
_buildStoryItem(
|
|
||||||
'Даша',
|
|
||||||
'https://i.pravatar.cc/150?u=dasha',
|
|
||||||
true,
|
|
||||||
),
|
),
|
||||||
_buildStoryItem(
|
|
||||||
'Мастика',
|
|
||||||
'https://i.pravatar.cc/150?u=mastika',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_showCacheWarning)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: cs.errorContainer.withValues(alpha: 0.3),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: cs.error.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
_buildStoryItem(
|
||||||
Symbols.cloud_off,
|
'Даша',
|
||||||
size: 18,
|
'https://i.pravatar.cc/150?u=dasha',
|
||||||
color: cs.error,
|
true,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
_buildStoryItem(
|
||||||
const Expanded(
|
'Мастика',
|
||||||
child: Text(
|
'https://i.pravatar.cc/150?u=mastika',
|
||||||
'Ошибка соединения, сейчас вы смотрите КЕШ',
|
false,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 3, 20, 4),
|
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 44,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1321,7 +1356,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
parent: const AlwaysScrollableScrollPhysics(),
|
parent: const AlwaysScrollableScrollPhysics(),
|
||||||
),
|
),
|
||||||
slivers: [
|
slivers: [
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 14)),
|
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||||
if (chats.isEmpty && !_isInitialLoading)
|
if (chats.isEmpty && !_isInitialLoading)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -1343,6 +1378,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
if (hasSeparator && index == pinnedCount) {
|
if (hasSeparator && index == pinnedCount) {
|
||||||
return Padding(
|
return Padding(
|
||||||
|
key: const ValueKey('pinned_divider'),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Divider(
|
child: Divider(
|
||||||
height: 1,
|
height: 1,
|
||||||
@@ -1357,20 +1393,28 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
final isPinned = (chat.favIndex ?? 0) > 0;
|
final isPinned = (chat.favIndex ?? 0) > 0;
|
||||||
|
|
||||||
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
|
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
|
||||||
final secondId = chat.participants.entries
|
int secondId = _profile?.id ?? 0;
|
||||||
.where((entry) => entry.key != _profile?.id)
|
for (final entry in chat.participants.entries) {
|
||||||
.first
|
if (entry.key != _profile?.id) {
|
||||||
.key;
|
secondId = entry.key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
final name = ContactCache.get(secondId);
|
final name = ContactCache.get(secondId);
|
||||||
final avatar = ContactCache.getAvatar(secondId);
|
final avatar = ContactCache.getAvatar(secondId);
|
||||||
// ContactCache.isOfficial covers contacts loaded via opcode 32;
|
// ContactCache.isOfficial covers contacts loaded via opcode 32;
|
||||||
// chat.isOfficial covers contacts from the login payload.
|
// chat.isOfficial covers contacts from the login payload.
|
||||||
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
|
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
|
||||||
|
|
||||||
|
final isPlaceholder =
|
||||||
|
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||||
|
final previewText = isPlaceholder
|
||||||
|
? 'зайдите в чат для подгрузки'
|
||||||
|
: (chat.lastMsgTextOneLine ?? '');
|
||||||
return _buildChatItem(
|
return _buildChatItem(
|
||||||
chat.id.toString(),
|
chat.id.toString(),
|
||||||
name ?? "Пользователь",
|
name ?? "Пользователь",
|
||||||
chat.lastMsgTextOneLine ?? '',
|
previewText,
|
||||||
_formatTime(chat.lastMsgTime),
|
_formatTime(chat.lastMsgTime),
|
||||||
avatar ?? "",
|
avatar ?? "",
|
||||||
isOnline: chat.isOnline,
|
isOnline: chat.isOnline,
|
||||||
@@ -1379,20 +1423,25 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
isVerified: isVerified,
|
isVerified: isVerified,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
chatType: "DIALOG",
|
chatType: "DIALOG",
|
||||||
|
messageItalic: isPlaceholder,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final name = chat.lastMsgSenderId != null
|
final isPlaceholder =
|
||||||
|
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||||
|
final sender = chat.lastMsgSenderId != null
|
||||||
? ContactCache.get(chat.lastMsgSenderId!)
|
? ContactCache.get(chat.lastMsgSenderId!)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
String fullMsg = "";
|
String fullMsg = "";
|
||||||
|
if (isPlaceholder) {
|
||||||
if (name?.isNotEmpty == true && chat.id != 0) {
|
fullMsg = 'зайдите в чат для подгрузки';
|
||||||
fullMsg += "$name: ";
|
} else {
|
||||||
}
|
if (sender?.isNotEmpty == true && chat.id != 0) {
|
||||||
|
fullMsg += "$sender: ";
|
||||||
if (chat.lastMsgText?.isNotEmpty == true) {
|
}
|
||||||
fullMsg += chat.lastMsgText ?? "";
|
if (chat.lastMsgText?.isNotEmpty == true) {
|
||||||
|
fullMsg += chat.lastMsgText ?? "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildChatItem(
|
return _buildChatItem(
|
||||||
@@ -1409,6 +1458,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
isVerified: chat.isOfficial,
|
isVerified: chat.isOfficial,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
chatType: chat.type,
|
chatType: chat.type,
|
||||||
|
messageItalic: isPlaceholder,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, childCount: totalItems),
|
}, childCount: totalItems),
|
||||||
@@ -1499,12 +1549,6 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
final minBubbleLeft = bubbleLeftForIndex(0);
|
final minBubbleLeft = bubbleLeftForIndex(0);
|
||||||
final maxBubbleLeft = bubbleLeftForIndex(3);
|
final maxBubbleLeft = bubbleLeftForIndex(3);
|
||||||
|
|
||||||
final bubbleLeft = _navDragging
|
|
||||||
? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft)
|
|
||||||
: leftOffset;
|
|
||||||
|
|
||||||
final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
|
|
||||||
|
|
||||||
double navInterpolatedWidth(int tabIndex, double rowT) {
|
double navInterpolatedWidth(int tabIndex, double rowT) {
|
||||||
final rt = rowT.clamp(0.0, 3.0);
|
final rt = rowT.clamp(0.0, 3.0);
|
||||||
final i0 = rt.floor().clamp(0, 3);
|
final i0 = rt.floor().clamp(0, 3);
|
||||||
@@ -1557,39 +1601,46 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
if (_isSelectionMode) return;
|
if (_isSelectionMode) return;
|
||||||
_navPageAnimController.stop();
|
_navPageAnimController.stop();
|
||||||
_navPageAnimController.value = 1.0;
|
_navPageAnimController.value = 1.0;
|
||||||
|
_navDragDx.value = 0;
|
||||||
setState(() {
|
setState(() {
|
||||||
_navDragging = true;
|
_navDragging = true;
|
||||||
_navDragDx = 0;
|
|
||||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
if (!_navDragging) return;
|
if (!_navDragging) return;
|
||||||
setState(() {
|
_navDragDx.value += details.delta.dx;
|
||||||
_navDragDx += details.delta.dx;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onHorizontalDragEnd: (_) {
|
onHorizontalDragEnd: (_) {
|
||||||
if (!_navDragging) return;
|
if (!_navDragging) return;
|
||||||
final left = (_navDragBaseLeft + _navDragDx).clamp(
|
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||||
minBubbleLeft,
|
minBubbleLeft,
|
||||||
maxBubbleLeft,
|
maxBubbleLeft,
|
||||||
);
|
);
|
||||||
final next = indexForBubbleLeft(left);
|
final next = indexForBubbleLeft(left);
|
||||||
|
_navDragDx.value = 0;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentNavIndex = next;
|
_currentNavIndex = next;
|
||||||
_navDragging = false;
|
_navDragging = false;
|
||||||
_navDragDx = 0;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onHorizontalDragCancel: () {
|
onHorizontalDragCancel: () {
|
||||||
if (!_navDragging) return;
|
if (!_navDragging) return;
|
||||||
|
_navDragDx.value = 0;
|
||||||
setState(() {
|
setState(() {
|
||||||
_navDragging = false;
|
_navDragging = false;
|
||||||
_navDragDx = 0;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: ValueListenableBuilder<double>(
|
||||||
|
valueListenable: _navDragDx,
|
||||||
|
builder: (context, navDragDx, _) {
|
||||||
|
final bubbleLeft = _navDragging
|
||||||
|
? (_navDragBaseLeft + navDragDx)
|
||||||
|
.clamp(minBubbleLeft, maxBubbleLeft)
|
||||||
|
: leftOffset;
|
||||||
|
final navRowT =
|
||||||
|
((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
|
||||||
|
return Stack(
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
children: [
|
children: [
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
@@ -1661,6 +1712,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1706,7 +1759,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
width: pageW * 4,
|
width: pageW * 4,
|
||||||
height: pageH,
|
height: pageH,
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: _navPageAnimController,
|
animation: Listenable.merge(
|
||||||
|
[_navPageAnimController, _navDragDx]),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -1812,9 +1866,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
onPressed: _toggleFab,
|
onPressed: _toggleFab,
|
||||||
backgroundColor: cs.primaryContainer,
|
backgroundColor: cs.primaryContainer,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
shape: RoundedRectangleBorder(
|
shape: const CircleBorder(),
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: val * (pi / 4),
|
angle: val * (pi / 4),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -2020,7 +2072,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(50),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
@@ -2049,11 +2101,13 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
bool isVerified = false,
|
bool isVerified = false,
|
||||||
bool isPinned = false,
|
bool isPinned = false,
|
||||||
String chatType = "CHAT",
|
String chatType = "CHAT",
|
||||||
|
bool messageItalic = false,
|
||||||
}) {
|
}) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final isSelected = _selectedChats.contains(id);
|
final isSelected = _selectedChats.contains(id);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
|
key: ValueKey('chat_$id'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (_isSelectionMode) {
|
if (_isSelectionMode) {
|
||||||
_toggleSelection(id);
|
_toggleSelection(id);
|
||||||
@@ -2230,6 +2284,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
fontWeight: isTyping
|
fontWeight: isTyping
|
||||||
? FontWeight.w500
|
? FontWeight.w500
|
||||||
: FontWeight.w400,
|
: FontWeight.w400,
|
||||||
|
fontStyle: messageItalic
|
||||||
|
? FontStyle.italic
|
||||||
|
: FontStyle.normal,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -2313,6 +2370,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
icon,
|
icon,
|
||||||
color: isSelected ? cs.onPrimary : cs.onSurface,
|
color: isSelected ? cs.onPrimary : cs.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
|
fill: 1,
|
||||||
),
|
),
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: animDur,
|
duration: animDur,
|
||||||
@@ -2355,9 +2413,16 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
controller.dispose();
|
controller.dispose();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (accountId == null) {
|
if (accountId == null) {
|
||||||
await Navigator.push(
|
final previousId = await TokenStorage.getActiveAccountId();
|
||||||
context,
|
try {
|
||||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
await accountModule.beginAddAccount();
|
||||||
|
} catch (_) {}
|
||||||
|
if (!mounted) return;
|
||||||
|
await Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => LoginScreen(returnToAccountId: previousId),
|
||||||
|
),
|
||||||
|
(route) => false,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io' show File;
|
import 'dart:io' show File;
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -13,17 +14,19 @@ import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
|||||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../../backend/api.dart';
|
|
||||||
import '../../../backend/modules/messages.dart';
|
import '../../../backend/modules/messages.dart';
|
||||||
import '../../../core/protocol/opcode_map.dart';
|
import '../../../core/protocol/opcode_map.dart';
|
||||||
import '../../../core/protocol/packet.dart';
|
import '../../../core/protocol/packet.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
|
import '../../../core/cache/info_cache.dart';
|
||||||
import '../../../core/utils/haptics.dart';
|
import '../../../core/utils/haptics.dart';
|
||||||
import '../../../core/config/app_cache_extent.dart';
|
import '../../../core/config/app_cache_extent.dart';
|
||||||
import '../../../core/config/app_message_actions_style.dart';
|
import '../../../core/config/app_message_actions_style.dart';
|
||||||
import '../../../core/config/app_swipe_back_desktop.dart';
|
import '../../../core/config/app_swipe_back_desktop.dart';
|
||||||
|
import '../../../core/config/app_pranks.dart';
|
||||||
import '../../../models/attachment.dart';
|
import '../../../models/attachment.dart';
|
||||||
import '../../widgets/message_bubble.dart';
|
import '../../widgets/message_bubble.dart';
|
||||||
|
import '../../widgets/theme_reveal.dart';
|
||||||
import '../../widgets/message_actions_overlay.dart';
|
import '../../widgets/message_actions_overlay.dart';
|
||||||
import '../../widgets/attachment_panel.dart';
|
import '../../widgets/attachment_panel.dart';
|
||||||
import '../../widgets/swipe_to_pop.dart';
|
import '../../widgets/swipe_to_pop.dart';
|
||||||
@@ -89,11 +92,42 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
|
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
|
||||||
StreamSubscription<UploadEvent>? _uploadSub;
|
StreamSubscription<UploadEvent>? _uploadSub;
|
||||||
StreamSubscription<Packet>? _pushSub;
|
StreamSubscription<Packet>? _pushSub;
|
||||||
|
StreamSubscription<MessageEvent>? _messageEventSub;
|
||||||
|
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers = {};
|
||||||
|
|
||||||
|
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
|
||||||
|
final existing = _reactionNotifiers[m.id];
|
||||||
|
if (existing != null) return existing;
|
||||||
|
final info = m.payload?['reactionInfo'];
|
||||||
|
final notifier = ValueNotifier<Map<String, dynamic>?>(
|
||||||
|
info is Map ? Map<String, dynamic>.from(info) : null,
|
||||||
|
);
|
||||||
|
_reactionNotifiers[m.id] = notifier;
|
||||||
|
return notifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pruneReactionNotifiers() {
|
||||||
|
final liveIds = _messages.map((m) => m.id).toSet();
|
||||||
|
final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList();
|
||||||
|
for (final id in dead) {
|
||||||
|
_reactionNotifiers.remove(id)?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
final Set<int> _typingUserIds = {};
|
final Set<int> _typingUserIds = {};
|
||||||
final Map<int, Timer> _typingTimers = {};
|
final Map<int, Timer> _typingTimers = {};
|
||||||
int _otherStatus = 0;
|
int _otherStatus = 0;
|
||||||
int? _otherSeenTime;
|
int? _otherSeenTime;
|
||||||
|
int? _participantsCount;
|
||||||
|
|
||||||
|
bool _prankActive = false;
|
||||||
|
String? _prankBubbleId;
|
||||||
|
final GlobalKey _prankBubbleKey = GlobalKey();
|
||||||
|
final GlobalKey _prankCaptureKey = GlobalKey();
|
||||||
|
OverlayEntry? _prankRevealEntry;
|
||||||
|
AnimationController? _prankRevealController;
|
||||||
|
ui.Image? _prankRevealImage;
|
||||||
final ValueNotifier<String> _headerStatusNotifier = ValueNotifier('');
|
final ValueNotifier<String> _headerStatusNotifier = ValueNotifier('');
|
||||||
|
final ValueNotifier<int> _otherReadTime = ValueNotifier(0);
|
||||||
int _tempIdCounter = 0;
|
int _tempIdCounter = 0;
|
||||||
late final AnimationController _attachAnim;
|
late final AnimationController _attachAnim;
|
||||||
|
|
||||||
@@ -102,6 +136,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
Timer? _shimmerStartTimer;
|
Timer? _shimmerStartTimer;
|
||||||
bool _historyKickedOff = false;
|
bool _historyKickedOff = false;
|
||||||
List<CachedMessage> _messages = [];
|
List<CachedMessage> _messages = [];
|
||||||
|
int _messagesRevision = 0;
|
||||||
|
List<Object>? _combinedItemsCache;
|
||||||
|
int? _combinedItemsKey;
|
||||||
|
bool _floatingDateScheduled = false;
|
||||||
int _myId = 0;
|
int _myId = 0;
|
||||||
CachedChat? chat;
|
CachedChat? chat;
|
||||||
|
|
||||||
@@ -110,7 +148,6 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
late final AnimationController _floatingDateAnimController;
|
late final AnimationController _floatingDateAnimController;
|
||||||
late final CurvedAnimation _floatingDateCurved;
|
late final CurvedAnimation _floatingDateCurved;
|
||||||
final Map<int, GlobalKey> _separatorKeys = {};
|
final Map<int, GlobalKey> _separatorKeys = {};
|
||||||
double _lastScrollOffset = 0;
|
|
||||||
String? _lastSentId;
|
String? _lastSentId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -130,10 +167,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||||||
_pushSub = api.pushStream
|
_pushSub = api.pushStream
|
||||||
.where((p) =>
|
.where((p) =>
|
||||||
p.opcode == Opcode.notifMessage ||
|
|
||||||
p.opcode == Opcode.notifMark ||
|
p.opcode == Opcode.notifMark ||
|
||||||
p.opcode == Opcode.notifTyping)
|
p.opcode == Opcode.notifTyping)
|
||||||
.listen(_onIncomingPush);
|
.listen(_onIncomingPush);
|
||||||
|
_messageEventSub = ChatsModule.messageEvents
|
||||||
|
.where((e) => e.chatId == widget.chatId)
|
||||||
|
.listen(_onMessageEvent);
|
||||||
_floatingDateAnimController = AnimationController(
|
_floatingDateAnimController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
@@ -145,9 +184,56 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
reverseCurve: Curves.easeIn,
|
reverseCurve: Curves.easeIn,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
unawaited(_fastPreloadCache());
|
||||||
|
unawaited(_loadParticipantsCount());
|
||||||
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
|
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadParticipantsCount() async {
|
||||||
|
if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return;
|
||||||
|
final info = await ChatsModule.getChatInfo(api, widget.chatId);
|
||||||
|
if (!mounted) return;
|
||||||
|
final count = info?['participantsCount'] as int?;
|
||||||
|
if (count != null && count != _participantsCount) {
|
||||||
|
_participantsCount = count;
|
||||||
|
_recomputeHeaderStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fastPreloadCache() async {
|
||||||
|
final p = await AppDatabase.loadActiveProfile();
|
||||||
|
if (!mounted) return;
|
||||||
|
_myId = p?.id ?? 0;
|
||||||
|
|
||||||
|
ChatsModule.getChat(_myId, widget.chatId).then((value) {
|
||||||
|
if (mounted && value.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
chat = value.first;
|
||||||
|
});
|
||||||
|
_recomputeHeaderStatus();
|
||||||
|
_syncOtherReadTime();
|
||||||
|
}
|
||||||
|
}).catchError((_) {});
|
||||||
|
|
||||||
|
final firstRows = await AppDatabase.loadMessages(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
limit: 20,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (firstRows.isNotEmpty) {
|
||||||
|
final first = firstRows.reversed
|
||||||
|
.map((r) => CachedMessage.fromDbRow(r))
|
||||||
|
.toList();
|
||||||
|
setState(() {
|
||||||
|
_messages = first;
|
||||||
|
_messagesRevision++;
|
||||||
|
_isLoading = false;
|
||||||
|
_onLoadingFinished();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onFirstFrameRendered(Duration _) {
|
void _onFirstFrameRendered(Duration _) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (widget.embedded) {
|
if (widget.embedded) {
|
||||||
@@ -192,37 +278,15 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
final activeProfile = await AppDatabase.loadActiveProfile();
|
if (_myId == 0) {
|
||||||
_myId = activeProfile?.id ?? 0;
|
final activeProfile = await AppDatabase.loadActiveProfile();
|
||||||
ChatsModule.getChat(_myId, widget.chatId).then((value) {
|
if (!mounted) return;
|
||||||
if (mounted && value.isNotEmpty) {
|
_myId = activeProfile?.id ?? 0;
|
||||||
setState(() { chat = value.first; });
|
}
|
||||||
_recomputeHeaderStatus();
|
|
||||||
}
|
|
||||||
}).catchError((_) {});
|
|
||||||
if (widget.chatType == 'DIALOG') {
|
if (widget.chatType == 'DIALOG') {
|
||||||
unawaited(_loadOtherPresence());
|
unawaited(_loadOtherPresence());
|
||||||
}
|
}
|
||||||
|
await _loadRemainingHistory();
|
||||||
final firstRows = await AppDatabase.loadMessages(
|
|
||||||
_myId,
|
|
||||||
widget.chatId,
|
|
||||||
limit: 20,
|
|
||||||
);
|
|
||||||
if (mounted && firstRows.isNotEmpty) {
|
|
||||||
final first = firstRows.reversed
|
|
||||||
.map((r) => CachedMessage.fromDbRow(r))
|
|
||||||
.toList();
|
|
||||||
setState(() {
|
|
||||||
_messages = first;
|
|
||||||
if (api.state == SessionState.online) {
|
|
||||||
_isLoading = false;
|
|
||||||
_onLoadingFinished();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
unawaited(_loadRemainingHistory());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadRemainingHistory() async {
|
Future<void> _loadRemainingHistory() async {
|
||||||
@@ -235,8 +299,20 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_applyMergedMessages(fullRows);
|
_applyMergedMessages(fullRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_onLoadingFinished();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_loadForwardedSenderNames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await messagesModule.fetchHistory(_myId, widget.chatId);
|
await messagesModule.fetchHistory(_myId, widget.chatId);
|
||||||
|
ChatsModule.markChatClean(widget.chatId);
|
||||||
final updatedRows = await AppDatabase.loadMessages(
|
final updatedRows = await AppDatabase.loadMessages(
|
||||||
_myId,
|
_myId,
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
@@ -245,6 +321,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
_applyMergedMessages(updatedRows, markLoaded: true);
|
_applyMergedMessages(updatedRows, markLoaded: true);
|
||||||
}
|
}
|
||||||
|
unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId));
|
||||||
_loadForwardedSenderNames();
|
_loadForwardedSenderNames();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error fetching history: $e');
|
debugPrint('Error fetching history: $e');
|
||||||
@@ -274,12 +351,42 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final changed = !_listsEquivalent(_messages, merged);
|
final changed = !_listsEquivalent(_messages, merged);
|
||||||
if (!changed && !markLoaded) return;
|
if (!changed && !markLoaded) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
if (changed) _messages = merged;
|
if (changed) {
|
||||||
|
_messages = merged;
|
||||||
|
_messagesRevision++;
|
||||||
|
}
|
||||||
if (markLoaded) {
|
if (markLoaded) {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_onLoadingFinished();
|
_onLoadingFinished();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (changed) {
|
||||||
|
_syncReactionNotifiersFromMessages();
|
||||||
|
_pruneReactionNotifiers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncReactionNotifiersFromMessages() {
|
||||||
|
for (final m in _messages) {
|
||||||
|
final info = m.payload?['reactionInfo'];
|
||||||
|
final value = info is Map ? Map<String, dynamic>.from(info) : null;
|
||||||
|
final existing = _reactionNotifiers[m.id];
|
||||||
|
if (existing == null) {
|
||||||
|
_reactionNotifiers[m.id] = ValueNotifier(value);
|
||||||
|
} else if (!_reactionsEqual(existing.value, value)) {
|
||||||
|
existing.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _reactionsEqual(Map<String, dynamic>? a, Map<String, dynamic>? b) {
|
||||||
|
if (identical(a, b)) return true;
|
||||||
|
if (a == null || b == null) return false;
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (final k in a.keys) {
|
||||||
|
if (a[k].toString() != b[k].toString()) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _sameMessage(CachedMessage a, CachedMessage b) {
|
bool _sameMessage(CachedMessage a, CachedMessage b) {
|
||||||
@@ -311,11 +418,18 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_showAttachmentPanel.dispose();
|
_showAttachmentPanel.dispose();
|
||||||
_uploadSub?.cancel();
|
_uploadSub?.cancel();
|
||||||
_pushSub?.cancel();
|
_pushSub?.cancel();
|
||||||
|
_messageEventSub?.cancel();
|
||||||
|
for (final n in _reactionNotifiers.values) {
|
||||||
|
n.dispose();
|
||||||
|
}
|
||||||
|
_reactionNotifiers.clear();
|
||||||
for (final t in _typingTimers.values) {
|
for (final t in _typingTimers.values) {
|
||||||
t.cancel();
|
t.cancel();
|
||||||
}
|
}
|
||||||
_typingTimers.clear();
|
_typingTimers.clear();
|
||||||
_headerStatusNotifier.dispose();
|
_headerStatusNotifier.dispose();
|
||||||
|
_otherReadTime.dispose();
|
||||||
|
_finishPrankReveal();
|
||||||
_uploadStatus.dispose();
|
_uploadStatus.dispose();
|
||||||
_attachAnim.dispose();
|
_attachAnim.dispose();
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
@@ -340,17 +454,124 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _effectiveStatus(CachedMessage msg) {
|
int _computeOtherReadTime() {
|
||||||
if (msg.senderId != _myId) return null;
|
|
||||||
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
|
|
||||||
final c = chat;
|
final c = chat;
|
||||||
if (c == null) return 'sent';
|
if (c == null) return 0;
|
||||||
int otherReadTime = 0;
|
int otherReadTime = 0;
|
||||||
for (final entry in c.participants.entries) {
|
for (final entry in c.participants.entries) {
|
||||||
if (entry.key != _myId && entry.value > otherReadTime) {
|
if (entry.key != _myId && entry.value > otherReadTime) {
|
||||||
otherReadTime = entry.value;
|
otherReadTime = entry.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return otherReadTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncOtherReadTime() {
|
||||||
|
final t = _computeOtherReadTime();
|
||||||
|
if (_otherReadTime.value != t) _otherReadTime.value = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkPrankTrigger(CachedMessage msg) {
|
||||||
|
if (!AppPranks.current.value || _prankActive || _prankBubbleId != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((msg.text ?? '').trim().toUpperCase() != 'THE WORLD') return;
|
||||||
|
setState(() => _prankBubbleId = msg.id);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _runPrankReveal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeData _prankPinkTheme(ThemeData base) {
|
||||||
|
final cs = base.colorScheme;
|
||||||
|
return base.copyWith(
|
||||||
|
scaffoldBackgroundColor: const Color(0xFFFFF0F5),
|
||||||
|
colorScheme: cs.copyWith(
|
||||||
|
surface: const Color(0xFFFFF0F5),
|
||||||
|
surfaceContainerHigh: const Color(0xFFFFE3EC),
|
||||||
|
surfaceContainerHighest: const Color(0xFFFFD9E6),
|
||||||
|
primary: const Color(0xFFE8579A),
|
||||||
|
primaryContainer: const Color(0xFFFFD6E5),
|
||||||
|
onPrimaryContainer: const Color(0xFF7A1F4B),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _runPrankReveal() {
|
||||||
|
if (_prankActive) return;
|
||||||
|
final overlay = Navigator.of(context).overlay;
|
||||||
|
final captureCtx = _prankCaptureKey.currentContext;
|
||||||
|
final renderObject = captureCtx?.findRenderObject();
|
||||||
|
if (overlay == null || renderObject is! RenderRepaintBoundary) {
|
||||||
|
setState(() => _prankActive = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset center;
|
||||||
|
final bubbleBox =
|
||||||
|
_prankBubbleKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (bubbleBox != null && bubbleBox.attached) {
|
||||||
|
center = bubbleBox.localToGlobal(bubbleBox.size.center(Offset.zero));
|
||||||
|
} else {
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
center = Offset(size.width / 2, size.height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
final ui.Image snapshot;
|
||||||
|
try {
|
||||||
|
final dpr = math.min(MediaQuery.of(context).devicePixelRatio, 2.0);
|
||||||
|
snapshot = renderObject.toImageSync(pixelRatio: dpr);
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _prankActive = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_finishPrankReveal();
|
||||||
|
|
||||||
|
final controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 650),
|
||||||
|
);
|
||||||
|
final entry = ThemeRevealOverlay.build(
|
||||||
|
snapshot: snapshot,
|
||||||
|
center: center,
|
||||||
|
animation: controller,
|
||||||
|
);
|
||||||
|
|
||||||
|
_prankRevealController = controller;
|
||||||
|
_prankRevealEntry = entry;
|
||||||
|
_prankRevealImage = snapshot;
|
||||||
|
|
||||||
|
overlay.insert(entry);
|
||||||
|
setState(() => _prankActive = true);
|
||||||
|
Haptics.success();
|
||||||
|
|
||||||
|
WidgetsBinding.instance.endOfFrame.then((_) {
|
||||||
|
if (_prankRevealController != controller) return;
|
||||||
|
controller.forward().then((_) {
|
||||||
|
if (_prankRevealController != controller) return;
|
||||||
|
_finishPrankReveal();
|
||||||
|
}, onError: (_) {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _finishPrankReveal() {
|
||||||
|
_prankRevealEntry?.remove();
|
||||||
|
_prankRevealEntry = null;
|
||||||
|
_prankRevealController?.dispose();
|
||||||
|
_prankRevealController = null;
|
||||||
|
final img = _prankRevealImage;
|
||||||
|
_prankRevealImage = null;
|
||||||
|
if (img != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _effectiveStatus(CachedMessage msg) {
|
||||||
|
if (msg.senderId != _myId) return null;
|
||||||
|
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
|
||||||
|
if (chat == null) return 'sent';
|
||||||
|
final otherReadTime = _otherReadTime.value;
|
||||||
if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read';
|
if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read';
|
||||||
return 'sent';
|
return 'sent';
|
||||||
}
|
}
|
||||||
@@ -358,8 +579,6 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
void _onIncomingPush(Packet packet) {
|
void _onIncomingPush(Packet packet) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
switch (packet.opcode) {
|
switch (packet.opcode) {
|
||||||
case Opcode.notifMessage:
|
|
||||||
_onIncomingMessage(packet);
|
|
||||||
case Opcode.notifMark:
|
case Opcode.notifMark:
|
||||||
_onMessageRead(packet);
|
_onMessageRead(packet);
|
||||||
case Opcode.notifTyping:
|
case Opcode.notifTyping:
|
||||||
@@ -367,23 +586,51 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onMessageEvent(MessageEvent event) {
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (event) {
|
||||||
|
case MessageAddedEvent(:final message):
|
||||||
|
if (message.senderId == _myId) return;
|
||||||
|
if (_messages.any((m) => m.id == message.id)) return;
|
||||||
|
setState(() {
|
||||||
|
_lastSentId = message.id;
|
||||||
|
_messages.add(message);
|
||||||
|
_messagesRevision++;
|
||||||
|
});
|
||||||
|
_clearTyping(message.senderId);
|
||||||
|
Haptics.tap();
|
||||||
|
_scrollToBottom();
|
||||||
|
_checkPrankTrigger(message);
|
||||||
|
case MessageEditedEvent(:final message):
|
||||||
|
final idx = _messages.indexWhere((m) => m.id == message.id);
|
||||||
|
if (idx == -1) return;
|
||||||
|
setState(() {
|
||||||
|
_messages[idx] = message;
|
||||||
|
_messagesRevision++;
|
||||||
|
});
|
||||||
|
case MessageRemovedEvent(:final messageId):
|
||||||
|
final idx = _messages.indexWhere((m) => m.id == messageId);
|
||||||
|
if (idx == -1) return;
|
||||||
|
setState(() {
|
||||||
|
_messages.removeAt(idx);
|
||||||
|
_messagesRevision++;
|
||||||
|
});
|
||||||
|
_reactionNotifiers.remove(messageId)?.dispose();
|
||||||
|
case MessageReactionsChangedEvent(:final messageId, :final reactionInfo):
|
||||||
|
_reactionNotifiers[messageId]?.value = reactionInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadOtherPresence() async {
|
Future<void> _loadOtherPresence() async {
|
||||||
if (_myId == 0) return;
|
if (_myId == 0) return;
|
||||||
final otherId = widget.chatId ^ _myId;
|
final otherId = widget.chatId ^ _myId;
|
||||||
if (otherId <= 0) return;
|
if (otherId <= 0) return;
|
||||||
try {
|
try {
|
||||||
final p = await api.sendRequest(
|
final entry = await PresenceFetch.get(otherId);
|
||||||
Opcode.contactPresence,
|
if (!mounted || entry == null) return;
|
||||||
{'contactIds': [otherId]},
|
_otherStatus = (entry['status'] as int?) ?? 0;
|
||||||
);
|
_otherSeenTime = entry['seen'] as int?;
|
||||||
if (!mounted) return;
|
_recomputeHeaderStatus();
|
||||||
final presence = (p.payload as Map?)?['presence'] as Map?;
|
|
||||||
final entry = presence?[otherId.toString()] ?? presence?[otherId];
|
|
||||||
if (entry is Map) {
|
|
||||||
_otherStatus = (entry['status'] as int?) ?? 0;
|
|
||||||
_otherSeenTime = entry['seen'] as int?;
|
|
||||||
_recomputeHeaderStatus();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,10 +655,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
String _headerStatus() {
|
String _headerStatus() {
|
||||||
if (_typingUserIds.isNotEmpty) return 'Печатает...';
|
if (_typingUserIds.isNotEmpty) return 'Печатает...';
|
||||||
if (widget.chatType == 'CHAT') {
|
if (widget.chatType == 'CHAT') {
|
||||||
return '${chat?.participants.length ?? 0} участников';
|
final count = _participantsCount ?? chat?.participants.length ?? 0;
|
||||||
|
return '$count участников';
|
||||||
}
|
}
|
||||||
if (widget.chatType == 'CHANNEL') {
|
if (widget.chatType == 'CHANNEL') {
|
||||||
return '${chat?.participants.length ?? 0} подписчиков';
|
final count = _participantsCount ?? chat?.participants.length ?? 0;
|
||||||
|
return '$count подписчиков';
|
||||||
}
|
}
|
||||||
if (_otherStatus == 1) return 'В сети';
|
if (_otherStatus == 1) return 'В сети';
|
||||||
if (_otherStatus == 3) return 'Был(-а) недавно';
|
if (_otherStatus == 3) return 'Был(-а) недавно';
|
||||||
@@ -458,56 +707,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final c = chat;
|
final c = chat;
|
||||||
if (c == null) return;
|
if (c == null) return;
|
||||||
if (c.participants[userId] == mark) return;
|
if (c.participants[userId] == mark) return;
|
||||||
setState(() {
|
c.participants[userId] = mark;
|
||||||
c.participants[userId] = mark;
|
_syncOtherReadTime();
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onIncomingMessage(Packet packet) {
|
|
||||||
if (!mounted) return;
|
|
||||||
final payload = packet.payload;
|
|
||||||
if (payload is! Map) return;
|
|
||||||
final chatId = payload['chatId'];
|
|
||||||
if (chatId != widget.chatId) return;
|
|
||||||
final msg = payload['message'];
|
|
||||||
if (msg is! Map) return;
|
|
||||||
|
|
||||||
final senderId = msg['sender'];
|
|
||||||
if (senderId is! int) return;
|
|
||||||
if (senderId == _myId) return;
|
|
||||||
|
|
||||||
final msgId = msg['id']?.toString();
|
|
||||||
if (msgId == null || msgId.isEmpty) return;
|
|
||||||
if (_messages.any((m) => m.id == msgId)) return;
|
|
||||||
|
|
||||||
List<MessageAttachment>? attachments;
|
|
||||||
final attaches = msg['attaches'];
|
|
||||||
if (attaches is List && attaches.isNotEmpty) {
|
|
||||||
attachments = attaches
|
|
||||||
.whereType<Map>()
|
|
||||||
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
final cached = CachedMessage(
|
|
||||||
id: msgId,
|
|
||||||
accountId: _myId,
|
|
||||||
chatId: widget.chatId,
|
|
||||||
senderId: senderId,
|
|
||||||
text: msg['text'] as String?,
|
|
||||||
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
|
|
||||||
status: 'sent',
|
|
||||||
payload: Map<String, dynamic>.from(msg),
|
|
||||||
attachments: attachments,
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_lastSentId = msgId;
|
|
||||||
_messages.add(cached);
|
|
||||||
});
|
|
||||||
_clearTyping(senderId);
|
|
||||||
Haptics.tap();
|
|
||||||
_scrollToBottom();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
@@ -533,30 +734,36 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_lastSentId = tempId;
|
_lastSentId = tempId;
|
||||||
_messages.add(tempMessage);
|
_messages.add(tempMessage);
|
||||||
|
_messagesRevision++;
|
||||||
_messageController.clear();
|
_messageController.clear();
|
||||||
});
|
});
|
||||||
|
unawaited(_persistOutgoing(tempMessage));
|
||||||
|
|
||||||
// Instant tactile "whoosh" the moment the message leaves the composer,
|
// Instant tactile "whoosh" the moment the message leaves the composer,
|
||||||
// not after the network round-trip — feedback must feel immediate.
|
// not after the network round-trip — feedback must feel immediate.
|
||||||
Haptics.send();
|
Haptics.send();
|
||||||
|
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
|
_checkPrankTrigger(tempMessage);
|
||||||
|
|
||||||
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
|
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
|
||||||
|
|
||||||
final index = _messages.indexWhere((m) => m.id == tempId);
|
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||||
if (index != -1 && mounted) {
|
if (index != -1 && mounted) {
|
||||||
|
final sent = CachedMessage(
|
||||||
|
id: actualId.isNotEmpty ? actualId : tempId,
|
||||||
|
accountId: _myId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
senderId: _myId,
|
||||||
|
text: text,
|
||||||
|
time: now,
|
||||||
|
status: 'sent',
|
||||||
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages[index] = CachedMessage(
|
_messages[index] = sent;
|
||||||
id: actualId.isNotEmpty ? actualId : tempId,
|
_messagesRevision++;
|
||||||
accountId: _myId,
|
|
||||||
chatId: widget.chatId,
|
|
||||||
senderId: _myId,
|
|
||||||
text: text,
|
|
||||||
time: now,
|
|
||||||
status: 'sent',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
unawaited(_persistOutgoing(sent, removeId: tempId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chat == null) {
|
if (chat == null) {
|
||||||
@@ -564,6 +771,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
ChatsModule.refreshChats(api, [widget.chatId]).then((list) {
|
ChatsModule.refreshChats(api, [widget.chatId]).then((list) {
|
||||||
if (!mounted || list.isEmpty) return;
|
if (!mounted || list.isEmpty) return;
|
||||||
setState(() => chat = list.first);
|
setState(() => chat = list.first);
|
||||||
|
_syncOtherReadTime();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -571,21 +779,33 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
Haptics.error();
|
Haptics.error();
|
||||||
final index = _messages.indexWhere((m) => m.id == tempId);
|
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||||
if (index != -1 && mounted) {
|
if (index != -1 && mounted) {
|
||||||
|
final failed = CachedMessage(
|
||||||
|
id: tempId,
|
||||||
|
accountId: _myId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
senderId: _myId,
|
||||||
|
text: text,
|
||||||
|
time: now,
|
||||||
|
status: 'error',
|
||||||
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages[index] = CachedMessage(
|
_messages[index] = failed;
|
||||||
id: tempId,
|
_messagesRevision++;
|
||||||
accountId: _myId,
|
|
||||||
chatId: widget.chatId,
|
|
||||||
senderId: _myId,
|
|
||||||
text: text,
|
|
||||||
time: now,
|
|
||||||
status: 'error',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
unawaited(_persistOutgoing(failed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
|
||||||
|
try {
|
||||||
|
if (removeId != null && removeId != msg.id) {
|
||||||
|
await AppDatabase.deleteMessage(_myId, widget.chatId, removeId);
|
||||||
|
}
|
||||||
|
await AppDatabase.saveMessages([msg.toDbRow()]);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadForwardedSenderNames() async {
|
Future<void> _loadForwardedSenderNames() async {
|
||||||
final forwardIds = <int>{};
|
final forwardIds = <int>{};
|
||||||
for (final msg in _messages) {
|
for (final msg in _messages) {
|
||||||
@@ -601,48 +821,61 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
if (forwardIds.isEmpty) return;
|
if (forwardIds.isEmpty) return;
|
||||||
|
|
||||||
|
final resolved = <int, ({String name, String? avatar})>{};
|
||||||
for (final id in forwardIds) {
|
for (final id in forwardIds) {
|
||||||
final name = await messagesModule.searchContactById(id);
|
final name = await messagesModule.searchContactById(id);
|
||||||
final avatar = ContactCache.getAvatar(id);
|
if (name != null) {
|
||||||
if (name != null && mounted) {
|
resolved[id] = (name: name, avatar: ContactCache.getAvatar(id));
|
||||||
setState(() {
|
|
||||||
for (var i = 0; i < _messages.length; i++) {
|
|
||||||
final msg = _messages[i];
|
|
||||||
if (msg.attachments != null) {
|
|
||||||
final newAttaches = msg.attachments!.map((a) {
|
|
||||||
if (a is ForwardedMessageAttachment &&
|
|
||||||
a.originalSenderId == id &&
|
|
||||||
a.originalSenderName == null) {
|
|
||||||
return ForwardedMessageAttachment(
|
|
||||||
originalSenderId: id,
|
|
||||||
originalSenderName: name,
|
|
||||||
originalSenderAvatar: avatar,
|
|
||||||
originalMessageId: a.originalMessageId,
|
|
||||||
originalTime: a.originalTime,
|
|
||||||
originalText: a.originalText,
|
|
||||||
originalChatId: a.originalChatId,
|
|
||||||
originalAttachments: a.originalAttachments,
|
|
||||||
originalContact: a.originalContact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
}).toList();
|
|
||||||
_messages[i] = CachedMessage(
|
|
||||||
id: msg.id,
|
|
||||||
accountId: msg.accountId,
|
|
||||||
chatId: msg.chatId,
|
|
||||||
senderId: msg.senderId,
|
|
||||||
text: msg.text,
|
|
||||||
time: msg.time,
|
|
||||||
status: msg.status,
|
|
||||||
payload: msg.payload,
|
|
||||||
attachments: newAttaches,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (resolved.isEmpty || !mounted) return;
|
||||||
|
|
||||||
|
var anyChanged = false;
|
||||||
|
for (var i = 0; i < _messages.length; i++) {
|
||||||
|
final msg = _messages[i];
|
||||||
|
final attaches = msg.attachments;
|
||||||
|
if (attaches == null) continue;
|
||||||
|
|
||||||
|
var msgChanged = false;
|
||||||
|
final newAttaches = attaches.map((a) {
|
||||||
|
if (a is ForwardedMessageAttachment &&
|
||||||
|
a.originalSenderName == null &&
|
||||||
|
resolved.containsKey(a.originalSenderId)) {
|
||||||
|
final r = resolved[a.originalSenderId]!;
|
||||||
|
msgChanged = true;
|
||||||
|
return ForwardedMessageAttachment(
|
||||||
|
originalSenderId: a.originalSenderId,
|
||||||
|
originalSenderName: r.name,
|
||||||
|
originalSenderAvatar: r.avatar,
|
||||||
|
originalMessageId: a.originalMessageId,
|
||||||
|
originalTime: a.originalTime,
|
||||||
|
originalText: a.originalText,
|
||||||
|
originalChatId: a.originalChatId,
|
||||||
|
originalAttachments: a.originalAttachments,
|
||||||
|
originalContact: a.originalContact,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (!msgChanged) continue;
|
||||||
|
anyChanged = true;
|
||||||
|
_messages[i] = CachedMessage(
|
||||||
|
id: msg.id,
|
||||||
|
accountId: msg.accountId,
|
||||||
|
chatId: msg.chatId,
|
||||||
|
senderId: msg.senderId,
|
||||||
|
text: msg.text,
|
||||||
|
time: msg.time,
|
||||||
|
status: msg.status,
|
||||||
|
payload: msg.payload,
|
||||||
|
attachments: newAttaches,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyChanged) {
|
||||||
|
setState(() => _messagesRevision++);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToBottom() {
|
void _scrollToBottom() {
|
||||||
@@ -658,6 +891,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Object> _buildCombinedItems() {
|
List<Object> _buildCombinedItems() {
|
||||||
|
final key = Object.hash(_messagesRevision, _messages.length);
|
||||||
|
final cached = _combinedItemsCache;
|
||||||
|
if (cached != null && _combinedItemsKey == key) return cached;
|
||||||
|
|
||||||
final List<Object> items = [];
|
final List<Object> items = [];
|
||||||
final Set<int> usedDates = {};
|
final Set<int> usedDates = {};
|
||||||
|
|
||||||
@@ -690,30 +927,29 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
_separatorKeys.removeWhere((k, _) => !usedDates.contains(k));
|
_separatorKeys.removeWhere((k, _) => !usedDates.contains(k));
|
||||||
|
_combinedItemsCache = items;
|
||||||
|
_combinedItemsKey = key;
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScrollForDate() {
|
void _onScrollForDate() {
|
||||||
if (!_scrollController.hasClients) return;
|
if (!_scrollController.hasClients) return;
|
||||||
final currentOffset = _scrollController.position.pixels;
|
|
||||||
final scrollingUp = currentOffset > _lastScrollOffset;
|
|
||||||
_lastScrollOffset = currentOffset;
|
|
||||||
|
|
||||||
_floatingDateTimer?.cancel();
|
_floatingDateTimer?.cancel();
|
||||||
|
_floatingDateTimer = Timer(const Duration(seconds: 1), () {
|
||||||
if (!scrollingUp) {
|
|
||||||
_floatingDateAnimController.reverse();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_floatingDateTimer = Timer(const Duration(seconds: 2), () {
|
|
||||||
if (mounted) _floatingDateAnimController.reverse();
|
if (mounted) _floatingDateAnimController.reverse();
|
||||||
});
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate());
|
|
||||||
|
if (_floatingDateScheduled) return;
|
||||||
|
_floatingDateScheduled = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_floatingDateScheduled = false;
|
||||||
|
_updateFloatingDate();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateFloatingDate() {
|
void _updateFloatingDate() {
|
||||||
if (!mounted) return;
|
if (!mounted || _separatorKeys.isEmpty) return;
|
||||||
DateTime? result;
|
DateTime? result;
|
||||||
|
|
||||||
final listRenderBox = _listKey.currentContext?.findRenderObject();
|
final listRenderBox = _listKey.currentContext?.findRenderObject();
|
||||||
@@ -792,11 +1028,17 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final theme =
|
||||||
|
_prankActive ? _prankPinkTheme(Theme.of(context)) : Theme.of(context);
|
||||||
|
final cs = theme.colorScheme;
|
||||||
|
|
||||||
// TODO: Локализация
|
// TODO: Локализация
|
||||||
// TODO: Cклонения
|
// TODO: Cклонения
|
||||||
return ValueListenableBuilder<bool>(
|
return Theme(
|
||||||
|
data: theme,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
key: _prankCaptureKey,
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
valueListenable: AppSwipeBackDesktop.current,
|
valueListenable: AppSwipeBackDesktop.current,
|
||||||
builder: (context, desktopSwipe, child) => SwipeToPop(
|
builder: (context, desktopSwipe, child) => SwipeToPop(
|
||||||
enabled: widget.embedded && desktopSwipe,
|
enabled: widget.embedded && desktopSwipe,
|
||||||
@@ -952,6 +1194,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,7 +1216,9 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
return Stack(
|
return Stack(
|
||||||
key: _listKey,
|
key: _listKey,
|
||||||
children: [
|
children: [
|
||||||
ValueListenableBuilder<double>(
|
ValueListenableBuilder<int>(
|
||||||
|
valueListenable: _otherReadTime,
|
||||||
|
builder: (context, _, _) => ValueListenableBuilder<double>(
|
||||||
valueListenable: AppCacheExtent.current,
|
valueListenable: AppCacheExtent.current,
|
||||||
builder: (context, cacheExtent, _) => ListView.builder(
|
builder: (context, cacheExtent, _) => ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
@@ -1006,6 +1252,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
nextMessage: nextMessage,
|
nextMessage: nextMessage,
|
||||||
chatType: chat?.type ?? 'CHAT',
|
chatType: chat?.type ?? 'CHAT',
|
||||||
overrideStatus: _effectiveStatus(message),
|
overrideStatus: _effectiveStatus(message),
|
||||||
|
reactionsListenable: _reactionNotifierFor(message),
|
||||||
);
|
);
|
||||||
|
|
||||||
final pressable = _LongPressBubble(
|
final pressable = _LongPressBubble(
|
||||||
@@ -1024,13 +1271,17 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
)
|
)
|
||||||
: pressable;
|
: pressable;
|
||||||
|
|
||||||
return RepaintBoundary(
|
final builtItem = RepaintBoundary(
|
||||||
key: ValueKey('msg_${message.id}'),
|
key: ValueKey('msg_${message.id}'),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
|
return message.id == _prankBubbleId
|
||||||
|
? KeyedSubtree(key: _prankBubbleKey, child: builtItem)
|
||||||
|
: builtItem;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -1385,6 +1636,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_lastSentId = tempId;
|
_lastSentId = tempId;
|
||||||
_messages.add(msg);
|
_messages.add(msg);
|
||||||
|
_messagesRevision++;
|
||||||
});
|
});
|
||||||
Haptics.send();
|
Haptics.send();
|
||||||
_scrollToBottom();
|
_scrollToBottom();
|
||||||
@@ -1412,6 +1664,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
payload: old.payload,
|
payload: old.payload,
|
||||||
attachments: attachment != null ? [attachment] : old.attachments,
|
attachments: attachment != null ? [attachment] : old.attachments,
|
||||||
);
|
);
|
||||||
|
_messagesRevision++;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import '../../../backend/modules/chats.dart';
|
import '../../../backend/modules/chats.dart';
|
||||||
import '../../../backend/modules/contacts.dart';
|
import '../../../backend/modules/contacts.dart';
|
||||||
import '../../../core/storage/token_storage.dart';
|
import '../../../core/storage/token_storage.dart';
|
||||||
|
import '../../../core/utils/image_utils.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/swipe_route.dart';
|
import '../../widgets/swipe_route.dart';
|
||||||
@@ -131,16 +132,20 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
|||||||
if (_avatar != null) {
|
if (_avatar != null) {
|
||||||
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
final bytes = await _avatar!.readAsBytes();
|
final bytes = await compressAvatar(await _avatar!.readAsBytes());
|
||||||
final token = await fileUploader.uploadImage(
|
if (bytes == null) {
|
||||||
Uri.parse(url),
|
if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку');
|
||||||
bytes,
|
} else {
|
||||||
filename: _avatar!.uri.pathSegments.last,
|
final token = await fileUploader.uploadImage(
|
||||||
);
|
Uri.parse(url),
|
||||||
if (token != null) {
|
bytes,
|
||||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
filename: 'avatar.jpg',
|
||||||
} else if (mounted) {
|
);
|
||||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
if (token != null) {
|
||||||
|
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
||||||
|
} else if (mounted) {
|
||||||
|
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
import '../../../core/protocol/opcode_map.dart';
|
import '../../../core/cache/info_cache.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/storage/token_storage.dart';
|
import '../../../core/storage/token_storage.dart';
|
||||||
import '../../../main.dart';
|
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/swipe_route.dart';
|
import '../../widgets/swipe_route.dart';
|
||||||
import '../chats/chat_screen.dart';
|
import '../chats/chat_screen.dart';
|
||||||
@@ -41,25 +40,18 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
|||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}),
|
ContactInfoFetch.get(widget.contactId),
|
||||||
api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}),
|
PresenceFetch.get(widget.contactId),
|
||||||
]);
|
]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final infoPacket = results[0];
|
final contact = results[0];
|
||||||
if (infoPacket.isOk) {
|
if (contact != null) {
|
||||||
final contacts = (infoPacket.payload as Map?)?['contacts'] as List?;
|
_contact = contact;
|
||||||
if (contacts != null && contacts.isNotEmpty) {
|
|
||||||
_contact = Map<String, dynamic>.from(contacts.first as Map);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
final presencePacket = results[1];
|
final presence = results[1];
|
||||||
if (presencePacket.isOk) {
|
if (presence != null) {
|
||||||
final presence = (presencePacket.payload as Map?)?['presence'] as Map?;
|
_seenTime = presence['seen'] as int?;
|
||||||
final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId];
|
_presenceStatus = (presence['status'] as int?) ?? 0;
|
||||||
if (p is Map) {
|
|
||||||
_seenTime = p['seen'] as int?;
|
|
||||||
_presenceStatus = (p['status'] as int?) ?? 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
|||||||
int? _accountId;
|
int? _accountId;
|
||||||
List<CloudFile> _files = [];
|
List<CloudFile> _files = [];
|
||||||
bool _isUploading = false;
|
bool _isUploading = false;
|
||||||
double _uploadProgress = 0;
|
final ValueNotifier<double> _uploadProgress = ValueNotifier(0);
|
||||||
bool _animateNewCard = false;
|
bool _animateNewCard = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -68,16 +68,19 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
|||||||
}
|
}
|
||||||
mgr.onProgress = (progress, _) {
|
mgr.onProgress = (progress, _) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() { _isUploading = true; _uploadProgress = progress; });
|
if (!_isUploading) setState(() => _isUploading = true);
|
||||||
|
_uploadProgress.value = progress;
|
||||||
};
|
};
|
||||||
mgr.onDone = (file) {
|
mgr.onDone = (file) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() { _isUploading = false; _uploadProgress = 0; });
|
_uploadProgress.value = 0;
|
||||||
|
setState(() => _isUploading = false);
|
||||||
_prependFile(file);
|
_prependFile(file);
|
||||||
};
|
};
|
||||||
mgr.onError = (msg) {
|
mgr.onError = (msg) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() { _isUploading = false; _uploadProgress = 0; });
|
_uploadProgress.value = 0;
|
||||||
|
setState(() => _isUploading = false);
|
||||||
showCustomNotification(context, 'Ошибка: $msg');
|
showCustomNotification(context, 'Ошибка: $msg');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -91,6 +94,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
|||||||
_mode.dispose();
|
_mode.dispose();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
_currentFilePage.dispose();
|
_currentFilePage.dispose();
|
||||||
|
_uploadProgress.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +222,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
|||||||
final picked = result.files.first;
|
final picked = result.files.first;
|
||||||
if (picked.path == null) return;
|
if (picked.path == null) return;
|
||||||
|
|
||||||
setState(() { _isUploading = true; _uploadProgress = 0; });
|
_uploadProgress.value = 0;
|
||||||
|
setState(() => _isUploading = true);
|
||||||
|
|
||||||
await UploadManager.instance.start(
|
await UploadManager.instance.start(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
@@ -427,17 +432,27 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
if (_isUploading) ...[
|
if (_isUploading) ...[
|
||||||
LinearProgressIndicator(
|
ValueListenableBuilder<double>(
|
||||||
value: _uploadProgress,
|
valueListenable: _uploadProgress,
|
||||||
borderRadius: BorderRadius.circular(4),
|
builder: (context, progress, _) => Column(
|
||||||
minHeight: 5,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: cs.primary,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
backgroundColor: cs.surfaceContainerHighest,
|
children: [
|
||||||
),
|
LinearProgressIndicator(
|
||||||
const SizedBox(height: 8),
|
value: progress,
|
||||||
Text(
|
borderRadius: BorderRadius.circular(4),
|
||||||
'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%',
|
minHeight: 5,
|
||||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
color: cs.primary,
|
||||||
|
backgroundColor: cs.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Загрузка ${(progress * 100).toStringAsFixed(0)}%',
|
||||||
|
style:
|
||||||
|
TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
] else if (_files.isEmpty) ...[
|
] else if (_files.isEmpty) ...[
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../backend/modules/chats.dart';
|
import '../../../backend/modules/chats.dart';
|
||||||
import '../../../core/config/app_swipe_back_desktop.dart';
|
import '../../../core/config/app_swipe_back_desktop.dart';
|
||||||
|
import '../../../core/config/app_pranks.dart';
|
||||||
|
import '../../../core/config/app_stories.dart';
|
||||||
|
import '../../../core/config/app_media_cache.dart';
|
||||||
import '../../../core/protocol/opcode_map.dart';
|
import '../../../core/protocol/opcode_map.dart';
|
||||||
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/protocol/packet.dart';
|
import '../../../core/protocol/packet.dart';
|
||||||
import '../../../core/utils/logger.dart';
|
import '../../../core/utils/logger.dart';
|
||||||
|
import '../../../core/utils/media_cache.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
|
import '../../widgets/login_success_screen.dart';
|
||||||
|
import '../calls/call_screen.dart';
|
||||||
|
|
||||||
class DebugMenuScreen extends StatefulWidget {
|
class DebugMenuScreen extends StatefulWidget {
|
||||||
const DebugMenuScreen({super.key});
|
const DebugMenuScreen({super.key});
|
||||||
@@ -23,6 +30,92 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
final List<_SearchHit> _hits = [];
|
final List<_SearchHit> _hits = [];
|
||||||
final Map<String, String> _errors = {};
|
final Map<String, String> _errors = {};
|
||||||
|
int _cacheSize = 0;
|
||||||
|
bool _clearingCache = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCacheSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCacheSize() async {
|
||||||
|
final size = await MediaCache.currentSize();
|
||||||
|
if (mounted) setState(() => _cacheSize = size);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearCache() async {
|
||||||
|
if (_clearingCache) return;
|
||||||
|
setState(() => _clearingCache = true);
|
||||||
|
final freed = await MediaCache.clear();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_clearingCache = false;
|
||||||
|
_cacheSize = 0;
|
||||||
|
});
|
||||||
|
showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickCacheLimit() {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: cs.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'Лимит кэша медиа',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final preset in AppMediaCacheLimit.presets)
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
_limitLabel(preset),
|
||||||
|
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||||
|
),
|
||||||
|
trailing: AppMediaCacheLimit.current.value == preset
|
||||||
|
? Icon(Symbols.check, color: cs.primary)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
AppMediaCacheLimit.save(preset);
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _limitLabel(int bytes) =>
|
||||||
|
bytes <= 0 ? 'Без лимита' : _formatBytes(bytes);
|
||||||
|
|
||||||
|
String _formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes Б';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -397,6 +490,404 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: AppPranks.current,
|
||||||
|
builder: (context, pranksOn, _) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 17,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.auto_awesome,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Приколь4ики',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: pranksOn,
|
||||||
|
onChanged: (v) {
|
||||||
|
AppPranks.save(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: AppStories.current,
|
||||||
|
builder: (context, storiesOn, _) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 17,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.amp_stories,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Истории',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Отображение ленты историй в списке чатов',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: storiesOn,
|
||||||
|
onChanged: (v) {
|
||||||
|
AppStories.save(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: _pickCacheLimit,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 17,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.data_usage,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Лимит кэша медиа',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_limitLabel(AppMediaCacheLimit.current.value),
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Symbols.chevron_right,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: _clearingCache ? null : _clearCache,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 17,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.delete_sweep,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Очистить кэш медиа',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_clearingCache
|
||||||
|
? 'Очистка…'
|
||||||
|
: 'Занято: ${_formatBytes(_cacheSize)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_clearingCache)
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: () async {
|
||||||
|
final profile = await AppDatabase.loadActiveProfile();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final avatar = await precacheLoginAvatar(
|
||||||
|
context,
|
||||||
|
profile?.baseUrl,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
LoginSuccessScreen(preview: true, avatar: avatar),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 17,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.celebration,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'test hello',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Показать приветственную анимацию входа',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Symbols.chevron_right,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Экран звонка',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Превью экранов звонков',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _DebugCallButton(
|
||||||
|
label: 'Входящий',
|
||||||
|
icon: Symbols.call_received,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const CallScreen(
|
||||||
|
name: 'Кирил Г.',
|
||||||
|
initialState: CallScreenState.incoming,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _DebugCallButton(
|
||||||
|
label: 'Исходящий',
|
||||||
|
icon: Symbols.call_made,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const CallScreen(
|
||||||
|
name: 'Кирил Г.',
|
||||||
|
initialState: CallScreenState.outgoing,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _DebugCallButton(
|
||||||
|
label: 'Активный',
|
||||||
|
icon: Symbols.phone_in_talk,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const CallScreen(
|
||||||
|
name: 'Кирил Г.',
|
||||||
|
initialState: CallScreenState.active,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
@@ -813,4 +1304,47 @@ class _ErrorChip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DebugCallButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _DebugCallButton({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: cs.onSurfaceVariant, size: 22, fill: 1),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
|
import '../../../core/utils/image_utils.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../main.dart' show accountModule, KometApp;
|
import '../../../main.dart' show accountModule, fileUploader, KometApp;
|
||||||
import '../../widgets/custom_notification.dart';
|
import '../../widgets/custom_notification.dart';
|
||||||
|
|
||||||
|
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||||
|
|
||||||
class EditProfileScreen extends StatefulWidget {
|
class EditProfileScreen extends StatefulWidget {
|
||||||
const EditProfileScreen({super.key});
|
const EditProfileScreen({super.key});
|
||||||
|
|
||||||
@@ -77,12 +81,56 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
|||||||
|
|
||||||
Future<void> _changeAvatar() async {
|
Future<void> _changeAvatar() async {
|
||||||
if (_isSaving) return;
|
if (_isSaving) return;
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.image,
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final picked = result.files.first;
|
||||||
|
final bytes = picked.bytes;
|
||||||
|
if (bytes == null) {
|
||||||
|
if (mounted) showCustomNotification(context, 'Не удалось прочитать файл');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bytes.length > _maxAvatarBytes) {
|
||||||
|
if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isSaving = true);
|
||||||
try {
|
try {
|
||||||
final uploadUrl = await accountModule.getAvatarUploadUrl();
|
final processed = await compressAvatar(bytes);
|
||||||
|
if (processed == null) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showCustomNotification(context, 'Не удалось обработать изображение');
|
||||||
|
setState(() => _isSaving = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final url = await accountModule.getAvatarUploadUrl();
|
||||||
|
final token = await fileUploader.uploadImage(
|
||||||
|
Uri.parse(url),
|
||||||
|
processed,
|
||||||
|
filename: 'avatar.jpg',
|
||||||
|
);
|
||||||
|
if (token == null) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||||
|
setState(() => _isSaving = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final newProfile = await accountModule.updateProfileAvatar(token);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)');
|
setState(() {
|
||||||
|
_avatarUrl = newProfile.baseUrl;
|
||||||
|
_photoId = newProfile.photoId;
|
||||||
|
_isSaving = false;
|
||||||
|
});
|
||||||
|
KometApp.stateOf(context)?.notifyProfileUpdate();
|
||||||
|
showCustomNotification(context, 'Аватарка обновлена');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
if (!mounted) return;
|
||||||
|
showCustomNotification(context, 'Ошибка: $e');
|
||||||
|
setState(() => _isSaving = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -378,7 +378,9 @@ class _FontSizeControl extends StatelessWidget {
|
|||||||
value: AppFonts.clampScale(scale),
|
value: AppFonts.clampScale(scale),
|
||||||
min: AppFonts.minScale,
|
min: AppFonts.minScale,
|
||||||
max: AppFonts.maxScale,
|
max: AppFonts.maxScale,
|
||||||
divisions: 10,
|
divisions:
|
||||||
|
((AppFonts.maxScale - AppFonts.minScale) / 0.05)
|
||||||
|
.round(),
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
onChangeEnd: onChangeEnd,
|
onChangeEnd: onChangeEnd,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:m3e_collection/m3e_collection.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
class NotificationsScreen extends StatefulWidget {
|
||||||
|
const NotificationsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NotificationsScreen> createState() => _NotificationsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||||
|
bool _fkmEnabled = false;
|
||||||
|
bool _personalChatsEnabled = true;
|
||||||
|
bool _groupsEnabled = true;
|
||||||
|
bool _channelsEnabled = true;
|
||||||
|
String _selectedSound = 'По умолчанию';
|
||||||
|
|
||||||
|
static const List<String> _sounds = [
|
||||||
|
'По умолчанию',
|
||||||
|
'Колокольчик',
|
||||||
|
'Звон',
|
||||||
|
'Капля',
|
||||||
|
'Беззвучно',
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> _pickSound() async {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final picked = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: cs.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
builder: (context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Звук уведомления',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final s in _sounds)
|
||||||
|
ListTile(
|
||||||
|
onTap: () => Navigator.of(context).pop(s),
|
||||||
|
leading: Icon(
|
||||||
|
s == _selectedSound
|
||||||
|
? Symbols.radio_button_checked
|
||||||
|
: Symbols.radio_button_unchecked,
|
||||||
|
color: s == _selectedSound
|
||||||
|
? cs.primary
|
||||||
|
: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
s,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (picked != null && picked != _selectedSound) {
|
||||||
|
setState(() => _selectedSound = picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: cs.surface,
|
||||||
|
appBar: AppBarM3E(
|
||||||
|
titleText: 'Уведомления',
|
||||||
|
backgroundColor: cs.surface,
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: ListView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
|
||||||
|
children: [
|
||||||
|
_sectionHeader(cs, 'FKM'),
|
||||||
|
_card(cs, [
|
||||||
|
_toggleRow(
|
||||||
|
cs,
|
||||||
|
icon: Symbols.notifications_active,
|
||||||
|
label: 'Включить уведомления',
|
||||||
|
subtitle:
|
||||||
|
'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.',
|
||||||
|
value: _fkmEnabled,
|
||||||
|
onChanged: (v) => setState(() => _fkmEnabled = v),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionHeader(cs, 'Настройки уведомлений'),
|
||||||
|
_card(cs, [
|
||||||
|
_toggleRow(
|
||||||
|
cs,
|
||||||
|
icon: Symbols.person,
|
||||||
|
label: 'Уведомления от личных чатов',
|
||||||
|
value: _personalChatsEnabled,
|
||||||
|
onChanged: (v) => setState(() => _personalChatsEnabled = v),
|
||||||
|
),
|
||||||
|
_divider(cs),
|
||||||
|
_toggleRow(
|
||||||
|
cs,
|
||||||
|
icon: Symbols.groups,
|
||||||
|
label: 'Уведомления от групп',
|
||||||
|
value: _groupsEnabled,
|
||||||
|
onChanged: (v) => setState(() => _groupsEnabled = v),
|
||||||
|
),
|
||||||
|
_divider(cs),
|
||||||
|
_toggleRow(
|
||||||
|
cs,
|
||||||
|
icon: Symbols.campaign,
|
||||||
|
label: 'Уведомления от каналов',
|
||||||
|
value: _channelsEnabled,
|
||||||
|
onChanged: (v) => setState(() => _channelsEnabled = v),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionHeader(cs, 'Звук'),
|
||||||
|
_card(cs, [
|
||||||
|
_tappableRow(
|
||||||
|
cs,
|
||||||
|
icon: Symbols.music_note,
|
||||||
|
label: 'Звук уведомления',
|
||||||
|
trailingText: _selectedSound,
|
||||||
|
onTap: _pickSound,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionHeader(ColorScheme cs, String title) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _card(ColorScheme cs, List<Widget> children) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(children: children),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _divider(ColorScheme cs) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 58),
|
||||||
|
child: Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
color: cs.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _toggleRow(
|
||||||
|
ColorScheme cs, {
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
String? subtitle,
|
||||||
|
required bool value,
|
||||||
|
required ValueChanged<bool> onChanged,
|
||||||
|
}) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => onChanged(!value),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Switch(value: value, onChanged: onChanged),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tappableRow(
|
||||||
|
ColorScheme cs, {
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required String trailingText,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
trailingText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(Symbols.chevron_right, color: cs.outline, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,10 +24,16 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
|
|||||||
|
|
||||||
Future<void> _check2faStatus() async {
|
Future<void> _check2faStatus() async {
|
||||||
try {
|
try {
|
||||||
final profile = await AppDatabase.loadActiveProfile();
|
bool is2faEnabled;
|
||||||
|
try {
|
||||||
|
is2faEnabled = (await accountModule.get2faStatus()).enabled;
|
||||||
|
} catch (_) {
|
||||||
|
final profile = await AppDatabase.loadActiveProfile();
|
||||||
|
is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
|
_is2faEnabled = is2faEnabled;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -307,33 +313,40 @@ class TwoFactorSetupScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final _confirmController = TextEditingController();
|
||||||
final _hintController = TextEditingController();
|
final _hintController = TextEditingController();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _codeController = TextEditingController();
|
final _codeController = TextEditingController();
|
||||||
|
|
||||||
int _step = 0;
|
int _step = 0;
|
||||||
bool _isLoading = false;
|
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||||
String? _trackId;
|
String? _trackId;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_confirmController.dispose();
|
||||||
_hintController.dispose();
|
_hintController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_codeController.dispose();
|
_codeController.dispose();
|
||||||
|
_isLoading.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _nextStep() async {
|
Future<void> _nextStep() async {
|
||||||
setState(() {
|
_isLoading.value = true;
|
||||||
_isLoading = true;
|
setState(() => _errorMessage = null);
|
||||||
_errorMessage = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (_step) {
|
switch (_step) {
|
||||||
case 0:
|
case 0:
|
||||||
|
if (_passwordController.text.length < 6) {
|
||||||
|
setState(
|
||||||
|
() => _errorMessage = 'Пароль должен быть минимум 6 символов',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
final trackId = await accountModule.create2faTrack();
|
final trackId = await accountModule.create2faTrack();
|
||||||
setState(() {
|
setState(() {
|
||||||
_trackId = trackId;
|
_trackId = trackId;
|
||||||
@@ -341,10 +354,8 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
if (_passwordController.text.length < 6) {
|
if (_confirmController.text != _passwordController.text) {
|
||||||
setState(
|
setState(() => _errorMessage = 'Пароли не совпадают');
|
||||||
() => _errorMessage = 'Пароль должен быть минимум 6 символов',
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await accountModule.set2faPassword(
|
await accountModule.set2faPassword(
|
||||||
@@ -392,7 +403,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
|||||||
setState(() => _errorMessage = e.toString());
|
setState(() => _errorMessage = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
_isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,26 +469,29 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton(
|
child: ValueListenableBuilder<bool>(
|
||||||
onPressed: _isLoading ? null : _nextStep,
|
valueListenable: _isLoading,
|
||||||
style: FilledButton.styleFrom(
|
builder: (context, loading, _) => FilledButton(
|
||||||
backgroundColor: cs.primary,
|
onPressed: loading ? null : _nextStep,
|
||||||
foregroundColor: cs.onPrimary,
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
backgroundColor: cs.primary,
|
||||||
shape: RoundedRectangleBorder(
|
foregroundColor: cs.onPrimary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
child: loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(_step == 4 ? 'Установить пароль' : 'Продолжить'),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
|
||||||
? SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: cs.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(_step == 4 ? 'Установить пароль' : 'Продолжить'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -564,18 +578,9 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
|||||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
_PasswordField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
hintText: 'Введите пароль',
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Введите пароль',
|
|
||||||
filled: true,
|
|
||||||
fillColor: cs.surfaceContainerHighest,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -599,18 +604,9 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
|||||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
_PasswordField(
|
||||||
controller: _passwordController,
|
controller: _confirmController,
|
||||||
obscureText: true,
|
hintText: 'Повторите пароль',
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Повторите пароль',
|
|
||||||
filled: true,
|
|
||||||
fillColor: cs.surfaceContainerHighest,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -732,7 +728,7 @@ class TwoFactorManageScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
bool _isLoading = false;
|
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||||
bool _isAuthenticated = false;
|
bool _isAuthenticated = false;
|
||||||
String? _trackId;
|
String? _trackId;
|
||||||
TwoFactorDetails? _details;
|
TwoFactorDetails? _details;
|
||||||
@@ -741,14 +737,13 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_isLoading.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _authenticate() async {
|
Future<void> _authenticate() async {
|
||||||
setState(() {
|
_isLoading.value = true;
|
||||||
_isLoading = true;
|
setState(() => _errorMessage = null);
|
||||||
_errorMessage = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_trackId = await accountModule.enter2faPanel();
|
_trackId = await accountModule.enter2faPanel();
|
||||||
@@ -761,7 +756,7 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _errorMessage = 'Неверный пароль');
|
setState(() => _errorMessage = 'Неверный пароль');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) _isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,42 +813,36 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
|||||||
style: TextStyle(color: cs.onErrorContainer),
|
style: TextStyle(color: cs.onErrorContainer),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextField(
|
_PasswordField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
hintText: 'Пароль',
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Пароль',
|
|
||||||
filled: true,
|
|
||||||
fillColor: cs.surfaceContainerHighest,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton(
|
child: ValueListenableBuilder<bool>(
|
||||||
onPressed: _isLoading ? null : _authenticate,
|
valueListenable: _isLoading,
|
||||||
style: FilledButton.styleFrom(
|
builder: (context, loading, _) => FilledButton(
|
||||||
backgroundColor: cs.primary,
|
onPressed: loading ? null : _authenticate,
|
||||||
foregroundColor: cs.onPrimary,
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
backgroundColor: cs.primary,
|
||||||
shape: RoundedRectangleBorder(
|
foregroundColor: cs.onPrimary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
child: loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Продолжить'),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
|
||||||
? SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: cs.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text('Продолжить'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -944,13 +933,14 @@ class _TwoFactorPasswordChangeScreenState
|
|||||||
extends State<TwoFactorPasswordChangeScreen> {
|
extends State<TwoFactorPasswordChangeScreen> {
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
final _hintController = TextEditingController();
|
final _hintController = TextEditingController();
|
||||||
bool _isLoading = false;
|
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_hintController.dispose();
|
_hintController.dispose();
|
||||||
|
_isLoading.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,10 +950,8 @@ class _TwoFactorPasswordChangeScreenState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
_isLoading.value = true;
|
||||||
_isLoading = true;
|
setState(() => _errorMessage = null);
|
||||||
_errorMessage = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final trackId = await accountModule.enter2faPanel();
|
final trackId = await accountModule.enter2faPanel();
|
||||||
@@ -983,7 +971,7 @@ class _TwoFactorPasswordChangeScreenState
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _errorMessage = e.toString());
|
setState(() => _errorMessage = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) _isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1035,18 +1023,9 @@ class _TwoFactorPasswordChangeScreenState
|
|||||||
style: TextStyle(color: cs.onErrorContainer),
|
style: TextStyle(color: cs.onErrorContainer),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextField(
|
_PasswordField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
hintText: 'Введите новый пароль',
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Введите новый пароль',
|
|
||||||
filled: true,
|
|
||||||
fillColor: cs.surfaceContainerHighest,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
@@ -1073,26 +1052,29 @@ class _TwoFactorPasswordChangeScreenState
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton(
|
child: ValueListenableBuilder<bool>(
|
||||||
onPressed: _isLoading ? null : _changePassword,
|
valueListenable: _isLoading,
|
||||||
style: FilledButton.styleFrom(
|
builder: (context, loading, _) => FilledButton(
|
||||||
backgroundColor: cs.primary,
|
onPressed: loading ? null : _changePassword,
|
||||||
foregroundColor: cs.onPrimary,
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
backgroundColor: cs.primary,
|
||||||
shape: RoundedRectangleBorder(
|
foregroundColor: cs.onPrimary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
child: loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Сохранить'),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
|
||||||
? SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: cs.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text('Сохранить'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1116,7 +1098,7 @@ class _TwoFactorEmailChangeScreenState
|
|||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _codeController = TextEditingController();
|
final _codeController = TextEditingController();
|
||||||
int _step = 0;
|
int _step = 0;
|
||||||
bool _isLoading = false;
|
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||||
String? _trackId;
|
String? _trackId;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
@@ -1125,14 +1107,13 @@ class _TwoFactorEmailChangeScreenState
|
|||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_codeController.dispose();
|
_codeController.dispose();
|
||||||
|
_isLoading.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _nextStep() async {
|
Future<void> _nextStep() async {
|
||||||
setState(() {
|
_isLoading.value = true;
|
||||||
_isLoading = true;
|
setState(() => _errorMessage = null);
|
||||||
_errorMessage = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (_step) {
|
switch (_step) {
|
||||||
@@ -1176,7 +1157,7 @@ class _TwoFactorEmailChangeScreenState
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _errorMessage = e.toString());
|
setState(() => _errorMessage = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) _isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,18 +1197,9 @@ class _TwoFactorEmailChangeScreenState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
_PasswordField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
hintText: 'Пароль',
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Пароль',
|
|
||||||
filled: true,
|
|
||||||
fillColor: cs.surfaceContainerHighest,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (_errorMessage != null)
|
if (_errorMessage != null)
|
||||||
@@ -1301,26 +1273,29 @@ class _TwoFactorEmailChangeScreenState
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton(
|
child: ValueListenableBuilder<bool>(
|
||||||
onPressed: _isLoading ? null : _nextStep,
|
valueListenable: _isLoading,
|
||||||
style: FilledButton.styleFrom(
|
builder: (context, loading, _) => FilledButton(
|
||||||
backgroundColor: cs.primary,
|
onPressed: loading ? null : _nextStep,
|
||||||
foregroundColor: cs.onPrimary,
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
backgroundColor: cs.primary,
|
||||||
shape: RoundedRectangleBorder(
|
foregroundColor: cs.onPrimary,
|
||||||
borderRadius: BorderRadius.circular(12),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
child: loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(_step == 2 ? 'Сохранить' : 'Продолжить'),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
|
||||||
? SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: cs.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(_step == 2 ? 'Сохранить' : 'Продолжить'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1339,20 +1314,19 @@ class TwoFactorRemoveScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
bool _isLoading = false;
|
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_isLoading.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _remove2fa() async {
|
Future<void> _remove2fa() async {
|
||||||
setState(() {
|
_isLoading.value = true;
|
||||||
_isLoading = true;
|
setState(() => _errorMessage = null);
|
||||||
_errorMessage = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final trackId = await accountModule.enter2faPanel();
|
final trackId = await accountModule.enter2faPanel();
|
||||||
@@ -1368,7 +1342,7 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _errorMessage = e.toString());
|
setState(() => _errorMessage = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) _isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1440,42 +1414,36 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
|||||||
style: TextStyle(color: cs.onErrorContainer),
|
style: TextStyle(color: cs.onErrorContainer),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextField(
|
_PasswordField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
hintText: 'Пароль',
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Пароль',
|
|
||||||
filled: true,
|
|
||||||
fillColor: cs.surfaceContainerHighest,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton(
|
child: ValueListenableBuilder<bool>(
|
||||||
onPressed: _isLoading ? null : _remove2fa,
|
valueListenable: _isLoading,
|
||||||
style: FilledButton.styleFrom(
|
builder: (context, loading, _) => FilledButton(
|
||||||
backgroundColor: cs.error,
|
onPressed: loading ? null : _remove2fa,
|
||||||
foregroundColor: cs.onError,
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
backgroundColor: cs.error,
|
||||||
shape: RoundedRectangleBorder(
|
foregroundColor: cs.onError,
|
||||||
borderRadius: BorderRadius.circular(12),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
child: loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.onError,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Удалить пароль'),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
|
||||||
? SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: cs.onError,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text('Удалить пароль'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1484,3 +1452,45 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String hintText;
|
||||||
|
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.hintText,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PasswordField> createState() => _PasswordFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordFieldState extends State<_PasswordField> {
|
||||||
|
bool _visible = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return TextField(
|
||||||
|
controller: widget.controller,
|
||||||
|
obscureText: !_visible,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.hintText,
|
||||||
|
filled: true,
|
||||||
|
fillColor: cs.surfaceContainerHighest,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_visible ? Symbols.visibility_off : Symbols.visibility,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _visible = !_visible),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,12 +47,18 @@ class _SecurityScreenState extends State<SecurityScreen>
|
|||||||
accountModule.getBlockedContacts(),
|
accountModule.getBlockedContacts(),
|
||||||
AppDatabase.loadActiveProfile(),
|
AppDatabase.loadActiveProfile(),
|
||||||
]);
|
]);
|
||||||
|
bool is2faEnabled;
|
||||||
|
try {
|
||||||
|
is2faEnabled = (await accountModule.get2faStatus()).enabled;
|
||||||
|
} catch (_) {
|
||||||
|
final profile = results[2] as ProfileData?;
|
||||||
|
is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_privacyConfig = results[0] as PrivacyConfig;
|
_privacyConfig = results[0] as PrivacyConfig;
|
||||||
_blockedContacts = results[1] as List<BlockedContact>;
|
_blockedContacts = results[1] as List<BlockedContact>;
|
||||||
final profile = results[2] as ProfileData?;
|
_is2faEnabled = is2faEnabled;
|
||||||
_is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import '../../../backend/modules/chats.dart';
|
||||||
import '../../../backend/modules/messages.dart';
|
import '../../../backend/modules/messages.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/storage/token_storage.dart';
|
import '../../../core/storage/token_storage.dart';
|
||||||
@@ -20,6 +21,7 @@ import 'debug_menu_screen.dart';
|
|||||||
import 'devices_screen.dart';
|
import 'devices_screen.dart';
|
||||||
import 'edit_profile_screen.dart';
|
import 'edit_profile_screen.dart';
|
||||||
import 'info_screen.dart';
|
import 'info_screen.dart';
|
||||||
|
import 'notifications_screen.dart';
|
||||||
import 'security_screen.dart';
|
import 'security_screen.dart';
|
||||||
import 'spoof_screen.dart';
|
import 'spoof_screen.dart';
|
||||||
|
|
||||||
@@ -206,6 +208,7 @@ class _SettingsTabState extends State<SettingsTab> {
|
|||||||
}
|
}
|
||||||
ContactCache.clear();
|
ContactCache.clear();
|
||||||
TranscriptionCache.clear();
|
TranscriptionCache.clear();
|
||||||
|
ChatsModule.resetForAccountSwitch();
|
||||||
try {
|
try {
|
||||||
await api.connect();
|
await api.connect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -309,9 +312,17 @@ child: _buildSection(
|
|||||||
context,
|
context,
|
||||||
cs,
|
cs,
|
||||||
items: [
|
items: [
|
||||||
const _SettingsItem(
|
_SettingsItem(
|
||||||
icon: Symbols.notifications_active,
|
icon: Symbols.notifications_active,
|
||||||
label: 'Уведомления и звук',
|
label: 'Уведомления',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const NotificationsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_SettingsItem(
|
_SettingsItem(
|
||||||
icon: Symbols.vibration,
|
icon: Symbols.vibration,
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ Future<ImageProvider?> precacheLoginAvatar(
|
|||||||
|
|
||||||
class LoginSuccessScreen extends StatefulWidget {
|
class LoginSuccessScreen extends StatefulWidget {
|
||||||
final ImageProvider? avatar;
|
final ImageProvider? avatar;
|
||||||
|
final bool preview;
|
||||||
|
|
||||||
const LoginSuccessScreen({super.key, this.avatar});
|
const LoginSuccessScreen({super.key, this.avatar, this.preview = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LoginSuccessScreen> createState() => _LoginSuccessScreenState();
|
State<LoginSuccessScreen> createState() => _LoginSuccessScreenState();
|
||||||
@@ -114,6 +115,10 @@ class _LoginSuccessScreenState extends State<LoginSuccessScreen>
|
|||||||
void _onStatus(AnimationStatus status) {
|
void _onStatus(AnimationStatus status) {
|
||||||
if (status == AnimationStatus.completed && !_navigated && mounted) {
|
if (status == AnimationStatus.completed && !_navigated && mounted) {
|
||||||
_navigated = true;
|
_navigated = true;
|
||||||
|
if (widget.preview) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 360),
|
transitionDuration: const Duration(milliseconds: 360),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:komet/main.dart';
|
import 'package:komet/main.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -7,7 +8,14 @@ import '../../core/config/app_bubble_behavior.dart';
|
|||||||
import '../../core/config/app_bubble_shape.dart';
|
import '../../core/config/app_bubble_shape.dart';
|
||||||
import '../../core/utils/bubble_radius.dart';
|
import '../../core/utils/bubble_radius.dart';
|
||||||
import '../../core/utils/haptics.dart';
|
import '../../core/utils/haptics.dart';
|
||||||
|
import '../../core/utils/file_download.dart';
|
||||||
|
import '../../core/utils/media_cache.dart';
|
||||||
|
import '../../core/utils/download_progress.dart';
|
||||||
|
import 'custom_notification.dart';
|
||||||
import '../../models/attachment.dart';
|
import '../../models/attachment.dart';
|
||||||
|
import 'poll_view.dart';
|
||||||
|
import 'photo_viewer.dart';
|
||||||
|
import 'video_player_screen.dart';
|
||||||
|
|
||||||
enum MessageType { text, attachment, voice, control }
|
enum MessageType { text, attachment, voice, control }
|
||||||
|
|
||||||
@@ -22,6 +30,7 @@ class _BubbleCtx {
|
|||||||
final MessageType contentType;
|
final MessageType contentType;
|
||||||
final bool hasPhotoWithCaption;
|
final bool hasPhotoWithCaption;
|
||||||
final bool hasMultiplePhotosNoCaption;
|
final bool hasMultiplePhotosNoCaption;
|
||||||
|
final Map? reactionInfo;
|
||||||
|
|
||||||
_BubbleCtx({
|
_BubbleCtx({
|
||||||
required this.context,
|
required this.context,
|
||||||
@@ -31,6 +40,7 @@ class _BubbleCtx {
|
|||||||
required this.contentType,
|
required this.contentType,
|
||||||
required this.hasPhotoWithCaption,
|
required this.hasPhotoWithCaption,
|
||||||
required this.hasMultiplePhotosNoCaption,
|
required this.hasMultiplePhotosNoCaption,
|
||||||
|
this.reactionInfo,
|
||||||
}) : dim = text.withValues(alpha: 0.7);
|
}) : dim = text.withValues(alpha: 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +57,10 @@ class MessageBubble extends StatelessWidget {
|
|||||||
static const Radius _smallRadius = Radius.circular(4);
|
static const Radius _smallRadius = Radius.circular(4);
|
||||||
static const Radius _photoRadius = Radius.circular(photoBorderRadius);
|
static const Radius _photoRadius = Radius.circular(photoBorderRadius);
|
||||||
|
|
||||||
|
static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18);
|
||||||
|
static const BorderRadius _reactionChipRadius =
|
||||||
|
BorderRadius.all(Radius.circular(10));
|
||||||
|
|
||||||
static Color bubbleTextColor(BuildContext context) =>
|
static Color bubbleTextColor(BuildContext context) =>
|
||||||
Theme.of(context).brightness == Brightness.dark
|
Theme.of(context).brightness == Brightness.dark
|
||||||
? Colors.white
|
? Colors.white
|
||||||
@@ -59,6 +73,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final CachedMessage? nextMessage;
|
final CachedMessage? nextMessage;
|
||||||
final String chatType;
|
final String chatType;
|
||||||
final String? overrideStatus;
|
final String? overrideStatus;
|
||||||
|
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
||||||
|
|
||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -69,6 +84,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
this.nextMessage,
|
this.nextMessage,
|
||||||
required this.chatType,
|
required this.chatType,
|
||||||
this.overrideStatus,
|
this.overrideStatus,
|
||||||
|
this.reactionsListenable,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool _computeHasPhotoWithCaption() {
|
bool _computeHasPhotoWithCaption() {
|
||||||
@@ -259,16 +275,6 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final hasMultiPhotos = _computeHasMultiplePhotosNoCaption();
|
final hasMultiPhotos = _computeHasMultiplePhotosNoCaption();
|
||||||
final textColor = bubbleTextColor(context);
|
final textColor = bubbleTextColor(context);
|
||||||
|
|
||||||
final ctx = _BubbleCtx(
|
|
||||||
context: context,
|
|
||||||
cs: cs,
|
|
||||||
text: textColor,
|
|
||||||
shape: shape,
|
|
||||||
contentType: contentType,
|
|
||||||
hasPhotoWithCaption: hasPhotoCap,
|
|
||||||
hasMultiplePhotosNoCaption: hasMultiPhotos,
|
|
||||||
);
|
|
||||||
|
|
||||||
final topMargin = _topMarginFor(contentType, shape);
|
final topMargin = _topMarginFor(contentType, shape);
|
||||||
final bottomMargin = _bottomMarginFor(contentType, shape);
|
final bottomMargin = _bottomMarginFor(contentType, shape);
|
||||||
final padding = _paddingFor(contentType, shape);
|
final padding = _paddingFor(contentType, shape);
|
||||||
@@ -276,8 +282,34 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final showAvatarSlot = !isMe;
|
final showAvatarSlot = !isMe;
|
||||||
final showAvatar = showAvatarSlot &&
|
final showAvatar = showAvatarSlot &&
|
||||||
chatType == "CHAT" &&
|
chatType == "CHAT" &&
|
||||||
nextMessage?.senderId != message.senderId &&
|
nextMessage?.senderId != message.senderId;
|
||||||
prevMessage?.senderId == message.senderId;
|
|
||||||
|
final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
|
||||||
|
final bubbleColor =
|
||||||
|
isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
|
||||||
|
|
||||||
|
_BubbleCtx makeCtx() => _BubbleCtx(
|
||||||
|
context: context,
|
||||||
|
cs: cs,
|
||||||
|
text: textColor,
|
||||||
|
shape: shape,
|
||||||
|
contentType: contentType,
|
||||||
|
hasPhotoWithCaption: hasPhotoCap,
|
||||||
|
hasMultiplePhotosNoCaption: hasMultiPhotos,
|
||||||
|
reactionInfo: _resolveReactionInfo(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Widget bubbleContent =
|
||||||
|
reactionsListenable != null && contentType == MessageType.text
|
||||||
|
? ValueListenableBuilder<Map<String, dynamic>?>(
|
||||||
|
valueListenable: reactionsListenable!,
|
||||||
|
builder: (context, _, _) => _buildContent(makeCtx()),
|
||||||
|
)
|
||||||
|
: _buildContent(makeCtx());
|
||||||
|
|
||||||
|
final reactionsUnder = _reactionsUnderBubble(contentType);
|
||||||
|
final reactionsInside =
|
||||||
|
contentType != MessageType.text && !reactionsUnder;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: Haptics.tap,
|
onTap: Haptics.tap,
|
||||||
@@ -304,32 +336,40 @@ class MessageBubble extends StatelessWidget {
|
|||||||
radius: 15,
|
radius: 15,
|
||||||
backgroundColor: Color(0x00000000),
|
backgroundColor: Color(0x00000000),
|
||||||
),
|
),
|
||||||
ListenableBuilder(
|
Column(
|
||||||
listenable: Listenable.merge(
|
crossAxisAlignment:
|
||||||
[AppBubbleShape.current, AppBubbleBehavior.current],
|
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
builder: (context, child) {
|
ListenableBuilder(
|
||||||
return Container(
|
listenable: Listenable.merge([
|
||||||
constraints: BoxConstraints(
|
AppBubbleShape.current,
|
||||||
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
|
AppBubbleBehavior.current,
|
||||||
),
|
]),
|
||||||
decoration: BoxDecoration(
|
builder: (context, child) => Container(
|
||||||
color: isMe
|
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
||||||
? cs.primaryContainer
|
decoration: BoxDecoration(
|
||||||
: cs.surfaceContainerHighest,
|
color: bubbleColor,
|
||||||
borderRadius: _borderRadiusFor(
|
borderRadius: _borderRadiusFor(
|
||||||
AppBubbleShape.current.value,
|
AppBubbleShape.current.value,
|
||||||
AppBubbleBehavior.current.value,
|
AppBubbleBehavior.current.value,
|
||||||
shape,
|
shape,
|
||||||
hasPhotoCap,
|
hasPhotoCap,
|
||||||
hasMultiPhotos,
|
hasMultiPhotos,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
padding: padding,
|
||||||
|
child: child,
|
||||||
),
|
),
|
||||||
padding: padding,
|
child: reactionsInside
|
||||||
child: child,
|
? Column(
|
||||||
);
|
mainAxisSize: MainAxisSize.min,
|
||||||
},
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: _buildContent(ctx),
|
children: [bubbleContent, _reactionsBar(cs)],
|
||||||
|
)
|
||||||
|
: bubbleContent,
|
||||||
|
),
|
||||||
|
if (reactionsUnder) _reactionsBar(cs),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -338,6 +378,37 @@ class MessageBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map? _resolveReactionInfo() {
|
||||||
|
if (reactionsListenable != null) {
|
||||||
|
final v = reactionsListenable!.value;
|
||||||
|
if (v != null) return v;
|
||||||
|
}
|
||||||
|
final info = message.payload?['reactionInfo'];
|
||||||
|
if (info is Map) return info;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _reactionsUnderBubble(MessageType contentType) {
|
||||||
|
if (contentType != MessageType.attachment) return false;
|
||||||
|
final attachments = message.attachments;
|
||||||
|
if (attachments == null || attachments.isEmpty) return false;
|
||||||
|
if (attachments.first is ForwardedMessageAttachment) return false;
|
||||||
|
if (attachments.any((a) => a is ContactAttachment)) return false;
|
||||||
|
if (attachments.whereType<PhotoAttachment>().length >= 2) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _reactionsBar(ColorScheme cs) {
|
||||||
|
final listenable = reactionsListenable;
|
||||||
|
if (listenable != null) {
|
||||||
|
return ValueListenableBuilder<Map<String, dynamic>?>(
|
||||||
|
valueListenable: listenable,
|
||||||
|
builder: (context, info, _) => _buildReactionsBarFor(cs, info),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildReactionsBar(cs);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildContent(_BubbleCtx ctx) {
|
Widget _buildContent(_BubbleCtx ctx) {
|
||||||
switch (ctx.contentType) {
|
switch (ctx.contentType) {
|
||||||
case MessageType.control:
|
case MessageType.control:
|
||||||
@@ -351,6 +422,65 @@ class MessageBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildReactionsBar(ColorScheme cs) {
|
||||||
|
final info = message.payload?['reactionInfo'];
|
||||||
|
return _buildReactionsBarFor(cs, info is Map ? info : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReactionsBarFor(ColorScheme cs, Map? info) {
|
||||||
|
final chips = _buildReactionChipsFor(cs, info);
|
||||||
|
if (chips.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Wrap(spacing: 4, runSpacing: 4, children: chips),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildReactionChipsFor(ColorScheme cs, Map? info) {
|
||||||
|
if (info == null) return const [];
|
||||||
|
final counters = info['counters'];
|
||||||
|
if (counters is! List || counters.isEmpty) return const [];
|
||||||
|
final yourReaction = info['yourReaction']?.toString();
|
||||||
|
|
||||||
|
final chips = <Widget>[];
|
||||||
|
for (final c in counters) {
|
||||||
|
if (c is! Map) continue;
|
||||||
|
final reaction = c['reaction']?.toString();
|
||||||
|
final count = c['count'];
|
||||||
|
if (reaction == null || reaction.isEmpty) continue;
|
||||||
|
final isYours = yourReaction == reaction;
|
||||||
|
chips.add(
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isYours
|
||||||
|
? cs.primary.withValues(alpha: 0.22)
|
||||||
|
: _reactionChipBg,
|
||||||
|
borderRadius: _reactionChipRadius,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(reaction, style: const TextStyle(fontSize: 13)),
|
||||||
|
if (count is int && count > 1) ...[
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
count.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isYours ? cs.primary : cs.onSurfaceVariant,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildControlContent(ColorScheme cs) {
|
Widget _buildControlContent(ColorScheme cs) {
|
||||||
final attachments = message.attachments;
|
final attachments = message.attachments;
|
||||||
if (attachments == null || attachments.isEmpty) {
|
if (attachments == null || attachments.isEmpty) {
|
||||||
@@ -421,49 +551,95 @@ class MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
final displaySender = ContactCache.get(message.senderId);
|
final displaySender = ContactCache.get(message.senderId);
|
||||||
|
|
||||||
|
final reactionChips = _buildReactionChipsFor(ctx.cs, ctx.reactionInfo);
|
||||||
|
final hasReactions = reactionChips.isNotEmpty;
|
||||||
|
|
||||||
|
final textWidget = isForwarded
|
||||||
|
? _buildForwardedInlineText(ctx, forwarded)
|
||||||
|
: Text(
|
||||||
|
message.text ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: ctx.text,
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final metaWidget = Text(
|
||||||
|
message.status == 'EDITED'
|
||||||
|
? '${_formatTime(message.time)} ред.'
|
||||||
|
: _formatTime(message.time),
|
||||||
|
style: TextStyle(color: ctx.dim, fontSize: 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
final showSender = message.senderId != message.accountId &&
|
||||||
|
prevMessage?.senderId != message.senderId &&
|
||||||
|
chatType == "CHAT";
|
||||||
|
|
||||||
|
if (hasReactions) {
|
||||||
|
return IntrinsicWidth(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (showSender)
|
||||||
|
Text(
|
||||||
|
displaySender ?? "",
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
style: TextStyle(color: ctx.text),
|
||||||
|
),
|
||||||
|
textWidget,
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: reactionChips,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: metaWidget,
|
||||||
|
),
|
||||||
|
if (isMe) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildStatusIcon(ctx),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (message.senderId != message.accountId &&
|
if (showSender)
|
||||||
prevMessage?.senderId != message.senderId &&
|
|
||||||
chatType == "CHAT")
|
|
||||||
Text(
|
Text(
|
||||||
displaySender ?? "",
|
displaySender ?? "",
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
style: TextStyle(color: ctx.text),
|
style: TextStyle(color: ctx.text),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(child: textWidget),
|
||||||
child: isForwarded
|
const SizedBox(width: 8),
|
||||||
? _buildForwardedInlineText(ctx, forwarded)
|
Padding(
|
||||||
: Text(
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
message.text ?? '',
|
child: metaWidget,
|
||||||
style: TextStyle(
|
|
||||||
color: ctx.text,
|
|
||||||
fontSize: 16,
|
|
||||||
height: 1.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
|
||||||
child: Text(
|
|
||||||
message.status == 'EDITED'
|
|
||||||
? '${_formatTime(message.time)} ред.'
|
|
||||||
: _formatTime(message.time),
|
|
||||||
style: TextStyle(color: ctx.dim, fontSize: 10),
|
|
||||||
),
|
),
|
||||||
),
|
if (isMe) ...[
|
||||||
if (isMe) ...[
|
const SizedBox(width: 4),
|
||||||
const SizedBox(width: 4),
|
_buildStatusIcon(ctx),
|
||||||
_buildStatusIcon(ctx),
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -576,6 +752,11 @@ class MessageBubble extends StatelessWidget {
|
|||||||
return _buildContactAttachment(ctx, contacts.first);
|
return _buildContactAttachment(ctx, contacts.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final polls = attachments.whereType<PollAttachment>().toList();
|
||||||
|
if (polls.isNotEmpty) {
|
||||||
|
return _buildPollAttachment(ctx, polls.first);
|
||||||
|
}
|
||||||
|
|
||||||
final photos = attachments.whereType<PhotoAttachment>().toList();
|
final photos = attachments.whereType<PhotoAttachment>().toList();
|
||||||
if (photos.isEmpty) {
|
if (photos.isEmpty) {
|
||||||
return _buildGenericAttachment(ctx, attachments.first);
|
return _buildGenericAttachment(ctx, attachments.first);
|
||||||
@@ -584,6 +765,21 @@ class MessageBubble extends StatelessWidget {
|
|||||||
return _buildPhotoContent(ctx, photos);
|
return _buildPhotoContent(ctx, photos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPollAttachment(_BubbleCtx ctx, PollAttachment poll) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||||
|
child: PollView(
|
||||||
|
chatId: message.chatId,
|
||||||
|
messageId: message.id,
|
||||||
|
pollId: poll.pollId,
|
||||||
|
fallbackTitle: poll.title ?? message.text,
|
||||||
|
textColor: ctx.text,
|
||||||
|
dimColor: ctx.dim,
|
||||||
|
accentColor: ctx.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildPhotoContent(_BubbleCtx ctx, List<PhotoAttachment> photos) {
|
Widget _buildPhotoContent(_BubbleCtx ctx, List<PhotoAttachment> photos) {
|
||||||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||||||
final count = photos.length;
|
final count = photos.length;
|
||||||
@@ -805,6 +1001,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
|
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
|
||||||
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
|
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
|
||||||
|
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
|
||||||
|
|
||||||
final matchTop = ctx.hasPhotoWithCaption;
|
final matchTop = ctx.hasPhotoWithCaption;
|
||||||
final matchBottom = !ctx.hasPhotoWithCaption;
|
final matchBottom = !ctx.hasPhotoWithCaption;
|
||||||
@@ -830,8 +1027,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
width: constrainedWidth,
|
width: constrainedWidth,
|
||||||
height: constrainedHeight,
|
height: constrainedHeight,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: (constrainedWidth * 2).round(),
|
memCacheWidth: (constrainedWidth * dpr).round(),
|
||||||
memCacheHeight: (constrainedHeight * 2).round(),
|
memCacheHeight: (constrainedHeight * dpr).round(),
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
|
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
|
||||||
ctx.cs,
|
ctx.cs,
|
||||||
@@ -926,6 +1123,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
|
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
|
||||||
final imageUrl = photo.baseUrl ?? '';
|
final imageUrl = photo.baseUrl ?? '';
|
||||||
|
final cachePx =
|
||||||
|
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -936,8 +1135,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
memCacheWidth: 280,
|
memCacheWidth: cachePx,
|
||||||
memCacheHeight: 280,
|
memCacheHeight: cachePx,
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
errorWidget: (_, _, _) =>
|
errorWidget: (_, _, _) =>
|
||||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||||
@@ -961,6 +1160,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
String overlay,
|
String overlay,
|
||||||
) {
|
) {
|
||||||
final imageUrl = photo.baseUrl ?? '';
|
final imageUrl = photo.baseUrl ?? '';
|
||||||
|
final cachePx =
|
||||||
|
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -971,8 +1172,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
memCacheWidth: 280,
|
memCacheWidth: cachePx,
|
||||||
memCacheHeight: 280,
|
memCacheHeight: cachePx,
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
errorWidget: (_, _, _) =>
|
errorWidget: (_, _, _) =>
|
||||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||||
@@ -1061,10 +1262,22 @@ class MessageBubble extends StatelessWidget {
|
|||||||
color: ctx.cs.onSurfaceVariant,
|
color: ctx.cs.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Symbols.play_arrow,
|
||||||
|
color: Colors.white, size: 30),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () {},
|
onTap: () => _playVideo(ctx.context, video),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1076,10 +1289,55 @@ class MessageBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _playVideo(
|
||||||
|
BuildContext context,
|
||||||
|
MessageAttachment video,
|
||||||
|
) async {
|
||||||
|
final videoId = (video as dynamic).videoId as int?;
|
||||||
|
final token = (video as dynamic).videoToken as String?;
|
||||||
|
if (videoId == null) {
|
||||||
|
showCustomNotification(context, 'Не удалось открыть видео');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Haptics.tap();
|
||||||
|
|
||||||
|
final cacheName = 'video_$videoId.mp4';
|
||||||
|
final cached = await MediaCache.existing(cacheName) != null;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
String? url;
|
||||||
|
if (!cached) {
|
||||||
|
if (token == null) {
|
||||||
|
showCustomNotification(context, 'Не удалось открыть видео');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
url = await messagesModule.getVideoUrl(
|
||||||
|
messageId: message.id,
|
||||||
|
chatId: message.chatId,
|
||||||
|
token: token,
|
||||||
|
videoId: videoId,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (url == null) {
|
||||||
|
showCustomNotification(context, 'Не удалось получить видео');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (_) => VideoPlayerScreen(cacheName: cacheName, url: url),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
|
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
|
||||||
final name = (file as dynamic).name as String? ?? 'File';
|
final name = (file as dynamic).name as String? ?? 'File';
|
||||||
final size = (file as dynamic).size as int? ?? 0;
|
final size = (file as dynamic).size as int? ?? 0;
|
||||||
final sizeStr = _formatFileSize(size);
|
final sizeStr = _formatFileSize(size);
|
||||||
|
final fileId = (file as dynamic).fileId as int?;
|
||||||
|
final cacheName = '${fileId}_$name';
|
||||||
|
|
||||||
return IntrinsicWidth(
|
return IntrinsicWidth(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -1125,35 +1383,61 @@ class MessageBubble extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
ValueListenableBuilder<double?>(
|
||||||
sizeStr,
|
valueListenable: MediaDownloadProgress.notifier(cacheName),
|
||||||
style: TextStyle(
|
builder: (context, progress, _) => Text(
|
||||||
color: ctx.dim,
|
progress != null
|
||||||
fontSize: 12,
|
? '${(progress * 100).round()}% · $sizeStr'
|
||||||
height: 1.2,
|
: sizeStr,
|
||||||
|
style: TextStyle(
|
||||||
|
color: ctx.dim,
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
GestureDetector(
|
ValueListenableBuilder<double?>(
|
||||||
onTap: () {},
|
valueListenable: MediaDownloadProgress.notifier(cacheName),
|
||||||
child: Container(
|
builder: (context, progress, _) {
|
||||||
width: 34,
|
final downloading = progress != null;
|
||||||
height: 34,
|
return GestureDetector(
|
||||||
decoration: BoxDecoration(
|
onTap: downloading
|
||||||
color: isMe
|
? null
|
||||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
: () => _downloadFile(ctx.context, file, name),
|
||||||
: ctx.cs.surfaceContainerHighest,
|
child: Container(
|
||||||
shape: BoxShape.circle,
|
width: 34,
|
||||||
),
|
height: 34,
|
||||||
child: Icon(
|
decoration: BoxDecoration(
|
||||||
Symbols.download,
|
color: isMe
|
||||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||||
size: 18,
|
: ctx.cs.surfaceContainerHighest,
|
||||||
),
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
|
child: downloading
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
value: progress > 0 ? progress : null,
|
||||||
|
color: isMe
|
||||||
|
? ctx.cs.onPrimaryContainer
|
||||||
|
: ctx.cs.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Symbols.download,
|
||||||
|
color: isMe
|
||||||
|
? ctx.cs.onPrimaryContainer
|
||||||
|
: ctx.cs.primary,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1436,7 +1720,48 @@ class MessageBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) {
|
void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) {
|
||||||
// TODO: Open photo viewer
|
final url = photo.baseUrl ?? '';
|
||||||
|
if (url.isEmpty) return;
|
||||||
|
Navigator.of(ctx).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
fullscreenDialog: true,
|
||||||
|
builder: (_) => PhotoViewerScreen(baseUrl: url),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadFile(
|
||||||
|
BuildContext context,
|
||||||
|
MessageAttachment file,
|
||||||
|
String name,
|
||||||
|
) async {
|
||||||
|
final fileId = (file as dynamic).fileId as int?;
|
||||||
|
if (fileId == null) {
|
||||||
|
showCustomNotification(context, 'Не удалось определить файл');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Haptics.tap();
|
||||||
|
|
||||||
|
final cacheName = '${fileId}_$name';
|
||||||
|
|
||||||
|
MediaDownloadProgress.set(cacheName, 0);
|
||||||
|
final result = await openCachedFile(
|
||||||
|
cacheName,
|
||||||
|
() => messagesModule.getFileUrl(
|
||||||
|
messageId: message.id,
|
||||||
|
chatId: message.chatId,
|
||||||
|
fileId: fileId,
|
||||||
|
),
|
||||||
|
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
|
||||||
|
);
|
||||||
|
MediaDownloadProgress.set(cacheName, null);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (!result.ok) {
|
||||||
|
showCustomNotification(
|
||||||
|
context,
|
||||||
|
'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildVoiceContent(_BubbleCtx ctx) {
|
Widget _buildVoiceContent(_BubbleCtx ctx) {
|
||||||
@@ -1599,7 +1924,7 @@ class _VoiceMessageBubble extends StatefulWidget {
|
|||||||
|
|
||||||
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||||
bool _isPlaying = false;
|
bool _isPlaying = false;
|
||||||
double _progress = 0.0;
|
final ValueNotifier<double> _progress = ValueNotifier(0.0);
|
||||||
bool _transcriptionVisible = false;
|
bool _transcriptionVisible = false;
|
||||||
String? _transcriptionText;
|
String? _transcriptionText;
|
||||||
bool _transcriptionLoading = false;
|
bool _transcriptionLoading = false;
|
||||||
@@ -1607,10 +1932,13 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.preloadedText != null) {
|
_transcriptionText = widget.preloadedText;
|
||||||
_transcriptionText = widget.preloadedText;
|
}
|
||||||
_transcriptionVisible = true;
|
|
||||||
}
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_progress.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(int seconds) {
|
String _formatDuration(int seconds) {
|
||||||
@@ -1705,18 +2033,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTapDown: (details) {
|
onTapDown: (details) {
|
||||||
setState(() {
|
_progress.value =
|
||||||
_progress = (details.localPosition.dx /
|
(details.localPosition.dx / constraints.maxWidth)
|
||||||
constraints.maxWidth)
|
.clamp(0.0, 1.0);
|
||||||
.clamp(0.0, 1.0);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
setState(() {
|
_progress.value =
|
||||||
_progress = (details.localPosition.dx /
|
(details.localPosition.dx / constraints.maxWidth)
|
||||||
constraints.maxWidth)
|
.clamp(0.0, 1.0);
|
||||||
.clamp(0.0, 1.0);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 4,
|
height: 4,
|
||||||
@@ -1724,13 +2048,16 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
|||||||
color: waveInactiveColor,
|
color: waveInactiveColor,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
child: FractionallySizedBox(
|
child: ValueListenableBuilder<double>(
|
||||||
alignment: Alignment.centerLeft,
|
valueListenable: _progress,
|
||||||
widthFactor: _progress.clamp(0.0, 1.0),
|
builder: (context, progress, _) => FractionallySizedBox(
|
||||||
child: Container(
|
alignment: Alignment.centerLeft,
|
||||||
decoration: BoxDecoration(
|
widthFactor: progress.clamp(0.0, 1.0),
|
||||||
color: waveActiveColor,
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(2),
|
decoration: BoxDecoration(
|
||||||
|
color: waveActiveColor,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1772,14 +2099,19 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
|||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
SizedBox(
|
||||||
_formatDuration(widget.duration),
|
width: 32,
|
||||||
style: TextStyle(
|
child: Center(
|
||||||
color: widget.textColor.withValues(alpha: 0.7),
|
child: Text(
|
||||||
fontSize: 11,
|
_formatDuration(widget.duration),
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.textColor.withValues(alpha: 0.7),
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
class PhotoViewerScreen extends StatelessWidget {
|
||||||
|
final String baseUrl;
|
||||||
|
|
||||||
|
const PhotoViewerScreen({super.key, required this.baseUrl});
|
||||||
|
|
||||||
|
String get _url => baseUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
minScale: 1,
|
||||||
|
maxScale: 5,
|
||||||
|
child: Center(
|
||||||
|
child: _url.isEmpty
|
||||||
|
? const Icon(Symbols.broken_image,
|
||||||
|
color: Colors.white54, size: 64)
|
||||||
|
: CachedNetworkImage(
|
||||||
|
imageUrl: _url,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: MediaQuery.of(context).padding.top + 8,
|
||||||
|
left: 8,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Symbols.close, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../main.dart';
|
||||||
|
import '../../models/poll.dart';
|
||||||
|
|
||||||
|
class PollView extends StatefulWidget {
|
||||||
|
final int chatId;
|
||||||
|
final String messageId;
|
||||||
|
final int pollId;
|
||||||
|
final String? fallbackTitle;
|
||||||
|
final Color textColor;
|
||||||
|
final Color dimColor;
|
||||||
|
final Color accentColor;
|
||||||
|
|
||||||
|
const PollView({
|
||||||
|
super.key,
|
||||||
|
required this.chatId,
|
||||||
|
required this.messageId,
|
||||||
|
required this.pollId,
|
||||||
|
required this.textColor,
|
||||||
|
required this.dimColor,
|
||||||
|
required this.accentColor,
|
||||||
|
this.fallbackTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PollView> createState() => _PollViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PollViewState extends State<PollView> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
pollsModule.fetch(widget.chatId, widget.messageId, widget.pollId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: pollsModule,
|
||||||
|
builder: (context, _) {
|
||||||
|
final poll = pollsModule.get(widget.pollId);
|
||||||
|
return _buildCard(poll);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCard(Poll? poll) {
|
||||||
|
final title = poll?.title.isNotEmpty == true
|
||||||
|
? poll!.title
|
||||||
|
: (widget.fallbackTitle ?? 'Опрос');
|
||||||
|
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: 220, maxWidth: 280),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.textColor,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
poll == null
|
||||||
|
? 'Загрузка опроса…'
|
||||||
|
: _votesLabel(poll.total),
|
||||||
|
style: TextStyle(color: widget.dimColor, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (poll != null)
|
||||||
|
...poll.answers.map((a) => _buildAnswer(a, poll.total)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnswer(PollAnswer answer, int total) {
|
||||||
|
final pct = total > 0 ? answer.voteCount / total : 0.0;
|
||||||
|
final pctLabel = '${(pct * 100).round()}%';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
answer.text,
|
||||||
|
style: TextStyle(color: widget.textColor, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
pctLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.dimColor,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: pct,
|
||||||
|
minHeight: 6,
|
||||||
|
backgroundColor: widget.dimColor.withValues(alpha: 0.2),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(widget.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _votesLabel(int total) {
|
||||||
|
if (total == 0) return 'Нет голосов';
|
||||||
|
final mod10 = total % 10;
|
||||||
|
final mod100 = total % 100;
|
||||||
|
String word;
|
||||||
|
if (mod10 == 1 && mod100 != 11) {
|
||||||
|
word = 'голос';
|
||||||
|
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||||||
|
word = 'голоса';
|
||||||
|
} else {
|
||||||
|
word = 'голосов';
|
||||||
|
}
|
||||||
|
return '$total $word';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
|
||||||
|
class RightwardDragRecognizer extends HorizontalDragGestureRecognizer {
|
||||||
|
RightwardDragRecognizer({super.debugOwner}) {
|
||||||
|
onlyAcceptDragOnThreshold = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const double _kMinAcceptVelocity = 700.0;
|
||||||
|
static const double _kMinAcceptDistance = 20.0;
|
||||||
|
|
||||||
|
final Map<int, Offset> _initialPositions = {};
|
||||||
|
final Map<int, VelocityTracker> _velocityTrackers = {};
|
||||||
|
final Map<int, double> _currentDeltaX = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void addAllowedPointer(PointerDownEvent event) {
|
||||||
|
_initialPositions[event.pointer] = event.position;
|
||||||
|
final tracker = VelocityTracker.withKind(event.kind);
|
||||||
|
tracker.addPosition(event.timeStamp, event.localPosition);
|
||||||
|
_velocityTrackers[event.pointer] = tracker;
|
||||||
|
super.addAllowedPointer(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleEvent(PointerEvent event) {
|
||||||
|
if (event is PointerMoveEvent) {
|
||||||
|
_velocityTrackers[event.pointer]
|
||||||
|
?.addPosition(event.timeStamp, event.localPosition);
|
||||||
|
final initial = _initialPositions[event.pointer];
|
||||||
|
if (initial != null) {
|
||||||
|
final dx = event.position.dx - initial.dx;
|
||||||
|
_currentDeltaX[event.pointer] = dx;
|
||||||
|
if (dx < -kTouchSlop) {
|
||||||
|
stopTrackingPointer(event.pointer);
|
||||||
|
_cleanup(event.pointer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.handleEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool hasSufficientGlobalDistanceToAccept(
|
||||||
|
PointerDeviceKind pointerDeviceKind,
|
||||||
|
double? deviceTouchSlop,
|
||||||
|
) {
|
||||||
|
if (!super.hasSufficientGlobalDistanceToAccept(
|
||||||
|
pointerDeviceKind, deviceTouchSlop)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
double maxDx = 0;
|
||||||
|
for (final dx in _currentDeltaX.values) {
|
||||||
|
if (dx > maxDx) maxDx = dx;
|
||||||
|
}
|
||||||
|
if (maxDx < _kMinAcceptDistance) return false;
|
||||||
|
for (final tracker in _velocityTrackers.values) {
|
||||||
|
final vx = tracker.getVelocity().pixelsPerSecond.dx;
|
||||||
|
if (vx >= _kMinAcceptVelocity) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanup(int pointer) {
|
||||||
|
_initialPositions.remove(pointer);
|
||||||
|
_velocityTrackers.remove(pointer);
|
||||||
|
_currentDeltaX.remove(pointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didStopTrackingLastPointer(int pointer) {
|
||||||
|
_cleanup(pointer);
|
||||||
|
super.didStopTrackingLastPointer(pointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void rejectGesture(int pointer) {
|
||||||
|
_cleanup(pointer);
|
||||||
|
super.rejectGesture(pointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,101 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
|
||||||
class SwipeRoute<T> extends CupertinoPageRoute<T> {
|
import 'rightward_drag_recognizer.dart';
|
||||||
|
|
||||||
|
class SwipeRoute<T> extends PageRoute<T> {
|
||||||
SwipeRoute({
|
SwipeRoute({
|
||||||
required super.builder,
|
required this.builder,
|
||||||
super.settings,
|
super.settings,
|
||||||
super.maintainState,
|
|
||||||
super.fullscreenDialog,
|
super.fullscreenDialog,
|
||||||
|
this.maintainState = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final WidgetBuilder builder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final bool maintainState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Color? get barrierColor => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get barrierLabel => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration get transitionDuration => const Duration(milliseconds: 400);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration get reverseTransitionDuration => const Duration(milliseconds: 400);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
|
||||||
|
return nextRoute is SwipeRoute || nextRoute is CupertinoRouteTransitionMixin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get popGestureInProgress => _gestureController != null;
|
||||||
|
|
||||||
|
_SwipeBackController<T>? _gestureController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get popGestureEnabled {
|
||||||
|
if (isFirst) return false;
|
||||||
|
if (willHandlePopInternally) return false;
|
||||||
|
if (popDisposition == RoutePopDisposition.doNotPop) return false;
|
||||||
|
if (animation?.status != AnimationStatus.completed) return false;
|
||||||
|
if (secondaryAnimation?.status != AnimationStatus.dismissed) return false;
|
||||||
|
if (popGestureInProgress) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_SwipeBackController<T> _startPopGesture() {
|
||||||
|
final gesture = _SwipeBackController<T>(
|
||||||
|
navigator: navigator!,
|
||||||
|
controller: controller!,
|
||||||
|
);
|
||||||
|
_gestureController = gesture;
|
||||||
|
gesture._onEnd = () {
|
||||||
|
if (_gestureController == gesture) {
|
||||||
|
_gestureController = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return gesture;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildPage(
|
||||||
|
BuildContext context,
|
||||||
|
Animation<double> animation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
) {
|
||||||
|
return Semantics(
|
||||||
|
scopesRoute: true,
|
||||||
|
explicitChildNodes: true,
|
||||||
|
child: builder(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildTransitions(
|
||||||
|
BuildContext context,
|
||||||
|
Animation<double> animation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
Widget child,
|
||||||
|
) {
|
||||||
|
return _SwipeBackGestureDetector<T>(
|
||||||
|
enabledCallback: () => popGestureEnabled,
|
||||||
|
onStartPopGesture: _startPopGesture,
|
||||||
|
child: CupertinoPageTransition(
|
||||||
|
primaryRouteAnimation: animation,
|
||||||
|
secondaryRouteAnimation: secondaryAnimation,
|
||||||
|
linearTransition: popGestureInProgress,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<T?> pushSwipeable<T>(
|
Future<T?> pushSwipeable<T>(
|
||||||
@@ -18,3 +107,130 @@ Future<T?> pushSwipeable<T>(
|
|||||||
SwipeRoute<T>(builder: builder, settings: settings),
|
SwipeRoute<T>(builder: builder, settings: settings),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SwipeBackGestureDetector<T> extends StatefulWidget {
|
||||||
|
const _SwipeBackGestureDetector({
|
||||||
|
required this.enabledCallback,
|
||||||
|
required this.onStartPopGesture,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ValueGetter<bool> enabledCallback;
|
||||||
|
final ValueGetter<_SwipeBackController<T>> onStartPopGesture;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SwipeBackGestureDetector<T>> createState() =>
|
||||||
|
_SwipeBackGestureDetectorState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwipeBackGestureDetectorState<T>
|
||||||
|
extends State<_SwipeBackGestureDetector<T>> {
|
||||||
|
_SwipeBackController<T>? _backController;
|
||||||
|
double _width = 0;
|
||||||
|
|
||||||
|
void _handleStart(DragStartDetails details) {
|
||||||
|
if (!widget.enabledCallback()) return;
|
||||||
|
_width = context.size?.width ?? MediaQuery.of(context).size.width;
|
||||||
|
if (_width <= 0) _width = 1.0;
|
||||||
|
_backController = widget.onStartPopGesture();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleUpdate(DragUpdateDetails details) {
|
||||||
|
final delta = details.primaryDelta ?? 0.0;
|
||||||
|
_backController?.dragUpdate(delta / _width);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleEnd(DragEndDetails details) {
|
||||||
|
final velocity = details.velocity.pixelsPerSecond.dx / _width;
|
||||||
|
_backController?.dragEnd(velocity);
|
||||||
|
_backController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleCancel() {
|
||||||
|
_backController?.dragEnd(0.0);
|
||||||
|
_backController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RawGestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
gestures: <Type, GestureRecognizerFactory>{
|
||||||
|
RightwardDragRecognizer:
|
||||||
|
GestureRecognizerFactoryWithHandlers<RightwardDragRecognizer>(
|
||||||
|
() => RightwardDragRecognizer(debugOwner: this),
|
||||||
|
(instance) {
|
||||||
|
instance
|
||||||
|
..onStart = _handleStart
|
||||||
|
..onUpdate = _handleUpdate
|
||||||
|
..onEnd = _handleEnd
|
||||||
|
..onCancel = _handleCancel;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwipeBackController<T> {
|
||||||
|
_SwipeBackController({
|
||||||
|
required this.navigator,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
final NavigatorState navigator;
|
||||||
|
final AnimationController controller;
|
||||||
|
VoidCallback? _onEnd;
|
||||||
|
|
||||||
|
static const double _kMinFlingVelocity = 1.0;
|
||||||
|
|
||||||
|
void dragUpdate(double delta) {
|
||||||
|
controller.value -= delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dragEnd(double velocity) {
|
||||||
|
const animationCurve = Curves.fastLinearToSlowEaseIn;
|
||||||
|
final bool animateForward;
|
||||||
|
|
||||||
|
if (velocity.abs() >= _kMinFlingVelocity) {
|
||||||
|
animateForward = velocity <= 0;
|
||||||
|
} else {
|
||||||
|
animateForward = controller.value > 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animateForward) {
|
||||||
|
final forwardMs = math.min(
|
||||||
|
lerpDouble(800, 0, controller.value)!.floor(),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
controller.animateTo(
|
||||||
|
1.0,
|
||||||
|
duration: Duration(milliseconds: forwardMs),
|
||||||
|
curve: animationCurve,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
navigator.pop();
|
||||||
|
if (controller.isAnimating) {
|
||||||
|
final backMs = lerpDouble(0, 800, controller.value)!.floor();
|
||||||
|
controller.animateBack(
|
||||||
|
0.0,
|
||||||
|
duration: Duration(milliseconds: backMs),
|
||||||
|
curve: animationCurve,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.isAnimating) {
|
||||||
|
late AnimationStatusListener statusCb;
|
||||||
|
statusCb = (status) {
|
||||||
|
_onEnd?.call();
|
||||||
|
controller.removeStatusListener(statusCb);
|
||||||
|
};
|
||||||
|
controller.addStatusListener(statusCb);
|
||||||
|
} else {
|
||||||
|
_onEnd?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'rightward_drag_recognizer.dart';
|
||||||
|
|
||||||
class SwipeToPop extends StatefulWidget {
|
class SwipeToPop extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final double edgeWidth;
|
|
||||||
final double popThreshold;
|
final double popThreshold;
|
||||||
final double velocityThreshold;
|
final double velocityThreshold;
|
||||||
final bool fullWidth;
|
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final VoidCallback? onPop;
|
final VoidCallback? onPop;
|
||||||
|
|
||||||
const SwipeToPop({
|
const SwipeToPop({
|
||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.edgeWidth = 28,
|
|
||||||
this.popThreshold = 0.35,
|
this.popThreshold = 0.35,
|
||||||
this.velocityThreshold = 700,
|
this.velocityThreshold = 700,
|
||||||
this.fullWidth = false,
|
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.onPop,
|
this.onPop,
|
||||||
});
|
});
|
||||||
@@ -28,6 +25,7 @@ class SwipeToPop extends StatefulWidget {
|
|||||||
class _SwipeToPopState extends State<SwipeToPop>
|
class _SwipeToPopState extends State<SwipeToPop>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final AnimationController _controller;
|
late final AnimationController _controller;
|
||||||
|
double _width = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -53,17 +51,19 @@ class _SwipeToPopState extends State<SwipeToPop>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragStart(DragStartDetails _) {
|
void _onDragStart(DragStartDetails _) {
|
||||||
|
_width = context.size?.width ?? MediaQuery.of(context).size.width;
|
||||||
|
if (_width <= 0) _width = 1.0;
|
||||||
_controller.stop();
|
_controller.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragUpdate(DragUpdateDetails d, double width) {
|
void _onDragUpdate(DragUpdateDetails d) {
|
||||||
if (width <= 0) return;
|
final next =
|
||||||
final next = (_controller.value + d.delta.dx / width).clamp(0.0, 1.0);
|
(_controller.value + (d.primaryDelta ?? 0.0) / _width).clamp(0.0, 1.0);
|
||||||
_controller.value = next;
|
_controller.value = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onDragEnd(DragEndDetails d, double width) async {
|
Future<void> _onDragEnd(DragEndDetails d) async {
|
||||||
final velocity = d.primaryVelocity ?? 0;
|
final velocity = d.velocity.pixelsPerSecond.dx;
|
||||||
final pastThreshold = _controller.value > widget.popThreshold ||
|
final pastThreshold = _controller.value > widget.popThreshold ||
|
||||||
velocity > widget.velocityThreshold;
|
velocity > widget.velocityThreshold;
|
||||||
if (pastThreshold) {
|
if (pastThreshold) {
|
||||||
@@ -96,44 +96,35 @@ class _SwipeToPopState extends State<SwipeToPop>
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final width = constraints.maxWidth;
|
||||||
final gestureChild = GestureDetector(
|
return RawGestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
dragStartBehavior: DragStartBehavior.down,
|
gestures: <Type, GestureRecognizerFactory>{
|
||||||
onHorizontalDragStart: _onDragStart,
|
RightwardDragRecognizer:
|
||||||
onHorizontalDragUpdate: (d) => _onDragUpdate(d, width),
|
GestureRecognizerFactoryWithHandlers<RightwardDragRecognizer>(
|
||||||
onHorizontalDragEnd: (d) => _onDragEnd(d, width),
|
() => RightwardDragRecognizer(debugOwner: this),
|
||||||
onHorizontalDragCancel: _onDragCancel,
|
(instance) {
|
||||||
);
|
instance
|
||||||
|
..onStart = _onDragStart
|
||||||
return Stack(
|
..onUpdate = _onDragUpdate
|
||||||
children: [
|
..onEnd = _onDragEnd
|
||||||
Positioned.fill(
|
..onCancel = _onDragCancel;
|
||||||
child: AnimatedBuilder(
|
},
|
||||||
animation: _controller,
|
|
||||||
builder: (context, child) {
|
|
||||||
final t = _controller.value;
|
|
||||||
return Transform.translate(
|
|
||||||
offset: Offset(t * width, 0),
|
|
||||||
child: Opacity(
|
|
||||||
opacity: (1.0 - t * 0.35).clamp(0.0, 1.0),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: widget.child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (widget.fullWidth)
|
},
|
||||||
Positioned.fill(child: gestureChild)
|
child: AnimatedBuilder(
|
||||||
else
|
animation: _controller,
|
||||||
Positioned(
|
builder: (context, child) {
|
||||||
left: 0,
|
final t = _controller.value;
|
||||||
top: 0,
|
return Transform.translate(
|
||||||
bottom: 0,
|
offset: Offset(t * width, 0),
|
||||||
width: widget.edgeWidth,
|
child: Opacity(
|
||||||
child: gestureChild,
|
opacity: (1.0 - t * 0.35).clamp(0.0, 1.0),
|
||||||
),
|
child: child,
|
||||||
],
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/media_cache.dart';
|
||||||
|
|
||||||
|
class VideoPlayerScreen extends StatefulWidget {
|
||||||
|
final String cacheName;
|
||||||
|
final String? url;
|
||||||
|
|
||||||
|
const VideoPlayerScreen({
|
||||||
|
super.key,
|
||||||
|
required this.cacheName,
|
||||||
|
this.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||||
|
VideoPlayerController? _controller;
|
||||||
|
bool _error = false;
|
||||||
|
double _progress = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
File? file = await MediaCache.existing(widget.cacheName);
|
||||||
|
if (file == null && widget.url != null) {
|
||||||
|
file = await MediaCache.getOrDownload(
|
||||||
|
widget.cacheName,
|
||||||
|
widget.url!,
|
||||||
|
onProgress: (p) {
|
||||||
|
if (mounted) setState(() => _progress = p);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
if (file == null) {
|
||||||
|
setState(() => _error = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = VideoPlayerController.file(file);
|
||||||
|
_controller = controller;
|
||||||
|
try {
|
||||||
|
await controller.initialize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {});
|
||||||
|
controller.play();
|
||||||
|
controller.addListener(_onTick);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _error = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTick() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller?.removeListener(_onTick);
|
||||||
|
_controller?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _togglePlay() {
|
||||||
|
final c = _controller;
|
||||||
|
if (c == null || !c.value.isInitialized) return;
|
||||||
|
setState(() => c.value.isPlaying ? c.pause() : c.play());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = _controller;
|
||||||
|
final ready = c != null && c.value.isInitialized;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: _error
|
||||||
|
? const Icon(Symbols.error, color: Colors.white54, size: 64)
|
||||||
|
: ready
|
||||||
|
? AspectRatio(
|
||||||
|
aspectRatio: c.value.aspectRatio,
|
||||||
|
child: VideoPlayer(c),
|
||||||
|
)
|
||||||
|
: _buildLoading(),
|
||||||
|
),
|
||||||
|
if (ready)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _togglePlay,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: c.value.isPlaying ? 0 : 1,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Symbols.play_arrow,
|
||||||
|
color: Colors.white, size: 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ready)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: VideoProgressIndicator(
|
||||||
|
c,
|
||||||
|
allowScrubbing: true,
|
||||||
|
colors: const VideoProgressColors(playedColor: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: MediaQuery.of(context).padding.top + 8,
|
||||||
|
left: 8,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Symbols.close, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoading() {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
value: _progress > 0 && _progress < 1 ? _progress : null,
|
||||||
|
),
|
||||||
|
if (_progress > 0 && _progress < 1) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'${(_progress * 100).round()}%',
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -168,5 +168,8 @@
|
|||||||
"editProfileSave": "Save",
|
"editProfileSave": "Save",
|
||||||
"editProfileFirstName": "First name",
|
"editProfileFirstName": "First name",
|
||||||
"editProfileLastName": "Last name",
|
"editProfileLastName": "Last name",
|
||||||
"editProfileRemovePhoto": "Remove photo"
|
"editProfileRemovePhoto": "Remove photo",
|
||||||
|
"registrationTitle": "Create your profile",
|
||||||
|
"registrationSubtitle": "Add your name and pick an avatar",
|
||||||
|
"registrationChooseAvatar": "Choose an avatar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1009,6 +1009,24 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Remove photo'**
|
/// **'Remove photo'**
|
||||||
String get editProfileRemovePhoto;
|
String get editProfileRemovePhoto;
|
||||||
|
|
||||||
|
/// No description provided for @registrationTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create your profile'**
|
||||||
|
String get registrationTitle;
|
||||||
|
|
||||||
|
/// No description provided for @registrationSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add your name and pick an avatar'**
|
||||||
|
String get registrationSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @registrationChooseAvatar.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose an avatar'**
|
||||||
|
String get registrationChooseAvatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -478,4 +478,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editProfileRemovePhoto => 'Remove photo';
|
String get editProfileRemovePhoto => 'Remove photo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get registrationTitle => 'Create your profile';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get registrationSubtitle => 'Add your name and pick an avatar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get registrationChooseAvatar => 'Choose an avatar';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,4 +480,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get editProfileRemovePhoto => 'Удалить фото';
|
String get editProfileRemovePhoto => 'Удалить фото';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get registrationTitle => 'Создание профиля';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get registrationSubtitle => 'Укажите имя и выберите аватар';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get registrationChooseAvatar => 'Выберите аватар';
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -168,5 +168,8 @@
|
|||||||
"editProfileSave": "Сохранить",
|
"editProfileSave": "Сохранить",
|
||||||
"editProfileFirstName": "Имя",
|
"editProfileFirstName": "Имя",
|
||||||
"editProfileLastName": "Фамилия",
|
"editProfileLastName": "Фамилия",
|
||||||
"editProfileRemovePhoto": "Удалить фото"
|
"editProfileRemovePhoto": "Удалить фото",
|
||||||
|
"registrationTitle": "Создание профиля",
|
||||||
|
"registrationSubtitle": "Укажите имя и выберите аватар",
|
||||||
|
"registrationChooseAvatar": "Выберите аватар"
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-14
@@ -10,6 +10,7 @@ import 'package:m3e_collection/m3e_collection.dart';
|
|||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'backend/api.dart';
|
import 'backend/api.dart';
|
||||||
|
import 'core/cache/info_cache.dart';
|
||||||
import 'core/config/app_accent.dart';
|
import 'core/config/app_accent.dart';
|
||||||
import 'core/config/app_amoled.dart';
|
import 'core/config/app_amoled.dart';
|
||||||
import 'core/config/app_bubble_behavior.dart';
|
import 'core/config/app_bubble_behavior.dart';
|
||||||
@@ -18,6 +19,9 @@ import 'core/config/app_cache_extent.dart';
|
|||||||
import 'core/config/app_fonts.dart';
|
import 'core/config/app_fonts.dart';
|
||||||
import 'core/config/app_message_actions_style.dart';
|
import 'core/config/app_message_actions_style.dart';
|
||||||
import 'core/config/app_swipe_back_desktop.dart';
|
import 'core/config/app_swipe_back_desktop.dart';
|
||||||
|
import 'core/config/app_pranks.dart';
|
||||||
|
import 'core/config/app_stories.dart';
|
||||||
|
import 'core/config/app_media_cache.dart';
|
||||||
import 'core/config/app_theme_mode.dart';
|
import 'core/config/app_theme_mode.dart';
|
||||||
import 'core/config/app_theme_schedule.dart';
|
import 'core/config/app_theme_schedule.dart';
|
||||||
import 'backend/modules/account.dart';
|
import 'backend/modules/account.dart';
|
||||||
@@ -25,6 +29,7 @@ import 'backend/modules/chats.dart';
|
|||||||
import 'backend/modules/contacts.dart';
|
import 'backend/modules/contacts.dart';
|
||||||
import 'backend/modules/file_uploader.dart';
|
import 'backend/modules/file_uploader.dart';
|
||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
|
import 'backend/modules/polls.dart';
|
||||||
import 'core/push/push_service.dart';
|
import 'core/push/push_service.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
import 'core/transport/tls_config.dart';
|
import 'core/transport/tls_config.dart';
|
||||||
@@ -41,7 +46,10 @@ import 'frontend/widgets/theme_reveal.dart';
|
|||||||
final api = Api();
|
final api = Api();
|
||||||
final accountModule = AccountModule(api);
|
final accountModule = AccountModule(api);
|
||||||
final messagesModule = MessagesModule(api);
|
final messagesModule = MessagesModule(api);
|
||||||
|
final pollsModule = PollsModule(api);
|
||||||
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
||||||
|
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
|
||||||
|
RouteObserver<PageRoute<dynamic>>();
|
||||||
|
|
||||||
Future<Locale> _loadInitialLocale() async {
|
Future<Locale> _loadInitialLocale() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -63,19 +71,38 @@ void main() async {
|
|||||||
if (activeAccountId != null) {
|
if (activeAccountId != null) {
|
||||||
await ContactsModule.primeCacheFromDb(activeAccountId);
|
await ContactsModule.primeCacheFromDb(activeAccountId);
|
||||||
}
|
}
|
||||||
|
attachInfoCacheApi(api);
|
||||||
ChatsModule.attachGlobalPushHandlers(api);
|
ChatsModule.attachGlobalPushHandlers(api);
|
||||||
|
|
||||||
|
final packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
final localeFuture = _loadInitialLocale();
|
||||||
|
final hapticsFuture = Haptics.load();
|
||||||
|
final prefsFuture = SharedPreferences.getInstance();
|
||||||
|
final accentFuture = AppAccent.load();
|
||||||
|
final bubbleShapeFuture = AppBubbleShape.load();
|
||||||
|
final bubbleBehaviorFuture = AppBubbleBehavior.load();
|
||||||
|
final cacheExtentFuture = AppCacheExtent.load();
|
||||||
|
final themeModeFuture = AppThemeModeConfig.load();
|
||||||
|
final amoledFuture = AppAmoled.load();
|
||||||
|
final themeScheduleFuture = AppThemeSchedule.load();
|
||||||
|
final messageActionsFuture = AppMessageActionsStyle.load();
|
||||||
|
final swipeBackFuture = AppSwipeBackDesktop.load();
|
||||||
|
final pranksFuture = AppPranks.load();
|
||||||
|
final storiesFuture = AppStories.load();
|
||||||
|
final cacheLimitFuture = AppMediaCacheLimit.load();
|
||||||
|
|
||||||
await api.connect();
|
await api.connect();
|
||||||
|
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await packageInfoFuture;
|
||||||
if (packageInfo.packageName == 'ru.oneme.app') {
|
if (packageInfo.packageName == 'ru.oneme.app') {
|
||||||
await PushService.instance.init(api: api, account: accountModule);
|
await PushService.instance.init(api: api, account: accountModule);
|
||||||
}
|
}
|
||||||
|
|
||||||
final initialLocale = await _loadInitialLocale();
|
final initialLocale = await localeFuture;
|
||||||
|
|
||||||
await Haptics.load();
|
await hapticsFuture;
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await prefsFuture;
|
||||||
await FileHistoryCache.load(prefs);
|
await FileHistoryCache.load(prefs);
|
||||||
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
||||||
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
||||||
@@ -85,15 +112,18 @@ void main() async {
|
|||||||
final initialFontScale = AppFonts.clampScale(
|
final initialFontScale = AppFonts.clampScale(
|
||||||
prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale,
|
prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale,
|
||||||
);
|
);
|
||||||
final initialAccentSeed = await AppAccent.load();
|
final initialAccentSeed = await accentFuture;
|
||||||
AppBubbleShape.current.value = await AppBubbleShape.load();
|
AppBubbleShape.current.value = await bubbleShapeFuture;
|
||||||
AppBubbleBehavior.current.value = await AppBubbleBehavior.load();
|
AppBubbleBehavior.current.value = await bubbleBehaviorFuture;
|
||||||
AppCacheExtent.current.value = await AppCacheExtent.load();
|
AppCacheExtent.current.value = await cacheExtentFuture;
|
||||||
AppThemeModeConfig.current.value = await AppThemeModeConfig.load();
|
AppThemeModeConfig.current.value = await themeModeFuture;
|
||||||
AppAmoled.current.value = await AppAmoled.load();
|
AppAmoled.current.value = await amoledFuture;
|
||||||
AppThemeSchedule.current.value = await AppThemeSchedule.load();
|
AppThemeSchedule.current.value = await themeScheduleFuture;
|
||||||
AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load();
|
AppMessageActionsStyle.current.value = await messageActionsFuture;
|
||||||
AppSwipeBackDesktop.current.value = await AppSwipeBackDesktop.load();
|
AppSwipeBackDesktop.current.value = await swipeBackFuture;
|
||||||
|
AppPranks.current.value = await pranksFuture;
|
||||||
|
AppStories.current.value = await storiesFuture;
|
||||||
|
AppMediaCacheLimit.current.value = await cacheLimitFuture;
|
||||||
runApp(
|
runApp(
|
||||||
KometApp(
|
KometApp(
|
||||||
initialLocale: initialLocale,
|
initialLocale: initialLocale,
|
||||||
@@ -626,6 +656,7 @@ class KometAppState extends State<KometApp>
|
|||||||
theme: _lightTheme,
|
theme: _lightTheme,
|
||||||
darkTheme: _darkTheme,
|
darkTheme: _darkTheme,
|
||||||
navigatorKey: KometApp.navigatorKey,
|
navigatorKey: KometApp.navigatorKey,
|
||||||
|
navigatorObservers: [appRouteObserver],
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return ValueListenableBuilder<double>(
|
return ValueListenableBuilder<double>(
|
||||||
valueListenable: fontScale,
|
valueListenable: fontScale,
|
||||||
@@ -683,8 +714,13 @@ class _StartupScreenState extends State<_StartupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _tryAutoLogin() async {
|
Future<void> _tryAutoLogin() async {
|
||||||
final accountId = await TokenStorage.getActiveAccountId();
|
int? accountId = await TokenStorage.getActiveAccountId();
|
||||||
|
|
||||||
if (accountId == null || await TokenStorage.readToken(accountId) == null) {
|
if (accountId == null || await TokenStorage.readToken(accountId) == null) {
|
||||||
|
accountId = await _recoverActiveAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountId == null) {
|
||||||
_goToLogin();
|
_goToLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -701,6 +737,19 @@ class _StartupScreenState extends State<_StartupScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int?> _recoverActiveAccount() async {
|
||||||
|
final profiles = await AppDatabase.loadAllProfiles();
|
||||||
|
for (final profile in profiles) {
|
||||||
|
if (await TokenStorage.readToken(profile.id) != null) {
|
||||||
|
await TokenStorage.setActiveAccount(profile.id);
|
||||||
|
await AppDatabase.setActiveAccount(profile.id);
|
||||||
|
await ContactsModule.primeCacheFromDb(profile.id);
|
||||||
|
return profile.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _goToLogin() {
|
void _goToLogin() {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum AttachmentType {
|
|||||||
location,
|
location,
|
||||||
sticker,
|
sticker,
|
||||||
control,
|
control,
|
||||||
|
poll,
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class MessageAttachment {
|
abstract class MessageAttachment {
|
||||||
@@ -41,6 +42,8 @@ abstract class MessageAttachment {
|
|||||||
return LocationAttachment.fromMap(map);
|
return LocationAttachment.fromMap(map);
|
||||||
case 'CONTROL':
|
case 'CONTROL':
|
||||||
return ControlAttachment.fromMap(map);
|
return ControlAttachment.fromMap(map);
|
||||||
|
case 'POLL':
|
||||||
|
return PollAttachment.fromMap(map);
|
||||||
case 'SHARE':
|
case 'SHARE':
|
||||||
return FileAttachment.fromMap(map);
|
return FileAttachment.fromMap(map);
|
||||||
case 'INLINE_KEYBOARD':
|
case 'INLINE_KEYBOARD':
|
||||||
@@ -471,6 +474,31 @@ class ControlAttachment extends MessageAttachment {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PollAttachment extends MessageAttachment {
|
||||||
|
final int pollId;
|
||||||
|
final String? title;
|
||||||
|
|
||||||
|
const PollAttachment({
|
||||||
|
required this.pollId,
|
||||||
|
this.title,
|
||||||
|
}) : super(type: AttachmentType.poll);
|
||||||
|
|
||||||
|
factory PollAttachment.fromMap(Map<String, dynamic> map) {
|
||||||
|
final id = map['pollId'] ?? map['id'];
|
||||||
|
return PollAttachment(
|
||||||
|
pollId: id is int ? id : int.tryParse(id?.toString() ?? '') ?? 0,
|
||||||
|
title: (map['title'] ?? map['question'])?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
'_type': 'POLL',
|
||||||
|
'pollId': pollId,
|
||||||
|
'title': title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class ForwardedMessageAttachment extends MessageAttachment {
|
class ForwardedMessageAttachment extends MessageAttachment {
|
||||||
final int originalSenderId;
|
final int originalSenderId;
|
||||||
final String? originalSenderName;
|
final String? originalSenderName;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
class PollAnswer {
|
||||||
|
final int answerId;
|
||||||
|
final String text;
|
||||||
|
final int voteCount;
|
||||||
|
final double rate;
|
||||||
|
final List<int> votes;
|
||||||
|
|
||||||
|
const PollAnswer({
|
||||||
|
required this.answerId,
|
||||||
|
required this.text,
|
||||||
|
this.voteCount = 0,
|
||||||
|
this.rate = 0,
|
||||||
|
this.votes = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Poll {
|
||||||
|
final int pollId;
|
||||||
|
final String title;
|
||||||
|
final int settings;
|
||||||
|
final int version;
|
||||||
|
final int total;
|
||||||
|
final List<PollAnswer> answers;
|
||||||
|
final List<int> voterPreviewIds;
|
||||||
|
|
||||||
|
const Poll({
|
||||||
|
required this.pollId,
|
||||||
|
required this.title,
|
||||||
|
this.settings = 0,
|
||||||
|
this.version = 0,
|
||||||
|
this.total = 0,
|
||||||
|
this.answers = const [],
|
||||||
|
this.voterPreviewIds = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isMultiple => settings & 0x1 != 0;
|
||||||
|
|
||||||
|
bool votedBy(int userId) =>
|
||||||
|
answers.any((a) => a.votes.contains(userId));
|
||||||
|
|
||||||
|
factory Poll.fromServerMap(Map<dynamic, dynamic> map) {
|
||||||
|
final state = map['state'];
|
||||||
|
final stateMap = state is Map ? state : const {};
|
||||||
|
|
||||||
|
final resultsById = <int, Map>{};
|
||||||
|
final result = stateMap['result'];
|
||||||
|
if (result is List) {
|
||||||
|
for (final r in result) {
|
||||||
|
if (r is Map && r['answerId'] is int) {
|
||||||
|
resultsById[r['answerId'] as int] = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final answers = <PollAnswer>[];
|
||||||
|
final rawAnswers = map['answers'];
|
||||||
|
if (rawAnswers is List) {
|
||||||
|
for (final a in rawAnswers) {
|
||||||
|
if (a is! Map) continue;
|
||||||
|
final id = a['answerId'] as int? ?? 0;
|
||||||
|
final res = resultsById[id];
|
||||||
|
answers.add(PollAnswer(
|
||||||
|
answerId: id,
|
||||||
|
text: a['text']?.toString() ?? '',
|
||||||
|
voteCount: (res?['voteCount'] as num?)?.toInt() ?? 0,
|
||||||
|
rate: (res?['rate'] as num?)?.toDouble() ?? 0,
|
||||||
|
votes: (res?['votes'] as List?)
|
||||||
|
?.whereType<int>()
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Poll(
|
||||||
|
pollId: map['pollId'] as int? ?? 0,
|
||||||
|
title: map['title']?.toString() ?? '',
|
||||||
|
settings: map['settings'] as int? ?? 0,
|
||||||
|
version: map['version'] as int? ?? 0,
|
||||||
|
total: (stateMap['total'] as num?)?.toInt() ?? 0,
|
||||||
|
answers: answers,
|
||||||
|
voterPreviewIds:
|
||||||
|
(stateMap['voterPreviewIds'] as List?)?.whereType<int>().toList() ??
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-7
@@ -146,13 +146,21 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+2"
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
dart_lz4:
|
dart_lz4:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -421,6 +429,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.2"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -446,7 +462,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.1"
|
version: "0.2.1"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
@@ -585,10 +601,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -645,6 +661,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
open_filex:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: open_filex
|
||||||
|
sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.7.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -670,7 +694,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
@@ -958,10 +982,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.10"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1002,6 +1026,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
video_player:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: video_player
|
||||||
|
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.1"
|
||||||
|
video_player_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_android
|
||||||
|
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.5"
|
||||||
|
video_player_avfoundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_avfoundation
|
||||||
|
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.7"
|
||||||
|
video_player_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_platform_interface
|
||||||
|
sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.7.0"
|
||||||
|
video_player_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_web
|
||||||
|
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ dependencies:
|
|||||||
dart_lz4: ^1.0.0
|
dart_lz4: ^1.0.0
|
||||||
libcompress: ^1.0.0
|
libcompress: ^1.0.0
|
||||||
msgpack_dart: ^1.0.1
|
msgpack_dart: ^1.0.1
|
||||||
|
crypto: ^3.0.7
|
||||||
logger: ^2.6.2
|
logger: ^2.6.2
|
||||||
device_info_plus: 12.3.0
|
device_info_plus: 12.3.0
|
||||||
flutter_timezone: ^5.0.1
|
flutter_timezone: ^5.0.1
|
||||||
timezone: ^0.11.0
|
timezone: ^0.11.0
|
||||||
file_picker: ^8.0.0
|
file_picker: ^8.0.0
|
||||||
|
image: ^4.3.0
|
||||||
sqflite: ^2.4.2
|
sqflite: ^2.4.2
|
||||||
sqflite_common_ffi: ^2.4.0+2
|
sqflite_common_ffi: ^2.4.0+2
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
@@ -55,6 +57,9 @@ dependencies:
|
|||||||
package_info_plus: ^9.0.1
|
package_info_plus: ^9.0.1
|
||||||
mobile_scanner: ^7.2.0
|
mobile_scanner: ^7.2.0
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
|
path_provider: ^2.1.4
|
||||||
|
open_filex: ^4.5.0
|
||||||
|
video_player: ^2.9.2
|
||||||
firebase_core: ^4.1.1
|
firebase_core: ^4.1.1
|
||||||
firebase_messaging: ^16.0.2
|
firebase_messaging: ^16.0.2
|
||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user