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
|
||||
.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;
|
||||
|
||||
int? _callsSeed;
|
||||
String? _deviceId;
|
||||
|
||||
int? get callsSeed => _callsSeed;
|
||||
String? get deviceId => _deviceId;
|
||||
|
||||
List<CountryName>? _registrationCountries;
|
||||
|
||||
List<CountryName> get registrationCountries =>
|
||||
@@ -111,6 +117,7 @@ class Api {
|
||||
try {
|
||||
final response = await sendHandshake();
|
||||
if (response.isOk) {
|
||||
_callsSeed = response.payload['callsSeed'] as int?;
|
||||
_registrationCountries = _parseRegistrationCountries(response.payload);
|
||||
_setSessionState(SessionState.online);
|
||||
_startPinging();
|
||||
@@ -164,7 +171,7 @@ class Api {
|
||||
String architecture = 'arm64';
|
||||
String appVersion = SpoofingService.hardcodedAppVersion;
|
||||
int buildNumber = SpoofingService.hardcodedBuildNumber;
|
||||
String screen = '1920x1080';
|
||||
String screen = '420dpi 420dpi 1080x2340';
|
||||
|
||||
tz.initializeTimeZones();
|
||||
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
||||
@@ -230,23 +237,25 @@ class Api {
|
||||
|
||||
_userAgent = {
|
||||
'deviceType': deviceType,
|
||||
'locale': locale,
|
||||
'deviceLocale': deviceLocale,
|
||||
'osVersion': osVersion,
|
||||
'deviceName': deviceName,
|
||||
'appVersion': appVersion,
|
||||
'screen': screen,
|
||||
'osVersion': osVersion,
|
||||
'timezone': timezone,
|
||||
'screen': screen,
|
||||
'pushDeviceType': 'GCM',
|
||||
'arch': architecture,
|
||||
'locale': locale,
|
||||
'buildNumber': buildNumber,
|
||||
'deviceName': deviceName,
|
||||
'deviceLocale': deviceLocale,
|
||||
};
|
||||
|
||||
_deviceId = deviceId;
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'mt_instanceid': await DeviceIdentity.instanceId(),
|
||||
'userAgent': _userAgent,
|
||||
'clientSessionId': DeviceIdentity.clientSessionId,
|
||||
'deviceId': deviceId,
|
||||
'userAgent': _userAgent,
|
||||
};
|
||||
|
||||
return sendRequest(Opcode.sessionInit, payload);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/chat_cache_fingerprint.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
@@ -223,6 +225,20 @@ class RequestCodeResult {
|
||||
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 {
|
||||
final Map<dynamic, dynamic> payload;
|
||||
|
||||
@@ -232,6 +248,37 @@ class VerifyCodeResult {
|
||||
|
||||
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;
|
||||
|
||||
Map<dynamic, dynamic>? get passwordChallenge {
|
||||
@@ -484,7 +531,7 @@ class AccountModule {
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
Future<ProfileData> updateProfileAvatar(String photoToken, String avatarType) async {
|
||||
Future<ProfileData> updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.profile, {
|
||||
'photoToken': photoToken,
|
||||
@@ -621,15 +668,17 @@ class AccountModule {
|
||||
String? hint,
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
final capabilities = <int>[0, if (hint != null) 3, 4];
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [0, 3, 4],
|
||||
'expectedCapabilities': capabilities,
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'confirm2fa');
|
||||
return _processProfileUpdate(packet);
|
||||
return _processProfileUpdate(
|
||||
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'confirm2fa',
|
||||
);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, {
|
||||
@@ -707,15 +761,16 @@ class AccountModule {
|
||||
}
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'expectedCapabilities': [1, 3],
|
||||
'expectedCapabilities': <int>[1, if (hint != null) 3],
|
||||
'trackId': trackId,
|
||||
'password': newPassword,
|
||||
};
|
||||
if (hint != null) payload['hint'] = hint;
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'update2faPassword');
|
||||
return _processProfileUpdate(packet);
|
||||
return _processProfileUpdate(
|
||||
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'update2faPassword',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> update2faEmail({
|
||||
@@ -740,9 +795,10 @@ class AccountModule {
|
||||
'expectedCapabilities': [4],
|
||||
'trackId': trackId,
|
||||
};
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'update2faEmail');
|
||||
return _processProfileUpdate(packet);
|
||||
return _processProfileUpdate(
|
||||
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'update2faEmail',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> remove2fa(String trackId) async {
|
||||
@@ -752,34 +808,46 @@ class AccountModule {
|
||||
'trackId': trackId,
|
||||
'remove2fa': true,
|
||||
};
|
||||
final packet = await _api.sendRequest(Opcode.authSet2fa, payload);
|
||||
_checkPacketError(packet, 'remove2fa');
|
||||
return _processProfileUpdate(packet);
|
||||
return _processProfileUpdate(
|
||||
_api.sendRequest(Opcode.authSet2fa, payload),
|
||||
'remove2fa',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> _processProfileUpdate(Packet packet) async {
|
||||
_api.registerPushHandler(Opcode.notifProfile, (p) {});
|
||||
try {
|
||||
await for (final push in _api.pushStream
|
||||
.where((p) => p.opcode == Opcode.notifProfile)
|
||||
.timeout(const Duration(seconds: 15))) {
|
||||
final payload = push.payload;
|
||||
if (payload is Map) {
|
||||
final profile = payload['profile'];
|
||||
if (profile is Map) {
|
||||
final contact = profile['contact'];
|
||||
if (contact is Map) {
|
||||
return ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
}
|
||||
}
|
||||
}
|
||||
Future<ProfileData> _processProfileUpdate(
|
||||
Future<Packet> requestFuture,
|
||||
String tag,
|
||||
) async {
|
||||
final completer = Completer<ProfileData>();
|
||||
final sub = _api.pushStream
|
||||
.where((p) => p.opcode == Opcode.notifProfile)
|
||||
.listen((push) {
|
||||
if (completer.isCompleted) return;
|
||||
final payload = push.payload;
|
||||
if (payload is! Map) return;
|
||||
final profile = payload['profile'];
|
||||
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 {
|
||||
_api.unregisterPushHandler(Opcode.notifProfile);
|
||||
timer.cancel();
|
||||
await sub.cancel();
|
||||
}
|
||||
throw Exception('Не удалось получить обновлённый профиль');
|
||||
}
|
||||
|
||||
Future<RequestCodeResult> requestCode(
|
||||
@@ -827,6 +895,61 @@ class AccountModule {
|
||||
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({
|
||||
int? accountId,
|
||||
String? token,
|
||||
@@ -921,6 +1044,20 @@ class AccountModule {
|
||||
_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 {
|
||||
final profile = await AppDatabase.loadProfile(accountId);
|
||||
if (profile == null) {
|
||||
@@ -940,6 +1077,7 @@ class AccountModule {
|
||||
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ChatsModule.resetForAccountSwitch();
|
||||
await ContactsModule.primeCacheFromDb(accountId);
|
||||
|
||||
try {
|
||||
@@ -1017,9 +1155,18 @@ class AccountModule {
|
||||
final payload = <dynamic, dynamic>{
|
||||
'token': token,
|
||||
'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) {
|
||||
payload['presenceSync'] = sync.presenceSync;
|
||||
payload['chatsSync'] = sync.chatsSync;
|
||||
@@ -1029,9 +1176,6 @@ class AccountModule {
|
||||
payload['bannersSync'] = sync.bannersSync;
|
||||
payload['lastLogin'] = sync.lastLogin;
|
||||
if (sync.configHash != null) payload['configHash'] = sync.configHash;
|
||||
if (sync.chatCacheFingerprint != null) {
|
||||
payload['chatCacheFingerprint'] = sync.chatCacheFingerprint;
|
||||
}
|
||||
} else {
|
||||
payload['presenceSync'] = 0;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/cache/info_cache.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../api.dart';
|
||||
import 'folders.dart';
|
||||
import 'messages.dart' show ContactCache;
|
||||
import 'messages.dart' show ContactCache, CachedMessage;
|
||||
|
||||
Map<int, int> _parseParticipants(dynamic raw) {
|
||||
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 {
|
||||
static const int muteOff = 0;
|
||||
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 void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||
|
||||
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) {
|
||||
_globalPushSub?.cancel();
|
||||
_globalStateSub?.cancel();
|
||||
_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 {
|
||||
switch (packet.opcode) {
|
||||
case Opcode.notifMessage:
|
||||
await _handleNotifMessage(packet);
|
||||
case Opcode.notifMark:
|
||||
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 {
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return;
|
||||
@@ -221,12 +512,15 @@ class ChatsModule {
|
||||
if (accountId == null) return;
|
||||
|
||||
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) {
|
||||
final cached = CachedChat.fromDbRow(row);
|
||||
for (final pid in cached.participants.keys) {
|
||||
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 affected = byParticipant[contactId];
|
||||
if (affected == null) continue;
|
||||
for (final row in affected) {
|
||||
final cached = CachedChat.fromDbRow(row);
|
||||
for (final entry in affected) {
|
||||
final row = entry.row;
|
||||
final cached = entry.cached;
|
||||
final sameTitle = cached.title == name;
|
||||
final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
|
||||
final sameOptions = cached.options.length == options.length &&
|
||||
@@ -288,6 +583,7 @@ class ChatsModule {
|
||||
logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
|
||||
return null;
|
||||
}
|
||||
_knownChats.add(parsed.id);
|
||||
final ex = existing[parsed.id];
|
||||
if (ex != null && _sameContent(ex, parsed)) {
|
||||
return parsed;
|
||||
@@ -383,7 +679,11 @@ class ChatsModule {
|
||||
static Future<List<CachedChat>> getChats(int accountId) async {
|
||||
try {
|
||||
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) {
|
||||
logger.e("Ошибка при получении чатов: $e");
|
||||
return [];
|
||||
|
||||
@@ -190,13 +190,23 @@ class FileUploader {
|
||||
Socket? socket;
|
||||
try {
|
||||
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(
|
||||
socket,
|
||||
uri,
|
||||
bytes.length,
|
||||
contentType: _contentTypeForFilename(filename),
|
||||
preamble.length + bytes.length + epilogue.length,
|
||||
boundary: boundary,
|
||||
);
|
||||
socket.add(preamble);
|
||||
socket.add(bytes);
|
||||
socket.add(epilogue);
|
||||
await socket.flush();
|
||||
|
||||
final response = await _readFullResponse(
|
||||
@@ -208,7 +218,6 @@ class FileUploader {
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) {
|
||||
logger.w('uploadImage: empty/timed-out response');
|
||||
return null;
|
||||
}
|
||||
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 headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\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('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')
|
||||
@@ -273,36 +282,63 @@ class FileUploader {
|
||||
Timer? timer;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
|
||||
void finish() {
|
||||
void finishWith((int, String)? value) {
|
||||
timer?.cancel();
|
||||
sub?.cancel();
|
||||
if (completer.isCompleted) return;
|
||||
if (!completer.isCompleted) completer.complete(value);
|
||||
}
|
||||
|
||||
(int, String)? tryParse({required bool atClose}) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) {
|
||||
completer.complete(null);
|
||||
return;
|
||||
}
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final lines = headerStr.split('\r\n');
|
||||
final parts = lines.first.split(' ');
|
||||
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.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 body = chunked ? _decodeChunked(rawBody) : rawBody;
|
||||
completer.complete((status, body));
|
||||
if (chunked) {
|
||||
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() {
|
||||
timer?.cancel();
|
||||
sub?.cancel();
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
}
|
||||
|
||||
sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish);
|
||||
timer = Timer(timeout, fail);
|
||||
sub = socket.listen(
|
||||
(chunk) {
|
||||
bytes.addAll(chunk);
|
||||
final parsed = tryParse(atClose: false);
|
||||
if (parsed != null) finishWith(parsed);
|
||||
},
|
||||
onError: (e) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,10 +44,13 @@ class FoldersModule {
|
||||
List<dynamic>? foldersOrder,
|
||||
) {
|
||||
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) {
|
||||
final aIndex = orderedIds.indexOf(a.id);
|
||||
final bIndex = orderedIds.indexOf(b.id);
|
||||
final aIndex = orderIndex[a.id] ?? -1;
|
||||
final bIndex = orderIndex[b.id] ?? -1;
|
||||
if (aIndex == -1 && bIndex == -1) return 0;
|
||||
if (aIndex == -1) return 1;
|
||||
if (bIndex == -1) return -1;
|
||||
|
||||
@@ -245,6 +245,29 @@ class CachedMessage {
|
||||
'status': status,
|
||||
'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 {
|
||||
@@ -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 {
|
||||
try {
|
||||
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 {
|
||||
try {
|
||||
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 {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
'url': baseUrl,
|
||||
'token': fileToken,
|
||||
'messageId': int.tryParse(messageId) ?? 0,
|
||||
'chatId': chatId,
|
||||
'fileId': fileId,
|
||||
});
|
||||
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
|
||||
return data['content'] as String?;
|
||||
return data['url'] as String?;
|
||||
} catch (e) {
|
||||
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 customPrefix = 'g:';
|
||||
|
||||
static const double minScale = 0.85;
|
||||
static const double minScale = 0.60;
|
||||
static const double maxScale = 1.35;
|
||||
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 : 'Неизвестная ошибка';
|
||||
}
|
||||
|
||||
/// Упаковка пакета для отправки на сервер
|
||||
/// 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}) {
|
||||
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(1, CmdType.request);
|
||||
header.setUint16(2, seq, Endian.big);
|
||||
header.setUint16(4, opcode, Endian.big);
|
||||
|
||||
final payloadBytes = msgpack.serialize(payload);
|
||||
final payloadLen = payloadBytes.length & 0xFFFFFF;
|
||||
header.setUint32(6, payloadLen, Endian.big);
|
||||
|
||||
return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes);
|
||||
header.setUint32(
|
||||
6,
|
||||
((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
|
||||
Endian.big,
|
||||
);
|
||||
out.setRange(headerSize, out.length, body);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Распаковка пакета от сервера
|
||||
|
||||
@@ -435,17 +435,20 @@ class AppDatabase {
|
||||
// Chats cache
|
||||
|
||||
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
try {
|
||||
final db = await _instance;
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'chats_cache',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'chats_cache',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при сохранении чата: $e");
|
||||
}
|
||||
@@ -587,4 +590,33 @@ class AppDatabase {
|
||||
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';
|
||||
|
||||
class SpoofingService {
|
||||
static const String hardcodedAppVersion = '26.14.1';
|
||||
static const int hardcodedBuildNumber = 6606;
|
||||
static const String hardcodedAppVersion = '26.17.1';
|
||||
static const int hardcodedBuildNumber = 6712;
|
||||
|
||||
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -24,6 +24,11 @@ class TokenStorage {
|
||||
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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getString(_activeAccountKey);
|
||||
|
||||
@@ -7,46 +7,73 @@ import '../utils/logger.dart';
|
||||
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
|
||||
class PacketReceiver {
|
||||
Uint8List _buffer = Uint8List(0);
|
||||
int _start = 0;
|
||||
int _end = 0;
|
||||
|
||||
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
||||
|
||||
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
|
||||
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
|
||||
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
|
||||
///
|
||||
/// Накопление идёт без перекопирования всего буфера на каждый чанк: целые
|
||||
/// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается
|
||||
/// сдвигом указателя `_start`, а не пересборкой буфера.
|
||||
List<Uint8List> feed(Uint8List data) {
|
||||
final newBuffer = Uint8List(_buffer.length + data.length);
|
||||
newBuffer.setAll(0, _buffer);
|
||||
newBuffer.setAll(_buffer.length, data);
|
||||
_buffer = newBuffer;
|
||||
_append(data);
|
||||
|
||||
if (_buffer.length > _maxBufferSize) {
|
||||
if (_end - _start > _maxBufferSize) {
|
||||
logger.e(
|
||||
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
|
||||
'PacketReceiver: переполнение буфера (${_end - _start} B), сброс',
|
||||
);
|
||||
reset();
|
||||
return const [];
|
||||
}
|
||||
|
||||
final packets = <Uint8List>[];
|
||||
while (_buffer.length >= headerSize) {
|
||||
while (_end - _start >= headerSize) {
|
||||
final bd = ByteData.view(
|
||||
_buffer.buffer,
|
||||
_buffer.offsetInBytes,
|
||||
_buffer.offsetInBytes + _start,
|
||||
headerSize,
|
||||
);
|
||||
final packedLen = bd.getUint32(6, Endian.big);
|
||||
final payloadLength = packedLen & 0xFFFFFF;
|
||||
final totalLength = headerSize + payloadLength;
|
||||
|
||||
if (_buffer.length < totalLength) break;
|
||||
if (_end - _start < totalLength) break;
|
||||
|
||||
packets.add(Uint8List.sublistView(_buffer, 0, totalLength));
|
||||
_buffer = _buffer.sublist(totalLength);
|
||||
packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength));
|
||||
_start += totalLength;
|
||||
}
|
||||
|
||||
if (_start == _end) {
|
||||
_start = 0;
|
||||
_end = 0;
|
||||
}
|
||||
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() {
|
||||
_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:google_fonts/google_fonts.dart';
|
||||
import 'password_2fa_screen.dart';
|
||||
import 'registration_screen.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
@@ -167,6 +168,20 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
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();
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -183,10 +198,8 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 240),
|
||||
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
||||
transitionsBuilder: (_, animation, __, child) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
transitionsBuilder: (_, animation, __, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
|
||||
@@ -13,11 +13,16 @@ import 'select_country_screen.dart';
|
||||
import 'proxy_settings_sheet.dart';
|
||||
import 'server_settings_sheet.dart';
|
||||
import '../profile/spoof_screen.dart';
|
||||
import '../profile/debug_menu_screen.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/adaptive_shell.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../main.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
final int? returnToAccountId;
|
||||
|
||||
const LoginScreen({super.key, this.returnToAccountId});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
@@ -30,15 +35,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
bool _isTOSRead = false;
|
||||
String? _phoneError;
|
||||
Timer? _phoneErrorTimer;
|
||||
int _logoTapCount = 0;
|
||||
Timer? _logoTapTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (api.state == SessionState.disconnected) {
|
||||
unawaited(api.connect());
|
||||
}
|
||||
_selectedCountry = countriesByCode['RU'] ?? allCountries.first;
|
||||
_clampCountryToAllowed();
|
||||
_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() {
|
||||
final allowed = api.registrationCountries;
|
||||
if (allowed.any((c) => c.code == _selectedCountry.code)) return;
|
||||
@@ -51,6 +77,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneErrorTimer?.cancel();
|
||||
_logoTapTimer?.cancel();
|
||||
_phoneController.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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('IsReadeTOS', true);
|
||||
@@ -666,9 +709,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (Navigator.canPop(context))
|
||||
if (Navigator.canPop(context) ||
|
||||
widget.returnToAccountId != null)
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: _onBackPressed,
|
||||
icon: Icon(
|
||||
Symbols.arrow_back,
|
||||
color: cs.onSurfaceVariant,
|
||||
@@ -704,10 +748,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/komet.png',
|
||||
height: 80,
|
||||
color: cs.onSurface,
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _onLogoTap,
|
||||
child: Image.asset(
|
||||
'assets/komet.png',
|
||||
height: 80,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
|
||||
@@ -56,10 +56,8 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 240),
|
||||
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
|
||||
transitionsBuilder: (_, animation, __, child) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
transitionsBuilder: (_, animation, __, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
(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:material_symbols_icons/symbols.dart';
|
||||
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 '../../../main.dart' as main;
|
||||
|
||||
class _MemberInfo {
|
||||
final int id;
|
||||
@@ -96,21 +95,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
_myId = profile?.id ?? 0;
|
||||
|
||||
final packet = await main.api.sendRequest(
|
||||
Opcode.chatInfo,
|
||||
{'chatIds': [widget.chatId]},
|
||||
);
|
||||
if (!packet.isOk || !mounted) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
final info = await ChatInfoFetch.get(widget.chatId);
|
||||
if (!mounted) return;
|
||||
if (info == null) {
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
_chatData = info;
|
||||
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
final parts = _chatData!['participants'] as Map? ?? {};
|
||||
@@ -123,32 +114,19 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
}
|
||||
|
||||
if (_otherId != null) {
|
||||
final cp = await main.api.sendRequest(
|
||||
Opcode.contactInfo,
|
||||
{'contactIds': [_otherId]},
|
||||
);
|
||||
if (cp.isOk) {
|
||||
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 contact = await ContactInfoFetch.get(_otherId!);
|
||||
if (contact != null) {
|
||||
_contactData = contact;
|
||||
final opts = _contactData!['options'];
|
||||
_isBot = (opts is List) && opts.contains('BOT');
|
||||
}
|
||||
|
||||
final pp = await main.api.sendRequest(
|
||||
Opcode.contactPresence,
|
||||
{'contactIds': [_otherId]},
|
||||
);
|
||||
if (pp.isOk) {
|
||||
final presence = (pp.payload as Map?)?['presence'] as Map?;
|
||||
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;
|
||||
}
|
||||
final presence = await PresenceFetch.get(_otherId!);
|
||||
if (presence != null) {
|
||||
_seenTime = presence['seen'] as int?;
|
||||
final st = (presence['status'] as int?) ?? 0;
|
||||
_presenceStatus = st;
|
||||
_isOnline = st == 1;
|
||||
}
|
||||
}
|
||||
} else if (widget.chatType == 'CHAT') {
|
||||
@@ -162,25 +140,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
if (id != null) memberIds.add(id);
|
||||
}
|
||||
|
||||
final Map<int, Map> presenceMap = {};
|
||||
Map<int, Map<String, dynamic>> presenceMap = {};
|
||||
if (memberIds.isNotEmpty) {
|
||||
final pp = await main.api.sendRequest(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
presenceMap = await PresenceFetch.getMany(memberIds);
|
||||
}
|
||||
|
||||
_onlineCount = 0;
|
||||
|
||||
@@ -19,13 +19,16 @@ import '../auth/login_screen.dart';
|
||||
import '../../widgets/account_switcher_overlay.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../core/config/app_stories.dart';
|
||||
import '../../../backend/models/chat_folder.dart';
|
||||
import '../../../backend/modules/account.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/cloud_storage.dart';
|
||||
import '../../../backend/modules/folders.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 {
|
||||
final bool Function() blockPositive;
|
||||
@@ -72,7 +75,7 @@ class ChatListScreen extends StatefulWidget {
|
||||
enum _DeleteKind { personalLike, ownerGroup, blocked }
|
||||
|
||||
class _ChatListScreenState extends State<ChatListScreen>
|
||||
with TickerProviderStateMixin {
|
||||
with TickerProviderStateMixin, RouteAware {
|
||||
String? _selectedFolderId;
|
||||
|
||||
List<ChatFolder> _folders = [];
|
||||
@@ -81,7 +84,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
double _navPageAnimStart = 0;
|
||||
double _navPageAnimEnd = 0;
|
||||
double _navDragDx = 0;
|
||||
final ValueNotifier<double> _navDragDx = ValueNotifier(0);
|
||||
double _navDragBaseLeft = 0;
|
||||
double _revealAnimBegin = 0.0;
|
||||
double _closeAnimBegin = 0.0;
|
||||
@@ -100,9 +103,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
bool _navDragging = false;
|
||||
bool _isFabOpen = false;
|
||||
bool _showCacheWarning = false;
|
||||
bool _storiesAnimClosing = false;
|
||||
Timer? _contactRebuildTimer;
|
||||
bool _deferReloads = false;
|
||||
bool _reloadQueued = false;
|
||||
Timer? _settleTimer;
|
||||
bool get _isSelectionMode => _selectedChats.isNotEmpty;
|
||||
bool? _foldersListKnown;
|
||||
|
||||
@@ -140,11 +145,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
_selectedFolderId,
|
||||
_isInitialLoading,
|
||||
_foldersListKnown,
|
||||
_showCacheWarning,
|
||||
_isSelectionMode,
|
||||
_shouldCollapseSearch,
|
||||
_selectedChats.length,
|
||||
_pullRatio,
|
||||
_storiesDockedOpen,
|
||||
_storiesAnimClosing,
|
||||
_storiesOverscrollRevealArmed,
|
||||
@@ -180,8 +183,12 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
List<CachedChat> _selectedChatObjects() {
|
||||
if (_selectedChats.isEmpty) return const [];
|
||||
final ids = _selectedChats;
|
||||
return _chats.where((c) => ids.contains(c.id.toString())).toList();
|
||||
final ids = <int>{};
|
||||
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) {
|
||||
@@ -404,6 +411,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
}
|
||||
|
||||
bool _allowStoriesPullOverscrollTop() {
|
||||
if (!AppStories.current.value) return false;
|
||||
if (_storiesDockedOpen ||
|
||||
_storiesRevealController.isAnimating ||
|
||||
_pullRatio > 0) {
|
||||
@@ -447,30 +455,73 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_sessionState = state;
|
||||
if (state == SessionState.disconnected && _chats.isNotEmpty) {
|
||||
_showCacheWarning = true;
|
||||
}
|
||||
if (state == SessionState.online) {
|
||||
_showCacheWarning = false;
|
||||
}
|
||||
});
|
||||
if (state == SessionState.online) {
|
||||
_reloadChatsAndFolders();
|
||||
_requestReload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_loginSub = accountModule.loginStatusStream.listen((status) {
|
||||
if (status == LoginStatus.success) {
|
||||
_reloadChatsAndFolders();
|
||||
_requestReload();
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
void _onChatsChanged() {
|
||||
if (mounted) _reloadChatsAndFolders();
|
||||
_requestReload();
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
if (_folders.isEmpty) {
|
||||
base = _chats;
|
||||
@@ -634,7 +697,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList()
|
||||
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
|
||||
final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList();
|
||||
return [...pinned, ...regular];
|
||||
final result = [...pinned, ...regular];
|
||||
_pageChatsCache[pageIndex] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
void _syncFolderChatScrollControllers() {
|
||||
@@ -928,7 +993,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
appRouteObserver.unsubscribe(this);
|
||||
_settleTimer?.cancel();
|
||||
ChatsModule.chatsChanged.removeListener(_onChatsChanged);
|
||||
AppStories.current.removeListener(_onStoriesEnabledChanged);
|
||||
_loginSub?.cancel();
|
||||
_stateSub?.cancel();
|
||||
_fabController.dispose();
|
||||
@@ -947,6 +1015,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
}
|
||||
_contactRebuildTimer?.cancel();
|
||||
_storiesUi.dispose();
|
||||
_navDragDx.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -955,7 +1024,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
required double Function(int index) bubbleLeftForIndex,
|
||||
}) {
|
||||
if (_navDragging) {
|
||||
final left = (_navDragBaseLeft + _navDragDx).clamp(
|
||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||
bubbleLeftForIndex(0),
|
||||
bubbleLeftForIndex(3),
|
||||
);
|
||||
@@ -1028,7 +1097,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (_pullRatio < 0.8)
|
||||
if (AppStories.current.value &&
|
||||
_pullRatio < 0.8)
|
||||
Opacity(
|
||||
opacity: 1.0 - _pullRatio,
|
||||
child: Container(
|
||||
@@ -1107,68 +1177,33 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 96 * _pullRatio,
|
||||
child: Opacity(
|
||||
opacity: _pullRatio,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
children: [
|
||||
_buildStoryItem(
|
||||
'Даша',
|
||||
'https://i.pravatar.cc/150?u=dasha',
|
||||
true,
|
||||
if (AppStories.current.value)
|
||||
SizedBox(
|
||||
height: 96 * _pullRatio,
|
||||
child: Opacity(
|
||||
opacity: _pullRatio,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
_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: [
|
||||
Icon(
|
||||
Symbols.cloud_off,
|
||||
size: 18,
|
||||
color: cs.error,
|
||||
_buildStoryItem(
|
||||
'Даша',
|
||||
'https://i.pravatar.cc/150?u=dasha',
|
||||
true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Ошибка соединения, сейчас вы смотрите КЕШ',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
_buildStoryItem(
|
||||
'Мастика',
|
||||
'https://i.pravatar.cc/150?u=mastika',
|
||||
false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 3, 20, 4),
|
||||
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
|
||||
child: Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
@@ -1321,7 +1356,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
parent: const AlwaysScrollableScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 14)),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
||||
if (chats.isEmpty && !_isInitialLoading)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
@@ -1343,6 +1378,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
if (hasSeparator && index == pinnedCount) {
|
||||
return Padding(
|
||||
key: const ValueKey('pinned_divider'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
@@ -1357,20 +1393,28 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final isPinned = (chat.favIndex ?? 0) > 0;
|
||||
|
||||
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
|
||||
final secondId = chat.participants.entries
|
||||
.where((entry) => entry.key != _profile?.id)
|
||||
.first
|
||||
.key;
|
||||
int secondId = _profile?.id ?? 0;
|
||||
for (final entry in chat.participants.entries) {
|
||||
if (entry.key != _profile?.id) {
|
||||
secondId = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final name = ContactCache.get(secondId);
|
||||
final avatar = ContactCache.getAvatar(secondId);
|
||||
// ContactCache.isOfficial covers contacts loaded via opcode 32;
|
||||
// chat.isOfficial covers contacts from the login payload.
|
||||
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
|
||||
|
||||
final isPlaceholder =
|
||||
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||
final previewText = isPlaceholder
|
||||
? 'зайдите в чат для подгрузки'
|
||||
: (chat.lastMsgTextOneLine ?? '');
|
||||
return _buildChatItem(
|
||||
chat.id.toString(),
|
||||
name ?? "Пользователь",
|
||||
chat.lastMsgTextOneLine ?? '',
|
||||
previewText,
|
||||
_formatTime(chat.lastMsgTime),
|
||||
avatar ?? "",
|
||||
isOnline: chat.isOnline,
|
||||
@@ -1379,20 +1423,25 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
isVerified: isVerified,
|
||||
isPinned: isPinned,
|
||||
chatType: "DIALOG",
|
||||
messageItalic: isPlaceholder,
|
||||
);
|
||||
} else {
|
||||
final name = chat.lastMsgSenderId != null
|
||||
final isPlaceholder =
|
||||
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
|
||||
final sender = chat.lastMsgSenderId != null
|
||||
? ContactCache.get(chat.lastMsgSenderId!)
|
||||
: null;
|
||||
|
||||
String fullMsg = "";
|
||||
|
||||
if (name?.isNotEmpty == true && chat.id != 0) {
|
||||
fullMsg += "$name: ";
|
||||
}
|
||||
|
||||
if (chat.lastMsgText?.isNotEmpty == true) {
|
||||
fullMsg += chat.lastMsgText ?? "";
|
||||
if (isPlaceholder) {
|
||||
fullMsg = 'зайдите в чат для подгрузки';
|
||||
} else {
|
||||
if (sender?.isNotEmpty == true && chat.id != 0) {
|
||||
fullMsg += "$sender: ";
|
||||
}
|
||||
if (chat.lastMsgText?.isNotEmpty == true) {
|
||||
fullMsg += chat.lastMsgText ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return _buildChatItem(
|
||||
@@ -1409,6 +1458,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
isVerified: chat.isOfficial,
|
||||
isPinned: isPinned,
|
||||
chatType: chat.type,
|
||||
messageItalic: isPlaceholder,
|
||||
);
|
||||
}
|
||||
}, childCount: totalItems),
|
||||
@@ -1499,12 +1549,6 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final minBubbleLeft = bubbleLeftForIndex(0);
|
||||
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) {
|
||||
final rt = rowT.clamp(0.0, 3.0);
|
||||
final i0 = rt.floor().clamp(0, 3);
|
||||
@@ -1557,39 +1601,46 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (_isSelectionMode) return;
|
||||
_navPageAnimController.stop();
|
||||
_navPageAnimController.value = 1.0;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_navDragging = true;
|
||||
_navDragDx = 0;
|
||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!_navDragging) return;
|
||||
setState(() {
|
||||
_navDragDx += details.delta.dx;
|
||||
});
|
||||
_navDragDx.value += details.delta.dx;
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
if (!_navDragging) return;
|
||||
final left = (_navDragBaseLeft + _navDragDx).clamp(
|
||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||
minBubbleLeft,
|
||||
maxBubbleLeft,
|
||||
);
|
||||
final next = indexForBubbleLeft(left);
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_currentNavIndex = next;
|
||||
_navDragging = false;
|
||||
_navDragDx = 0;
|
||||
});
|
||||
},
|
||||
onHorizontalDragCancel: () {
|
||||
if (!_navDragging) return;
|
||||
_navDragDx.value = 0;
|
||||
setState(() {
|
||||
_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,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
@@ -1661,6 +1712,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1706,7 +1759,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
width: pageW * 4,
|
||||
height: pageH,
|
||||
child: AnimatedBuilder(
|
||||
animation: _navPageAnimController,
|
||||
animation: Listenable.merge(
|
||||
[_navPageAnimController, _navDragDx]),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -1812,9 +1866,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
onPressed: _toggleFab,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
shape: const CircleBorder(),
|
||||
child: Transform.rotate(
|
||||
angle: val * (pi / 4),
|
||||
child: Icon(
|
||||
@@ -2020,7 +2072,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
@@ -2049,11 +2101,13 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
bool isVerified = false,
|
||||
bool isPinned = false,
|
||||
String chatType = "CHAT",
|
||||
bool messageItalic = false,
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isSelected = _selectedChats.contains(id);
|
||||
|
||||
return InkWell(
|
||||
key: ValueKey('chat_$id'),
|
||||
onTap: () {
|
||||
if (_isSelectionMode) {
|
||||
_toggleSelection(id);
|
||||
@@ -2230,6 +2284,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
fontWeight: isTyping
|
||||
? FontWeight.w500
|
||||
: FontWeight.w400,
|
||||
fontStyle: messageItalic
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -2313,6 +2370,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
icon,
|
||||
color: isSelected ? cs.onPrimary : cs.onSurface,
|
||||
size: 20,
|
||||
fill: 1,
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: animDur,
|
||||
@@ -2355,9 +2413,16 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
controller.dispose();
|
||||
if (!mounted) return;
|
||||
if (accountId == null) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
final previousId = await TokenStorage.getActiveAccountId();
|
||||
try {
|
||||
await accountModule.beginAddAccount();
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LoginScreen(returnToAccountId: previousId),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show File;
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:cached_network_image/cached_network_image.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:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../../backend/api.dart';
|
||||
import '../../../backend/modules/messages.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/cache/info_cache.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../core/config/app_cache_extent.dart';
|
||||
import '../../../core/config/app_message_actions_style.dart';
|
||||
import '../../../core/config/app_swipe_back_desktop.dart';
|
||||
import '../../../core/config/app_pranks.dart';
|
||||
import '../../../models/attachment.dart';
|
||||
import '../../widgets/message_bubble.dart';
|
||||
import '../../widgets/theme_reveal.dart';
|
||||
import '../../widgets/message_actions_overlay.dart';
|
||||
import '../../widgets/attachment_panel.dart';
|
||||
import '../../widgets/swipe_to_pop.dart';
|
||||
@@ -89,11 +92,42 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
|
||||
StreamSubscription<UploadEvent>? _uploadSub;
|
||||
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 Map<int, Timer> _typingTimers = {};
|
||||
int _otherStatus = 0;
|
||||
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<int> _otherReadTime = ValueNotifier(0);
|
||||
int _tempIdCounter = 0;
|
||||
late final AnimationController _attachAnim;
|
||||
|
||||
@@ -102,6 +136,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
Timer? _shimmerStartTimer;
|
||||
bool _historyKickedOff = false;
|
||||
List<CachedMessage> _messages = [];
|
||||
int _messagesRevision = 0;
|
||||
List<Object>? _combinedItemsCache;
|
||||
int? _combinedItemsKey;
|
||||
bool _floatingDateScheduled = false;
|
||||
int _myId = 0;
|
||||
CachedChat? chat;
|
||||
|
||||
@@ -110,7 +148,6 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
late final AnimationController _floatingDateAnimController;
|
||||
late final CurvedAnimation _floatingDateCurved;
|
||||
final Map<int, GlobalKey> _separatorKeys = {};
|
||||
double _lastScrollOffset = 0;
|
||||
String? _lastSentId;
|
||||
|
||||
@override
|
||||
@@ -130,10 +167,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||||
_pushSub = api.pushStream
|
||||
.where((p) =>
|
||||
p.opcode == Opcode.notifMessage ||
|
||||
p.opcode == Opcode.notifMark ||
|
||||
p.opcode == Opcode.notifTyping)
|
||||
.listen(_onIncomingPush);
|
||||
_messageEventSub = ChatsModule.messageEvents
|
||||
.where((e) => e.chatId == widget.chatId)
|
||||
.listen(_onMessageEvent);
|
||||
_floatingDateAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
@@ -145,9 +184,56 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
reverseCurve: Curves.easeIn,
|
||||
);
|
||||
|
||||
unawaited(_fastPreloadCache());
|
||||
unawaited(_loadParticipantsCount());
|
||||
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 _) {
|
||||
if (!mounted) return;
|
||||
if (widget.embedded) {
|
||||
@@ -192,37 +278,15 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final activeProfile = await AppDatabase.loadActiveProfile();
|
||||
_myId = activeProfile?.id ?? 0;
|
||||
ChatsModule.getChat(_myId, widget.chatId).then((value) {
|
||||
if (mounted && value.isNotEmpty) {
|
||||
setState(() { chat = value.first; });
|
||||
_recomputeHeaderStatus();
|
||||
}
|
||||
}).catchError((_) {});
|
||||
if (_myId == 0) {
|
||||
final activeProfile = await AppDatabase.loadActiveProfile();
|
||||
if (!mounted) return;
|
||||
_myId = activeProfile?.id ?? 0;
|
||||
}
|
||||
if (widget.chatType == 'DIALOG') {
|
||||
unawaited(_loadOtherPresence());
|
||||
}
|
||||
|
||||
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());
|
||||
await _loadRemainingHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadRemainingHistory() async {
|
||||
@@ -235,8 +299,20 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_applyMergedMessages(fullRows);
|
||||
}
|
||||
|
||||
if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_onLoadingFinished();
|
||||
});
|
||||
}
|
||||
_loadForwardedSenderNames();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await messagesModule.fetchHistory(_myId, widget.chatId);
|
||||
ChatsModule.markChatClean(widget.chatId);
|
||||
final updatedRows = await AppDatabase.loadMessages(
|
||||
_myId,
|
||||
widget.chatId,
|
||||
@@ -245,6 +321,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (mounted) {
|
||||
_applyMergedMessages(updatedRows, markLoaded: true);
|
||||
}
|
||||
unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId));
|
||||
_loadForwardedSenderNames();
|
||||
} catch (e) {
|
||||
debugPrint('Error fetching history: $e');
|
||||
@@ -274,12 +351,42 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final changed = !_listsEquivalent(_messages, merged);
|
||||
if (!changed && !markLoaded) return;
|
||||
setState(() {
|
||||
if (changed) _messages = merged;
|
||||
if (changed) {
|
||||
_messages = merged;
|
||||
_messagesRevision++;
|
||||
}
|
||||
if (markLoaded) {
|
||||
_isLoading = false;
|
||||
_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) {
|
||||
@@ -311,11 +418,18 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_showAttachmentPanel.dispose();
|
||||
_uploadSub?.cancel();
|
||||
_pushSub?.cancel();
|
||||
_messageEventSub?.cancel();
|
||||
for (final n in _reactionNotifiers.values) {
|
||||
n.dispose();
|
||||
}
|
||||
_reactionNotifiers.clear();
|
||||
for (final t in _typingTimers.values) {
|
||||
t.cancel();
|
||||
}
|
||||
_typingTimers.clear();
|
||||
_headerStatusNotifier.dispose();
|
||||
_otherReadTime.dispose();
|
||||
_finishPrankReveal();
|
||||
_uploadStatus.dispose();
|
||||
_attachAnim.dispose();
|
||||
_messageController.dispose();
|
||||
@@ -340,17 +454,124 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
}
|
||||
|
||||
String? _effectiveStatus(CachedMessage msg) {
|
||||
if (msg.senderId != _myId) return null;
|
||||
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
|
||||
int _computeOtherReadTime() {
|
||||
final c = chat;
|
||||
if (c == null) return 'sent';
|
||||
if (c == null) return 0;
|
||||
int otherReadTime = 0;
|
||||
for (final entry in c.participants.entries) {
|
||||
if (entry.key != _myId && entry.value > otherReadTime) {
|
||||
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';
|
||||
return 'sent';
|
||||
}
|
||||
@@ -358,8 +579,6 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
void _onIncomingPush(Packet packet) {
|
||||
if (!mounted) return;
|
||||
switch (packet.opcode) {
|
||||
case Opcode.notifMessage:
|
||||
_onIncomingMessage(packet);
|
||||
case Opcode.notifMark:
|
||||
_onMessageRead(packet);
|
||||
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 {
|
||||
if (_myId == 0) return;
|
||||
final otherId = widget.chatId ^ _myId;
|
||||
if (otherId <= 0) return;
|
||||
try {
|
||||
final p = await api.sendRequest(
|
||||
Opcode.contactPresence,
|
||||
{'contactIds': [otherId]},
|
||||
);
|
||||
if (!mounted) return;
|
||||
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();
|
||||
}
|
||||
final entry = await PresenceFetch.get(otherId);
|
||||
if (!mounted || entry == null) return;
|
||||
_otherStatus = (entry['status'] as int?) ?? 0;
|
||||
_otherSeenTime = entry['seen'] as int?;
|
||||
_recomputeHeaderStatus();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -408,10 +655,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
String _headerStatus() {
|
||||
if (_typingUserIds.isNotEmpty) return 'Печатает...';
|
||||
if (widget.chatType == 'CHAT') {
|
||||
return '${chat?.participants.length ?? 0} участников';
|
||||
final count = _participantsCount ?? chat?.participants.length ?? 0;
|
||||
return '$count участников';
|
||||
}
|
||||
if (widget.chatType == 'CHANNEL') {
|
||||
return '${chat?.participants.length ?? 0} подписчиков';
|
||||
final count = _participantsCount ?? chat?.participants.length ?? 0;
|
||||
return '$count подписчиков';
|
||||
}
|
||||
if (_otherStatus == 1) return 'В сети';
|
||||
if (_otherStatus == 3) return 'Был(-а) недавно';
|
||||
@@ -458,56 +707,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
final c = chat;
|
||||
if (c == null) return;
|
||||
if (c.participants[userId] == mark) return;
|
||||
setState(() {
|
||||
c.participants[userId] = mark;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
c.participants[userId] = mark;
|
||||
_syncOtherReadTime();
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
@@ -533,30 +734,36 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
setState(() {
|
||||
_lastSentId = tempId;
|
||||
_messages.add(tempMessage);
|
||||
_messagesRevision++;
|
||||
_messageController.clear();
|
||||
});
|
||||
unawaited(_persistOutgoing(tempMessage));
|
||||
|
||||
// Instant tactile "whoosh" the moment the message leaves the composer,
|
||||
// not after the network round-trip — feedback must feel immediate.
|
||||
Haptics.send();
|
||||
|
||||
_scrollToBottom();
|
||||
_checkPrankTrigger(tempMessage);
|
||||
|
||||
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||
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(() {
|
||||
_messages[index] = CachedMessage(
|
||||
id: actualId.isNotEmpty ? actualId : tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: text,
|
||||
time: now,
|
||||
status: 'sent',
|
||||
);
|
||||
_messages[index] = sent;
|
||||
_messagesRevision++;
|
||||
});
|
||||
unawaited(_persistOutgoing(sent, removeId: tempId));
|
||||
}
|
||||
|
||||
if (chat == null) {
|
||||
@@ -564,6 +771,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
ChatsModule.refreshChats(api, [widget.chatId]).then((list) {
|
||||
if (!mounted || list.isEmpty) return;
|
||||
setState(() => chat = list.first);
|
||||
_syncOtherReadTime();
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -571,21 +779,33 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
Haptics.error();
|
||||
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (index != -1 && mounted) {
|
||||
final failed = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: text,
|
||||
time: now,
|
||||
status: 'error',
|
||||
);
|
||||
setState(() {
|
||||
_messages[index] = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: text,
|
||||
time: now,
|
||||
status: 'error',
|
||||
);
|
||||
_messages[index] = failed;
|
||||
_messagesRevision++;
|
||||
});
|
||||
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 {
|
||||
final forwardIds = <int>{};
|
||||
for (final msg in _messages) {
|
||||
@@ -601,48 +821,61 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
if (forwardIds.isEmpty) return;
|
||||
|
||||
final resolved = <int, ({String name, String? avatar})>{};
|
||||
for (final id in forwardIds) {
|
||||
final name = await messagesModule.searchContactById(id);
|
||||
final avatar = ContactCache.getAvatar(id);
|
||||
if (name != null && mounted) {
|
||||
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 (name != null) {
|
||||
resolved[id] = (name: name, avatar: ContactCache.getAvatar(id));
|
||||
}
|
||||
}
|
||||
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() {
|
||||
@@ -658,6 +891,10 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
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 Set<int> usedDates = {};
|
||||
|
||||
@@ -690,30 +927,29 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
|
||||
_separatorKeys.removeWhere((k, _) => !usedDates.contains(k));
|
||||
_combinedItemsCache = items;
|
||||
_combinedItemsKey = key;
|
||||
return items;
|
||||
}
|
||||
|
||||
void _onScrollForDate() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final currentOffset = _scrollController.position.pixels;
|
||||
final scrollingUp = currentOffset > _lastScrollOffset;
|
||||
_lastScrollOffset = currentOffset;
|
||||
|
||||
_floatingDateTimer?.cancel();
|
||||
|
||||
if (!scrollingUp) {
|
||||
_floatingDateAnimController.reverse();
|
||||
return;
|
||||
}
|
||||
|
||||
_floatingDateTimer = Timer(const Duration(seconds: 2), () {
|
||||
_floatingDateTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (mounted) _floatingDateAnimController.reverse();
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate());
|
||||
|
||||
if (_floatingDateScheduled) return;
|
||||
_floatingDateScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_floatingDateScheduled = false;
|
||||
_updateFloatingDate();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateFloatingDate() {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _separatorKeys.isEmpty) return;
|
||||
DateTime? result;
|
||||
|
||||
final listRenderBox = _listKey.currentContext?.findRenderObject();
|
||||
@@ -792,11 +1028,17 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
@override
|
||||
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: Cклонения
|
||||
return ValueListenableBuilder<bool>(
|
||||
return Theme(
|
||||
data: theme,
|
||||
child: RepaintBoundary(
|
||||
key: _prankCaptureKey,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: AppSwipeBackDesktop.current,
|
||||
builder: (context, desktopSwipe, child) => SwipeToPop(
|
||||
enabled: widget.embedded && desktopSwipe,
|
||||
@@ -952,6 +1194,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -972,7 +1216,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return Stack(
|
||||
key: _listKey,
|
||||
children: [
|
||||
ValueListenableBuilder<double>(
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: _otherReadTime,
|
||||
builder: (context, _, _) => ValueListenableBuilder<double>(
|
||||
valueListenable: AppCacheExtent.current,
|
||||
builder: (context, cacheExtent, _) => ListView.builder(
|
||||
controller: _scrollController,
|
||||
@@ -1006,6 +1252,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
nextMessage: nextMessage,
|
||||
chatType: chat?.type ?? 'CHAT',
|
||||
overrideStatus: _effectiveStatus(message),
|
||||
reactionsListenable: _reactionNotifierFor(message),
|
||||
);
|
||||
|
||||
final pressable = _LongPressBubble(
|
||||
@@ -1024,13 +1271,17 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
)
|
||||
: pressable;
|
||||
|
||||
return RepaintBoundary(
|
||||
final builtItem = RepaintBoundary(
|
||||
key: ValueKey('msg_${message.id}'),
|
||||
child: child,
|
||||
);
|
||||
return message.id == _prankBubbleId
|
||||
? KeyedSubtree(key: _prankBubbleKey, child: builtItem)
|
||||
: builtItem;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 0,
|
||||
@@ -1385,6 +1636,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
setState(() {
|
||||
_lastSentId = tempId;
|
||||
_messages.add(msg);
|
||||
_messagesRevision++;
|
||||
});
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
@@ -1412,6 +1664,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
payload: old.payload,
|
||||
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/contacts.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../core/utils/image_utils.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
@@ -131,16 +132,20 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
if (_avatar != null) {
|
||||
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
||||
if (url != null) {
|
||||
final bytes = await _avatar!.readAsBytes();
|
||||
final token = await fileUploader.uploadImage(
|
||||
Uri.parse(url),
|
||||
bytes,
|
||||
filename: _avatar!.uri.pathSegments.last,
|
||||
);
|
||||
if (token != null) {
|
||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
||||
} else if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||
final bytes = await compressAvatar(await _avatar!.readAsBytes());
|
||||
if (bytes == null) {
|
||||
if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку');
|
||||
} else {
|
||||
final token = await fileUploader.uploadImage(
|
||||
Uri.parse(url),
|
||||
bytes,
|
||||
filename: 'avatar.jpg',
|
||||
);
|
||||
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: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/token_storage.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../chats/chat_screen.dart';
|
||||
@@ -41,25 +40,18 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}),
|
||||
api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}),
|
||||
ContactInfoFetch.get(widget.contactId),
|
||||
PresenceFetch.get(widget.contactId),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
final infoPacket = results[0];
|
||||
if (infoPacket.isOk) {
|
||||
final contacts = (infoPacket.payload as Map?)?['contacts'] as List?;
|
||||
if (contacts != null && contacts.isNotEmpty) {
|
||||
_contact = Map<String, dynamic>.from(contacts.first as Map);
|
||||
}
|
||||
final contact = results[0];
|
||||
if (contact != null) {
|
||||
_contact = contact;
|
||||
}
|
||||
final presencePacket = results[1];
|
||||
if (presencePacket.isOk) {
|
||||
final presence = (presencePacket.payload as Map?)?['presence'] as Map?;
|
||||
final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId];
|
||||
if (p is Map) {
|
||||
_seenTime = p['seen'] as int?;
|
||||
_presenceStatus = (p['status'] as int?) ?? 0;
|
||||
}
|
||||
final presence = results[1];
|
||||
if (presence != null) {
|
||||
_seenTime = presence['seen'] as int?;
|
||||
_presenceStatus = (presence['status'] as int?) ?? 0;
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||
|
||||
@@ -43,7 +43,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
int? _accountId;
|
||||
List<CloudFile> _files = [];
|
||||
bool _isUploading = false;
|
||||
double _uploadProgress = 0;
|
||||
final ValueNotifier<double> _uploadProgress = ValueNotifier(0);
|
||||
bool _animateNewCard = false;
|
||||
|
||||
@override
|
||||
@@ -68,16 +68,19 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
}
|
||||
mgr.onProgress = (progress, _) {
|
||||
if (!mounted) return;
|
||||
setState(() { _isUploading = true; _uploadProgress = progress; });
|
||||
if (!_isUploading) setState(() => _isUploading = true);
|
||||
_uploadProgress.value = progress;
|
||||
};
|
||||
mgr.onDone = (file) {
|
||||
if (!mounted) return;
|
||||
setState(() { _isUploading = false; _uploadProgress = 0; });
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = false);
|
||||
_prependFile(file);
|
||||
};
|
||||
mgr.onError = (msg) {
|
||||
if (!mounted) return;
|
||||
setState(() { _isUploading = false; _uploadProgress = 0; });
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = false);
|
||||
showCustomNotification(context, 'Ошибка: $msg');
|
||||
};
|
||||
}
|
||||
@@ -91,6 +94,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
_mode.dispose();
|
||||
_pageController.dispose();
|
||||
_currentFilePage.dispose();
|
||||
_uploadProgress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -218,7 +222,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
final picked = result.files.first;
|
||||
if (picked.path == null) return;
|
||||
|
||||
setState(() { _isUploading = true; _uploadProgress = 0; });
|
||||
_uploadProgress.value = 0;
|
||||
setState(() => _isUploading = true);
|
||||
|
||||
await UploadManager.instance.start(
|
||||
chatId: chatId,
|
||||
@@ -427,17 +432,27 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (_isUploading) ...[
|
||||
LinearProgressIndicator(
|
||||
value: _uploadProgress,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
minHeight: 5,
|
||||
color: cs.primary,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: _uploadProgress,
|
||||
builder: (context, progress, _) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
minHeight: 5,
|
||||
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) ...[
|
||||
Text(
|
||||
|
||||
@@ -4,11 +4,18 @@ import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../backend/modules/chats.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/storage/app_database.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../../../core/utils/media_cache.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../../widgets/login_success_screen.dart';
|
||||
import '../calls/call_screen.dart';
|
||||
|
||||
class DebugMenuScreen extends StatefulWidget {
|
||||
const DebugMenuScreen({super.key});
|
||||
@@ -23,6 +30,92 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
bool _hasSearched = false;
|
||||
final List<_SearchHit> _hits = [];
|
||||
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
|
||||
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(
|
||||
child: Padding(
|
||||
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:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/utils/image_utils.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart' show accountModule, KometApp;
|
||||
import '../../../main.dart' show accountModule, fileUploader, KometApp;
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||
|
||||
class EditProfileScreen extends StatefulWidget {
|
||||
const EditProfileScreen({super.key});
|
||||
|
||||
@@ -77,12 +81,56 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
|
||||
Future<void> _changeAvatar() async {
|
||||
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 {
|
||||
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;
|
||||
showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)');
|
||||
setState(() {
|
||||
_avatarUrl = newProfile.baseUrl;
|
||||
_photoId = newProfile.photoId;
|
||||
_isSaving = false;
|
||||
});
|
||||
KometApp.stateOf(context)?.notifyProfileUpdate();
|
||||
showCustomNotification(context, 'Аватарка обновлена');
|
||||
} 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),
|
||||
min: AppFonts.minScale,
|
||||
max: AppFonts.maxScale,
|
||||
divisions: 10,
|
||||
divisions:
|
||||
((AppFonts.maxScale - AppFonts.minScale) / 0.05)
|
||||
.round(),
|
||||
onChanged: onChanged,
|
||||
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 {
|
||||
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) {
|
||||
setState(() {
|
||||
_is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
|
||||
_is2faEnabled = is2faEnabled;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -307,33 +313,40 @@ class TwoFactorSetupScreen extends StatefulWidget {
|
||||
|
||||
class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmController = TextEditingController();
|
||||
final _hintController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _codeController = TextEditingController();
|
||||
|
||||
int _step = 0;
|
||||
bool _isLoading = false;
|
||||
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||
String? _trackId;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_confirmController.dispose();
|
||||
_hintController.dispose();
|
||||
_emailController.dispose();
|
||||
_codeController.dispose();
|
||||
_isLoading.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _nextStep() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_isLoading.value = true;
|
||||
setState(() => _errorMessage = null);
|
||||
|
||||
try {
|
||||
switch (_step) {
|
||||
case 0:
|
||||
if (_passwordController.text.length < 6) {
|
||||
setState(
|
||||
() => _errorMessage = 'Пароль должен быть минимум 6 символов',
|
||||
);
|
||||
break;
|
||||
}
|
||||
final trackId = await accountModule.create2faTrack();
|
||||
setState(() {
|
||||
_trackId = trackId;
|
||||
@@ -341,10 +354,8 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
if (_passwordController.text.length < 6) {
|
||||
setState(
|
||||
() => _errorMessage = 'Пароль должен быть минимум 6 символов',
|
||||
);
|
||||
if (_confirmController.text != _passwordController.text) {
|
||||
setState(() => _errorMessage = 'Пароли не совпадают');
|
||||
break;
|
||||
}
|
||||
await accountModule.set2faPassword(
|
||||
@@ -392,7 +403,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
_isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,26 +469,29 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isLoading ? null : _nextStep,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _isLoading,
|
||||
builder: (context, loading, _) => FilledButton(
|
||||
onPressed: loading ? null : _nextStep,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
_PasswordField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите пароль',
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
hintText: 'Введите пароль',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -599,18 +604,9 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Повторите пароль',
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
_PasswordField(
|
||||
controller: _confirmController,
|
||||
hintText: 'Повторите пароль',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -732,7 +728,7 @@ class TwoFactorManageScreen extends StatefulWidget {
|
||||
|
||||
class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||
bool _isAuthenticated = false;
|
||||
String? _trackId;
|
||||
TwoFactorDetails? _details;
|
||||
@@ -741,14 +737,13 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_isLoading.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _authenticate() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_isLoading.value = true;
|
||||
setState(() => _errorMessage = null);
|
||||
|
||||
try {
|
||||
_trackId = await accountModule.enter2faPanel();
|
||||
@@ -761,7 +756,7 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = 'Неверный пароль');
|
||||
} 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),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
_PasswordField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Пароль',
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
hintText: 'Пароль',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isLoading ? null : _authenticate,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _isLoading,
|
||||
builder: (context, loading, _) => FilledButton(
|
||||
onPressed: loading ? null : _authenticate,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
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> {
|
||||
final _passwordController = TextEditingController();
|
||||
final _hintController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_hintController.dispose();
|
||||
_isLoading.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -960,10 +950,8 @@ class _TwoFactorPasswordChangeScreenState
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_isLoading.value = true;
|
||||
setState(() => _errorMessage = null);
|
||||
|
||||
try {
|
||||
final trackId = await accountModule.enter2faPanel();
|
||||
@@ -983,7 +971,7 @@ class _TwoFactorPasswordChangeScreenState
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
if (mounted) _isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,18 +1023,9 @@ class _TwoFactorPasswordChangeScreenState
|
||||
style: TextStyle(color: cs.onErrorContainer),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
_PasswordField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите новый пароль',
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
hintText: 'Введите новый пароль',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
@@ -1073,26 +1052,29 @@ class _TwoFactorPasswordChangeScreenState
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isLoading ? null : _changePassword,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _isLoading,
|
||||
builder: (context, loading, _) => FilledButton(
|
||||
onPressed: loading ? null : _changePassword,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
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 _codeController = TextEditingController();
|
||||
int _step = 0;
|
||||
bool _isLoading = false;
|
||||
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||
String? _trackId;
|
||||
String? _errorMessage;
|
||||
|
||||
@@ -1125,14 +1107,13 @@ class _TwoFactorEmailChangeScreenState
|
||||
_passwordController.dispose();
|
||||
_emailController.dispose();
|
||||
_codeController.dispose();
|
||||
_isLoading.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _nextStep() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_isLoading.value = true;
|
||||
setState(() => _errorMessage = null);
|
||||
|
||||
try {
|
||||
switch (_step) {
|
||||
@@ -1176,7 +1157,7 @@ class _TwoFactorEmailChangeScreenState
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
if (mounted) _isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,18 +1197,9 @@ class _TwoFactorEmailChangeScreenState
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
_PasswordField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Пароль',
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
hintText: 'Пароль',
|
||||
),
|
||||
] else ...[
|
||||
if (_errorMessage != null)
|
||||
@@ -1301,26 +1273,29 @@ class _TwoFactorEmailChangeScreenState
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isLoading ? null : _nextStep,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _isLoading,
|
||||
builder: (context, loading, _) => FilledButton(
|
||||
onPressed: loading ? null : _nextStep,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
foregroundColor: cs.onPrimary,
|
||||
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> {
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
final ValueNotifier<bool> _isLoading = ValueNotifier(false);
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_isLoading.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _remove2fa() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_isLoading.value = true;
|
||||
setState(() => _errorMessage = null);
|
||||
|
||||
try {
|
||||
final trackId = await accountModule.enter2faPanel();
|
||||
@@ -1368,7 +1342,7 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
|
||||
} catch (e) {
|
||||
setState(() => _errorMessage = e.toString());
|
||||
} 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),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
_PasswordField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Пароль',
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
hintText: 'Пароль',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _isLoading ? null : _remove2fa,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.error,
|
||||
foregroundColor: cs.onError,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _isLoading,
|
||||
builder: (context, loading, _) => FilledButton(
|
||||
onPressed: loading ? null : _remove2fa,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.error,
|
||||
foregroundColor: cs.onError,
|
||||
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(),
|
||||
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) {
|
||||
setState(() {
|
||||
_privacyConfig = results[0] as PrivacyConfig;
|
||||
_blockedContacts = results[1] as List<BlockedContact>;
|
||||
final profile = results[2] as ProfileData?;
|
||||
_is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
|
||||
_is2faEnabled = is2faEnabled;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/messages.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
@@ -20,6 +21,7 @@ import 'debug_menu_screen.dart';
|
||||
import 'devices_screen.dart';
|
||||
import 'edit_profile_screen.dart';
|
||||
import 'info_screen.dart';
|
||||
import 'notifications_screen.dart';
|
||||
import 'security_screen.dart';
|
||||
import 'spoof_screen.dart';
|
||||
|
||||
@@ -206,6 +208,7 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
}
|
||||
ContactCache.clear();
|
||||
TranscriptionCache.clear();
|
||||
ChatsModule.resetForAccountSwitch();
|
||||
try {
|
||||
await api.connect();
|
||||
} catch (_) {}
|
||||
@@ -309,9 +312,17 @@ child: _buildSection(
|
||||
context,
|
||||
cs,
|
||||
items: [
|
||||
const _SettingsItem(
|
||||
_SettingsItem(
|
||||
icon: Symbols.notifications_active,
|
||||
label: 'Уведомления и звук',
|
||||
label: 'Уведомления',
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const NotificationsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Symbols.vibration,
|
||||
|
||||
@@ -22,8 +22,9 @@ Future<ImageProvider?> precacheLoginAvatar(
|
||||
|
||||
class LoginSuccessScreen extends StatefulWidget {
|
||||
final ImageProvider? avatar;
|
||||
final bool preview;
|
||||
|
||||
const LoginSuccessScreen({super.key, this.avatar});
|
||||
const LoginSuccessScreen({super.key, this.avatar, this.preview = false});
|
||||
|
||||
@override
|
||||
State<LoginSuccessScreen> createState() => _LoginSuccessScreenState();
|
||||
@@ -114,6 +115,10 @@ class _LoginSuccessScreenState extends State<LoginSuccessScreen>
|
||||
void _onStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed && !_navigated && mounted) {
|
||||
_navigated = true;
|
||||
if (widget.preview) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 360),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:komet/main.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/utils/bubble_radius.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 'poll_view.dart';
|
||||
import 'photo_viewer.dart';
|
||||
import 'video_player_screen.dart';
|
||||
|
||||
enum MessageType { text, attachment, voice, control }
|
||||
|
||||
@@ -22,6 +30,7 @@ class _BubbleCtx {
|
||||
final MessageType contentType;
|
||||
final bool hasPhotoWithCaption;
|
||||
final bool hasMultiplePhotosNoCaption;
|
||||
final Map? reactionInfo;
|
||||
|
||||
_BubbleCtx({
|
||||
required this.context,
|
||||
@@ -31,6 +40,7 @@ class _BubbleCtx {
|
||||
required this.contentType,
|
||||
required this.hasPhotoWithCaption,
|
||||
required this.hasMultiplePhotosNoCaption,
|
||||
this.reactionInfo,
|
||||
}) : dim = text.withValues(alpha: 0.7);
|
||||
}
|
||||
|
||||
@@ -47,6 +57,10 @@ class MessageBubble extends StatelessWidget {
|
||||
static const Radius _smallRadius = Radius.circular(4);
|
||||
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) =>
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.white
|
||||
@@ -59,6 +73,7 @@ class MessageBubble extends StatelessWidget {
|
||||
final CachedMessage? nextMessage;
|
||||
final String chatType;
|
||||
final String? overrideStatus;
|
||||
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
||||
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
@@ -69,6 +84,7 @@ class MessageBubble extends StatelessWidget {
|
||||
this.nextMessage,
|
||||
required this.chatType,
|
||||
this.overrideStatus,
|
||||
this.reactionsListenable,
|
||||
});
|
||||
|
||||
bool _computeHasPhotoWithCaption() {
|
||||
@@ -259,16 +275,6 @@ class MessageBubble extends StatelessWidget {
|
||||
final hasMultiPhotos = _computeHasMultiplePhotosNoCaption();
|
||||
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 bottomMargin = _bottomMarginFor(contentType, shape);
|
||||
final padding = _paddingFor(contentType, shape);
|
||||
@@ -276,8 +282,34 @@ class MessageBubble extends StatelessWidget {
|
||||
final showAvatarSlot = !isMe;
|
||||
final showAvatar = showAvatarSlot &&
|
||||
chatType == "CHAT" &&
|
||||
nextMessage?.senderId != message.senderId &&
|
||||
prevMessage?.senderId == message.senderId;
|
||||
nextMessage?.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(
|
||||
onTap: Haptics.tap,
|
||||
@@ -304,32 +336,40 @@ class MessageBubble extends StatelessWidget {
|
||||
radius: 15,
|
||||
backgroundColor: Color(0x00000000),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge(
|
||||
[AppBubbleShape.current, AppBubbleBehavior.current],
|
||||
),
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? cs.primaryContainer
|
||||
: cs.surfaceContainerHighest,
|
||||
borderRadius: _borderRadiusFor(
|
||||
AppBubbleShape.current.value,
|
||||
AppBubbleBehavior.current.value,
|
||||
shape,
|
||||
hasPhotoCap,
|
||||
hasMultiPhotos,
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([
|
||||
AppBubbleShape.current,
|
||||
AppBubbleBehavior.current,
|
||||
]),
|
||||
builder: (context, child) => Container(
|
||||
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: _borderRadiusFor(
|
||||
AppBubbleShape.current.value,
|
||||
AppBubbleBehavior.current.value,
|
||||
shape,
|
||||
hasPhotoCap,
|
||||
hasMultiPhotos,
|
||||
),
|
||||
),
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
padding: padding,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _buildContent(ctx),
|
||||
child: reactionsInside
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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) {
|
||||
switch (ctx.contentType) {
|
||||
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) {
|
||||
final attachments = message.attachments;
|
||||
if (attachments == null || attachments.isEmpty) {
|
||||
@@ -421,49 +551,95 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (message.senderId != message.accountId &&
|
||||
prevMessage?.senderId != message.senderId &&
|
||||
chatType == "CHAT")
|
||||
if (showSender)
|
||||
Text(
|
||||
displaySender ?? "",
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(color: ctx.text),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: isForwarded
|
||||
? _buildForwardedInlineText(ctx, forwarded)
|
||||
: Text(
|
||||
message.text ?? '',
|
||||
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),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(child: textWidget),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: metaWidget,
|
||||
),
|
||||
),
|
||||
if (isMe) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(ctx),
|
||||
if (isMe) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(ctx),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -576,6 +752,11 @@ class MessageBubble extends StatelessWidget {
|
||||
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();
|
||||
if (photos.isEmpty) {
|
||||
return _buildGenericAttachment(ctx, attachments.first);
|
||||
@@ -584,6 +765,21 @@ class MessageBubble extends StatelessWidget {
|
||||
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) {
|
||||
final hasCaption = message.text != null && message.text!.isNotEmpty;
|
||||
final count = photos.length;
|
||||
@@ -805,6 +1001,7 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
|
||||
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
|
||||
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
|
||||
|
||||
final matchTop = ctx.hasPhotoWithCaption;
|
||||
final matchBottom = !ctx.hasPhotoWithCaption;
|
||||
@@ -830,8 +1027,8 @@ class MessageBubble extends StatelessWidget {
|
||||
width: constrainedWidth,
|
||||
height: constrainedHeight,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (constrainedWidth * 2).round(),
|
||||
memCacheHeight: (constrainedHeight * 2).round(),
|
||||
memCacheWidth: (constrainedWidth * dpr).round(),
|
||||
memCacheHeight: (constrainedHeight * dpr).round(),
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
|
||||
ctx.cs,
|
||||
@@ -926,6 +1123,8 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
final cachePx =
|
||||
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
@@ -936,8 +1135,8 @@ class MessageBubble extends StatelessWidget {
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: 280,
|
||||
memCacheHeight: 280,
|
||||
memCacheWidth: cachePx,
|
||||
memCacheHeight: cachePx,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, _, _) =>
|
||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||
@@ -961,6 +1160,8 @@ class MessageBubble extends StatelessWidget {
|
||||
String overlay,
|
||||
) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
final cachePx =
|
||||
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
@@ -971,8 +1172,8 @@ class MessageBubble extends StatelessWidget {
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: 280,
|
||||
memCacheHeight: 280,
|
||||
memCacheWidth: cachePx,
|
||||
memCacheHeight: cachePx,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, _, _) =>
|
||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||
@@ -1061,10 +1262,22 @@ class MessageBubble extends StatelessWidget {
|
||||
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(
|
||||
child: GestureDetector(
|
||||
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) {
|
||||
final name = (file as dynamic).name as String? ?? 'File';
|
||||
final size = (file as dynamic).size as int? ?? 0;
|
||||
final sizeStr = _formatFileSize(size);
|
||||
final fileId = (file as dynamic).fileId as int?;
|
||||
final cacheName = '${fileId}_$name';
|
||||
|
||||
return IntrinsicWidth(
|
||||
child: Padding(
|
||||
@@ -1125,35 +1383,61 @@ class MessageBubble extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
sizeStr,
|
||||
style: TextStyle(
|
||||
color: ctx.dim,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
ValueListenableBuilder<double?>(
|
||||
valueListenable: MediaDownloadProgress.notifier(cacheName),
|
||||
builder: (context, progress, _) => Text(
|
||||
progress != null
|
||||
? '${(progress * 100).round()}% · $sizeStr'
|
||||
: sizeStr,
|
||||
style: TextStyle(
|
||||
color: ctx.dim,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||
: ctx.cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Symbols.download,
|
||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<double?>(
|
||||
valueListenable: MediaDownloadProgress.notifier(cacheName),
|
||||
builder: (context, progress, _) {
|
||||
final downloading = progress != null;
|
||||
return GestureDetector(
|
||||
onTap: downloading
|
||||
? null
|
||||
: () => _downloadFile(ctx.context, file, name),
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||
: 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) {
|
||||
// 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) {
|
||||
@@ -1599,7 +1924,7 @@ class _VoiceMessageBubble extends StatefulWidget {
|
||||
|
||||
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
bool _isPlaying = false;
|
||||
double _progress = 0.0;
|
||||
final ValueNotifier<double> _progress = ValueNotifier(0.0);
|
||||
bool _transcriptionVisible = false;
|
||||
String? _transcriptionText;
|
||||
bool _transcriptionLoading = false;
|
||||
@@ -1607,10 +1932,13 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.preloadedText != null) {
|
||||
_transcriptionText = widget.preloadedText;
|
||||
_transcriptionVisible = true;
|
||||
}
|
||||
_transcriptionText = widget.preloadedText;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_progress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
@@ -1705,18 +2033,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
builder: (context, constraints) {
|
||||
return GestureDetector(
|
||||
onTapDown: (details) {
|
||||
setState(() {
|
||||
_progress = (details.localPosition.dx /
|
||||
constraints.maxWidth)
|
||||
.clamp(0.0, 1.0);
|
||||
});
|
||||
_progress.value =
|
||||
(details.localPosition.dx / constraints.maxWidth)
|
||||
.clamp(0.0, 1.0);
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
setState(() {
|
||||
_progress = (details.localPosition.dx /
|
||||
constraints.maxWidth)
|
||||
.clamp(0.0, 1.0);
|
||||
});
|
||||
_progress.value =
|
||||
(details.localPosition.dx / constraints.maxWidth)
|
||||
.clamp(0.0, 1.0);
|
||||
},
|
||||
child: Container(
|
||||
height: 4,
|
||||
@@ -1724,13 +2048,16 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
color: waveInactiveColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: _progress.clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: waveActiveColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: _progress,
|
||||
builder: (context, progress, _) => FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: progress.clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: waveActiveColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1772,14 +2099,19 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(widget.duration),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.7),
|
||||
fontSize: 11,
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_formatDuration(widget.duration),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.7),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: AnimatedSize(
|
||||
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';
|
||||
|
||||
class SwipeRoute<T> extends CupertinoPageRoute<T> {
|
||||
import 'rightward_drag_recognizer.dart';
|
||||
|
||||
class SwipeRoute<T> extends PageRoute<T> {
|
||||
SwipeRoute({
|
||||
required super.builder,
|
||||
required this.builder,
|
||||
super.settings,
|
||||
super.maintainState,
|
||||
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>(
|
||||
@@ -18,3 +107,130 @@ Future<T?> pushSwipeable<T>(
|
||||
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 'rightward_drag_recognizer.dart';
|
||||
|
||||
class SwipeToPop extends StatefulWidget {
|
||||
final Widget child;
|
||||
final double edgeWidth;
|
||||
final double popThreshold;
|
||||
final double velocityThreshold;
|
||||
final bool fullWidth;
|
||||
final bool enabled;
|
||||
final VoidCallback? onPop;
|
||||
|
||||
const SwipeToPop({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.edgeWidth = 28,
|
||||
this.popThreshold = 0.35,
|
||||
this.velocityThreshold = 700,
|
||||
this.fullWidth = false,
|
||||
this.enabled = true,
|
||||
this.onPop,
|
||||
});
|
||||
@@ -28,6 +25,7 @@ class SwipeToPop extends StatefulWidget {
|
||||
class _SwipeToPopState extends State<SwipeToPop>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
double _width = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -53,17 +51,19 @@ class _SwipeToPopState extends State<SwipeToPop>
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_width = context.size?.width ?? MediaQuery.of(context).size.width;
|
||||
if (_width <= 0) _width = 1.0;
|
||||
_controller.stop();
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails d, double width) {
|
||||
if (width <= 0) return;
|
||||
final next = (_controller.value + d.delta.dx / width).clamp(0.0, 1.0);
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
final next =
|
||||
(_controller.value + (d.primaryDelta ?? 0.0) / _width).clamp(0.0, 1.0);
|
||||
_controller.value = next;
|
||||
}
|
||||
|
||||
Future<void> _onDragEnd(DragEndDetails d, double width) async {
|
||||
final velocity = d.primaryVelocity ?? 0;
|
||||
Future<void> _onDragEnd(DragEndDetails d) async {
|
||||
final velocity = d.velocity.pixelsPerSecond.dx;
|
||||
final pastThreshold = _controller.value > widget.popThreshold ||
|
||||
velocity > widget.velocityThreshold;
|
||||
if (pastThreshold) {
|
||||
@@ -96,44 +96,35 @@ class _SwipeToPopState extends State<SwipeToPop>
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final gestureChild = GestureDetector(
|
||||
return RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
dragStartBehavior: DragStartBehavior.down,
|
||||
onHorizontalDragStart: _onDragStart,
|
||||
onHorizontalDragUpdate: (d) => _onDragUpdate(d, width),
|
||||
onHorizontalDragEnd: (d) => _onDragEnd(d, width),
|
||||
onHorizontalDragCancel: _onDragCancel,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
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,
|
||||
),
|
||||
gestures: <Type, GestureRecognizerFactory>{
|
||||
RightwardDragRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<RightwardDragRecognizer>(
|
||||
() => RightwardDragRecognizer(debugOwner: this),
|
||||
(instance) {
|
||||
instance
|
||||
..onStart = _onDragStart
|
||||
..onUpdate = _onDragUpdate
|
||||
..onEnd = _onDragEnd
|
||||
..onCancel = _onDragCancel;
|
||||
},
|
||||
),
|
||||
if (widget.fullWidth)
|
||||
Positioned.fill(child: gestureChild)
|
||||
else
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: widget.edgeWidth,
|
||||
child: gestureChild,
|
||||
),
|
||||
],
|
||||
},
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
"editProfileFirstName": "First 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:
|
||||
/// **'Remove photo'**
|
||||
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
|
||||
|
||||
@@ -478,4 +478,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String get editProfileRemovePhoto => 'Удалить фото';
|
||||
|
||||
@override
|
||||
String get registrationTitle => 'Создание профиля';
|
||||
|
||||
@override
|
||||
String get registrationSubtitle => 'Укажите имя и выберите аватар';
|
||||
|
||||
@override
|
||||
String get registrationChooseAvatar => 'Выберите аватар';
|
||||
}
|
||||
|
||||
+4
-1
@@ -168,5 +168,8 @@
|
||||
"editProfileSave": "Сохранить",
|
||||
"editProfileFirstName": "Имя",
|
||||
"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:shared_preferences/shared_preferences.dart';
|
||||
import 'backend/api.dart';
|
||||
import 'core/cache/info_cache.dart';
|
||||
import 'core/config/app_accent.dart';
|
||||
import 'core/config/app_amoled.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_message_actions_style.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_schedule.dart';
|
||||
import 'backend/modules/account.dart';
|
||||
@@ -25,6 +29,7 @@ import 'backend/modules/chats.dart';
|
||||
import 'backend/modules/contacts.dart';
|
||||
import 'backend/modules/file_uploader.dart';
|
||||
import 'backend/modules/messages.dart';
|
||||
import 'backend/modules/polls.dart';
|
||||
import 'core/push/push_service.dart';
|
||||
import 'core/storage/app_database.dart';
|
||||
import 'core/transport/tls_config.dart';
|
||||
@@ -41,7 +46,10 @@ import 'frontend/widgets/theme_reveal.dart';
|
||||
final api = Api();
|
||||
final accountModule = AccountModule(api);
|
||||
final messagesModule = MessagesModule(api);
|
||||
final pollsModule = PollsModule(api);
|
||||
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
||||
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
|
||||
RouteObserver<PageRoute<dynamic>>();
|
||||
|
||||
Future<Locale> _loadInitialLocale() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -63,19 +71,38 @@ void main() async {
|
||||
if (activeAccountId != null) {
|
||||
await ContactsModule.primeCacheFromDb(activeAccountId);
|
||||
}
|
||||
attachInfoCacheApi(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();
|
||||
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final packageInfo = await packageInfoFuture;
|
||||
if (packageInfo.packageName == 'ru.oneme.app') {
|
||||
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);
|
||||
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
||||
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
||||
@@ -85,15 +112,18 @@ void main() async {
|
||||
final initialFontScale = AppFonts.clampScale(
|
||||
prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale,
|
||||
);
|
||||
final initialAccentSeed = await AppAccent.load();
|
||||
AppBubbleShape.current.value = await AppBubbleShape.load();
|
||||
AppBubbleBehavior.current.value = await AppBubbleBehavior.load();
|
||||
AppCacheExtent.current.value = await AppCacheExtent.load();
|
||||
AppThemeModeConfig.current.value = await AppThemeModeConfig.load();
|
||||
AppAmoled.current.value = await AppAmoled.load();
|
||||
AppThemeSchedule.current.value = await AppThemeSchedule.load();
|
||||
AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load();
|
||||
AppSwipeBackDesktop.current.value = await AppSwipeBackDesktop.load();
|
||||
final initialAccentSeed = await accentFuture;
|
||||
AppBubbleShape.current.value = await bubbleShapeFuture;
|
||||
AppBubbleBehavior.current.value = await bubbleBehaviorFuture;
|
||||
AppCacheExtent.current.value = await cacheExtentFuture;
|
||||
AppThemeModeConfig.current.value = await themeModeFuture;
|
||||
AppAmoled.current.value = await amoledFuture;
|
||||
AppThemeSchedule.current.value = await themeScheduleFuture;
|
||||
AppMessageActionsStyle.current.value = await messageActionsFuture;
|
||||
AppSwipeBackDesktop.current.value = await swipeBackFuture;
|
||||
AppPranks.current.value = await pranksFuture;
|
||||
AppStories.current.value = await storiesFuture;
|
||||
AppMediaCacheLimit.current.value = await cacheLimitFuture;
|
||||
runApp(
|
||||
KometApp(
|
||||
initialLocale: initialLocale,
|
||||
@@ -626,6 +656,7 @@ class KometAppState extends State<KometApp>
|
||||
theme: _lightTheme,
|
||||
darkTheme: _darkTheme,
|
||||
navigatorKey: KometApp.navigatorKey,
|
||||
navigatorObservers: [appRouteObserver],
|
||||
builder: (context, child) {
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: fontScale,
|
||||
@@ -683,8 +714,13 @@ class _StartupScreenState extends State<_StartupScreen> {
|
||||
}
|
||||
|
||||
Future<void> _tryAutoLogin() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
int? accountId = await TokenStorage.getActiveAccountId();
|
||||
|
||||
if (accountId == null || await TokenStorage.readToken(accountId) == null) {
|
||||
accountId = await _recoverActiveAccount();
|
||||
}
|
||||
|
||||
if (accountId == null) {
|
||||
_goToLogin();
|
||||
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() {
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
|
||||
@@ -7,6 +7,7 @@ enum AttachmentType {
|
||||
location,
|
||||
sticker,
|
||||
control,
|
||||
poll,
|
||||
}
|
||||
|
||||
abstract class MessageAttachment {
|
||||
@@ -41,6 +42,8 @@ abstract class MessageAttachment {
|
||||
return LocationAttachment.fromMap(map);
|
||||
case 'CONTROL':
|
||||
return ControlAttachment.fromMap(map);
|
||||
case 'POLL':
|
||||
return PollAttachment.fromMap(map);
|
||||
case 'SHARE':
|
||||
return FileAttachment.fromMap(map);
|
||||
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 {
|
||||
final int originalSenderId;
|
||||
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
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
dart_lz4:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -421,6 +429,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -446,7 +462,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
image:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
@@ -585,10 +601,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.17.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -645,6 +661,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -670,7 +694,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -958,10 +982,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.10"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1002,6 +1026,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,11 +39,13 @@ dependencies:
|
||||
dart_lz4: ^1.0.0
|
||||
libcompress: ^1.0.0
|
||||
msgpack_dart: ^1.0.1
|
||||
crypto: ^3.0.7
|
||||
logger: ^2.6.2
|
||||
device_info_plus: 12.3.0
|
||||
flutter_timezone: ^5.0.1
|
||||
timezone: ^0.11.0
|
||||
file_picker: ^8.0.0
|
||||
image: ^4.3.0
|
||||
sqflite: ^2.4.2
|
||||
sqflite_common_ffi: ^2.4.0+2
|
||||
path: ^1.9.1
|
||||
@@ -55,6 +57,9 @@ dependencies:
|
||||
package_info_plus: ^9.0.1
|
||||
mobile_scanner: ^7.2.0
|
||||
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_messaging: ^16.0.2
|
||||
flutter_local_notifications: ^21.0.0
|
||||
|
||||
Reference in New Issue
Block a user