Merge remote-tracking branch 'origin/feature/FullStack' into feature/app-icon-switcher

# Conflicts:
#	pubspec.lock
This commit is contained in:
klockky
2026-06-03 18:06:43 +03:00
59 changed files with 5470 additions and 931 deletions
+6 -1
View File
@@ -133,4 +133,9 @@ agents.md
# Environment variables # Environment variables
.env .env
.env.* .env.*
# Локальные дампы трафика и скрипты анализа (содержат секреты)
komet.txt
original_app.txt
fingerprint.py
PCAPdroid_*.txt
+16 -7
View File
@@ -39,6 +39,12 @@ class Api {
Map<dynamic, dynamic>? get userAgent => _userAgent; Map<dynamic, dynamic>? get userAgent => _userAgent;
int? _callsSeed;
String? _deviceId;
int? get callsSeed => _callsSeed;
String? get deviceId => _deviceId;
List<CountryName>? _registrationCountries; List<CountryName>? _registrationCountries;
List<CountryName> get registrationCountries => List<CountryName> get registrationCountries =>
@@ -111,6 +117,7 @@ class Api {
try { try {
final response = await sendHandshake(); final response = await sendHandshake();
if (response.isOk) { if (response.isOk) {
_callsSeed = response.payload['callsSeed'] as int?;
_registrationCountries = _parseRegistrationCountries(response.payload); _registrationCountries = _parseRegistrationCountries(response.payload);
_setSessionState(SessionState.online); _setSessionState(SessionState.online);
_startPinging(); _startPinging();
@@ -164,7 +171,7 @@ class Api {
String architecture = 'arm64'; String architecture = 'arm64';
String appVersion = SpoofingService.hardcodedAppVersion; String appVersion = SpoofingService.hardcodedAppVersion;
int buildNumber = SpoofingService.hardcodedBuildNumber; int buildNumber = SpoofingService.hardcodedBuildNumber;
String screen = '1920x1080'; String screen = '420dpi 420dpi 1080x2340';
tz.initializeTimeZones(); tz.initializeTimeZones();
final timeZoneName = await FlutterTimezone.getLocalTimezone(); final timeZoneName = await FlutterTimezone.getLocalTimezone();
@@ -230,23 +237,25 @@ class Api {
_userAgent = { _userAgent = {
'deviceType': deviceType, 'deviceType': deviceType,
'locale': locale,
'deviceLocale': deviceLocale,
'osVersion': osVersion,
'deviceName': deviceName,
'appVersion': appVersion, 'appVersion': appVersion,
'screen': screen, 'osVersion': osVersion,
'timezone': timezone, 'timezone': timezone,
'screen': screen,
'pushDeviceType': 'GCM', 'pushDeviceType': 'GCM',
'arch': architecture, 'arch': architecture,
'locale': locale,
'buildNumber': buildNumber, 'buildNumber': buildNumber,
'deviceName': deviceName,
'deviceLocale': deviceLocale,
}; };
_deviceId = deviceId;
final payload = <dynamic, dynamic>{ final payload = <dynamic, dynamic>{
'mt_instanceid': await DeviceIdentity.instanceId(), 'mt_instanceid': await DeviceIdentity.instanceId(),
'userAgent': _userAgent,
'clientSessionId': DeviceIdentity.clientSessionId, 'clientSessionId': DeviceIdentity.clientSessionId,
'deviceId': deviceId, 'deviceId': deviceId,
'userAgent': _userAgent,
}; };
return sendRequest(Opcode.sessionInit, payload); return sendRequest(Opcode.sessionInit, payload);
+183 -39
View File
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import '../api.dart'; import '../api.dart';
import '../../core/protocol/chat_cache_fingerprint.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart'; import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart'; import '../../core/storage/app_database.dart';
@@ -223,6 +225,20 @@ class RequestCodeResult {
const RequestCodeResult({required this.token}); const RequestCodeResult({required this.token});
} }
class PresetAvatar {
final int id;
final String url;
const PresetAvatar({required this.id, required this.url});
}
class PresetAvatarCategory {
final String name;
final List<PresetAvatar> avatars;
const PresetAvatarCategory({required this.name, required this.avatars});
}
class VerifyCodeResult { class VerifyCodeResult {
final Map<dynamic, dynamic> payload; final Map<dynamic, dynamic> payload;
@@ -232,6 +248,37 @@ class VerifyCodeResult {
String? get registerToken => _nestedToken('REGISTER'); String? get registerToken => _nestedToken('REGISTER');
bool get isRegistration => registerToken != null && loginToken == null;
List<PresetAvatarCategory> get presetAvatars {
final raw = payload['presetAvatars'];
if (raw is! List) return const [];
final categories = <PresetAvatarCategory>[];
for (final cat in raw) {
if (cat is! Map) continue;
final avatarsRaw = cat['avatars'];
if (avatarsRaw is! List) continue;
final avatars = <PresetAvatar>[];
for (final a in avatarsRaw) {
if (a is! Map) continue;
final id = a['id'];
final url = a['url'];
if (id is int && url is String && url.isNotEmpty) {
avatars.add(PresetAvatar(id: id, url: url));
}
}
if (avatars.isNotEmpty) {
categories.add(
PresetAvatarCategory(
name: cat['name']?.toString() ?? '',
avatars: avatars,
),
);
}
}
return categories;
}
bool get requiresPassword => payload['passwordChallenge'] != null; bool get requiresPassword => payload['passwordChallenge'] != null;
Map<dynamic, dynamic>? get passwordChallenge { Map<dynamic, dynamic>? get passwordChallenge {
@@ -484,7 +531,7 @@ class AccountModule {
return newProfile; return newProfile;
} }
Future<ProfileData> updateProfileAvatar(String photoToken, String avatarType) async { Future<ProfileData> updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async {
_ensureOnline(); _ensureOnline();
final packet = await _api.sendRequest(Opcode.profile, { final packet = await _api.sendRequest(Opcode.profile, {
'photoToken': photoToken, 'photoToken': photoToken,
@@ -621,15 +668,17 @@ class AccountModule {
String? hint, String? hint,
}) async { }) async {
_ensureOnline(); _ensureOnline();
final capabilities = <int>[0, if (hint != null) 3, 4];
final payload = <dynamic, dynamic>{ final payload = <dynamic, dynamic>{
'expectedCapabilities': [0, 3, 4], 'expectedCapabilities': capabilities,
'trackId': trackId, 'trackId': trackId,
'password': password, 'password': password,
}; };
if (hint != null) payload['hint'] = hint; if (hint != null) payload['hint'] = hint;
final packet = await _api.sendRequest(Opcode.authSet2fa, payload); return _processProfileUpdate(
_checkPacketError(packet, 'confirm2fa'); _api.sendRequest(Opcode.authSet2fa, payload),
return _processProfileUpdate(packet); 'confirm2fa',
);
} }
// 2FA Management (when already set) // 2FA Management (when already set)
@@ -670,6 +719,11 @@ class AccountModule {
); );
} }
Future<TwoFactorDetails> get2faStatus() async {
final trackId = await enter2faPanel();
return get2faDetails(trackId);
}
Future<void> check2faPassword(String trackId, String password) async { Future<void> check2faPassword(String trackId, String password) async {
_ensureOnline(); _ensureOnline();
final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, { final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, {
@@ -707,15 +761,16 @@ class AccountModule {
} }
final payload = <dynamic, dynamic>{ final payload = <dynamic, dynamic>{
'expectedCapabilities': [1, 3], 'expectedCapabilities': <int>[1, if (hint != null) 3],
'trackId': trackId, 'trackId': trackId,
'password': newPassword, 'password': newPassword,
}; };
if (hint != null) payload['hint'] = hint; if (hint != null) payload['hint'] = hint;
final packet = await _api.sendRequest(Opcode.authSet2fa, payload); return _processProfileUpdate(
_checkPacketError(packet, 'update2faPassword'); _api.sendRequest(Opcode.authSet2fa, payload),
return _processProfileUpdate(packet); 'update2faPassword',
);
} }
Future<ProfileData> update2faEmail({ Future<ProfileData> update2faEmail({
@@ -740,9 +795,10 @@ class AccountModule {
'expectedCapabilities': [4], 'expectedCapabilities': [4],
'trackId': trackId, 'trackId': trackId,
}; };
final packet = await _api.sendRequest(Opcode.authSet2fa, payload); return _processProfileUpdate(
_checkPacketError(packet, 'update2faEmail'); _api.sendRequest(Opcode.authSet2fa, payload),
return _processProfileUpdate(packet); 'update2faEmail',
);
} }
Future<ProfileData> remove2fa(String trackId) async { Future<ProfileData> remove2fa(String trackId) async {
@@ -752,34 +808,46 @@ class AccountModule {
'trackId': trackId, 'trackId': trackId,
'remove2fa': true, 'remove2fa': true,
}; };
final packet = await _api.sendRequest(Opcode.authSet2fa, payload); return _processProfileUpdate(
_checkPacketError(packet, 'remove2fa'); _api.sendRequest(Opcode.authSet2fa, payload),
return _processProfileUpdate(packet); 'remove2fa',
);
} }
Future<ProfileData> _processProfileUpdate(Packet packet) async { Future<ProfileData> _processProfileUpdate(
_api.registerPushHandler(Opcode.notifProfile, (p) {}); Future<Packet> requestFuture,
try { String tag,
await for (final push in _api.pushStream ) async {
.where((p) => p.opcode == Opcode.notifProfile) final completer = Completer<ProfileData>();
.timeout(const Duration(seconds: 15))) { final sub = _api.pushStream
final payload = push.payload; .where((p) => p.opcode == Opcode.notifProfile)
if (payload is Map) { .listen((push) {
final profile = payload['profile']; if (completer.isCompleted) return;
if (profile is Map) { final payload = push.payload;
final contact = profile['contact']; if (payload is! Map) return;
if (contact is Map) { final profile = payload['profile'];
return ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()); if (profile is! Map) return;
} final contact = profile['contact'];
} if (contact is! Map) return;
} completer.complete(
ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()),
);
});
final timer = Timer(const Duration(seconds: 15), () {
if (!completer.isCompleted) {
completer.completeError(
Exception('Таймаут ожидания обновления профиля'),
);
} }
} on TimeoutException { });
throw Exception('Таймаут ожидания обновления профиля'); try {
final packet = await requestFuture;
_checkPacketError(packet, tag);
return await completer.future;
} finally { } finally {
_api.unregisterPushHandler(Opcode.notifProfile); timer.cancel();
await sub.cancel();
} }
throw Exception('Не удалось получить обновлённый профиль');
} }
Future<RequestCodeResult> requestCode( Future<RequestCodeResult> requestCode(
@@ -827,6 +895,61 @@ class AccountModule {
return result; return result;
} }
Future<int> completeRegistration({
required String token,
required String firstName,
String? lastName,
int? photoId,
}) async {
_ensureOnline();
final payload = <dynamic, dynamic>{
'token': token,
'tokenType': AuthRequestType.register.value,
'firstName': firstName,
};
if (lastName != null && lastName.isNotEmpty) {
payload['lastName'] = lastName;
}
if (photoId != null) {
payload['photoId'] = photoId;
payload['avatarType'] = 'PRESET_AVATAR';
}
logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})');
final packet = await _api.sendRequest(Opcode.authConfirm, payload);
_checkPacketError(packet, 'completeRegistration');
final data = packet.payload;
if (data is! Map) {
throw Exception(
'completeRegistration: неожиданный тип payload: ${data.runtimeType}',
);
}
final profileMap = data['profile'];
if (profileMap is! Map) {
throw Exception('completeRegistration: отсутствует profile в ответе');
}
final contact = profileMap['contact'];
if (contact is! Map) {
throw Exception('completeRegistration: отсутствует profile.contact');
}
final accountId = contact['id'] as int?;
if (accountId == null) {
throw Exception('completeRegistration: отсутствует id аккаунта');
}
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
await AppDatabase.saveProfile(profile, isActive: true);
await TokenStorage.setActiveAccount(accountId);
logger.i('Регистрация завершена, accountId=$accountId');
return accountId;
}
Future<LoginResult> login({ Future<LoginResult> login({
int? accountId, int? accountId,
String? token, String? token,
@@ -921,6 +1044,20 @@ class AccountModule {
_checkPacketError(packet, 'authorizeWebQrLogin'); _checkPacketError(packet, 'authorizeWebQrLogin');
} }
Future<void> beginAddAccount() async {
try {
await _api.disconnect();
} catch (_) {}
await TokenStorage.clearActiveAccount();
ContactCache.clear();
TranscriptionCache.clear();
ChatsModule.resetForAccountSwitch();
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
}
Future<ProfileData> switchAccount(int accountId) async { Future<ProfileData> switchAccount(int accountId) async {
final profile = await AppDatabase.loadProfile(accountId); final profile = await AppDatabase.loadProfile(accountId);
if (profile == null) { if (profile == null) {
@@ -940,6 +1077,7 @@ class AccountModule {
ContactCache.clear(); ContactCache.clear();
TranscriptionCache.clear(); TranscriptionCache.clear();
ChatsModule.resetForAccountSwitch();
await ContactsModule.primeCacheFromDb(accountId); await ContactsModule.primeCacheFromDb(accountId);
try { try {
@@ -1017,9 +1155,18 @@ class AccountModule {
final payload = <dynamic, dynamic>{ final payload = <dynamic, dynamic>{
'token': token, 'token': token,
'interactive': true, 'interactive': true,
if (_api.userAgent != null) 'userAgent': _api.userAgent, 'exp': {
'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]),
},
}; };
final callsSeed = _api.callsSeed;
final deviceId = _api.deviceId;
if (callsSeed != null && deviceId != null) {
payload['chatCacheFingerprint'] =
ChatCacheFingerprint.compute(callsSeed, deviceId);
}
if (sync != null) { if (sync != null) {
payload['presenceSync'] = sync.presenceSync; payload['presenceSync'] = sync.presenceSync;
payload['chatsSync'] = sync.chatsSync; payload['chatsSync'] = sync.chatsSync;
@@ -1029,9 +1176,6 @@ class AccountModule {
payload['bannersSync'] = sync.bannersSync; payload['bannersSync'] = sync.bannersSync;
payload['lastLogin'] = sync.lastLogin; payload['lastLogin'] = sync.lastLogin;
if (sync.configHash != null) payload['configHash'] = sync.configHash; if (sync.configHash != null) payload['configHash'] = sync.configHash;
if (sync.chatCacheFingerprint != null) {
payload['chatCacheFingerprint'] = sync.chatCacheFingerprint;
}
} else { } else {
payload['presenceSync'] = 0; payload['presenceSync'] = 0;
} }
+306 -6
View File
@@ -5,12 +5,13 @@ import 'package:flutter/foundation.dart';
import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart'; import '../../core/protocol/packet.dart';
import '../../core/cache/info_cache.dart';
import '../../core/storage/app_database.dart'; import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart'; import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import '../api.dart'; import '../api.dart';
import 'folders.dart'; import 'folders.dart';
import 'messages.dart' show ContactCache; import 'messages.dart' show ContactCache, CachedMessage;
Map<int, int> _parseParticipants(dynamic raw) { Map<int, int> _parseParticipants(dynamic raw) {
try { try {
@@ -146,27 +147,317 @@ class CachedChat {
}; };
} }
sealed class MessageEvent {
final int chatId;
const MessageEvent(this.chatId);
}
class MessageAddedEvent extends MessageEvent {
final CachedMessage message;
const MessageAddedEvent(super.chatId, this.message);
}
class MessageEditedEvent extends MessageEvent {
final CachedMessage message;
const MessageEditedEvent(super.chatId, this.message);
}
class MessageRemovedEvent extends MessageEvent {
final String messageId;
const MessageRemovedEvent(super.chatId, this.messageId);
}
class MessageReactionsChangedEvent extends MessageEvent {
final String messageId;
final Map<String, dynamic>? reactionInfo;
const MessageReactionsChangedEvent(super.chatId, this.messageId, this.reactionInfo);
}
class ChatsModule { class ChatsModule {
static const int muteOff = 0; static const int muteOff = 0;
static const int muteForever = -1; static const int muteForever = -1;
/// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено,
/// а кеша истории нет — UI должен отрисовать курсивную плашку.
static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__';
static final _messageEventsController =
StreamController<MessageEvent>.broadcast();
static Stream<MessageEvent> get messageEvents =>
_messageEventsController.stream;
static final ValueNotifier<int> chatsChanged = ValueNotifier(0); static final ValueNotifier<int> chatsChanged = ValueNotifier(0);
static void _bump() => chatsChanged.value = chatsChanged.value + 1; static void _bump() => chatsChanged.value = chatsChanged.value + 1;
static StreamSubscription<Packet>? _globalPushSub; static StreamSubscription<Packet>? _globalPushSub;
static StreamSubscription<SessionState>? _globalStateSub;
static final Set<int> _dirtyChats = {};
static final Set<int> _knownChats = {};
static bool isChatDirty(int chatId) => _dirtyChats.contains(chatId);
static void markChatClean(int chatId) => _dirtyChats.remove(chatId);
static void markChatDirty(int chatId) => _dirtyChats.add(chatId);
static void registerKnownChat(int chatId) => _knownChats.add(chatId);
static void attachGlobalPushHandlers(Api api) { static void attachGlobalPushHandlers(Api api) {
_globalPushSub?.cancel(); _globalPushSub?.cancel();
_globalStateSub?.cancel();
_globalPushSub = api.pushStream.listen(_handleGlobalPush); _globalPushSub = api.pushStream.listen(_handleGlobalPush);
_globalStateSub = api.stateStream.listen(_handleSessionState);
if (api.state != SessionState.online) {
_markAllKnownChatsDirty();
}
}
static Future<void> _handleSessionState(SessionState state) async {
if (state == SessionState.disconnected) {
ContactInfoFetch.clear();
PresenceFetch.clear();
ChatInfoFetch.clear();
await _markAllKnownChatsDirty();
}
}
static void resetForAccountSwitch() {
_dirtyChats.clear();
_knownChats.clear();
ContactInfoFetch.clear();
PresenceFetch.clear();
ChatInfoFetch.clear();
}
static Future<void> _markAllKnownChatsDirty() async {
if (_knownChats.isEmpty) {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final rows = await AppDatabase.loadChats(accountId);
for (final row in rows) {
final id = row['id'];
if (id is int) _knownChats.add(id);
}
}
_dirtyChats.addAll(_knownChats);
} }
static Future<void> _handleGlobalPush(Packet packet) async { static Future<void> _handleGlobalPush(Packet packet) async {
switch (packet.opcode) { switch (packet.opcode) {
case Opcode.notifMessage:
await _handleNotifMessage(packet);
case Opcode.notifMark: case Opcode.notifMark:
await _handleNotifMark(packet); await _handleNotifMark(packet);
case Opcode.notifMsgReactionsChanged:
await _handleNotifMsgReactionsChanged(packet);
} }
} }
static Future<void> _handleNotifMessage(Packet packet) async {
final payload = packet.payload;
if (payload is! Map) return;
final chatId = payload['chatId'];
if (chatId is! int) return;
final msg = payload['message'];
if (msg is! Map) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final senderId = msg['sender'] as int?;
final msgIdStr = msg['id']?.toString();
final msgIdInt = (msg['id'] is int)
? msg['id'] as int
: int.tryParse(msgIdStr ?? '');
final msgTime = msg['time'] as int?;
final msgText = msg['text'] as String?;
final status = msg['status'] as String?;
final unread = payload['unread'] as int?;
var rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) {
try {
final chatInfo = await ChatInfoFetch.get(chatId);
if (chatInfo != null) {
await cacheServerChat(chatInfo, accountId);
}
} catch (e) {
logger.w('notifMessage: fetch info for unknown chat $chatId failed: $e');
return;
}
rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) return;
}
if (status == 'REMOVED' && msgIdStr != null) {
await AppDatabase.deleteMessage(accountId, chatId, msgIdStr);
final cachedChat = CachedChat.fromDbRow(rows.first);
if (cachedChat.lastMsgId == msgIdInt) {
await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread);
} else if (unread != null) {
final newRow = Map<String, dynamic>.from(rows.first);
newRow['unread_count'] = unread;
await AppDatabase.saveChats([newRow]);
}
_messageEventsController.add(MessageRemovedEvent(chatId, msgIdStr));
_bump();
return;
}
CachedMessage? emittedMessage;
if (status == 'EDITED' && msgIdStr != null) {
final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr);
if (existing != null) {
Map<String, dynamic> mergedPayload;
final existingPayloadRaw = existing['payload'];
if (existingPayloadRaw is String && existingPayloadRaw.isNotEmpty) {
try {
mergedPayload = Map<String, dynamic>.from(
jsonDecode(existingPayloadRaw) as Map,
);
} catch (_) {
mergedPayload = Map<String, dynamic>.from(msg);
}
} else {
mergedPayload = Map<String, dynamic>.from(msg);
}
for (final entry in msg.entries) {
if (entry.key == 'reactionInfo') continue;
mergedPayload[entry.key.toString()] = entry.value;
}
final newRow = Map<String, dynamic>.from(existing);
newRow['text'] = msgText;
newRow['status'] = status;
newRow['payload'] = jsonEncode(mergedPayload);
await AppDatabase.saveMessages([newRow]);
emittedMessage = CachedMessage.fromDbRow(newRow);
_messageEventsController.add(MessageEditedEvent(chatId, emittedMessage));
}
} else if (msgIdStr != null) {
final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr);
if (existing == null) {
final cached = CachedMessage.fromPushPayload(accountId, chatId, msg);
await AppDatabase.saveMessages([cached.toDbRow()]);
emittedMessage = cached;
_messageEventsController.add(MessageAddedEvent(chatId, cached));
}
}
final cached = CachedChat.fromDbRow(rows.first);
final isStaleLast = status != 'REMOVED' &&
msgIdInt != null &&
cached.lastMsgId == msgIdInt &&
status != 'EDITED';
if (isStaleLast) {
_bump();
return;
}
final newRow = Map<String, dynamic>.from(rows.first);
if (status != 'REMOVED') {
if (msgIdInt != null) newRow['last_msg_id'] = msgIdInt;
if (msgTime != null) {
newRow['last_msg_time'] = msgTime;
if (status != 'EDITED') {
newRow['last_event_time'] = msgTime;
}
}
newRow['last_msg_text'] = msgText;
if (senderId != null) newRow['last_msg_sender'] = senderId;
}
if (unread != null) newRow['unread_count'] = unread;
await AppDatabase.saveChats([newRow]);
_bump();
}
static Future<void> _reconcileLastMessage(
int accountId,
int chatId,
Map<String, dynamic> chatRow, {
int? unread,
}) async {
final latest = await AppDatabase.loadMessages(accountId, chatId, limit: 1);
final newRow = Map<String, dynamic>.from(chatRow);
if (latest.isNotEmpty) {
final m = latest.first;
newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? '');
newRow['last_msg_text'] = m['text'];
newRow['last_msg_time'] = m['time'];
newRow['last_msg_sender'] = m['sender_id'];
} else {
newRow['last_msg_id'] = null;
newRow['last_msg_text'] = lastMsgPlaceholder;
newRow['last_msg_sender'] = null;
}
if (unread != null) newRow['unread_count'] = unread;
await AppDatabase.saveChats([newRow]);
}
/// Вызывается после успешного фетча истории чата —
/// если в превью был placeholder, заменяем его на актуальное
/// последнее сообщение из кеша.
static Future<void> reconcileLastMessageIfPlaceholder(
int accountId,
int chatId,
) async {
final rows = await AppDatabase.loadChat(accountId, chatId);
if (rows.isEmpty) return;
final chat = CachedChat.fromDbRow(rows.first);
if (chat.lastMsgText != lastMsgPlaceholder) return;
await _reconcileLastMessage(accountId, chatId, rows.first);
_bump();
}
static Future<void> _handleNotifMsgReactionsChanged(Packet packet) async {
final payload = packet.payload;
if (payload is! Map) return;
final chatId = payload['chatId'];
if (chatId is! int) return;
final messageId = payload['messageId']?.toString();
if (messageId == null || messageId.isEmpty) return;
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return;
final existing = await AppDatabase.loadMessage(accountId, chatId, messageId);
if (existing == null) return;
Map<String, dynamic> payloadMap;
final raw = existing['payload'];
if (raw is String && raw.isNotEmpty) {
try {
payloadMap = Map<String, dynamic>.from(jsonDecode(raw) as Map);
} catch (_) {
payloadMap = {};
}
} else {
payloadMap = {};
}
final counters = payload['counters'];
final totalCount = payload['totalCount'];
final reactionInfo = <String, dynamic>{};
final prev = payloadMap['reactionInfo'];
if (prev is Map && prev['yourReaction'] != null) {
reactionInfo['yourReaction'] = prev['yourReaction'];
}
if (counters is List) reactionInfo['counters'] = counters;
if (totalCount is int) reactionInfo['totalCount'] = totalCount;
if (reactionInfo['counters'] == null || (counters is List && counters.isEmpty)) {
payloadMap.remove('reactionInfo');
} else {
payloadMap['reactionInfo'] = reactionInfo;
}
final newRow = Map<String, dynamic>.from(existing);
newRow['payload'] = jsonEncode(payloadMap);
await AppDatabase.saveMessages([newRow]);
final emitted = payloadMap['reactionInfo'] as Map<String, dynamic>?;
_messageEventsController.add(
MessageReactionsChangedEvent(chatId, messageId, emitted),
);
_bump();
}
static Future<void> _handleNotifMark(Packet packet) async { static Future<void> _handleNotifMark(Packet packet) async {
final payload = packet.payload; final payload = packet.payload;
if (payload is! Map) return; if (payload is! Map) return;
@@ -221,12 +512,15 @@ class ChatsModule {
if (accountId == null) return; if (accountId == null) return;
final dialogRows = await AppDatabase.loadDialogChats(accountId); final dialogRows = await AppDatabase.loadDialogChats(accountId);
final byParticipant = <int, List<Map<String, dynamic>>>{}; final byParticipant =
<int, List<({Map<String, dynamic> row, CachedChat cached})>>{};
for (final row in dialogRows) { for (final row in dialogRows) {
final cached = CachedChat.fromDbRow(row); final cached = CachedChat.fromDbRow(row);
for (final pid in cached.participants.keys) { for (final pid in cached.participants.keys) {
if (pid == accountId) continue; if (pid == accountId) continue;
byParticipant.putIfAbsent(pid, () => []).add(row); byParticipant
.putIfAbsent(pid, () => [])
.add((row: row, cached: cached));
} }
} }
@@ -238,8 +532,9 @@ class ChatsModule {
final options = ContactCache.getOptions(contactId) ?? const <String>{}; final options = ContactCache.getOptions(contactId) ?? const <String>{};
final affected = byParticipant[contactId]; final affected = byParticipant[contactId];
if (affected == null) continue; if (affected == null) continue;
for (final row in affected) { for (final entry in affected) {
final cached = CachedChat.fromDbRow(row); final row = entry.row;
final cached = entry.cached;
final sameTitle = cached.title == name; final sameTitle = cached.title == name;
final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? ''); final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
final sameOptions = cached.options.length == options.length && final sameOptions = cached.options.length == options.length &&
@@ -288,6 +583,7 @@ class ChatsModule {
logger.w('cacheServerChat: parse returned null for chat=${chat['id']}'); logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
return null; return null;
} }
_knownChats.add(parsed.id);
final ex = existing[parsed.id]; final ex = existing[parsed.id];
if (ex != null && _sameContent(ex, parsed)) { if (ex != null && _sameContent(ex, parsed)) {
return parsed; return parsed;
@@ -383,7 +679,11 @@ class ChatsModule {
static Future<List<CachedChat>> getChats(int accountId) async { static Future<List<CachedChat>> getChats(int accountId) async {
try { try {
final rows = await AppDatabase.loadChats(accountId); final rows = await AppDatabase.loadChats(accountId);
return rows.map(CachedChat.fromDbRow).toList(); final chats = rows.map(CachedChat.fromDbRow).toList();
for (final c in chats) {
_knownChats.add(c.id);
}
return chats;
} catch (e) { } catch (e) {
logger.e("Ошибка при получении чатов: $e"); logger.e("Ошибка при получении чатов: $e");
return []; return [];
+58 -22
View File
@@ -190,13 +190,23 @@ class FileUploader {
Socket? socket; Socket? socket;
try { try {
socket = await _openSocket(uri); socket = await _openSocket(uri);
final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
final preamble = utf8.encode(
'--$boundary\r\n'
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
'\r\n',
);
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
_writeImageHeaders( _writeImageHeaders(
socket, socket,
uri, uri,
bytes.length, preamble.length + bytes.length + epilogue.length,
contentType: _contentTypeForFilename(filename), boundary: boundary,
); );
socket.add(preamble);
socket.add(bytes); socket.add(bytes);
socket.add(epilogue);
await socket.flush(); await socket.flush();
final response = await _readFullResponse( final response = await _readFullResponse(
@@ -208,7 +218,6 @@ class FileUploader {
} catch (_) {} } catch (_) {}
if (response == null) { if (response == null) {
logger.w('uploadImage: empty/timed-out response');
return null; return null;
} }
final (status, body) = response; final (status, body) = response;
@@ -230,12 +239,12 @@ class FileUploader {
} }
} }
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) { void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
final headers = StringBuffer() final headers = StringBuffer()
..write('POST $path HTTP/1.1\r\n') ..write('POST $path HTTP/1.1\r\n')
..write('Host: ${uri.host}\r\n') ..write('Host: ${uri.host}\r\n')
..write('Content-Type: $contentType\r\n') ..write('Content-Type: multipart/form-data; boundary=$boundary\r\n')
..write('Content-Length: $total\r\n') ..write('Content-Length: $total\r\n')
..write('Connection: keep-alive\r\n') ..write('Connection: keep-alive\r\n')
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
@@ -273,36 +282,63 @@ class FileUploader {
Timer? timer; Timer? timer;
StreamSubscription<List<int>>? sub; StreamSubscription<List<int>>? sub;
void finish() { void finishWith((int, String)? value) {
timer?.cancel(); timer?.cancel();
sub?.cancel(); sub?.cancel();
if (completer.isCompleted) return; if (!completer.isCompleted) completer.complete(value);
}
(int, String)? tryParse({required bool atClose}) {
final headerEnd = _findHeaderEnd(bytes); final headerEnd = _findHeaderEnd(bytes);
if (headerEnd == -1) { if (headerEnd == -1) return null;
completer.complete(null);
return;
}
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true); final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
final lines = headerStr.split('\r\n'); final lines = headerStr.split('\r\n');
final parts = lines.first.split(' '); final parts = lines.first.split(' ');
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
final chunked = lines.skip(1).any( final headerLines = lines.skip(1);
final chunked = headerLines.any(
(l) => l.toLowerCase().startsWith('transfer-encoding:') && (l) => l.toLowerCase().startsWith('transfer-encoding:') &&
l.toLowerCase().contains('chunked'), l.toLowerCase().contains('chunked'),
); );
int? contentLength;
for (final l in headerLines) {
if (l.toLowerCase().startsWith('content-length:')) {
contentLength = int.tryParse(l.split(':').last.trim());
}
}
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true); final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
final body = chunked ? _decodeChunked(rawBody) : rawBody; if (chunked) {
completer.complete((status, body)); if (!atClose && !rawBody.contains('\r\n0\r\n')) return null;
return (status, _decodeChunked(rawBody));
}
if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) {
return null;
}
return (status, rawBody);
} }
void fail() { sub = socket.listen(
timer?.cancel(); (chunk) {
sub?.cancel(); bytes.addAll(chunk);
if (!completer.isCompleted) completer.complete(null); final parsed = tryParse(atClose: false);
} if (parsed != null) finishWith(parsed);
},
sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish); onError: (e) {
timer = Timer(timeout, fail); logger.w('uploadImage: socket error after ${bytes.length} bytes: $e');
finishWith(tryParse(atClose: true));
},
onDone: () {
final parsed = tryParse(atClose: true);
if (parsed == null) {
logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)');
}
finishWith(parsed);
},
);
timer = Timer(timeout, () {
logger.w('uploadImage: response timeout after ${bytes.length} bytes');
finishWith(tryParse(atClose: true));
});
return completer.future; return completer.future;
} }
+6 -3
View File
@@ -44,10 +44,13 @@ class FoldersModule {
List<dynamic>? foldersOrder, List<dynamic>? foldersOrder,
) { ) {
if (foldersOrder == null || foldersOrder.isEmpty) return; if (foldersOrder == null || foldersOrder.isEmpty) return;
final orderedIds = foldersOrder.map((id) => id.toString()).toList(); final orderIndex = <String, int>{};
for (var i = 0; i < foldersOrder.length; i++) {
orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i);
}
folders.sort((a, b) { folders.sort((a, b) {
final aIndex = orderedIds.indexOf(a.id); final aIndex = orderIndex[a.id] ?? -1;
final bIndex = orderedIds.indexOf(b.id); final bIndex = orderIndex[b.id] ?? -1;
if (aIndex == -1 && bIndex == -1) return 0; if (aIndex == -1 && bIndex == -1) return 0;
if (aIndex == -1) return 1; if (aIndex == -1) return 1;
if (bIndex == -1) return -1; if (bIndex == -1) return -1;
+73 -21
View File
@@ -245,6 +245,29 @@ class CachedMessage {
'status': status, 'status': status,
'payload': payload != null ? jsonEncode(payload) : null, 'payload': payload != null ? jsonEncode(payload) : null,
}; };
static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) {
List<MessageAttachment>? attachments;
final attaches = msg['attaches'];
if (attaches is List && attaches.isNotEmpty) {
attachments = attaches
.whereType<Map>()
.map((a) =>
MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList();
}
return CachedMessage(
id: msg['id']?.toString() ?? '',
accountId: accountId,
chatId: chatId,
senderId: msg['sender'] as int? ?? 0,
text: msg['text'] as String?,
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
status: (msg['status'] as String?) ?? 'sent',
payload: Map<String, dynamic>.from(msg),
attachments: attachments,
);
}
} }
class MessagesModule { class MessagesModule {
@@ -545,6 +568,43 @@ class MessagesModule {
} }
} }
/// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83).
///
/// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`,
/// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`.
/// Возвращает лучший доступный progressive-MP4 (или HLS как запасной).
Future<String?> getVideoUrl({
required String messageId,
required int chatId,
required String token,
required int videoId,
}) async {
try {
final response = await _api.sendRequest(Opcode.videoPlay, {
'messageId': int.tryParse(messageId) ?? 0,
'chatId': chatId,
'token': token,
'videoId': videoId,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
const mp4Keys = ['MP4_1080', 'MP4_720', 'MP4_480', 'MP4_360', 'MP4_240'];
for (final key in mp4Keys) {
final url = data[key];
if (url is String && url.isNotEmpty) return url;
}
final hls = data['HLS'];
if (hls is String && hls.isNotEmpty) return hls;
final external = data['EXTERNAL'];
if (external is String && external.isNotEmpty) return external;
return null;
} catch (_) {
return null;
}
}
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async { Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
try { try {
final response = await _api.sendRequest(Opcode.fileDownload, { final response = await _api.sendRequest(Opcode.fileDownload, {
@@ -565,23 +625,6 @@ class MessagesModule {
} }
} }
Future<String?> getVideoUrl(String baseUrl, String videoToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
'url': baseUrl,
'token': videoToken,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
return data['content'] as String?;
} catch (e) {
return null;
}
}
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async { Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
try { try {
final response = await _api.sendRequest(Opcode.fileDownload, { final response = await _api.sendRequest(Opcode.fileDownload, {
@@ -602,18 +645,27 @@ class MessagesModule {
} }
} }
Future<String?> getFileUrl(String baseUrl, String fileToken) async { /// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88).
///
/// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`,
/// ответ `{url: "https://fd.oneme.ru/getfile?..."}`.
Future<String?> getFileUrl({
required String messageId,
required int chatId,
required int fileId,
}) async {
try { try {
final response = await _api.sendRequest(Opcode.fileDownload, { final response = await _api.sendRequest(Opcode.fileDownload, {
'url': baseUrl, 'messageId': int.tryParse(messageId) ?? 0,
'token': fileToken, 'chatId': chatId,
'fileId': fileId,
}); });
if (!response.isOk) return null; if (!response.isOk) return null;
final data = response.payload; final data = response.payload;
if (data is! Map) return null; if (data is! Map) return null;
return data['content'] as String?; return data['url'] as String?;
} catch (e) { } catch (e) {
return null; return null;
} }
+61
View File
@@ -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);
}
}
}
+226
View File
@@ -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);
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ class AppFonts {
static const String customPrefKey = 'app_custom_fonts'; static const String customPrefKey = 'app_custom_fonts';
static const String customPrefix = 'g:'; static const String customPrefix = 'g:';
static const double minScale = 0.85; static const double minScale = 0.60;
static const double maxScale = 1.35; static const double maxScale = 1.35;
static const double defaultScale = 1.0; static const double defaultScale = 1.0;
+33
View File
@@ -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);
}
}
+20
View File
@@ -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);
}
}
+20
View File
@@ -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;
}
}
+30 -8
View File
@@ -82,19 +82,41 @@ String messageFromErrorPayload(dynamic payload) {
return s.isNotEmpty ? s : 'Неизвестная ошибка'; return s.isNotEmpty ? s : 'Неизвестная ошибка';
} }
/// Упаковка пакета для отправки на сервер /// Payload меньше этого размера отправляется без сжатия (как в оригинале).
const int _compressionThreshold = 32;
/// Упаковка пакета для отправки на сервер.
///
/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold]
/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия:
/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по
/// которому получатель выделяет буфер под распаковку).
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) { Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
final header = ByteData(headerSize); final Uint8List raw = msgpack.serialize(payload);
final List<int> body;
final int flag;
if (raw.length < _compressionThreshold) {
body = raw;
flag = 0;
} else {
body = lz4Compress(raw);
flag = (raw.length ~/ body.length) + 1;
}
final out = Uint8List(headerSize + body.length);
final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize);
header.setUint8(0, 10); header.setUint8(0, 10);
header.setUint8(1, CmdType.request); header.setUint8(1, CmdType.request);
header.setUint16(2, seq, Endian.big); header.setUint16(2, seq, Endian.big);
header.setUint16(4, opcode, Endian.big); header.setUint16(4, opcode, Endian.big);
header.setUint32(
final payloadBytes = msgpack.serialize(payload); 6,
final payloadLen = payloadBytes.length & 0xFFFFFF; ((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
header.setUint32(6, payloadLen, Endian.big); Endian.big,
);
return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes); out.setRange(headerSize, out.length, body);
return out;
} }
/// Распаковка пакета от сервера /// Распаковка пакета от сервера
+41 -9
View File
@@ -435,17 +435,20 @@ class AppDatabase {
// Chats cache // Chats cache
static Future<void> saveChats(List<Map<String, dynamic>> rows) async { static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
if (rows.isEmpty) return;
try { try {
final db = await _instance; final db = await _instance;
final batch = db.batch(); await db.transaction((txn) async {
for (final row in rows) { final batch = txn.batch();
batch.insert( for (final row in rows) {
'chats_cache', batch.insert(
row, 'chats_cache',
conflictAlgorithm: ConflictAlgorithm.replace, row,
); conflictAlgorithm: ConflictAlgorithm.replace,
} );
await batch.commit(noResult: true); }
await batch.commit(noResult: true);
});
} catch (e) { } catch (e) {
logger.e("Ошибка при сохранении чата: $e"); logger.e("Ошибка при сохранении чата: $e");
} }
@@ -587,4 +590,33 @@ class AppDatabase {
whereArgs: [accountId, chatId], whereArgs: [accountId, chatId],
); );
} }
static Future<Map<String, dynamic>?> loadMessage(
int accountId,
int chatId,
String messageId,
) async {
final db = await _instance;
final rows = await db.query(
'messages',
where: 'account_id = ? AND chat_id = ? AND id = ?',
whereArgs: [accountId, chatId, messageId],
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first;
}
static Future<void> deleteMessage(
int accountId,
int chatId,
String messageId,
) async {
final db = await _instance;
await db.delete(
'messages',
where: 'account_id = ? AND chat_id = ? AND id = ?',
whereArgs: [accountId, chatId, messageId],
);
}
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class SpoofingService { class SpoofingService {
static const String hardcodedAppVersion = '26.14.1'; static const String hardcodedAppVersion = '26.17.1';
static const int hardcodedBuildNumber = 6606; static const int hardcodedBuildNumber = 6712;
static Future<Map<String, dynamic>?> getSpoofedSessionData() async { static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
+5
View File
@@ -24,6 +24,11 @@ class TokenStorage {
await prefs.setString(_activeAccountKey, accountId.toString()); await prefs.setString(_activeAccountKey, accountId.toString());
} }
static Future<void> clearActiveAccount() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_activeAccountKey);
}
static Future<int?> getActiveAccountId() async { static Future<int?> getActiveAccountId() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final val = prefs.getString(_activeAccountKey); final val = prefs.getString(_activeAccountKey);
+38 -11
View File
@@ -7,46 +7,73 @@ import '../utils/logger.dart';
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов. /// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
class PacketReceiver { class PacketReceiver {
Uint8List _buffer = Uint8List(0); Uint8List _buffer = Uint8List(0);
int _start = 0;
int _end = 0;
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы. /// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому /// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`. /// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
///
/// Накопление идёт без перекопирования всего буфера на каждый чанк: целые
/// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается
/// сдвигом указателя `_start`, а не пересборкой буфера.
List<Uint8List> feed(Uint8List data) { List<Uint8List> feed(Uint8List data) {
final newBuffer = Uint8List(_buffer.length + data.length); _append(data);
newBuffer.setAll(0, _buffer);
newBuffer.setAll(_buffer.length, data);
_buffer = newBuffer;
if (_buffer.length > _maxBufferSize) { if (_end - _start > _maxBufferSize) {
logger.e( logger.e(
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', 'PacketReceiver: переполнение буфера (${_end - _start} B), сброс',
); );
reset(); reset();
return const []; return const [];
} }
final packets = <Uint8List>[]; final packets = <Uint8List>[];
while (_buffer.length >= headerSize) { while (_end - _start >= headerSize) {
final bd = ByteData.view( final bd = ByteData.view(
_buffer.buffer, _buffer.buffer,
_buffer.offsetInBytes, _buffer.offsetInBytes + _start,
headerSize, headerSize,
); );
final packedLen = bd.getUint32(6, Endian.big); final packedLen = bd.getUint32(6, Endian.big);
final payloadLength = packedLen & 0xFFFFFF; final payloadLength = packedLen & 0xFFFFFF;
final totalLength = headerSize + payloadLength; final totalLength = headerSize + payloadLength;
if (_buffer.length < totalLength) break; if (_end - _start < totalLength) break;
packets.add(Uint8List.sublistView(_buffer, 0, totalLength)); packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength));
_buffer = _buffer.sublist(totalLength); _start += totalLength;
}
if (_start == _end) {
_start = 0;
_end = 0;
} }
return packets; return packets;
} }
void _append(Uint8List data) {
final pending = _end - _start;
if (pending == 0) {
_buffer = Uint8List.fromList(data);
_start = 0;
_end = data.length;
return;
}
final total = pending + data.length;
final newBuffer = Uint8List(total);
newBuffer.setRange(0, pending, _buffer, _start);
newBuffer.setRange(pending, total, data);
_buffer = newBuffer;
_start = 0;
_end = total;
}
void reset() { void reset() {
_buffer = Uint8List(0); _buffer = Uint8List(0);
_start = 0;
_end = 0;
} }
} }
+15
View File
@@ -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;
}
}
+50
View File
@@ -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());
}
}
+28
View File
@@ -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;
}
+185
View File
@@ -0,0 +1,185 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../config/app_media_cache.dart';
/// Постоянный дисковый кэш скачанных медиа (файлы, видео).
///
/// Хранит файлы в `<appSupport>/media_cache/` под детерминированным именем
/// (обычно по id вложения), чтобы повторные открытия не качали заново.
class MediaCache {
/// Максимальный размер кэша (настраивается в дев-меню); при превышении
/// вытесняются старые файлы (LRU).
static int get maxBytes => AppMediaCacheLimit.current.value;
static Directory? _dir;
static int? _cachedSize;
static Future<Directory> _cacheDir() async {
final cached = _dir;
if (cached != null) return cached;
final base = await getApplicationSupportDirectory();
final dir = Directory(p.join(base.path, 'media_cache'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
_dir = dir;
return dir;
}
/// Путь к кэш-файлу с именем [name] (файл может ещё не существовать).
static Future<File> fileFor(String name) async {
final dir = await _cacheDir();
return File(p.join(dir.path, _sanitize(name)));
}
/// Существует ли непустой кэш-файл [name].
///
/// При попадании обновляет mtime файла — это делает вытеснение LRU
/// (часто используемые файлы переживают очистку).
static Future<File?> existing(String name) async {
final file = await fileFor(name);
if (await file.exists() && await file.length() > 0) {
try {
await file.setLastModified(DateTime.now());
} catch (_) {}
return file;
}
return null;
}
/// Возвращает кэш-файл [name], скачивая [url] при отсутствии.
///
/// Загрузка идёт во временный `.part` и переименовывается атомарно —
/// прерванная закачка не считается валидным кэшем.
static Future<File?> getOrDownload(
String name,
String url, {
void Function(double progress)? onProgress,
}) async {
final existingFile = await existing(name);
if (existingFile != null) return existingFile;
final file = await fileFor(name);
final part = File('${file.path}.part');
final client = HttpClient();
try {
final request = await client.getUrl(Uri.parse(url));
final response = await request.close();
if (response.statusCode != 200) return null;
final total = response.contentLength;
var received = 0;
final sink = part.openWrite();
await for (final chunk in response) {
received += chunk.length;
sink.add(chunk);
if (onProgress != null && total > 0) {
onProgress(received / total);
}
}
await sink.close();
await part.rename(file.path);
final known = _cachedSize;
if (known != null) {
try {
_cachedSize = known + await file.length();
} catch (_) {}
}
await _enforceLimit();
return file;
} catch (_) {
if (await part.exists()) {
try {
await part.delete();
} catch (_) {}
}
return null;
} finally {
client.close();
}
}
/// Суммарный размер кэша в байтах.
///
/// Результат держится в памяти и поддерживается инкрементально при
/// загрузке/очистке/вытеснении — повторные вызовы не пересканируют каталог.
static Future<int> currentSize() async {
final cached = _cachedSize;
if (cached != null) return cached;
final total = await _scanSize();
_cachedSize = total;
return total;
}
static Future<int> _scanSize() async {
final dir = await _cacheDir();
var total = 0;
await for (final entity in dir.list()) {
if (entity is File) {
try {
total += await entity.length();
} catch (_) {}
}
}
return total;
}
/// Полностью очищает кэш. Возвращает число удалённых байт.
static Future<int> clear() async {
final dir = await _cacheDir();
var freed = 0;
await for (final entity in dir.list()) {
if (entity is File) {
try {
freed += await entity.length();
await entity.delete();
} catch (_) {}
}
}
_cachedSize = 0;
return freed;
}
/// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes].
///
/// Под лимитом — ранний выход без сканирования каталога (частый случай).
/// Каталог обходится только когда лимит реально превышен.
static Future<void> _enforceLimit() async {
final limit = maxBytes;
if (limit <= 0) return;
var total = _cachedSize ?? await _scanSize();
if (total <= limit) {
_cachedSize = total;
return;
}
final dir = await _cacheDir();
final files = <File>[];
await for (final entity in dir.list()) {
if (entity is File && !entity.path.endsWith('.part')) {
files.add(entity);
}
}
files.sort((a, b) =>
a.statSync().modified.compareTo(b.statSync().modified));
for (final file in files) {
if (total <= limit) break;
try {
total -= await file.length();
await file.delete();
} catch (_) {}
}
_cachedSize = total;
}
static String _sanitize(String name) {
final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
return cleaned.isEmpty ? 'file' : cleaned;
}
}
@@ -4,6 +4,7 @@ import 'package:komet/l10n/app_localizations.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'password_2fa_screen.dart'; import 'password_2fa_screen.dart';
import 'registration_screen.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/login_success_screen.dart'; import '../../widgets/login_success_screen.dart';
@@ -167,6 +168,20 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
return; return;
} }
if (result.isRegistration) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => RegistrationScreen(
phoneNumber: widget.phoneNumber,
registerToken: result.registerToken!,
presetAvatars: result.presetAvatars,
),
),
);
return;
}
final loginResult = await accountModule.login(); final loginResult = await accountModule.login();
if (!mounted) return; if (!mounted) return;
@@ -183,10 +198,8 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
PageRouteBuilder( PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 240), transitionDuration: const Duration(milliseconds: 240),
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
transitionsBuilder: (_, animation, __, child) => FadeTransition( transitionsBuilder: (_, animation, __, child) =>
opacity: animation, FadeTransition(opacity: animation, child: child),
child: child,
),
), ),
(route) => false, (route) => false,
); );
+55 -7
View File
@@ -13,11 +13,16 @@ import 'select_country_screen.dart';
import 'proxy_settings_sheet.dart'; import 'proxy_settings_sheet.dart';
import 'server_settings_sheet.dart'; import 'server_settings_sheet.dart';
import '../profile/spoof_screen.dart'; import '../profile/spoof_screen.dart';
import '../profile/debug_menu_screen.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/adaptive_shell.dart';
import '../../../backend/api.dart';
import '../../../main.dart'; import '../../../main.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); final int? returnToAccountId;
const LoginScreen({super.key, this.returnToAccountId});
@override @override
State<LoginScreen> createState() => _LoginScreenState(); State<LoginScreen> createState() => _LoginScreenState();
@@ -30,15 +35,36 @@ class _LoginScreenState extends State<LoginScreen> {
bool _isTOSRead = false; bool _isTOSRead = false;
String? _phoneError; String? _phoneError;
Timer? _phoneErrorTimer; Timer? _phoneErrorTimer;
int _logoTapCount = 0;
Timer? _logoTapTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (api.state == SessionState.disconnected) {
unawaited(api.connect());
}
_selectedCountry = countriesByCode['RU'] ?? allCountries.first; _selectedCountry = countriesByCode['RU'] ?? allCountries.first;
_clampCountryToAllowed(); _clampCountryToAllowed();
_checkTOS(); _checkTOS();
} }
Future<void> _onBackPressed() async {
final returnId = widget.returnToAccountId;
if (returnId != null) {
try {
await accountModule.switchAccount(returnId);
} catch (_) {}
if (!mounted) return;
await Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const AdaptiveShell()),
(route) => false,
);
return;
}
if (Navigator.canPop(context)) Navigator.pop(context);
}
void _clampCountryToAllowed() { void _clampCountryToAllowed() {
final allowed = api.registrationCountries; final allowed = api.registrationCountries;
if (allowed.any((c) => c.code == _selectedCountry.code)) return; if (allowed.any((c) => c.code == _selectedCountry.code)) return;
@@ -51,6 +77,7 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
void dispose() { void dispose() {
_phoneErrorTimer?.cancel(); _phoneErrorTimer?.cancel();
_logoTapTimer?.cancel();
_phoneController.dispose(); _phoneController.dispose();
super.dispose(); super.dispose();
} }
@@ -64,6 +91,22 @@ class _LoginScreenState extends State<LoginScreen> {
} }
} }
void _onLogoTap() {
_logoTapTimer?.cancel();
_logoTapTimer = Timer(const Duration(milliseconds: 600), () {
_logoTapCount = 0;
});
_logoTapCount++;
if (_logoTapCount >= 7) {
_logoTapTimer?.cancel();
_logoTapCount = 0;
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const DebugMenuScreen()),
);
}
}
Future<void> _markTOSRead() async { Future<void> _markTOSRead() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('IsReadeTOS', true); await prefs.setBool('IsReadeTOS', true);
@@ -666,9 +709,10 @@ class _LoginScreenState extends State<LoginScreen> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
if (Navigator.canPop(context)) if (Navigator.canPop(context) ||
widget.returnToAccountId != null)
IconButton( IconButton(
onPressed: () => Navigator.pop(context), onPressed: _onBackPressed,
icon: Icon( icon: Icon(
Symbols.arrow_back, Symbols.arrow_back,
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
@@ -704,10 +748,14 @@ class _LoginScreenState extends State<LoginScreen> {
Center( Center(
child: Column( child: Column(
children: [ children: [
Image.asset( GestureDetector(
'assets/komet.png', behavior: HitTestBehavior.opaque,
height: 80, onTap: _onLogoTap,
color: cs.onSurface, child: Image.asset(
'assets/komet.png',
height: 80,
color: cs.onSurface,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@@ -56,10 +56,8 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
PageRouteBuilder( PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 240), transitionDuration: const Duration(milliseconds: 240),
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
transitionsBuilder: (_, animation, __, child) => FadeTransition( transitionsBuilder: (_, animation, __, child) =>
opacity: animation, FadeTransition(opacity: animation, child: child),
child: child,
),
), ),
(route) => false, (route) => false,
); );
@@ -0,0 +1,327 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:komet/l10n/app_localizations.dart';
import '../../../backend/modules/account.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/login_success_screen.dart';
class RegistrationScreen extends StatefulWidget {
final String phoneNumber;
final String registerToken;
final List<PresetAvatarCategory> presetAvatars;
const RegistrationScreen({
super.key,
required this.phoneNumber,
required this.registerToken,
required this.presetAvatars,
});
@override
State<RegistrationScreen> createState() => _RegistrationScreenState();
}
class _RegistrationScreenState extends State<RegistrationScreen> {
final TextEditingController _firstNameController = TextEditingController();
final TextEditingController _lastNameController = TextEditingController();
int? _selectedPhotoId;
String? _selectedAvatarUrl;
bool _isSubmitting = false;
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
super.dispose();
}
bool get _canSubmit =>
!_isSubmitting && _firstNameController.text.trim().isNotEmpty;
Future<void> _submit() async {
final firstName = _firstNameController.text.trim();
if (firstName.isEmpty) return;
final lastName = _lastNameController.text.trim();
setState(() => _isSubmitting = true);
try {
final accountId = await accountModule.completeRegistration(
token: widget.registerToken,
firstName: firstName,
lastName: lastName.isEmpty ? null : lastName,
photoId: _selectedPhotoId,
);
final loginResult = await accountModule.login(
accountId: accountId,
token: '',
);
if (!mounted) return;
final avatar = await precacheLoginAvatar(
context,
loginResult.profile.baseUrl,
);
if (!mounted) return;
Navigator.pushAndRemoveUntil(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 240),
pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar),
transitionsBuilder: (_, animation, __, child) =>
FadeTransition(opacity: animation, child: child),
),
(route) => false,
);
} catch (e) {
if (!mounted) return;
setState(() => _isSubmitting = false);
showCustomNotification(context, e.toString());
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final firstName = _firstNameController.text.trim();
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant),
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _canSubmit ? _submit : null,
backgroundColor: _canSubmit
? cs.primaryContainer
: cs.surfaceContainerHighest,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
child: _isSubmitting
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: cs.onSurfaceVariant,
),
)
: Icon(
Icons.arrow_forward,
color: _canSubmit
? cs.onPrimaryContainer
: cs.onSurfaceVariant,
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 96),
children: [
Text(
l10n.registrationTitle,
style: GoogleFonts.inter(
color: cs.onSurface,
fontSize: 26,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
l10n.registrationSubtitle,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1.4,
),
),
const SizedBox(height: 28),
Center(
child: Container(
width: 96,
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.5),
width: 2.5,
),
),
child: ClipOval(
child: _selectedAvatarUrl != null
? CachedNetworkImage(
imageUrl: _selectedAvatarUrl!,
fit: BoxFit.cover,
)
: Container(
color: cs.primaryContainer,
alignment: Alignment.center,
child: Text(
firstName.isNotEmpty
? firstName[0].toUpperCase()
: '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
const SizedBox(height: 28),
_buildTextField(
cs,
label: l10n.editProfileFirstName,
controller: _firstNameController,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 14),
_buildTextField(
cs,
label: l10n.editProfileLastName,
controller: _lastNameController,
textInputAction: TextInputAction.done,
),
if (widget.presetAvatars.isNotEmpty) ...[
const SizedBox(height: 28),
Text(
l10n.registrationChooseAvatar,
style: GoogleFonts.inter(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
for (final category in widget.presetAvatars)
_buildAvatarCategory(cs, category),
],
],
),
),
);
}
Widget _buildTextField(
ColorScheme cs, {
required String label,
required TextEditingController controller,
required TextInputAction textInputAction,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 6),
child: Text(
label,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
),
TextField(
controller: controller,
enabled: !_isSubmitting,
textInputAction: textInputAction,
onChanged: (_) => setState(() {}),
style: TextStyle(color: cs.onSurface, fontSize: 15),
decoration: InputDecoration(
filled: true,
fillColor: cs.surfaceContainerHigh,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
],
);
}
Widget _buildAvatarCategory(ColorScheme cs, PresetAvatarCategory category) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
if (category.name.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10),
child: Text(
category.name,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
SizedBox(
height: 64,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: category.avatars.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final avatar = category.avatars[index];
final selected = _selectedPhotoId == avatar.id;
return GestureDetector(
onTap: _isSubmitting
? null
: () => setState(() {
_selectedPhotoId = avatar.id;
_selectedAvatarUrl = avatar.url;
}),
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.primary : Colors.transparent,
width: 2.5,
),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ClipOval(
child: CachedNetworkImage(
imageUrl: avatar.url,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: cs.surfaceContainerHigh,
),
errorWidget: (_, __, ___) => Container(
color: cs.surfaceContainerHigh,
),
),
),
),
),
);
},
),
),
],
);
}
}
+381
View File
@@ -0,0 +1,381 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
enum CallScreenState { incoming, outgoing, active }
class CallScreen extends StatefulWidget {
final String name;
final String? avatarUrl;
final CallScreenState initialState;
const CallScreen({
super.key,
required this.name,
this.avatarUrl,
this.initialState = CallScreenState.incoming,
});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen>
with SingleTickerProviderStateMixin {
late CallScreenState _state;
Timer? _timer;
int _seconds = 0;
bool _isMuted = false;
bool _isSpeaker = false;
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_state = widget.initialState;
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
if (_state == CallScreenState.outgoing) {
_startOutgoingTimer();
}
}
void _startOutgoingTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _seconds++);
if (_seconds >= 3 && _state == CallScreenState.outgoing) {
_timer?.cancel();
setState(() => _state = CallScreenState.active);
_startActiveTimer();
}
});
}
void _startActiveTimer() {
_seconds = 0;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _seconds++);
});
}
String get _timerText {
final m = (_seconds ~/ 60).toString().padLeft(2, '0');
final s = (_seconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
void _accept() {
setState(() {
_state = CallScreenState.active;
_seconds = 0;
});
_startActiveTimer();
}
void _endCall() {
_timer?.cancel();
Navigator.pop(context);
}
@override
void dispose() {
_timer?.cancel();
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenH = MediaQuery.of(context).size.height;
return Scaffold(
backgroundColor: const Color(0xFF0E0E14),
body: SafeArea(
child: Column(
children: [
const Spacer(flex: 3),
_buildAvatar(screenH),
const SizedBox(height: 24),
_buildName(),
const SizedBox(height: 8),
_buildStatus(),
const Spacer(flex: 2),
_buildActions(),
const SizedBox(height: 48),
],
),
),
);
}
Widget _buildAvatar(double screenH) {
final size = screenH * 0.18;
final cs = Theme.of(context).colorScheme;
final isRinging = _state == CallScreenState.incoming;
final isOutgoing = _state == CallScreenState.outgoing;
return AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
final scale = (isRinging || isOutgoing)
? _pulseAnimation.value
: 1.0;
return Transform.scale(
scale: scale,
child: child,
);
},
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.primaryContainer.withValues(alpha: 0.2),
border: Border.all(
color: cs.primary.withValues(alpha: 0.3),
width: 2,
),
),
child: ClipOval(
child: widget.avatarUrl != null && widget.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: widget.avatarUrl!,
fit: BoxFit.cover,
memCacheWidth: 360,
memCacheHeight: 360,
errorWidget: (_, _, _) => _fallbackAvatar(size),
)
: _fallbackAvatar(size),
),
),
);
}
Widget _fallbackAvatar(double size) {
final cs = Theme.of(context).colorScheme;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.primaryContainer,
),
alignment: Alignment.center,
child: Text(
widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?',
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: size * 0.4,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildName() {
final cs = Theme.of(context).colorScheme;
return Text(
widget.name,
style: TextStyle(
color: cs.onSurface,
fontSize: 26,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
);
}
Widget _buildStatus() {
final cs = Theme.of(context).colorScheme;
String text;
switch (_state) {
case CallScreenState.incoming:
text = 'Входящий звонок';
case CallScreenState.outgoing:
text = 'Вызов...';
case CallScreenState.active:
text = _timerText;
}
return Text(
text,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w400,
),
);
}
Widget _buildActions() {
switch (_state) {
case CallScreenState.incoming:
return _buildIncomingActions();
case CallScreenState.outgoing:
return _buildOutgoingActions();
case CallScreenState.active:
return _buildActiveActions();
}
}
Widget _buildIncomingActions() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ActionButton(
icon: Symbols.phone_disabled,
label: 'Отклонить',
color: const Color(0xFFBA1A1A),
onTap: _endCall,
),
const SizedBox(width: 48),
_ActionButton(
icon: Symbols.phone,
label: 'Принять',
color: const Color(0xFF3A691E),
onTap: _accept,
),
],
);
}
Widget _buildOutgoingActions() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ActionButton(
icon: Symbols.phone_disabled,
label: 'Отмена',
color: const Color(0xFFBA1A1A),
onTap: _endCall,
),
],
);
}
Widget _buildActiveActions() {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_CircleActionButton(
icon: _isMuted ? Symbols.mic_off : Symbols.mic,
active: _isMuted,
onTap: () => setState(() => _isMuted = !_isMuted),
),
const SizedBox(width: 32),
_CircleActionButton(
icon: _isMuted ? Symbols.volume_off : Symbols.volume_up,
active: _isSpeaker,
onTap: () => setState(() => _isSpeaker = !_isSpeaker),
),
const SizedBox(width: 32),
_CircleActionButton(
icon: Symbols.bluetooth_audio,
active: false,
onTap: () {},
),
],
),
const SizedBox(height: 40),
_ActionButton(
icon: Symbols.phone_disabled,
label: 'Завершить',
color: const Color(0xFFBA1A1A),
onTap: _endCall,
),
],
);
}
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
const _ActionButton({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
alignment: Alignment.center,
child: Icon(icon, color: Colors.white, size: 28, fill: 1),
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}
class _CircleActionButton extends StatelessWidget {
final IconData icon;
final bool active;
final VoidCallback onTap;
const _CircleActionButton({
required this.icon,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active
? Colors.white.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.1),
),
alignment: Alignment.center,
child: Icon(
icon,
color: active ? Colors.white : Colors.white70,
size: 24,
fill: 1,
),
),
);
}
}
@@ -3,9 +3,8 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/messages.dart' show ContactCache; import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/protocol/opcode_map.dart'; import '../../../core/cache/info_cache.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../main.dart' as main;
class _MemberInfo { class _MemberInfo {
final int id; final int id;
@@ -96,21 +95,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
final profile = await AppDatabase.loadActiveProfile(); final profile = await AppDatabase.loadActiveProfile();
_myId = profile?.id ?? 0; _myId = profile?.id ?? 0;
final packet = await main.api.sendRequest( final info = await ChatInfoFetch.get(widget.chatId);
Opcode.chatInfo, if (!mounted) return;
{'chatIds': [widget.chatId]}, if (info == null) {
); setState(() => _isLoading = false);
if (!packet.isOk || !mounted) {
if (mounted) setState(() => _isLoading = false);
return; return;
} }
_chatData = info;
final chats = (packet.payload as Map?)?['chats'] as List?;
if (chats == null || chats.isEmpty) {
if (mounted) setState(() => _isLoading = false);
return;
}
_chatData = Map<String, dynamic>.from(chats.first as Map);
if (widget.chatType == 'DIALOG') { if (widget.chatType == 'DIALOG') {
final parts = _chatData!['participants'] as Map? ?? {}; final parts = _chatData!['participants'] as Map? ?? {};
@@ -123,32 +114,19 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
} }
if (_otherId != null) { if (_otherId != null) {
final cp = await main.api.sendRequest( final contact = await ContactInfoFetch.get(_otherId!);
Opcode.contactInfo, if (contact != null) {
{'contactIds': [_otherId]}, _contactData = contact;
); final opts = _contactData!['options'];
if (cp.isOk) { _isBot = (opts is List) && opts.contains('BOT');
final contacts = (cp.payload as Map?)?['contacts'] as List?;
if (contacts != null && contacts.isNotEmpty) {
_contactData = Map<String, dynamic>.from(contacts.first as Map);
final opts = _contactData!['options'];
_isBot = (opts is List) && opts.contains('BOT');
}
} }
final pp = await main.api.sendRequest( final presence = await PresenceFetch.get(_otherId!);
Opcode.contactPresence, if (presence != null) {
{'contactIds': [_otherId]}, _seenTime = presence['seen'] as int?;
); final st = (presence['status'] as int?) ?? 0;
if (pp.isOk) { _presenceStatus = st;
final presence = (pp.payload as Map?)?['presence'] as Map?; _isOnline = st == 1;
final p = presence?[_otherId.toString()] ?? presence?[_otherId];
if (p is Map) {
_seenTime = p['seen'] as int?;
final st = (p['status'] as int?) ?? 0;
_presenceStatus = st;
_isOnline = st == 1;
}
} }
} }
} else if (widget.chatType == 'CHAT') { } else if (widget.chatType == 'CHAT') {
@@ -162,25 +140,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
if (id != null) memberIds.add(id); if (id != null) memberIds.add(id);
} }
final Map<int, Map> presenceMap = {}; Map<int, Map<String, dynamic>> presenceMap = {};
if (memberIds.isNotEmpty) { if (memberIds.isNotEmpty) {
final pp = await main.api.sendRequest( presenceMap = await PresenceFetch.getMany(memberIds);
Opcode.contactPresence,
{'contactIds': memberIds},
);
if (pp.isOk) {
final presence = (pp.payload as Map?)?['presence'] as Map?;
if (presence != null) {
for (final e in presence.entries) {
final id = e.key is int
? e.key as int
: int.tryParse(e.key.toString());
if (id != null && e.value is Map) {
presenceMap[id] = e.value as Map;
}
}
}
}
} }
_onlineCount = 0; _onlineCount = 0;
+174 -109
View File
@@ -19,13 +19,16 @@ import '../auth/login_screen.dart';
import '../../widgets/account_switcher_overlay.dart'; import '../../widgets/account_switcher_overlay.dart';
import '../../../backend/api.dart'; import '../../../backend/api.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../core/config/app_stories.dart';
import '../../../backend/models/chat_folder.dart'; import '../../../backend/models/chat_folder.dart';
import '../../../backend/modules/account.dart'; import '../../../backend/modules/account.dart';
import '../../../backend/modules/chats.dart'; import '../../../backend/modules/chats.dart';
import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/cloud_storage.dart';
import '../../../backend/modules/folders.dart'; import '../../../backend/modules/folders.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../main.dart' show accountModule, api, messagesModule; import '../../../core/storage/token_storage.dart';
import '../../../main.dart'
show accountModule, api, messagesModule, appRouteObserver;
class _StoriesScrollPhysics extends BouncingScrollPhysics { class _StoriesScrollPhysics extends BouncingScrollPhysics {
final bool Function() blockPositive; final bool Function() blockPositive;
@@ -72,7 +75,7 @@ class ChatListScreen extends StatefulWidget {
enum _DeleteKind { personalLike, ownerGroup, blocked } enum _DeleteKind { personalLike, ownerGroup, blocked }
class _ChatListScreenState extends State<ChatListScreen> class _ChatListScreenState extends State<ChatListScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin, RouteAware {
String? _selectedFolderId; String? _selectedFolderId;
List<ChatFolder> _folders = []; List<ChatFolder> _folders = [];
@@ -81,7 +84,7 @@ class _ChatListScreenState extends State<ChatListScreen>
double _navPageAnimStart = 0; double _navPageAnimStart = 0;
double _navPageAnimEnd = 0; double _navPageAnimEnd = 0;
double _navDragDx = 0; final ValueNotifier<double> _navDragDx = ValueNotifier(0);
double _navDragBaseLeft = 0; double _navDragBaseLeft = 0;
double _revealAnimBegin = 0.0; double _revealAnimBegin = 0.0;
double _closeAnimBegin = 0.0; double _closeAnimBegin = 0.0;
@@ -100,9 +103,11 @@ class _ChatListScreenState extends State<ChatListScreen>
bool _navDragging = false; bool _navDragging = false;
bool _isFabOpen = false; bool _isFabOpen = false;
bool _showCacheWarning = false;
bool _storiesAnimClosing = false; bool _storiesAnimClosing = false;
Timer? _contactRebuildTimer; Timer? _contactRebuildTimer;
bool _deferReloads = false;
bool _reloadQueued = false;
Timer? _settleTimer;
bool get _isSelectionMode => _selectedChats.isNotEmpty; bool get _isSelectionMode => _selectedChats.isNotEmpty;
bool? _foldersListKnown; bool? _foldersListKnown;
@@ -140,11 +145,9 @@ class _ChatListScreenState extends State<ChatListScreen>
_selectedFolderId, _selectedFolderId,
_isInitialLoading, _isInitialLoading,
_foldersListKnown, _foldersListKnown,
_showCacheWarning,
_isSelectionMode, _isSelectionMode,
_shouldCollapseSearch, _shouldCollapseSearch,
_selectedChats.length, _selectedChats.length,
_pullRatio,
_storiesDockedOpen, _storiesDockedOpen,
_storiesAnimClosing, _storiesAnimClosing,
_storiesOverscrollRevealArmed, _storiesOverscrollRevealArmed,
@@ -180,8 +183,12 @@ class _ChatListScreenState extends State<ChatListScreen>
List<CachedChat> _selectedChatObjects() { List<CachedChat> _selectedChatObjects() {
if (_selectedChats.isEmpty) return const []; if (_selectedChats.isEmpty) return const [];
final ids = _selectedChats; final ids = <int>{};
return _chats.where((c) => ids.contains(c.id.toString())).toList(); for (final s in _selectedChats) {
final v = int.tryParse(s);
if (v != null) ids.add(v);
}
return _chats.where((c) => ids.contains(c.id)).toList();
} }
_DeleteKind _categorizeChat(CachedChat c, int myId) { _DeleteKind _categorizeChat(CachedChat c, int myId) {
@@ -404,6 +411,7 @@ class _ChatListScreenState extends State<ChatListScreen>
} }
bool _allowStoriesPullOverscrollTop() { bool _allowStoriesPullOverscrollTop() {
if (!AppStories.current.value) return false;
if (_storiesDockedOpen || if (_storiesDockedOpen ||
_storiesRevealController.isAnimating || _storiesRevealController.isAnimating ||
_pullRatio > 0) { _pullRatio > 0) {
@@ -447,30 +455,73 @@ class _ChatListScreenState extends State<ChatListScreen>
if (mounted) { if (mounted) {
setState(() { setState(() {
_sessionState = state; _sessionState = state;
if (state == SessionState.disconnected && _chats.isNotEmpty) {
_showCacheWarning = true;
}
if (state == SessionState.online) {
_showCacheWarning = false;
}
}); });
if (state == SessionState.online) { if (state == SessionState.online) {
_reloadChatsAndFolders(); _requestReload();
} }
} }
}); });
_loginSub = accountModule.loginStatusStream.listen((status) { _loginSub = accountModule.loginStatusStream.listen((status) {
if (status == LoginStatus.success) { if (status == LoginStatus.success) {
_reloadChatsAndFolders(); _requestReload();
} }
}); });
ChatsModule.chatsChanged.addListener(_onChatsChanged); ChatsModule.chatsChanged.addListener(_onChatsChanged);
AppStories.current.addListener(_onStoriesEnabledChanged);
_reloadChatsAndFolders();
}
void _onStoriesEnabledChanged() {
if (!mounted) return;
if (!AppStories.current.value) {
_storiesRevealController.stop();
_pullRatio = 0;
_storiesDockedOpen = false;
_storiesAnimClosing = false;
_storiesOverscrollRevealArmed = false;
}
setState(() {});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is PageRoute) {
appRouteObserver.subscribe(this, route);
}
}
@override
void didPushNext() {
_deferReloads = true;
}
@override
void didPopNext() {
_settleTimer?.cancel();
_settleTimer = Timer(const Duration(milliseconds: 420), () {
if (!mounted) return;
_deferReloads = false;
if (_reloadQueued) {
_reloadQueued = false;
_reloadChatsAndFolders();
}
});
}
void _requestReload() {
if (!mounted) return;
if (_deferReloads) {
_reloadQueued = true;
return;
}
_reloadChatsAndFolders(); _reloadChatsAndFolders();
} }
void _onChatsChanged() { void _onChatsChanged() {
if (mounted) _reloadChatsAndFolders(); _requestReload();
} }
Future<void> _reloadChatsAndFolders() async { Future<void> _reloadChatsAndFolders() async {
@@ -619,7 +670,19 @@ class _ChatListScreenState extends State<ChatListScreen>
}); });
} }
int? _pageChatsBaseKey;
final Map<int, List<CachedChat>> _pageChatsCache = {};
List<CachedChat> _chatsForPageIndex(int pageIndex) { List<CachedChat> _chatsForPageIndex(int pageIndex) {
final baseKey =
Object.hash(identityHashCode(_chats), identityHashCode(_folders));
if (_pageChatsBaseKey != baseKey) {
_pageChatsBaseKey = baseKey;
_pageChatsCache.clear();
}
final cached = _pageChatsCache[pageIndex];
if (cached != null) return cached;
List<CachedChat> base; List<CachedChat> base;
if (_folders.isEmpty) { if (_folders.isEmpty) {
base = _chats; base = _chats;
@@ -634,7 +697,9 @@ class _ChatListScreenState extends State<ChatListScreen>
final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList()
..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!));
final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList(); final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList();
return [...pinned, ...regular]; final result = [...pinned, ...regular];
_pageChatsCache[pageIndex] = result;
return result;
} }
void _syncFolderChatScrollControllers() { void _syncFolderChatScrollControllers() {
@@ -928,7 +993,10 @@ class _ChatListScreenState extends State<ChatListScreen>
@override @override
void dispose() { void dispose() {
appRouteObserver.unsubscribe(this);
_settleTimer?.cancel();
ChatsModule.chatsChanged.removeListener(_onChatsChanged); ChatsModule.chatsChanged.removeListener(_onChatsChanged);
AppStories.current.removeListener(_onStoriesEnabledChanged);
_loginSub?.cancel(); _loginSub?.cancel();
_stateSub?.cancel(); _stateSub?.cancel();
_fabController.dispose(); _fabController.dispose();
@@ -947,6 +1015,7 @@ class _ChatListScreenState extends State<ChatListScreen>
} }
_contactRebuildTimer?.cancel(); _contactRebuildTimer?.cancel();
_storiesUi.dispose(); _storiesUi.dispose();
_navDragDx.dispose();
super.dispose(); super.dispose();
} }
@@ -955,7 +1024,7 @@ class _ChatListScreenState extends State<ChatListScreen>
required double Function(int index) bubbleLeftForIndex, required double Function(int index) bubbleLeftForIndex,
}) { }) {
if (_navDragging) { if (_navDragging) {
final left = (_navDragBaseLeft + _navDragDx).clamp( final left = (_navDragBaseLeft + _navDragDx.value).clamp(
bubbleLeftForIndex(0), bubbleLeftForIndex(0),
bubbleLeftForIndex(3), bubbleLeftForIndex(3),
); );
@@ -1028,7 +1097,8 @@ class _ChatListScreenState extends State<ChatListScreen>
children: [ children: [
Row( Row(
children: [ children: [
if (_pullRatio < 0.8) if (AppStories.current.value &&
_pullRatio < 0.8)
Opacity( Opacity(
opacity: 1.0 - _pullRatio, opacity: 1.0 - _pullRatio,
child: Container( child: Container(
@@ -1107,68 +1177,33 @@ class _ChatListScreenState extends State<ChatListScreen>
], ],
), ),
), ),
SizedBox( if (AppStories.current.value)
height: 96 * _pullRatio, SizedBox(
child: Opacity( height: 96 * _pullRatio,
opacity: _pullRatio, child: Opacity(
child: ListView( opacity: _pullRatio,
scrollDirection: Axis.horizontal, child: ListView(
padding: const EdgeInsets.symmetric( scrollDirection: Axis.horizontal,
horizontal: 20, padding: const EdgeInsets.symmetric(
), horizontal: 20,
children: [
_buildStoryItem(
'Даша',
'https://i.pravatar.cc/150?u=dasha',
true,
), ),
_buildStoryItem(
'Мастика',
'https://i.pravatar.cc/150?u=mastika',
false,
),
],
),
),
),
if (_showCacheWarning)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: cs.errorContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: cs.error.withValues(alpha: 0.2),
),
),
child: Row(
children: [ children: [
Icon( _buildStoryItem(
Symbols.cloud_off, 'Даша',
size: 18, 'https://i.pravatar.cc/150?u=dasha',
color: cs.error, true,
), ),
const SizedBox(width: 12), _buildStoryItem(
const Expanded( 'Мастика',
child: Text( 'https://i.pravatar.cc/150?u=mastika',
'Ошибка соединения, сейчас вы смотрите КЕШ', false,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
), ),
], ],
), ),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
child: Container( child: Container(
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1321,7 +1356,7 @@ class _ChatListScreenState extends State<ChatListScreen>
parent: const AlwaysScrollableScrollPhysics(), parent: const AlwaysScrollableScrollPhysics(),
), ),
slivers: [ slivers: [
const SliverToBoxAdapter(child: SizedBox(height: 14)), const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (chats.isEmpty && !_isInitialLoading) if (chats.isEmpty && !_isInitialLoading)
SliverFillRemaining( SliverFillRemaining(
child: Center( child: Center(
@@ -1343,6 +1378,7 @@ class _ChatListScreenState extends State<ChatListScreen>
if (hasSeparator && index == pinnedCount) { if (hasSeparator && index == pinnedCount) {
return Padding( return Padding(
key: const ValueKey('pinned_divider'),
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: Divider( child: Divider(
height: 1, height: 1,
@@ -1357,20 +1393,28 @@ class _ChatListScreenState extends State<ChatListScreen>
final isPinned = (chat.favIndex ?? 0) > 0; final isPinned = (chat.favIndex ?? 0) > 0;
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
final secondId = chat.participants.entries int secondId = _profile?.id ?? 0;
.where((entry) => entry.key != _profile?.id) for (final entry in chat.participants.entries) {
.first if (entry.key != _profile?.id) {
.key; secondId = entry.key;
break;
}
}
final name = ContactCache.get(secondId); final name = ContactCache.get(secondId);
final avatar = ContactCache.getAvatar(secondId); final avatar = ContactCache.getAvatar(secondId);
// ContactCache.isOfficial covers contacts loaded via opcode 32; // ContactCache.isOfficial covers contacts loaded via opcode 32;
// chat.isOfficial covers contacts from the login payload. // chat.isOfficial covers contacts from the login payload.
final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial;
final isPlaceholder =
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
final previewText = isPlaceholder
? 'зайдите в чат для подгрузки'
: (chat.lastMsgTextOneLine ?? '');
return _buildChatItem( return _buildChatItem(
chat.id.toString(), chat.id.toString(),
name ?? "Пользователь", name ?? "Пользователь",
chat.lastMsgTextOneLine ?? '', previewText,
_formatTime(chat.lastMsgTime), _formatTime(chat.lastMsgTime),
avatar ?? "", avatar ?? "",
isOnline: chat.isOnline, isOnline: chat.isOnline,
@@ -1379,20 +1423,25 @@ class _ChatListScreenState extends State<ChatListScreen>
isVerified: isVerified, isVerified: isVerified,
isPinned: isPinned, isPinned: isPinned,
chatType: "DIALOG", chatType: "DIALOG",
messageItalic: isPlaceholder,
); );
} else { } else {
final name = chat.lastMsgSenderId != null final isPlaceholder =
chat.lastMsgText == ChatsModule.lastMsgPlaceholder;
final sender = chat.lastMsgSenderId != null
? ContactCache.get(chat.lastMsgSenderId!) ? ContactCache.get(chat.lastMsgSenderId!)
: null; : null;
String fullMsg = ""; String fullMsg = "";
if (isPlaceholder) {
if (name?.isNotEmpty == true && chat.id != 0) { fullMsg = 'зайдите в чат для подгрузки';
fullMsg += "$name: "; } else {
} if (sender?.isNotEmpty == true && chat.id != 0) {
fullMsg += "$sender: ";
if (chat.lastMsgText?.isNotEmpty == true) { }
fullMsg += chat.lastMsgText ?? ""; if (chat.lastMsgText?.isNotEmpty == true) {
fullMsg += chat.lastMsgText ?? "";
}
} }
return _buildChatItem( return _buildChatItem(
@@ -1409,6 +1458,7 @@ class _ChatListScreenState extends State<ChatListScreen>
isVerified: chat.isOfficial, isVerified: chat.isOfficial,
isPinned: isPinned, isPinned: isPinned,
chatType: chat.type, chatType: chat.type,
messageItalic: isPlaceholder,
); );
} }
}, childCount: totalItems), }, childCount: totalItems),
@@ -1499,12 +1549,6 @@ class _ChatListScreenState extends State<ChatListScreen>
final minBubbleLeft = bubbleLeftForIndex(0); final minBubbleLeft = bubbleLeftForIndex(0);
final maxBubbleLeft = bubbleLeftForIndex(3); final maxBubbleLeft = bubbleLeftForIndex(3);
final bubbleLeft = _navDragging
? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft)
: leftOffset;
final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
double navInterpolatedWidth(int tabIndex, double rowT) { double navInterpolatedWidth(int tabIndex, double rowT) {
final rt = rowT.clamp(0.0, 3.0); final rt = rowT.clamp(0.0, 3.0);
final i0 = rt.floor().clamp(0, 3); final i0 = rt.floor().clamp(0, 3);
@@ -1557,39 +1601,46 @@ class _ChatListScreenState extends State<ChatListScreen>
if (_isSelectionMode) return; if (_isSelectionMode) return;
_navPageAnimController.stop(); _navPageAnimController.stop();
_navPageAnimController.value = 1.0; _navPageAnimController.value = 1.0;
_navDragDx.value = 0;
setState(() { setState(() {
_navDragging = true; _navDragging = true;
_navDragDx = 0;
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
}); });
}, },
onHorizontalDragUpdate: (details) { onHorizontalDragUpdate: (details) {
if (!_navDragging) return; if (!_navDragging) return;
setState(() { _navDragDx.value += details.delta.dx;
_navDragDx += details.delta.dx;
});
}, },
onHorizontalDragEnd: (_) { onHorizontalDragEnd: (_) {
if (!_navDragging) return; if (!_navDragging) return;
final left = (_navDragBaseLeft + _navDragDx).clamp( final left = (_navDragBaseLeft + _navDragDx.value).clamp(
minBubbleLeft, minBubbleLeft,
maxBubbleLeft, maxBubbleLeft,
); );
final next = indexForBubbleLeft(left); final next = indexForBubbleLeft(left);
_navDragDx.value = 0;
setState(() { setState(() {
_currentNavIndex = next; _currentNavIndex = next;
_navDragging = false; _navDragging = false;
_navDragDx = 0;
}); });
}, },
onHorizontalDragCancel: () { onHorizontalDragCancel: () {
if (!_navDragging) return; if (!_navDragging) return;
_navDragDx.value = 0;
setState(() { setState(() {
_navDragging = false; _navDragging = false;
_navDragDx = 0;
}); });
}, },
child: Stack( child: ValueListenableBuilder<double>(
valueListenable: _navDragDx,
builder: (context, navDragDx, _) {
final bubbleLeft = _navDragging
? (_navDragBaseLeft + navDragDx)
.clamp(minBubbleLeft, maxBubbleLeft)
: leftOffset;
final navRowT =
((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0);
return Stack(
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
children: [ children: [
AnimatedPositioned( AnimatedPositioned(
@@ -1661,6 +1712,8 @@ class _ChatListScreenState extends State<ChatListScreen>
), ),
), ),
], ],
);
},
), ),
), ),
), ),
@@ -1706,7 +1759,8 @@ class _ChatListScreenState extends State<ChatListScreen>
width: pageW * 4, width: pageW * 4,
height: pageH, height: pageH,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _navPageAnimController, animation: Listenable.merge(
[_navPageAnimController, _navDragDx]),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -1812,9 +1866,7 @@ class _ChatListScreenState extends State<ChatListScreen>
onPressed: _toggleFab, onPressed: _toggleFab,
backgroundColor: cs.primaryContainer, backgroundColor: cs.primaryContainer,
elevation: 4, elevation: 4,
shape: RoundedRectangleBorder( shape: const CircleBorder(),
borderRadius: BorderRadius.circular(20),
),
child: Transform.rotate( child: Transform.rotate(
angle: val * (pi / 4), angle: val * (pi / 4),
child: Icon( child: Icon(
@@ -2020,7 +2072,7 @@ class _ChatListScreenState extends State<ChatListScreen>
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(50),
), ),
child: Text( child: Text(
title, title,
@@ -2049,11 +2101,13 @@ class _ChatListScreenState extends State<ChatListScreen>
bool isVerified = false, bool isVerified = false,
bool isPinned = false, bool isPinned = false,
String chatType = "CHAT", String chatType = "CHAT",
bool messageItalic = false,
}) { }) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final isSelected = _selectedChats.contains(id); final isSelected = _selectedChats.contains(id);
return InkWell( return InkWell(
key: ValueKey('chat_$id'),
onTap: () { onTap: () {
if (_isSelectionMode) { if (_isSelectionMode) {
_toggleSelection(id); _toggleSelection(id);
@@ -2230,6 +2284,9 @@ class _ChatListScreenState extends State<ChatListScreen>
fontWeight: isTyping fontWeight: isTyping
? FontWeight.w500 ? FontWeight.w500
: FontWeight.w400, : FontWeight.w400,
fontStyle: messageItalic
? FontStyle.italic
: FontStyle.normal,
height: 1.2, height: 1.2,
), ),
maxLines: 1, maxLines: 1,
@@ -2313,6 +2370,7 @@ class _ChatListScreenState extends State<ChatListScreen>
icon, icon,
color: isSelected ? cs.onPrimary : cs.onSurface, color: isSelected ? cs.onPrimary : cs.onSurface,
size: 20, size: 20,
fill: 1,
), ),
AnimatedContainer( AnimatedContainer(
duration: animDur, duration: animDur,
@@ -2355,9 +2413,16 @@ class _ChatListScreenState extends State<ChatListScreen>
controller.dispose(); controller.dispose();
if (!mounted) return; if (!mounted) return;
if (accountId == null) { if (accountId == null) {
await Navigator.push( final previousId = await TokenStorage.getActiveAccountId();
context, try {
MaterialPageRoute(builder: (_) => const LoginScreen()), await accountModule.beginAddAccount();
} catch (_) {}
if (!mounted) return;
await Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (_) => LoginScreen(returnToAccountId: previousId),
),
(route) => false,
); );
return; return;
} }
+427 -174
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show File; import 'dart:io' show File;
import 'dart:math' as math;
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@@ -13,17 +14,19 @@ import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../../backend/api.dart';
import '../../../backend/modules/messages.dart'; import '../../../backend/modules/messages.dart';
import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/opcode_map.dart';
import '../../../core/protocol/packet.dart'; import '../../../core/protocol/packet.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_cache_extent.dart';
import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_message_actions_style.dart';
import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_swipe_back_desktop.dart';
import '../../../core/config/app_pranks.dart';
import '../../../models/attachment.dart'; import '../../../models/attachment.dart';
import '../../widgets/message_bubble.dart'; import '../../widgets/message_bubble.dart';
import '../../widgets/theme_reveal.dart';
import '../../widgets/message_actions_overlay.dart'; import '../../widgets/message_actions_overlay.dart';
import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment_panel.dart';
import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_to_pop.dart';
@@ -89,11 +92,42 @@ class _ChatScreenState extends State<ChatScreen>
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
StreamSubscription<UploadEvent>? _uploadSub; StreamSubscription<UploadEvent>? _uploadSub;
StreamSubscription<Packet>? _pushSub; StreamSubscription<Packet>? _pushSub;
StreamSubscription<MessageEvent>? _messageEventSub;
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers = {};
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
final existing = _reactionNotifiers[m.id];
if (existing != null) return existing;
final info = m.payload?['reactionInfo'];
final notifier = ValueNotifier<Map<String, dynamic>?>(
info is Map ? Map<String, dynamic>.from(info) : null,
);
_reactionNotifiers[m.id] = notifier;
return notifier;
}
void _pruneReactionNotifiers() {
final liveIds = _messages.map((m) => m.id).toSet();
final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList();
for (final id in dead) {
_reactionNotifiers.remove(id)?.dispose();
}
}
final Set<int> _typingUserIds = {}; final Set<int> _typingUserIds = {};
final Map<int, Timer> _typingTimers = {}; final Map<int, Timer> _typingTimers = {};
int _otherStatus = 0; int _otherStatus = 0;
int? _otherSeenTime; int? _otherSeenTime;
int? _participantsCount;
bool _prankActive = false;
String? _prankBubbleId;
final GlobalKey _prankBubbleKey = GlobalKey();
final GlobalKey _prankCaptureKey = GlobalKey();
OverlayEntry? _prankRevealEntry;
AnimationController? _prankRevealController;
ui.Image? _prankRevealImage;
final ValueNotifier<String> _headerStatusNotifier = ValueNotifier(''); final ValueNotifier<String> _headerStatusNotifier = ValueNotifier('');
final ValueNotifier<int> _otherReadTime = ValueNotifier(0);
int _tempIdCounter = 0; int _tempIdCounter = 0;
late final AnimationController _attachAnim; late final AnimationController _attachAnim;
@@ -102,6 +136,10 @@ class _ChatScreenState extends State<ChatScreen>
Timer? _shimmerStartTimer; Timer? _shimmerStartTimer;
bool _historyKickedOff = false; bool _historyKickedOff = false;
List<CachedMessage> _messages = []; List<CachedMessage> _messages = [];
int _messagesRevision = 0;
List<Object>? _combinedItemsCache;
int? _combinedItemsKey;
bool _floatingDateScheduled = false;
int _myId = 0; int _myId = 0;
CachedChat? chat; CachedChat? chat;
@@ -110,7 +148,6 @@ class _ChatScreenState extends State<ChatScreen>
late final AnimationController _floatingDateAnimController; late final AnimationController _floatingDateAnimController;
late final CurvedAnimation _floatingDateCurved; late final CurvedAnimation _floatingDateCurved;
final Map<int, GlobalKey> _separatorKeys = {}; final Map<int, GlobalKey> _separatorKeys = {};
double _lastScrollOffset = 0;
String? _lastSentId; String? _lastSentId;
@override @override
@@ -130,10 +167,12 @@ class _ChatScreenState extends State<ChatScreen>
_showAttachmentPanel.addListener(_onAttachPanelToggle); _showAttachmentPanel.addListener(_onAttachPanelToggle);
_pushSub = api.pushStream _pushSub = api.pushStream
.where((p) => .where((p) =>
p.opcode == Opcode.notifMessage ||
p.opcode == Opcode.notifMark || p.opcode == Opcode.notifMark ||
p.opcode == Opcode.notifTyping) p.opcode == Opcode.notifTyping)
.listen(_onIncomingPush); .listen(_onIncomingPush);
_messageEventSub = ChatsModule.messageEvents
.where((e) => e.chatId == widget.chatId)
.listen(_onMessageEvent);
_floatingDateAnimController = AnimationController( _floatingDateAnimController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 220), duration: const Duration(milliseconds: 220),
@@ -145,9 +184,56 @@ class _ChatScreenState extends State<ChatScreen>
reverseCurve: Curves.easeIn, reverseCurve: Curves.easeIn,
); );
unawaited(_fastPreloadCache());
unawaited(_loadParticipantsCount());
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
} }
Future<void> _loadParticipantsCount() async {
if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return;
final info = await ChatsModule.getChatInfo(api, widget.chatId);
if (!mounted) return;
final count = info?['participantsCount'] as int?;
if (count != null && count != _participantsCount) {
_participantsCount = count;
_recomputeHeaderStatus();
}
}
Future<void> _fastPreloadCache() async {
final p = await AppDatabase.loadActiveProfile();
if (!mounted) return;
_myId = p?.id ?? 0;
ChatsModule.getChat(_myId, widget.chatId).then((value) {
if (mounted && value.isNotEmpty) {
setState(() {
chat = value.first;
});
_recomputeHeaderStatus();
_syncOtherReadTime();
}
}).catchError((_) {});
final firstRows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
limit: 20,
);
if (!mounted) return;
if (firstRows.isNotEmpty) {
final first = firstRows.reversed
.map((r) => CachedMessage.fromDbRow(r))
.toList();
setState(() {
_messages = first;
_messagesRevision++;
_isLoading = false;
_onLoadingFinished();
});
}
}
void _onFirstFrameRendered(Duration _) { void _onFirstFrameRendered(Duration _) {
if (!mounted) return; if (!mounted) return;
if (widget.embedded) { if (widget.embedded) {
@@ -192,37 +278,15 @@ class _ChatScreenState extends State<ChatScreen>
} }
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
final activeProfile = await AppDatabase.loadActiveProfile(); if (_myId == 0) {
_myId = activeProfile?.id ?? 0; final activeProfile = await AppDatabase.loadActiveProfile();
ChatsModule.getChat(_myId, widget.chatId).then((value) { if (!mounted) return;
if (mounted && value.isNotEmpty) { _myId = activeProfile?.id ?? 0;
setState(() { chat = value.first; }); }
_recomputeHeaderStatus();
}
}).catchError((_) {});
if (widget.chatType == 'DIALOG') { if (widget.chatType == 'DIALOG') {
unawaited(_loadOtherPresence()); unawaited(_loadOtherPresence());
} }
await _loadRemainingHistory();
final firstRows = await AppDatabase.loadMessages(
_myId,
widget.chatId,
limit: 20,
);
if (mounted && firstRows.isNotEmpty) {
final first = firstRows.reversed
.map((r) => CachedMessage.fromDbRow(r))
.toList();
setState(() {
_messages = first;
if (api.state == SessionState.online) {
_isLoading = false;
_onLoadingFinished();
}
});
}
unawaited(_loadRemainingHistory());
} }
Future<void> _loadRemainingHistory() async { Future<void> _loadRemainingHistory() async {
@@ -235,8 +299,20 @@ class _ChatScreenState extends State<ChatScreen>
_applyMergedMessages(fullRows); _applyMergedMessages(fullRows);
} }
if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) {
if (mounted) {
setState(() {
_isLoading = false;
_onLoadingFinished();
});
}
_loadForwardedSenderNames();
return;
}
try { try {
await messagesModule.fetchHistory(_myId, widget.chatId); await messagesModule.fetchHistory(_myId, widget.chatId);
ChatsModule.markChatClean(widget.chatId);
final updatedRows = await AppDatabase.loadMessages( final updatedRows = await AppDatabase.loadMessages(
_myId, _myId,
widget.chatId, widget.chatId,
@@ -245,6 +321,7 @@ class _ChatScreenState extends State<ChatScreen>
if (mounted) { if (mounted) {
_applyMergedMessages(updatedRows, markLoaded: true); _applyMergedMessages(updatedRows, markLoaded: true);
} }
unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId));
_loadForwardedSenderNames(); _loadForwardedSenderNames();
} catch (e) { } catch (e) {
debugPrint('Error fetching history: $e'); debugPrint('Error fetching history: $e');
@@ -274,12 +351,42 @@ class _ChatScreenState extends State<ChatScreen>
final changed = !_listsEquivalent(_messages, merged); final changed = !_listsEquivalent(_messages, merged);
if (!changed && !markLoaded) return; if (!changed && !markLoaded) return;
setState(() { setState(() {
if (changed) _messages = merged; if (changed) {
_messages = merged;
_messagesRevision++;
}
if (markLoaded) { if (markLoaded) {
_isLoading = false; _isLoading = false;
_onLoadingFinished(); _onLoadingFinished();
} }
}); });
if (changed) {
_syncReactionNotifiersFromMessages();
_pruneReactionNotifiers();
}
}
void _syncReactionNotifiersFromMessages() {
for (final m in _messages) {
final info = m.payload?['reactionInfo'];
final value = info is Map ? Map<String, dynamic>.from(info) : null;
final existing = _reactionNotifiers[m.id];
if (existing == null) {
_reactionNotifiers[m.id] = ValueNotifier(value);
} else if (!_reactionsEqual(existing.value, value)) {
existing.value = value;
}
}
}
bool _reactionsEqual(Map<String, dynamic>? a, Map<String, dynamic>? b) {
if (identical(a, b)) return true;
if (a == null || b == null) return false;
if (a.length != b.length) return false;
for (final k in a.keys) {
if (a[k].toString() != b[k].toString()) return false;
}
return true;
} }
bool _sameMessage(CachedMessage a, CachedMessage b) { bool _sameMessage(CachedMessage a, CachedMessage b) {
@@ -311,11 +418,18 @@ class _ChatScreenState extends State<ChatScreen>
_showAttachmentPanel.dispose(); _showAttachmentPanel.dispose();
_uploadSub?.cancel(); _uploadSub?.cancel();
_pushSub?.cancel(); _pushSub?.cancel();
_messageEventSub?.cancel();
for (final n in _reactionNotifiers.values) {
n.dispose();
}
_reactionNotifiers.clear();
for (final t in _typingTimers.values) { for (final t in _typingTimers.values) {
t.cancel(); t.cancel();
} }
_typingTimers.clear(); _typingTimers.clear();
_headerStatusNotifier.dispose(); _headerStatusNotifier.dispose();
_otherReadTime.dispose();
_finishPrankReveal();
_uploadStatus.dispose(); _uploadStatus.dispose();
_attachAnim.dispose(); _attachAnim.dispose();
_messageController.dispose(); _messageController.dispose();
@@ -340,17 +454,124 @@ class _ChatScreenState extends State<ChatScreen>
} }
} }
String? _effectiveStatus(CachedMessage msg) { int _computeOtherReadTime() {
if (msg.senderId != _myId) return null;
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
final c = chat; final c = chat;
if (c == null) return 'sent'; if (c == null) return 0;
int otherReadTime = 0; int otherReadTime = 0;
for (final entry in c.participants.entries) { for (final entry in c.participants.entries) {
if (entry.key != _myId && entry.value > otherReadTime) { if (entry.key != _myId && entry.value > otherReadTime) {
otherReadTime = entry.value; otherReadTime = entry.value;
} }
} }
return otherReadTime;
}
void _syncOtherReadTime() {
final t = _computeOtherReadTime();
if (_otherReadTime.value != t) _otherReadTime.value = t;
}
void _checkPrankTrigger(CachedMessage msg) {
if (!AppPranks.current.value || _prankActive || _prankBubbleId != null) {
return;
}
if ((msg.text ?? '').trim().toUpperCase() != 'THE WORLD') return;
setState(() => _prankBubbleId = msg.id);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _runPrankReveal();
});
}
ThemeData _prankPinkTheme(ThemeData base) {
final cs = base.colorScheme;
return base.copyWith(
scaffoldBackgroundColor: const Color(0xFFFFF0F5),
colorScheme: cs.copyWith(
surface: const Color(0xFFFFF0F5),
surfaceContainerHigh: const Color(0xFFFFE3EC),
surfaceContainerHighest: const Color(0xFFFFD9E6),
primary: const Color(0xFFE8579A),
primaryContainer: const Color(0xFFFFD6E5),
onPrimaryContainer: const Color(0xFF7A1F4B),
),
);
}
void _runPrankReveal() {
if (_prankActive) return;
final overlay = Navigator.of(context).overlay;
final captureCtx = _prankCaptureKey.currentContext;
final renderObject = captureCtx?.findRenderObject();
if (overlay == null || renderObject is! RenderRepaintBoundary) {
setState(() => _prankActive = true);
return;
}
Offset center;
final bubbleBox =
_prankBubbleKey.currentContext?.findRenderObject() as RenderBox?;
if (bubbleBox != null && bubbleBox.attached) {
center = bubbleBox.localToGlobal(bubbleBox.size.center(Offset.zero));
} else {
final size = MediaQuery.sizeOf(context);
center = Offset(size.width / 2, size.height / 2);
}
final ui.Image snapshot;
try {
final dpr = math.min(MediaQuery.of(context).devicePixelRatio, 2.0);
snapshot = renderObject.toImageSync(pixelRatio: dpr);
} catch (_) {
setState(() => _prankActive = true);
return;
}
_finishPrankReveal();
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 650),
);
final entry = ThemeRevealOverlay.build(
snapshot: snapshot,
center: center,
animation: controller,
);
_prankRevealController = controller;
_prankRevealEntry = entry;
_prankRevealImage = snapshot;
overlay.insert(entry);
setState(() => _prankActive = true);
Haptics.success();
WidgetsBinding.instance.endOfFrame.then((_) {
if (_prankRevealController != controller) return;
controller.forward().then((_) {
if (_prankRevealController != controller) return;
_finishPrankReveal();
}, onError: (_) {});
});
}
void _finishPrankReveal() {
_prankRevealEntry?.remove();
_prankRevealEntry = null;
_prankRevealController?.dispose();
_prankRevealController = null;
final img = _prankRevealImage;
_prankRevealImage = null;
if (img != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose());
}
}
String? _effectiveStatus(CachedMessage msg) {
if (msg.senderId != _myId) return null;
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
if (chat == null) return 'sent';
final otherReadTime = _otherReadTime.value;
if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read'; if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read';
return 'sent'; return 'sent';
} }
@@ -358,8 +579,6 @@ class _ChatScreenState extends State<ChatScreen>
void _onIncomingPush(Packet packet) { void _onIncomingPush(Packet packet) {
if (!mounted) return; if (!mounted) return;
switch (packet.opcode) { switch (packet.opcode) {
case Opcode.notifMessage:
_onIncomingMessage(packet);
case Opcode.notifMark: case Opcode.notifMark:
_onMessageRead(packet); _onMessageRead(packet);
case Opcode.notifTyping: case Opcode.notifTyping:
@@ -367,23 +586,51 @@ class _ChatScreenState extends State<ChatScreen>
} }
} }
void _onMessageEvent(MessageEvent event) {
if (!mounted) return;
switch (event) {
case MessageAddedEvent(:final message):
if (message.senderId == _myId) return;
if (_messages.any((m) => m.id == message.id)) return;
setState(() {
_lastSentId = message.id;
_messages.add(message);
_messagesRevision++;
});
_clearTyping(message.senderId);
Haptics.tap();
_scrollToBottom();
_checkPrankTrigger(message);
case MessageEditedEvent(:final message):
final idx = _messages.indexWhere((m) => m.id == message.id);
if (idx == -1) return;
setState(() {
_messages[idx] = message;
_messagesRevision++;
});
case MessageRemovedEvent(:final messageId):
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx == -1) return;
setState(() {
_messages.removeAt(idx);
_messagesRevision++;
});
_reactionNotifiers.remove(messageId)?.dispose();
case MessageReactionsChangedEvent(:final messageId, :final reactionInfo):
_reactionNotifiers[messageId]?.value = reactionInfo;
}
}
Future<void> _loadOtherPresence() async { Future<void> _loadOtherPresence() async {
if (_myId == 0) return; if (_myId == 0) return;
final otherId = widget.chatId ^ _myId; final otherId = widget.chatId ^ _myId;
if (otherId <= 0) return; if (otherId <= 0) return;
try { try {
final p = await api.sendRequest( final entry = await PresenceFetch.get(otherId);
Opcode.contactPresence, if (!mounted || entry == null) return;
{'contactIds': [otherId]}, _otherStatus = (entry['status'] as int?) ?? 0;
); _otherSeenTime = entry['seen'] as int?;
if (!mounted) return; _recomputeHeaderStatus();
final presence = (p.payload as Map?)?['presence'] as Map?;
final entry = presence?[otherId.toString()] ?? presence?[otherId];
if (entry is Map) {
_otherStatus = (entry['status'] as int?) ?? 0;
_otherSeenTime = entry['seen'] as int?;
_recomputeHeaderStatus();
}
} catch (_) {} } catch (_) {}
} }
@@ -408,10 +655,12 @@ class _ChatScreenState extends State<ChatScreen>
String _headerStatus() { String _headerStatus() {
if (_typingUserIds.isNotEmpty) return 'Печатает...'; if (_typingUserIds.isNotEmpty) return 'Печатает...';
if (widget.chatType == 'CHAT') { if (widget.chatType == 'CHAT') {
return '${chat?.participants.length ?? 0} участников'; final count = _participantsCount ?? chat?.participants.length ?? 0;
return '$count участников';
} }
if (widget.chatType == 'CHANNEL') { if (widget.chatType == 'CHANNEL') {
return '${chat?.participants.length ?? 0} подписчиков'; final count = _participantsCount ?? chat?.participants.length ?? 0;
return '$count подписчиков';
} }
if (_otherStatus == 1) return 'В сети'; if (_otherStatus == 1) return 'В сети';
if (_otherStatus == 3) return 'Был(-а) недавно'; if (_otherStatus == 3) return 'Был(-а) недавно';
@@ -458,56 +707,8 @@ class _ChatScreenState extends State<ChatScreen>
final c = chat; final c = chat;
if (c == null) return; if (c == null) return;
if (c.participants[userId] == mark) return; if (c.participants[userId] == mark) return;
setState(() { c.participants[userId] = mark;
c.participants[userId] = mark; _syncOtherReadTime();
});
}
void _onIncomingMessage(Packet packet) {
if (!mounted) return;
final payload = packet.payload;
if (payload is! Map) return;
final chatId = payload['chatId'];
if (chatId != widget.chatId) return;
final msg = payload['message'];
if (msg is! Map) return;
final senderId = msg['sender'];
if (senderId is! int) return;
if (senderId == _myId) return;
final msgId = msg['id']?.toString();
if (msgId == null || msgId.isEmpty) return;
if (_messages.any((m) => m.id == msgId)) return;
List<MessageAttachment>? attachments;
final attaches = msg['attaches'];
if (attaches is List && attaches.isNotEmpty) {
attachments = attaches
.whereType<Map>()
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.from(a)))
.toList();
}
final cached = CachedMessage(
id: msgId,
accountId: _myId,
chatId: widget.chatId,
senderId: senderId,
text: msg['text'] as String?,
time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch,
status: 'sent',
payload: Map<String, dynamic>.from(msg),
attachments: attachments,
);
setState(() {
_lastSentId = msgId;
_messages.add(cached);
});
_clearTyping(senderId);
Haptics.tap();
_scrollToBottom();
} }
Future<void> _sendMessage() async { Future<void> _sendMessage() async {
@@ -533,30 +734,36 @@ class _ChatScreenState extends State<ChatScreen>
setState(() { setState(() {
_lastSentId = tempId; _lastSentId = tempId;
_messages.add(tempMessage); _messages.add(tempMessage);
_messagesRevision++;
_messageController.clear(); _messageController.clear();
}); });
unawaited(_persistOutgoing(tempMessage));
// Instant tactile "whoosh" the moment the message leaves the composer, // Instant tactile "whoosh" the moment the message leaves the composer,
// not after the network round-trip — feedback must feel immediate. // not after the network round-trip — feedback must feel immediate.
Haptics.send(); Haptics.send();
_scrollToBottom(); _scrollToBottom();
_checkPrankTrigger(tempMessage);
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
final index = _messages.indexWhere((m) => m.id == tempId); final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1 && mounted) { if (index != -1 && mounted) {
final sent = CachedMessage(
id: actualId.isNotEmpty ? actualId : tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'sent',
);
setState(() { setState(() {
_messages[index] = CachedMessage( _messages[index] = sent;
id: actualId.isNotEmpty ? actualId : tempId, _messagesRevision++;
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'sent',
);
}); });
unawaited(_persistOutgoing(sent, removeId: tempId));
} }
if (chat == null) { if (chat == null) {
@@ -564,6 +771,7 @@ class _ChatScreenState extends State<ChatScreen>
ChatsModule.refreshChats(api, [widget.chatId]).then((list) { ChatsModule.refreshChats(api, [widget.chatId]).then((list) {
if (!mounted || list.isEmpty) return; if (!mounted || list.isEmpty) return;
setState(() => chat = list.first); setState(() => chat = list.first);
_syncOtherReadTime();
}), }),
); );
} }
@@ -571,21 +779,33 @@ class _ChatScreenState extends State<ChatScreen>
Haptics.error(); Haptics.error();
final index = _messages.indexWhere((m) => m.id == tempId); final index = _messages.indexWhere((m) => m.id == tempId);
if (index != -1 && mounted) { if (index != -1 && mounted) {
final failed = CachedMessage(
id: tempId,
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'error',
);
setState(() { setState(() {
_messages[index] = CachedMessage( _messages[index] = failed;
id: tempId, _messagesRevision++;
accountId: _myId,
chatId: widget.chatId,
senderId: _myId,
text: text,
time: now,
status: 'error',
);
}); });
unawaited(_persistOutgoing(failed));
} }
} }
} }
Future<void> _persistOutgoing(CachedMessage msg, {String? removeId}) async {
try {
if (removeId != null && removeId != msg.id) {
await AppDatabase.deleteMessage(_myId, widget.chatId, removeId);
}
await AppDatabase.saveMessages([msg.toDbRow()]);
} catch (_) {}
}
Future<void> _loadForwardedSenderNames() async { Future<void> _loadForwardedSenderNames() async {
final forwardIds = <int>{}; final forwardIds = <int>{};
for (final msg in _messages) { for (final msg in _messages) {
@@ -601,48 +821,61 @@ class _ChatScreenState extends State<ChatScreen>
} }
if (forwardIds.isEmpty) return; if (forwardIds.isEmpty) return;
final resolved = <int, ({String name, String? avatar})>{};
for (final id in forwardIds) { for (final id in forwardIds) {
final name = await messagesModule.searchContactById(id); final name = await messagesModule.searchContactById(id);
final avatar = ContactCache.getAvatar(id); if (name != null) {
if (name != null && mounted) { resolved[id] = (name: name, avatar: ContactCache.getAvatar(id));
setState(() {
for (var i = 0; i < _messages.length; i++) {
final msg = _messages[i];
if (msg.attachments != null) {
final newAttaches = msg.attachments!.map((a) {
if (a is ForwardedMessageAttachment &&
a.originalSenderId == id &&
a.originalSenderName == null) {
return ForwardedMessageAttachment(
originalSenderId: id,
originalSenderName: name,
originalSenderAvatar: avatar,
originalMessageId: a.originalMessageId,
originalTime: a.originalTime,
originalText: a.originalText,
originalChatId: a.originalChatId,
originalAttachments: a.originalAttachments,
originalContact: a.originalContact,
);
}
return a;
}).toList();
_messages[i] = CachedMessage(
id: msg.id,
accountId: msg.accountId,
chatId: msg.chatId,
senderId: msg.senderId,
text: msg.text,
time: msg.time,
status: msg.status,
payload: msg.payload,
attachments: newAttaches,
);
}
}
});
} }
} }
if (resolved.isEmpty || !mounted) return;
var anyChanged = false;
for (var i = 0; i < _messages.length; i++) {
final msg = _messages[i];
final attaches = msg.attachments;
if (attaches == null) continue;
var msgChanged = false;
final newAttaches = attaches.map((a) {
if (a is ForwardedMessageAttachment &&
a.originalSenderName == null &&
resolved.containsKey(a.originalSenderId)) {
final r = resolved[a.originalSenderId]!;
msgChanged = true;
return ForwardedMessageAttachment(
originalSenderId: a.originalSenderId,
originalSenderName: r.name,
originalSenderAvatar: r.avatar,
originalMessageId: a.originalMessageId,
originalTime: a.originalTime,
originalText: a.originalText,
originalChatId: a.originalChatId,
originalAttachments: a.originalAttachments,
originalContact: a.originalContact,
);
}
return a;
}).toList();
if (!msgChanged) continue;
anyChanged = true;
_messages[i] = CachedMessage(
id: msg.id,
accountId: msg.accountId,
chatId: msg.chatId,
senderId: msg.senderId,
text: msg.text,
time: msg.time,
status: msg.status,
payload: msg.payload,
attachments: newAttaches,
);
}
if (anyChanged) {
setState(() => _messagesRevision++);
}
} }
void _scrollToBottom() { void _scrollToBottom() {
@@ -658,6 +891,10 @@ class _ChatScreenState extends State<ChatScreen>
} }
List<Object> _buildCombinedItems() { List<Object> _buildCombinedItems() {
final key = Object.hash(_messagesRevision, _messages.length);
final cached = _combinedItemsCache;
if (cached != null && _combinedItemsKey == key) return cached;
final List<Object> items = []; final List<Object> items = [];
final Set<int> usedDates = {}; final Set<int> usedDates = {};
@@ -690,30 +927,29 @@ class _ChatScreenState extends State<ChatScreen>
} }
_separatorKeys.removeWhere((k, _) => !usedDates.contains(k)); _separatorKeys.removeWhere((k, _) => !usedDates.contains(k));
_combinedItemsCache = items;
_combinedItemsKey = key;
return items; return items;
} }
void _onScrollForDate() { void _onScrollForDate() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final currentOffset = _scrollController.position.pixels;
final scrollingUp = currentOffset > _lastScrollOffset;
_lastScrollOffset = currentOffset;
_floatingDateTimer?.cancel(); _floatingDateTimer?.cancel();
_floatingDateTimer = Timer(const Duration(seconds: 1), () {
if (!scrollingUp) {
_floatingDateAnimController.reverse();
return;
}
_floatingDateTimer = Timer(const Duration(seconds: 2), () {
if (mounted) _floatingDateAnimController.reverse(); if (mounted) _floatingDateAnimController.reverse();
}); });
WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate());
if (_floatingDateScheduled) return;
_floatingDateScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_floatingDateScheduled = false;
_updateFloatingDate();
});
} }
void _updateFloatingDate() { void _updateFloatingDate() {
if (!mounted) return; if (!mounted || _separatorKeys.isEmpty) return;
DateTime? result; DateTime? result;
final listRenderBox = _listKey.currentContext?.findRenderObject(); final listRenderBox = _listKey.currentContext?.findRenderObject();
@@ -792,11 +1028,17 @@ class _ChatScreenState extends State<ChatScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final theme =
_prankActive ? _prankPinkTheme(Theme.of(context)) : Theme.of(context);
final cs = theme.colorScheme;
// TODO: Локализация // TODO: Локализация
// TODO: Cклонения // TODO: Cклонения
return ValueListenableBuilder<bool>( return Theme(
data: theme,
child: RepaintBoundary(
key: _prankCaptureKey,
child: ValueListenableBuilder<bool>(
valueListenable: AppSwipeBackDesktop.current, valueListenable: AppSwipeBackDesktop.current,
builder: (context, desktopSwipe, child) => SwipeToPop( builder: (context, desktopSwipe, child) => SwipeToPop(
enabled: widget.embedded && desktopSwipe, enabled: widget.embedded && desktopSwipe,
@@ -952,6 +1194,8 @@ class _ChatScreenState extends State<ChatScreen>
], ],
), ),
), ),
),
),
); );
} }
@@ -972,7 +1216,9 @@ class _ChatScreenState extends State<ChatScreen>
return Stack( return Stack(
key: _listKey, key: _listKey,
children: [ children: [
ValueListenableBuilder<double>( ValueListenableBuilder<int>(
valueListenable: _otherReadTime,
builder: (context, _, _) => ValueListenableBuilder<double>(
valueListenable: AppCacheExtent.current, valueListenable: AppCacheExtent.current,
builder: (context, cacheExtent, _) => ListView.builder( builder: (context, cacheExtent, _) => ListView.builder(
controller: _scrollController, controller: _scrollController,
@@ -1006,6 +1252,7 @@ class _ChatScreenState extends State<ChatScreen>
nextMessage: nextMessage, nextMessage: nextMessage,
chatType: chat?.type ?? 'CHAT', chatType: chat?.type ?? 'CHAT',
overrideStatus: _effectiveStatus(message), overrideStatus: _effectiveStatus(message),
reactionsListenable: _reactionNotifierFor(message),
); );
final pressable = _LongPressBubble( final pressable = _LongPressBubble(
@@ -1024,13 +1271,17 @@ class _ChatScreenState extends State<ChatScreen>
) )
: pressable; : pressable;
return RepaintBoundary( final builtItem = RepaintBoundary(
key: ValueKey('msg_${message.id}'), key: ValueKey('msg_${message.id}'),
child: child, child: child,
); );
return message.id == _prankBubbleId
? KeyedSubtree(key: _prankBubbleKey, child: builtItem)
: builtItem;
}, },
), ),
), ),
),
Positioned( Positioned(
top: 8, top: 8,
left: 0, left: 0,
@@ -1385,6 +1636,7 @@ class _ChatScreenState extends State<ChatScreen>
setState(() { setState(() {
_lastSentId = tempId; _lastSentId = tempId;
_messages.add(msg); _messages.add(msg);
_messagesRevision++;
}); });
Haptics.send(); Haptics.send();
_scrollToBottom(); _scrollToBottom();
@@ -1412,6 +1664,7 @@ class _ChatScreenState extends State<ChatScreen>
payload: old.payload, payload: old.payload,
attachments: attachment != null ? [attachment] : old.attachments, attachments: attachment != null ? [attachment] : old.attachments,
); );
_messagesRevision++;
}); });
} }
@@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart'; import '../../../backend/modules/chats.dart';
import '../../../backend/modules/contacts.dart'; import '../../../backend/modules/contacts.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../../../core/utils/image_utils.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/swipe_route.dart'; import '../../widgets/swipe_route.dart';
@@ -131,16 +132,20 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> {
if (_avatar != null) { if (_avatar != null) {
final url = await ChatsModule.requestChatPhotoUploadUrl(api); final url = await ChatsModule.requestChatPhotoUploadUrl(api);
if (url != null) { if (url != null) {
final bytes = await _avatar!.readAsBytes(); final bytes = await compressAvatar(await _avatar!.readAsBytes());
final token = await fileUploader.uploadImage( if (bytes == null) {
Uri.parse(url), if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку');
bytes, } else {
filename: _avatar!.uri.pathSegments.last, final token = await fileUploader.uploadImage(
); Uri.parse(url),
if (token != null) { bytes,
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); filename: 'avatar.jpg',
} else if (mounted) { );
showCustomNotification(context, 'Не удалось загрузить аватарку'); if (token != null) {
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
} else if (mounted) {
showCustomNotification(context, 'Не удалось загрузить аватарку');
}
} }
} }
} }
@@ -2,10 +2,9 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../core/protocol/opcode_map.dart'; import '../../../core/cache/info_cache.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/swipe_route.dart'; import '../../widgets/swipe_route.dart';
import '../chats/chat_screen.dart'; import '../chats/chat_screen.dart';
@@ -41,25 +40,18 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
Future<void> _load() async { Future<void> _load() async {
try { try {
final results = await Future.wait([ final results = await Future.wait([
api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}), ContactInfoFetch.get(widget.contactId),
api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}), PresenceFetch.get(widget.contactId),
]); ]);
if (!mounted) return; if (!mounted) return;
final infoPacket = results[0]; final contact = results[0];
if (infoPacket.isOk) { if (contact != null) {
final contacts = (infoPacket.payload as Map?)?['contacts'] as List?; _contact = contact;
if (contacts != null && contacts.isNotEmpty) {
_contact = Map<String, dynamic>.from(contacts.first as Map);
}
} }
final presencePacket = results[1]; final presence = results[1];
if (presencePacket.isOk) { if (presence != null) {
final presence = (presencePacket.payload as Map?)?['presence'] as Map?; _seenTime = presence['seen'] as int?;
final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId]; _presenceStatus = (presence['status'] as int?) ?? 0;
if (p is Map) {
_seenTime = p['seen'] as int?;
_presenceStatus = (p['status'] as int?) ?? 0;
}
} }
} catch (e) { } catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e'); if (mounted) showCustomNotification(context, 'Ошибка: $e');
@@ -43,7 +43,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
int? _accountId; int? _accountId;
List<CloudFile> _files = []; List<CloudFile> _files = [];
bool _isUploading = false; bool _isUploading = false;
double _uploadProgress = 0; final ValueNotifier<double> _uploadProgress = ValueNotifier(0);
bool _animateNewCard = false; bool _animateNewCard = false;
@override @override
@@ -68,16 +68,19 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
} }
mgr.onProgress = (progress, _) { mgr.onProgress = (progress, _) {
if (!mounted) return; if (!mounted) return;
setState(() { _isUploading = true; _uploadProgress = progress; }); if (!_isUploading) setState(() => _isUploading = true);
_uploadProgress.value = progress;
}; };
mgr.onDone = (file) { mgr.onDone = (file) {
if (!mounted) return; if (!mounted) return;
setState(() { _isUploading = false; _uploadProgress = 0; }); _uploadProgress.value = 0;
setState(() => _isUploading = false);
_prependFile(file); _prependFile(file);
}; };
mgr.onError = (msg) { mgr.onError = (msg) {
if (!mounted) return; if (!mounted) return;
setState(() { _isUploading = false; _uploadProgress = 0; }); _uploadProgress.value = 0;
setState(() => _isUploading = false);
showCustomNotification(context, 'Ошибка: $msg'); showCustomNotification(context, 'Ошибка: $msg');
}; };
} }
@@ -91,6 +94,7 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
_mode.dispose(); _mode.dispose();
_pageController.dispose(); _pageController.dispose();
_currentFilePage.dispose(); _currentFilePage.dispose();
_uploadProgress.dispose();
super.dispose(); super.dispose();
} }
@@ -218,7 +222,8 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
final picked = result.files.first; final picked = result.files.first;
if (picked.path == null) return; if (picked.path == null) return;
setState(() { _isUploading = true; _uploadProgress = 0; }); _uploadProgress.value = 0;
setState(() => _isUploading = true);
await UploadManager.instance.start( await UploadManager.instance.start(
chatId: chatId, chatId: chatId,
@@ -427,17 +432,27 @@ class _CloudStorageScreenState extends State<CloudStorageScreen>
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
if (_isUploading) ...[ if (_isUploading) ...[
LinearProgressIndicator( ValueListenableBuilder<double>(
value: _uploadProgress, valueListenable: _uploadProgress,
borderRadius: BorderRadius.circular(4), builder: (context, progress, _) => Column(
minHeight: 5, mainAxisSize: MainAxisSize.min,
color: cs.primary, crossAxisAlignment: CrossAxisAlignment.stretch,
backgroundColor: cs.surfaceContainerHighest, children: [
), LinearProgressIndicator(
const SizedBox(height: 8), value: progress,
Text( borderRadius: BorderRadius.circular(4),
'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%', minHeight: 5,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), color: cs.primary,
backgroundColor: cs.surfaceContainerHighest,
),
const SizedBox(height: 8),
Text(
'Загрузка ${(progress * 100).toStringAsFixed(0)}%',
style:
TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
),
), ),
] else if (_files.isEmpty) ...[ ] else if (_files.isEmpty) ...[
Text( Text(
@@ -4,11 +4,18 @@ import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/chats.dart'; import '../../../backend/modules/chats.dart';
import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_swipe_back_desktop.dart';
import '../../../core/config/app_pranks.dart';
import '../../../core/config/app_stories.dart';
import '../../../core/config/app_media_cache.dart';
import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/opcode_map.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/protocol/packet.dart'; import '../../../core/protocol/packet.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/utils/media_cache.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/login_success_screen.dart';
import '../calls/call_screen.dart';
class DebugMenuScreen extends StatefulWidget { class DebugMenuScreen extends StatefulWidget {
const DebugMenuScreen({super.key}); const DebugMenuScreen({super.key});
@@ -23,6 +30,92 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
bool _hasSearched = false; bool _hasSearched = false;
final List<_SearchHit> _hits = []; final List<_SearchHit> _hits = [];
final Map<String, String> _errors = {}; final Map<String, String> _errors = {};
int _cacheSize = 0;
bool _clearingCache = false;
@override
void initState() {
super.initState();
_loadCacheSize();
}
Future<void> _loadCacheSize() async {
final size = await MediaCache.currentSize();
if (mounted) setState(() => _cacheSize = size);
}
Future<void> _clearCache() async {
if (_clearingCache) return;
setState(() => _clearingCache = true);
final freed = await MediaCache.clear();
if (!mounted) return;
setState(() {
_clearingCache = false;
_cacheSize = 0;
});
showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})');
}
void _pickCacheLimit() {
final cs = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Лимит кэша медиа',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
),
for (final preset in AppMediaCacheLimit.presets)
ListTile(
title: Text(
_limitLabel(preset),
style: TextStyle(color: cs.onSurface, fontSize: 16),
),
trailing: AppMediaCacheLimit.current.value == preset
? Icon(Symbols.check, color: cs.primary)
: null,
onTap: () {
AppMediaCacheLimit.save(preset);
Navigator.pop(sheetContext);
setState(() {});
},
),
const SizedBox(height: 8),
],
),
),
);
}
String _limitLabel(int bytes) =>
bytes <= 0 ? 'Без лимита' : _formatBytes(bytes);
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes Б';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ';
}
@override @override
void dispose() { void dispose() {
@@ -397,6 +490,404 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
), ),
), ),
), ),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: ValueListenableBuilder<bool>(
valueListenable: AppPranks.current,
builder: (context, pranksOn, _) {
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 17,
),
child: Row(
children: [
Icon(
Symbols.auto_awesome,
color: cs.onSurfaceVariant,
size: 22,
weight: 400,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Приколь4ики',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
Switch(
value: pranksOn,
onChanged: (v) {
AppPranks.save(v);
},
),
],
),
),
);
},
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: ValueListenableBuilder<bool>(
valueListenable: AppStories.current,
builder: (context, storiesOn, _) {
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 17,
),
child: Row(
children: [
Icon(
Symbols.amp_stories,
color: cs.onSurfaceVariant,
size: 22,
weight: 400,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Истории',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
'Отображение ленты историй в списке чатов',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
Switch(
value: storiesOn,
onChanged: (v) {
AppStories.save(v);
},
),
],
),
),
);
},
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: _pickCacheLimit,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 17,
),
child: Row(
children: [
Icon(
Symbols.data_usage,
color: cs.onSurfaceVariant,
size: 22,
weight: 400,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Лимит кэша медиа',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
_limitLabel(AppMediaCacheLimit.current.value),
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
Icon(
Symbols.chevron_right,
color: cs.onSurfaceVariant,
size: 22,
weight: 400,
),
],
),
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: _clearingCache ? null : _clearCache,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 17,
),
child: Row(
children: [
Icon(
Symbols.delete_sweep,
color: cs.onSurfaceVariant,
size: 22,
weight: 400,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Очистить кэш медиа',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
_clearingCache
? 'Очистка…'
: 'Занято: ${_formatBytes(_cacheSize)}',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
if (_clearingCache)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onSurfaceVariant,
),
),
],
),
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () async {
final profile = await AppDatabase.loadActiveProfile();
if (!context.mounted) return;
final avatar = await precacheLoginAvatar(
context,
profile?.baseUrl,
);
if (!context.mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
LoginSuccessScreen(preview: true, avatar: avatar),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 17,
),
child: Row(
children: [
Icon(
Symbols.celebration,
color: cs.onSurfaceVariant,
size: 22,
weight: 400,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'test hello',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
'Показать приветственную анимацию входа',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
Icon(
Symbols.chevron_right,
color: cs.onSurfaceVariant,
size: 22,
weight: 400,
),
],
),
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Экран звонка',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'Превью экранов звонков',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _DebugCallButton(
label: 'Входящий',
icon: Symbols.call_received,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CallScreen(
name: 'Кирил Г.',
initialState: CallScreenState.incoming,
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: _DebugCallButton(
label: 'Исходящий',
icon: Symbols.call_made,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CallScreen(
name: 'Кирил Г.',
initialState: CallScreenState.outgoing,
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: _DebugCallButton(
label: 'Активный',
icon: Symbols.phone_in_talk,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CallScreen(
name: 'Кирил Г.',
initialState: CallScreenState.active,
),
),
),
),
),
],
),
],
),
),
),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
@@ -813,4 +1304,47 @@ class _ErrorChip extends StatelessWidget {
), ),
); );
} }
}
class _DebugCallButton extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback onTap;
const _DebugCallButton({
required this.label,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 22, fill: 1),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
} }
@@ -1,10 +1,14 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/utils/image_utils.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show accountModule, KometApp; import '../../../main.dart' show accountModule, fileUploader, KometApp;
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
const int _maxAvatarBytes = 8 * 1024 * 1024;
class EditProfileScreen extends StatefulWidget { class EditProfileScreen extends StatefulWidget {
const EditProfileScreen({super.key}); const EditProfileScreen({super.key});
@@ -77,12 +81,56 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
Future<void> _changeAvatar() async { Future<void> _changeAvatar() async {
if (_isSaving) return; if (_isSaving) return;
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final picked = result.files.first;
final bytes = picked.bytes;
if (bytes == null) {
if (mounted) showCustomNotification(context, 'Не удалось прочитать файл');
return;
}
if (bytes.length > _maxAvatarBytes) {
if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
return;
}
if (!mounted) return;
setState(() => _isSaving = true);
try { try {
final uploadUrl = await accountModule.getAvatarUploadUrl(); final processed = await compressAvatar(bytes);
if (processed == null) {
if (!mounted) return;
showCustomNotification(context, 'Не удалось обработать изображение');
setState(() => _isSaving = false);
return;
}
final url = await accountModule.getAvatarUploadUrl();
final token = await fileUploader.uploadImage(
Uri.parse(url),
processed,
filename: 'avatar.jpg',
);
if (token == null) {
if (!mounted) return;
showCustomNotification(context, 'Не удалось загрузить аватарку');
setState(() => _isSaving = false);
return;
}
final newProfile = await accountModule.updateProfileAvatar(token);
if (!mounted) return; if (!mounted) return;
showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)'); setState(() {
_avatarUrl = newProfile.baseUrl;
_photoId = newProfile.photoId;
_isSaving = false;
});
KometApp.stateOf(context)?.notifyProfileUpdate();
showCustomNotification(context, 'Аватарка обновлена');
} catch (e) { } catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e'); if (!mounted) return;
showCustomNotification(context, 'Ошибка: $e');
setState(() => _isSaving = false);
} }
} }
@@ -378,7 +378,9 @@ class _FontSizeControl extends StatelessWidget {
value: AppFonts.clampScale(scale), value: AppFonts.clampScale(scale),
min: AppFonts.minScale, min: AppFonts.minScale,
max: AppFonts.maxScale, max: AppFonts.maxScale,
divisions: 10, divisions:
((AppFonts.maxScale - AppFonts.minScale) / 0.05)
.round(),
onChanged: onChanged, onChanged: onChanged,
onChangeEnd: onChangeEnd, onChangeEnd: onChangeEnd,
), ),
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
class NotificationsScreen extends StatefulWidget {
const NotificationsScreen({super.key});
@override
State<NotificationsScreen> createState() => _NotificationsScreenState();
}
class _NotificationsScreenState extends State<NotificationsScreen> {
bool _fkmEnabled = false;
bool _personalChatsEnabled = true;
bool _groupsEnabled = true;
bool _channelsEnabled = true;
String _selectedSound = 'По умолчанию';
static const List<String> _sounds = [
'По умолчанию',
'Колокольчик',
'Звон',
'Капля',
'Беззвучно',
];
Future<void> _pickSound() async {
final cs = Theme.of(context).colorScheme;
final picked = await showModalBottomSheet<String>(
context: context,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
children: [
Text(
'Звук уведомления',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
),
),
for (final s in _sounds)
ListTile(
onTap: () => Navigator.of(context).pop(s),
leading: Icon(
s == _selectedSound
? Symbols.radio_button_checked
: Symbols.radio_button_unchecked,
color: s == _selectedSound
? cs.primary
: cs.onSurfaceVariant,
),
title: Text(
s,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
),
),
),
],
),
),
);
},
);
if (picked != null && picked != _selectedSound) {
setState(() => _selectedSound = picked);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Уведомления',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: [
_sectionHeader(cs, 'FKM'),
_card(cs, [
_toggleRow(
cs,
icon: Symbols.notifications_active,
label: 'Включить уведомления',
subtitle:
'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.',
value: _fkmEnabled,
onChanged: (v) => setState(() => _fkmEnabled = v),
),
]),
const SizedBox(height: 20),
_sectionHeader(cs, 'Настройки уведомлений'),
_card(cs, [
_toggleRow(
cs,
icon: Symbols.person,
label: 'Уведомления от личных чатов',
value: _personalChatsEnabled,
onChanged: (v) => setState(() => _personalChatsEnabled = v),
),
_divider(cs),
_toggleRow(
cs,
icon: Symbols.groups,
label: 'Уведомления от групп',
value: _groupsEnabled,
onChanged: (v) => setState(() => _groupsEnabled = v),
),
_divider(cs),
_toggleRow(
cs,
icon: Symbols.campaign,
label: 'Уведомления от каналов',
value: _channelsEnabled,
onChanged: (v) => setState(() => _channelsEnabled = v),
),
]),
const SizedBox(height: 20),
_sectionHeader(cs, 'Звук'),
_card(cs, [
_tappableRow(
cs,
icon: Symbols.music_note,
label: 'Звук уведомления',
trailingText: _selectedSound,
onTap: _pickSound,
),
]),
],
),
),
);
}
Widget _sectionHeader(ColorScheme cs, String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Text(
title,
style: TextStyle(
color: cs.primary,
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
);
}
Widget _card(ColorScheme cs, List<Widget> children) {
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Column(children: children),
);
}
Widget _divider(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.only(left: 58),
child: Divider(
height: 1,
thickness: 1,
color: cs.outlineVariant.withValues(alpha: 0.35),
),
);
}
Widget _toggleRow(
ColorScheme cs, {
required IconData icon,
required String label,
String? subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => onChanged(!value),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
height: 1.3,
),
),
],
],
),
),
const SizedBox(width: 12),
Switch(value: value, onChanged: onChanged),
],
),
),
),
);
}
Widget _tappableRow(
ColorScheme cs, {
required IconData icon,
required String label,
required String trailingText,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
child: Row(
children: [
Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400),
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
Text(
trailingText,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(width: 6),
Icon(Symbols.chevron_right, color: cs.outline, size: 20),
],
),
),
),
);
}
}
@@ -24,10 +24,16 @@ class _PasswordEntryScreenState extends State<PasswordEntryScreen> {
Future<void> _check2faStatus() async { Future<void> _check2faStatus() async {
try { try {
final profile = await AppDatabase.loadActiveProfile(); bool is2faEnabled;
try {
is2faEnabled = (await accountModule.get2faStatus()).enabled;
} catch (_) {
final profile = await AppDatabase.loadActiveProfile();
is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_is2faEnabled = profile?.profileOptions?.contains(2) ?? false; _is2faEnabled = is2faEnabled;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -307,33 +313,40 @@ class TwoFactorSetupScreen extends StatefulWidget {
class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> { class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _confirmController = TextEditingController();
final _hintController = TextEditingController(); final _hintController = TextEditingController();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _codeController = TextEditingController(); final _codeController = TextEditingController();
int _step = 0; int _step = 0;
bool _isLoading = false; final ValueNotifier<bool> _isLoading = ValueNotifier(false);
String? _trackId; String? _trackId;
String? _errorMessage; String? _errorMessage;
@override @override
void dispose() { void dispose() {
_passwordController.dispose(); _passwordController.dispose();
_confirmController.dispose();
_hintController.dispose(); _hintController.dispose();
_emailController.dispose(); _emailController.dispose();
_codeController.dispose(); _codeController.dispose();
_isLoading.dispose();
super.dispose(); super.dispose();
} }
Future<void> _nextStep() async { Future<void> _nextStep() async {
setState(() { _isLoading.value = true;
_isLoading = true; setState(() => _errorMessage = null);
_errorMessage = null;
});
try { try {
switch (_step) { switch (_step) {
case 0: case 0:
if (_passwordController.text.length < 6) {
setState(
() => _errorMessage = 'Пароль должен быть минимум 6 символов',
);
break;
}
final trackId = await accountModule.create2faTrack(); final trackId = await accountModule.create2faTrack();
setState(() { setState(() {
_trackId = trackId; _trackId = trackId;
@@ -341,10 +354,8 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
}); });
break; break;
case 1: case 1:
if (_passwordController.text.length < 6) { if (_confirmController.text != _passwordController.text) {
setState( setState(() => _errorMessage = 'Пароли не совпадают');
() => _errorMessage = 'Пароль должен быть минимум 6 символов',
);
break; break;
} }
await accountModule.set2faPassword( await accountModule.set2faPassword(
@@ -392,7 +403,7 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
setState(() => _errorMessage = e.toString()); setState(() => _errorMessage = e.toString());
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); _isLoading.value = false;
} }
} }
} }
@@ -458,26 +469,29 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton( child: ValueListenableBuilder<bool>(
onPressed: _isLoading ? null : _nextStep, valueListenable: _isLoading,
style: FilledButton.styleFrom( builder: (context, loading, _) => FilledButton(
backgroundColor: cs.primary, onPressed: loading ? null : _nextStep,
foregroundColor: cs.onPrimary, style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: cs.primary,
shape: RoundedRectangleBorder( foregroundColor: cs.onPrimary,
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: Text(_step == 4 ? 'Установить пароль' : 'Продолжить'),
), ),
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: Text(_step == 4 ? 'Установить пароль' : 'Продолжить'),
), ),
), ),
], ],
@@ -564,18 +578,9 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( _PasswordField(
controller: _passwordController, controller: _passwordController,
obscureText: true, hintText: 'Введите пароль',
decoration: InputDecoration(
hintText: 'Введите пароль',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
), ),
], ],
); );
@@ -599,18 +604,9 @@ class _TwoFactorSetupScreenState extends State<TwoFactorSetupScreen> {
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( _PasswordField(
controller: _passwordController, controller: _confirmController,
obscureText: true, hintText: 'Повторите пароль',
decoration: InputDecoration(
hintText: 'Повторите пароль',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
), ),
], ],
); );
@@ -732,7 +728,7 @@ class TwoFactorManageScreen extends StatefulWidget {
class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> { class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _isLoading = false; final ValueNotifier<bool> _isLoading = ValueNotifier(false);
bool _isAuthenticated = false; bool _isAuthenticated = false;
String? _trackId; String? _trackId;
TwoFactorDetails? _details; TwoFactorDetails? _details;
@@ -741,14 +737,13 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
@override @override
void dispose() { void dispose() {
_passwordController.dispose(); _passwordController.dispose();
_isLoading.dispose();
super.dispose(); super.dispose();
} }
Future<void> _authenticate() async { Future<void> _authenticate() async {
setState(() { _isLoading.value = true;
_isLoading = true; setState(() => _errorMessage = null);
_errorMessage = null;
});
try { try {
_trackId = await accountModule.enter2faPanel(); _trackId = await accountModule.enter2faPanel();
@@ -761,7 +756,7 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
} catch (e) { } catch (e) {
setState(() => _errorMessage = 'Неверный пароль'); setState(() => _errorMessage = 'Неверный пароль');
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) _isLoading.value = false;
} }
} }
@@ -818,42 +813,36 @@ class _TwoFactorManageScreenState extends State<TwoFactorManageScreen> {
style: TextStyle(color: cs.onErrorContainer), style: TextStyle(color: cs.onErrorContainer),
), ),
), ),
TextField( _PasswordField(
controller: _passwordController, controller: _passwordController,
obscureText: true, hintText: 'Пароль',
decoration: InputDecoration(
hintText: 'Пароль',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton( child: ValueListenableBuilder<bool>(
onPressed: _isLoading ? null : _authenticate, valueListenable: _isLoading,
style: FilledButton.styleFrom( builder: (context, loading, _) => FilledButton(
backgroundColor: cs.primary, onPressed: loading ? null : _authenticate,
foregroundColor: cs.onPrimary, style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: cs.primary,
shape: RoundedRectangleBorder( foregroundColor: cs.onPrimary,
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Text('Продолжить'),
), ),
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Text('Продолжить'),
), ),
), ),
], ],
@@ -944,13 +933,14 @@ class _TwoFactorPasswordChangeScreenState
extends State<TwoFactorPasswordChangeScreen> { extends State<TwoFactorPasswordChangeScreen> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _hintController = TextEditingController(); final _hintController = TextEditingController();
bool _isLoading = false; final ValueNotifier<bool> _isLoading = ValueNotifier(false);
String? _errorMessage; String? _errorMessage;
@override @override
void dispose() { void dispose() {
_passwordController.dispose(); _passwordController.dispose();
_hintController.dispose(); _hintController.dispose();
_isLoading.dispose();
super.dispose(); super.dispose();
} }
@@ -960,10 +950,8 @@ class _TwoFactorPasswordChangeScreenState
return; return;
} }
setState(() { _isLoading.value = true;
_isLoading = true; setState(() => _errorMessage = null);
_errorMessage = null;
});
try { try {
final trackId = await accountModule.enter2faPanel(); final trackId = await accountModule.enter2faPanel();
@@ -983,7 +971,7 @@ class _TwoFactorPasswordChangeScreenState
} catch (e) { } catch (e) {
setState(() => _errorMessage = e.toString()); setState(() => _errorMessage = e.toString());
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) _isLoading.value = false;
} }
} }
@@ -1035,18 +1023,9 @@ class _TwoFactorPasswordChangeScreenState
style: TextStyle(color: cs.onErrorContainer), style: TextStyle(color: cs.onErrorContainer),
), ),
), ),
TextField( _PasswordField(
controller: _passwordController, controller: _passwordController,
obscureText: true, hintText: 'Введите новый пароль',
decoration: InputDecoration(
hintText: 'Введите новый пароль',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
@@ -1073,26 +1052,29 @@ class _TwoFactorPasswordChangeScreenState
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton( child: ValueListenableBuilder<bool>(
onPressed: _isLoading ? null : _changePassword, valueListenable: _isLoading,
style: FilledButton.styleFrom( builder: (context, loading, _) => FilledButton(
backgroundColor: cs.primary, onPressed: loading ? null : _changePassword,
foregroundColor: cs.onPrimary, style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: cs.primary,
shape: RoundedRectangleBorder( foregroundColor: cs.onPrimary,
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Text('Сохранить'),
), ),
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: const Text('Сохранить'),
), ),
), ),
], ],
@@ -1116,7 +1098,7 @@ class _TwoFactorEmailChangeScreenState
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _codeController = TextEditingController(); final _codeController = TextEditingController();
int _step = 0; int _step = 0;
bool _isLoading = false; final ValueNotifier<bool> _isLoading = ValueNotifier(false);
String? _trackId; String? _trackId;
String? _errorMessage; String? _errorMessage;
@@ -1125,14 +1107,13 @@ class _TwoFactorEmailChangeScreenState
_passwordController.dispose(); _passwordController.dispose();
_emailController.dispose(); _emailController.dispose();
_codeController.dispose(); _codeController.dispose();
_isLoading.dispose();
super.dispose(); super.dispose();
} }
Future<void> _nextStep() async { Future<void> _nextStep() async {
setState(() { _isLoading.value = true;
_isLoading = true; setState(() => _errorMessage = null);
_errorMessage = null;
});
try { try {
switch (_step) { switch (_step) {
@@ -1176,7 +1157,7 @@ class _TwoFactorEmailChangeScreenState
} catch (e) { } catch (e) {
setState(() => _errorMessage = e.toString()); setState(() => _errorMessage = e.toString());
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) _isLoading.value = false;
} }
} }
@@ -1216,18 +1197,9 @@ class _TwoFactorEmailChangeScreenState
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( _PasswordField(
controller: _passwordController, controller: _passwordController,
obscureText: true, hintText: 'Пароль',
decoration: InputDecoration(
hintText: 'Пароль',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
), ),
] else ...[ ] else ...[
if (_errorMessage != null) if (_errorMessage != null)
@@ -1301,26 +1273,29 @@ class _TwoFactorEmailChangeScreenState
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton( child: ValueListenableBuilder<bool>(
onPressed: _isLoading ? null : _nextStep, valueListenable: _isLoading,
style: FilledButton.styleFrom( builder: (context, loading, _) => FilledButton(
backgroundColor: cs.primary, onPressed: loading ? null : _nextStep,
foregroundColor: cs.onPrimary, style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: cs.primary,
shape: RoundedRectangleBorder( foregroundColor: cs.onPrimary,
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: Text(_step == 2 ? 'Сохранить' : 'Продолжить'),
), ),
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onPrimary,
),
)
: Text(_step == 2 ? 'Сохранить' : 'Продолжить'),
), ),
), ),
], ],
@@ -1339,20 +1314,19 @@ class TwoFactorRemoveScreen extends StatefulWidget {
class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> { class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _isLoading = false; final ValueNotifier<bool> _isLoading = ValueNotifier(false);
String? _errorMessage; String? _errorMessage;
@override @override
void dispose() { void dispose() {
_passwordController.dispose(); _passwordController.dispose();
_isLoading.dispose();
super.dispose(); super.dispose();
} }
Future<void> _remove2fa() async { Future<void> _remove2fa() async {
setState(() { _isLoading.value = true;
_isLoading = true; setState(() => _errorMessage = null);
_errorMessage = null;
});
try { try {
final trackId = await accountModule.enter2faPanel(); final trackId = await accountModule.enter2faPanel();
@@ -1368,7 +1342,7 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
} catch (e) { } catch (e) {
setState(() => _errorMessage = e.toString()); setState(() => _errorMessage = e.toString());
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) _isLoading.value = false;
} }
} }
@@ -1440,42 +1414,36 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
style: TextStyle(color: cs.onErrorContainer), style: TextStyle(color: cs.onErrorContainer),
), ),
), ),
TextField( _PasswordField(
controller: _passwordController, controller: _passwordController,
obscureText: true, hintText: 'Пароль',
decoration: InputDecoration(
hintText: 'Пароль',
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton( child: ValueListenableBuilder<bool>(
onPressed: _isLoading ? null : _remove2fa, valueListenable: _isLoading,
style: FilledButton.styleFrom( builder: (context, loading, _) => FilledButton(
backgroundColor: cs.error, onPressed: loading ? null : _remove2fa,
foregroundColor: cs.onError, style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: cs.error,
shape: RoundedRectangleBorder( foregroundColor: cs.onError,
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onError,
),
)
: const Text('Удалить пароль'),
), ),
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: cs.onError,
),
)
: const Text('Удалить пароль'),
), ),
), ),
], ],
@@ -1484,3 +1452,45 @@ class _TwoFactorRemoveScreenState extends State<TwoFactorRemoveScreen> {
); );
} }
} }
class _PasswordField extends StatefulWidget {
final TextEditingController controller;
final String hintText;
const _PasswordField({
required this.controller,
required this.hintText,
});
@override
State<_PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<_PasswordField> {
bool _visible = false;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return TextField(
controller: widget.controller,
obscureText: !_visible,
decoration: InputDecoration(
hintText: widget.hintText,
filled: true,
fillColor: cs.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
suffixIcon: IconButton(
icon: Icon(
_visible ? Symbols.visibility_off : Symbols.visibility,
color: cs.onSurfaceVariant,
),
onPressed: () => setState(() => _visible = !_visible),
),
),
);
}
}
@@ -47,12 +47,18 @@ class _SecurityScreenState extends State<SecurityScreen>
accountModule.getBlockedContacts(), accountModule.getBlockedContacts(),
AppDatabase.loadActiveProfile(), AppDatabase.loadActiveProfile(),
]); ]);
bool is2faEnabled;
try {
is2faEnabled = (await accountModule.get2faStatus()).enabled;
} catch (_) {
final profile = results[2] as ProfileData?;
is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_privacyConfig = results[0] as PrivacyConfig; _privacyConfig = results[0] as PrivacyConfig;
_blockedContacts = results[1] as List<BlockedContact>; _blockedContacts = results[1] as List<BlockedContact>;
final profile = results[2] as ProfileData?; _is2faEnabled = is2faEnabled;
_is2faEnabled = profile?.profileOptions?.contains(2) ?? false;
_isLoading = false; _isLoading = false;
}); });
} }
+13 -2
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../../backend/modules/chats.dart';
import '../../../backend/modules/messages.dart'; import '../../../backend/modules/messages.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
@@ -20,6 +21,7 @@ import 'debug_menu_screen.dart';
import 'devices_screen.dart'; import 'devices_screen.dart';
import 'edit_profile_screen.dart'; import 'edit_profile_screen.dart';
import 'info_screen.dart'; import 'info_screen.dart';
import 'notifications_screen.dart';
import 'security_screen.dart'; import 'security_screen.dart';
import 'spoof_screen.dart'; import 'spoof_screen.dart';
@@ -206,6 +208,7 @@ class _SettingsTabState extends State<SettingsTab> {
} }
ContactCache.clear(); ContactCache.clear();
TranscriptionCache.clear(); TranscriptionCache.clear();
ChatsModule.resetForAccountSwitch();
try { try {
await api.connect(); await api.connect();
} catch (_) {} } catch (_) {}
@@ -309,9 +312,17 @@ child: _buildSection(
context, context,
cs, cs,
items: [ items: [
const _SettingsItem( _SettingsItem(
icon: Symbols.notifications_active, icon: Symbols.notifications_active,
label: 'Уведомления и звук', label: 'Уведомления',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const NotificationsScreen(),
),
);
},
), ),
_SettingsItem( _SettingsItem(
icon: Symbols.vibration, icon: Symbols.vibration,
@@ -22,8 +22,9 @@ Future<ImageProvider?> precacheLoginAvatar(
class LoginSuccessScreen extends StatefulWidget { class LoginSuccessScreen extends StatefulWidget {
final ImageProvider? avatar; final ImageProvider? avatar;
final bool preview;
const LoginSuccessScreen({super.key, this.avatar}); const LoginSuccessScreen({super.key, this.avatar, this.preview = false});
@override @override
State<LoginSuccessScreen> createState() => _LoginSuccessScreenState(); State<LoginSuccessScreen> createState() => _LoginSuccessScreenState();
@@ -114,6 +115,10 @@ class _LoginSuccessScreenState extends State<LoginSuccessScreen>
void _onStatus(AnimationStatus status) { void _onStatus(AnimationStatus status) {
if (status == AnimationStatus.completed && !_navigated && mounted) { if (status == AnimationStatus.completed && !_navigated && mounted) {
_navigated = true; _navigated = true;
if (widget.preview) {
Navigator.of(context).pop();
return;
}
Navigator.of(context).pushAndRemoveUntil( Navigator.of(context).pushAndRemoveUntil(
PageRouteBuilder( PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 360), transitionDuration: const Duration(milliseconds: 360),
+459 -127
View File
@@ -1,4 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:komet/main.dart'; import 'package:komet/main.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -7,7 +8,14 @@ import '../../core/config/app_bubble_behavior.dart';
import '../../core/config/app_bubble_shape.dart'; import '../../core/config/app_bubble_shape.dart';
import '../../core/utils/bubble_radius.dart'; import '../../core/utils/bubble_radius.dart';
import '../../core/utils/haptics.dart'; import '../../core/utils/haptics.dart';
import '../../core/utils/file_download.dart';
import '../../core/utils/media_cache.dart';
import '../../core/utils/download_progress.dart';
import 'custom_notification.dart';
import '../../models/attachment.dart'; import '../../models/attachment.dart';
import 'poll_view.dart';
import 'photo_viewer.dart';
import 'video_player_screen.dart';
enum MessageType { text, attachment, voice, control } enum MessageType { text, attachment, voice, control }
@@ -22,6 +30,7 @@ class _BubbleCtx {
final MessageType contentType; final MessageType contentType;
final bool hasPhotoWithCaption; final bool hasPhotoWithCaption;
final bool hasMultiplePhotosNoCaption; final bool hasMultiplePhotosNoCaption;
final Map? reactionInfo;
_BubbleCtx({ _BubbleCtx({
required this.context, required this.context,
@@ -31,6 +40,7 @@ class _BubbleCtx {
required this.contentType, required this.contentType,
required this.hasPhotoWithCaption, required this.hasPhotoWithCaption,
required this.hasMultiplePhotosNoCaption, required this.hasMultiplePhotosNoCaption,
this.reactionInfo,
}) : dim = text.withValues(alpha: 0.7); }) : dim = text.withValues(alpha: 0.7);
} }
@@ -47,6 +57,10 @@ class MessageBubble extends StatelessWidget {
static const Radius _smallRadius = Radius.circular(4); static const Radius _smallRadius = Radius.circular(4);
static const Radius _photoRadius = Radius.circular(photoBorderRadius); static const Radius _photoRadius = Radius.circular(photoBorderRadius);
static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18);
static const BorderRadius _reactionChipRadius =
BorderRadius.all(Radius.circular(10));
static Color bubbleTextColor(BuildContext context) => static Color bubbleTextColor(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark Theme.of(context).brightness == Brightness.dark
? Colors.white ? Colors.white
@@ -59,6 +73,7 @@ class MessageBubble extends StatelessWidget {
final CachedMessage? nextMessage; final CachedMessage? nextMessage;
final String chatType; final String chatType;
final String? overrideStatus; final String? overrideStatus;
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
const MessageBubble({ const MessageBubble({
super.key, super.key,
@@ -69,6 +84,7 @@ class MessageBubble extends StatelessWidget {
this.nextMessage, this.nextMessage,
required this.chatType, required this.chatType,
this.overrideStatus, this.overrideStatus,
this.reactionsListenable,
}); });
bool _computeHasPhotoWithCaption() { bool _computeHasPhotoWithCaption() {
@@ -259,16 +275,6 @@ class MessageBubble extends StatelessWidget {
final hasMultiPhotos = _computeHasMultiplePhotosNoCaption(); final hasMultiPhotos = _computeHasMultiplePhotosNoCaption();
final textColor = bubbleTextColor(context); final textColor = bubbleTextColor(context);
final ctx = _BubbleCtx(
context: context,
cs: cs,
text: textColor,
shape: shape,
contentType: contentType,
hasPhotoWithCaption: hasPhotoCap,
hasMultiplePhotosNoCaption: hasMultiPhotos,
);
final topMargin = _topMarginFor(contentType, shape); final topMargin = _topMarginFor(contentType, shape);
final bottomMargin = _bottomMarginFor(contentType, shape); final bottomMargin = _bottomMarginFor(contentType, shape);
final padding = _paddingFor(contentType, shape); final padding = _paddingFor(contentType, shape);
@@ -276,8 +282,34 @@ class MessageBubble extends StatelessWidget {
final showAvatarSlot = !isMe; final showAvatarSlot = !isMe;
final showAvatar = showAvatarSlot && final showAvatar = showAvatarSlot &&
chatType == "CHAT" && chatType == "CHAT" &&
nextMessage?.senderId != message.senderId && nextMessage?.senderId != message.senderId;
prevMessage?.senderId == message.senderId;
final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
final bubbleColor =
isMe ? cs.primaryContainer : cs.surfaceContainerHighest;
_BubbleCtx makeCtx() => _BubbleCtx(
context: context,
cs: cs,
text: textColor,
shape: shape,
contentType: contentType,
hasPhotoWithCaption: hasPhotoCap,
hasMultiplePhotosNoCaption: hasMultiPhotos,
reactionInfo: _resolveReactionInfo(),
);
final Widget bubbleContent =
reactionsListenable != null && contentType == MessageType.text
? ValueListenableBuilder<Map<String, dynamic>?>(
valueListenable: reactionsListenable!,
builder: (context, _, _) => _buildContent(makeCtx()),
)
: _buildContent(makeCtx());
final reactionsUnder = _reactionsUnderBubble(contentType);
final reactionsInside =
contentType != MessageType.text && !reactionsUnder;
return GestureDetector( return GestureDetector(
onTap: Haptics.tap, onTap: Haptics.tap,
@@ -304,32 +336,40 @@ class MessageBubble extends StatelessWidget {
radius: 15, radius: 15,
backgroundColor: Color(0x00000000), backgroundColor: Color(0x00000000),
), ),
ListenableBuilder( Column(
listenable: Listenable.merge( crossAxisAlignment:
[AppBubbleShape.current, AppBubbleBehavior.current], isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
), children: [
builder: (context, child) { ListenableBuilder(
return Container( listenable: Listenable.merge([
constraints: BoxConstraints( AppBubbleShape.current,
maxWidth: MediaQuery.sizeOf(context).width * 0.75, AppBubbleBehavior.current,
), ]),
decoration: BoxDecoration( builder: (context, child) => Container(
color: isMe constraints: BoxConstraints(maxWidth: maxBubbleWidth),
? cs.primaryContainer decoration: BoxDecoration(
: cs.surfaceContainerHighest, color: bubbleColor,
borderRadius: _borderRadiusFor( borderRadius: _borderRadiusFor(
AppBubbleShape.current.value, AppBubbleShape.current.value,
AppBubbleBehavior.current.value, AppBubbleBehavior.current.value,
shape, shape,
hasPhotoCap, hasPhotoCap,
hasMultiPhotos, hasMultiPhotos,
),
), ),
padding: padding,
child: child,
), ),
padding: padding, child: reactionsInside
child: child, ? Column(
); mainAxisSize: MainAxisSize.min,
}, crossAxisAlignment: CrossAxisAlignment.start,
child: _buildContent(ctx), children: [bubbleContent, _reactionsBar(cs)],
)
: bubbleContent,
),
if (reactionsUnder) _reactionsBar(cs),
],
), ),
], ],
), ),
@@ -338,6 +378,37 @@ class MessageBubble extends StatelessWidget {
); );
} }
Map? _resolveReactionInfo() {
if (reactionsListenable != null) {
final v = reactionsListenable!.value;
if (v != null) return v;
}
final info = message.payload?['reactionInfo'];
if (info is Map) return info;
return null;
}
bool _reactionsUnderBubble(MessageType contentType) {
if (contentType != MessageType.attachment) return false;
final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) return false;
if (attachments.first is ForwardedMessageAttachment) return false;
if (attachments.any((a) => a is ContactAttachment)) return false;
if (attachments.whereType<PhotoAttachment>().length >= 2) return false;
return true;
}
Widget _reactionsBar(ColorScheme cs) {
final listenable = reactionsListenable;
if (listenable != null) {
return ValueListenableBuilder<Map<String, dynamic>?>(
valueListenable: listenable,
builder: (context, info, _) => _buildReactionsBarFor(cs, info),
);
}
return _buildReactionsBar(cs);
}
Widget _buildContent(_BubbleCtx ctx) { Widget _buildContent(_BubbleCtx ctx) {
switch (ctx.contentType) { switch (ctx.contentType) {
case MessageType.control: case MessageType.control:
@@ -351,6 +422,65 @@ class MessageBubble extends StatelessWidget {
} }
} }
Widget _buildReactionsBar(ColorScheme cs) {
final info = message.payload?['reactionInfo'];
return _buildReactionsBarFor(cs, info is Map ? info : null);
}
Widget _buildReactionsBarFor(ColorScheme cs, Map? info) {
final chips = _buildReactionChipsFor(cs, info);
if (chips.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(spacing: 4, runSpacing: 4, children: chips),
);
}
List<Widget> _buildReactionChipsFor(ColorScheme cs, Map? info) {
if (info == null) return const [];
final counters = info['counters'];
if (counters is! List || counters.isEmpty) return const [];
final yourReaction = info['yourReaction']?.toString();
final chips = <Widget>[];
for (final c in counters) {
if (c is! Map) continue;
final reaction = c['reaction']?.toString();
final count = c['count'];
if (reaction == null || reaction.isEmpty) continue;
final isYours = yourReaction == reaction;
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isYours
? cs.primary.withValues(alpha: 0.22)
: _reactionChipBg,
borderRadius: _reactionChipRadius,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(reaction, style: const TextStyle(fontSize: 13)),
if (count is int && count > 1) ...[
const SizedBox(width: 3),
Text(
count.toString(),
style: TextStyle(
color: isYours ? cs.primary : cs.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
);
}
return chips;
}
Widget _buildControlContent(ColorScheme cs) { Widget _buildControlContent(ColorScheme cs) {
final attachments = message.attachments; final attachments = message.attachments;
if (attachments == null || attachments.isEmpty) { if (attachments == null || attachments.isEmpty) {
@@ -421,49 +551,95 @@ class MessageBubble extends StatelessWidget {
final displaySender = ContactCache.get(message.senderId); final displaySender = ContactCache.get(message.senderId);
final reactionChips = _buildReactionChipsFor(ctx.cs, ctx.reactionInfo);
final hasReactions = reactionChips.isNotEmpty;
final textWidget = isForwarded
? _buildForwardedInlineText(ctx, forwarded)
: Text(
message.text ?? '',
style: TextStyle(
color: ctx.text,
fontSize: 16,
height: 1.3,
),
);
final metaWidget = Text(
message.status == 'EDITED'
? '${_formatTime(message.time)} ред.'
: _formatTime(message.time),
style: TextStyle(color: ctx.dim, fontSize: 10),
);
final showSender = message.senderId != message.accountId &&
prevMessage?.senderId != message.senderId &&
chatType == "CHAT";
if (hasReactions) {
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSender)
Text(
displaySender ?? "",
textAlign: TextAlign.left,
style: TextStyle(color: ctx.text),
),
textWidget,
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Wrap(
spacing: 4,
runSpacing: 4,
children: reactionChips,
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: metaWidget,
),
if (isMe) ...[
const SizedBox(width: 4),
_buildStatusIcon(ctx),
],
],
),
],
),
);
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (message.senderId != message.accountId && if (showSender)
prevMessage?.senderId != message.senderId &&
chatType == "CHAT")
Text( Text(
displaySender ?? "", displaySender ?? "",
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: TextStyle(color: ctx.text), style: TextStyle(color: ctx.text),
), ),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Flexible( Flexible(child: textWidget),
child: isForwarded const SizedBox(width: 8),
? _buildForwardedInlineText(ctx, forwarded) Padding(
: Text( padding: const EdgeInsets.only(bottom: 2),
message.text ?? '', child: metaWidget,
style: TextStyle(
color: ctx.text,
fontSize: 16,
height: 1.3,
),
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
message.status == 'EDITED'
? '${_formatTime(message.time)} ред.'
: _formatTime(message.time),
style: TextStyle(color: ctx.dim, fontSize: 10),
), ),
), if (isMe) ...[
if (isMe) ...[ const SizedBox(width: 4),
const SizedBox(width: 4), _buildStatusIcon(ctx),
_buildStatusIcon(ctx), ],
], ],
], ),
),
], ],
); );
} }
@@ -576,6 +752,11 @@ class MessageBubble extends StatelessWidget {
return _buildContactAttachment(ctx, contacts.first); return _buildContactAttachment(ctx, contacts.first);
} }
final polls = attachments.whereType<PollAttachment>().toList();
if (polls.isNotEmpty) {
return _buildPollAttachment(ctx, polls.first);
}
final photos = attachments.whereType<PhotoAttachment>().toList(); final photos = attachments.whereType<PhotoAttachment>().toList();
if (photos.isEmpty) { if (photos.isEmpty) {
return _buildGenericAttachment(ctx, attachments.first); return _buildGenericAttachment(ctx, attachments.first);
@@ -584,6 +765,21 @@ class MessageBubble extends StatelessWidget {
return _buildPhotoContent(ctx, photos); return _buildPhotoContent(ctx, photos);
} }
Widget _buildPollAttachment(_BubbleCtx ctx, PollAttachment poll) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: PollView(
chatId: message.chatId,
messageId: message.id,
pollId: poll.pollId,
fallbackTitle: poll.title ?? message.text,
textColor: ctx.text,
dimColor: ctx.dim,
accentColor: ctx.text,
),
);
}
Widget _buildPhotoContent(_BubbleCtx ctx, List<PhotoAttachment> photos) { Widget _buildPhotoContent(_BubbleCtx ctx, List<PhotoAttachment> photos) {
final hasCaption = message.text != null && message.text!.isNotEmpty; final hasCaption = message.text != null && message.text!.isNotEmpty;
final count = photos.length; final count = photos.length;
@@ -805,6 +1001,7 @@ class MessageBubble extends StatelessWidget {
final constrainedWidth = width.clamp(photoMinSize, photoMaxSize); final constrainedWidth = width.clamp(photoMinSize, photoMaxSize);
final constrainedHeight = height.clamp(photoMinSize, photoMaxSize); final constrainedHeight = height.clamp(photoMinSize, photoMaxSize);
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
final matchTop = ctx.hasPhotoWithCaption; final matchTop = ctx.hasPhotoWithCaption;
final matchBottom = !ctx.hasPhotoWithCaption; final matchBottom = !ctx.hasPhotoWithCaption;
@@ -830,8 +1027,8 @@ class MessageBubble extends StatelessWidget {
width: constrainedWidth, width: constrainedWidth,
height: constrainedHeight, height: constrainedHeight,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (constrainedWidth * 2).round(), memCacheWidth: (constrainedWidth * dpr).round(),
memCacheHeight: (constrainedHeight * 2).round(), memCacheHeight: (constrainedHeight * dpr).round(),
fadeInDuration: const Duration(milliseconds: 120), fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => _buildPhotoPlaceholder( errorWidget: (_, _, _) => _buildPhotoPlaceholder(
ctx.cs, ctx.cs,
@@ -926,6 +1123,8 @@ class MessageBubble extends StatelessWidget {
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) { Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
final imageUrl = photo.baseUrl ?? ''; final imageUrl = photo.baseUrl ?? '';
final cachePx =
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
return AspectRatio( return AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Stack( child: Stack(
@@ -936,8 +1135,8 @@ class MessageBubble extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
memCacheWidth: 280, memCacheWidth: cachePx,
memCacheHeight: 280, memCacheHeight: cachePx,
fadeInDuration: const Duration(milliseconds: 120), fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, 100, 100), _buildPhotoPlaceholder(ctx.cs, 100, 100),
@@ -961,6 +1160,8 @@ class MessageBubble extends StatelessWidget {
String overlay, String overlay,
) { ) {
final imageUrl = photo.baseUrl ?? ''; final imageUrl = photo.baseUrl ?? '';
final cachePx =
(photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round();
return AspectRatio( return AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Stack( child: Stack(
@@ -971,8 +1172,8 @@ class MessageBubble extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
memCacheWidth: 280, memCacheWidth: cachePx,
memCacheHeight: 280, memCacheHeight: cachePx,
fadeInDuration: const Duration(milliseconds: 120), fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, _, _) => errorWidget: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, 100, 100), _buildPhotoPlaceholder(ctx.cs, 100, 100),
@@ -1061,10 +1262,22 @@ class MessageBubble extends StatelessWidget {
color: ctx.cs.onSurfaceVariant, color: ctx.cs.onSurfaceVariant,
), ),
), ),
Center(
child: Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Symbols.play_arrow,
color: Colors.white, size: 30),
),
),
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () {}, onTap: () => _playVideo(ctx.context, video),
), ),
), ),
], ],
@@ -1076,10 +1289,55 @@ class MessageBubble extends StatelessWidget {
); );
} }
Future<void> _playVideo(
BuildContext context,
MessageAttachment video,
) async {
final videoId = (video as dynamic).videoId as int?;
final token = (video as dynamic).videoToken as String?;
if (videoId == null) {
showCustomNotification(context, 'Не удалось открыть видео');
return;
}
Haptics.tap();
final cacheName = 'video_$videoId.mp4';
final cached = await MediaCache.existing(cacheName) != null;
if (!context.mounted) return;
String? url;
if (!cached) {
if (token == null) {
showCustomNotification(context, 'Не удалось открыть видео');
return;
}
url = await messagesModule.getVideoUrl(
messageId: message.id,
chatId: message.chatId,
token: token,
videoId: videoId,
);
if (!context.mounted) return;
if (url == null) {
showCustomNotification(context, 'Не удалось получить видео');
return;
}
}
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => VideoPlayerScreen(cacheName: cacheName, url: url),
),
);
}
Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) { Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) {
final name = (file as dynamic).name as String? ?? 'File'; final name = (file as dynamic).name as String? ?? 'File';
final size = (file as dynamic).size as int? ?? 0; final size = (file as dynamic).size as int? ?? 0;
final sizeStr = _formatFileSize(size); final sizeStr = _formatFileSize(size);
final fileId = (file as dynamic).fileId as int?;
final cacheName = '${fileId}_$name';
return IntrinsicWidth( return IntrinsicWidth(
child: Padding( child: Padding(
@@ -1125,35 +1383,61 @@ class MessageBubble extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( ValueListenableBuilder<double?>(
sizeStr, valueListenable: MediaDownloadProgress.notifier(cacheName),
style: TextStyle( builder: (context, progress, _) => Text(
color: ctx.dim, progress != null
fontSize: 12, ? '${(progress * 100).round()}% · $sizeStr'
height: 1.2, : sizeStr,
style: TextStyle(
color: ctx.dim,
fontSize: 12,
height: 1.2,
),
), ),
), ),
], ],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
GestureDetector( ValueListenableBuilder<double?>(
onTap: () {}, valueListenable: MediaDownloadProgress.notifier(cacheName),
child: Container( builder: (context, progress, _) {
width: 34, final downloading = progress != null;
height: 34, return GestureDetector(
decoration: BoxDecoration( onTap: downloading
color: isMe ? null
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) : () => _downloadFile(ctx.context, file, name),
: ctx.cs.surfaceContainerHighest, child: Container(
shape: BoxShape.circle, width: 34,
), height: 34,
child: Icon( decoration: BoxDecoration(
Symbols.download, color: isMe
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
size: 18, : ctx.cs.surfaceContainerHighest,
), shape: BoxShape.circle,
), ),
child: downloading
? Padding(
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
),
)
: Icon(
Symbols.download,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
size: 18,
),
),
);
},
), ),
], ],
), ),
@@ -1436,7 +1720,48 @@ class MessageBubble extends StatelessWidget {
} }
void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) {
// TODO: Open photo viewer final url = photo.baseUrl ?? '';
if (url.isEmpty) return;
Navigator.of(ctx).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => PhotoViewerScreen(baseUrl: url),
),
);
}
Future<void> _downloadFile(
BuildContext context,
MessageAttachment file,
String name,
) async {
final fileId = (file as dynamic).fileId as int?;
if (fileId == null) {
showCustomNotification(context, 'Не удалось определить файл');
return;
}
Haptics.tap();
final cacheName = '${fileId}_$name';
MediaDownloadProgress.set(cacheName, 0);
final result = await openCachedFile(
cacheName,
() => messagesModule.getFileUrl(
messageId: message.id,
chatId: message.chatId,
fileId: fileId,
),
onProgress: (p) => MediaDownloadProgress.set(cacheName, p),
);
MediaDownloadProgress.set(cacheName, null);
if (!context.mounted) return;
if (!result.ok) {
showCustomNotification(
context,
'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}',
);
}
} }
Widget _buildVoiceContent(_BubbleCtx ctx) { Widget _buildVoiceContent(_BubbleCtx ctx) {
@@ -1599,7 +1924,7 @@ class _VoiceMessageBubble extends StatefulWidget {
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
bool _isPlaying = false; bool _isPlaying = false;
double _progress = 0.0; final ValueNotifier<double> _progress = ValueNotifier(0.0);
bool _transcriptionVisible = false; bool _transcriptionVisible = false;
String? _transcriptionText; String? _transcriptionText;
bool _transcriptionLoading = false; bool _transcriptionLoading = false;
@@ -1607,10 +1932,13 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.preloadedText != null) { _transcriptionText = widget.preloadedText;
_transcriptionText = widget.preloadedText; }
_transcriptionVisible = true;
} @override
void dispose() {
_progress.dispose();
super.dispose();
} }
String _formatDuration(int seconds) { String _formatDuration(int seconds) {
@@ -1705,18 +2033,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
builder: (context, constraints) { builder: (context, constraints) {
return GestureDetector( return GestureDetector(
onTapDown: (details) { onTapDown: (details) {
setState(() { _progress.value =
_progress = (details.localPosition.dx / (details.localPosition.dx / constraints.maxWidth)
constraints.maxWidth) .clamp(0.0, 1.0);
.clamp(0.0, 1.0);
});
}, },
onHorizontalDragUpdate: (details) { onHorizontalDragUpdate: (details) {
setState(() { _progress.value =
_progress = (details.localPosition.dx / (details.localPosition.dx / constraints.maxWidth)
constraints.maxWidth) .clamp(0.0, 1.0);
.clamp(0.0, 1.0);
});
}, },
child: Container( child: Container(
height: 4, height: 4,
@@ -1724,13 +2048,16 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
color: waveInactiveColor, color: waveInactiveColor,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
child: FractionallySizedBox( child: ValueListenableBuilder<double>(
alignment: Alignment.centerLeft, valueListenable: _progress,
widthFactor: _progress.clamp(0.0, 1.0), builder: (context, progress, _) => FractionallySizedBox(
child: Container( alignment: Alignment.centerLeft,
decoration: BoxDecoration( widthFactor: progress.clamp(0.0, 1.0),
color: waveActiveColor, child: Container(
borderRadius: BorderRadius.circular(2), decoration: BoxDecoration(
color: waveActiveColor,
borderRadius: BorderRadius.circular(2),
),
), ),
), ),
), ),
@@ -1772,14 +2099,19 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( SizedBox(
_formatDuration(widget.duration), width: 32,
style: TextStyle( child: Center(
color: widget.textColor.withValues(alpha: 0.7), child: Text(
fontSize: 11, _formatDuration(widget.duration),
style: TextStyle(
color: widget.textColor.withValues(alpha: 0.7),
fontSize: 11,
),
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 10),
Expanded( Expanded(
child: AnimatedSize( child: AnimatedSize(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
+54
View File
@@ -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(),
),
),
],
),
);
}
}
+139
View File
@@ -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);
}
}
+219 -3
View File
@@ -1,12 +1,101 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
class SwipeRoute<T> extends CupertinoPageRoute<T> { import 'rightward_drag_recognizer.dart';
class SwipeRoute<T> extends PageRoute<T> {
SwipeRoute({ SwipeRoute({
required super.builder, required this.builder,
super.settings, super.settings,
super.maintainState,
super.fullscreenDialog, super.fullscreenDialog,
this.maintainState = true,
}); });
final WidgetBuilder builder;
@override
final bool maintainState;
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
@override
Duration get transitionDuration => const Duration(milliseconds: 400);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 400);
@override
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
return nextRoute is SwipeRoute || nextRoute is CupertinoRouteTransitionMixin;
}
@override
bool get popGestureInProgress => _gestureController != null;
_SwipeBackController<T>? _gestureController;
@override
bool get popGestureEnabled {
if (isFirst) return false;
if (willHandlePopInternally) return false;
if (popDisposition == RoutePopDisposition.doNotPop) return false;
if (animation?.status != AnimationStatus.completed) return false;
if (secondaryAnimation?.status != AnimationStatus.dismissed) return false;
if (popGestureInProgress) return false;
return true;
}
_SwipeBackController<T> _startPopGesture() {
final gesture = _SwipeBackController<T>(
navigator: navigator!,
controller: controller!,
);
_gestureController = gesture;
gesture._onEnd = () {
if (_gestureController == gesture) {
_gestureController = null;
}
};
return gesture;
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: builder(context),
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return _SwipeBackGestureDetector<T>(
enabledCallback: () => popGestureEnabled,
onStartPopGesture: _startPopGesture,
child: CupertinoPageTransition(
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: popGestureInProgress,
child: child,
),
);
}
} }
Future<T?> pushSwipeable<T>( Future<T?> pushSwipeable<T>(
@@ -18,3 +107,130 @@ Future<T?> pushSwipeable<T>(
SwipeRoute<T>(builder: builder, settings: settings), SwipeRoute<T>(builder: builder, settings: settings),
); );
} }
class _SwipeBackGestureDetector<T> extends StatefulWidget {
const _SwipeBackGestureDetector({
required this.enabledCallback,
required this.onStartPopGesture,
required this.child,
});
final ValueGetter<bool> enabledCallback;
final ValueGetter<_SwipeBackController<T>> onStartPopGesture;
final Widget child;
@override
State<_SwipeBackGestureDetector<T>> createState() =>
_SwipeBackGestureDetectorState<T>();
}
class _SwipeBackGestureDetectorState<T>
extends State<_SwipeBackGestureDetector<T>> {
_SwipeBackController<T>? _backController;
double _width = 0;
void _handleStart(DragStartDetails details) {
if (!widget.enabledCallback()) return;
_width = context.size?.width ?? MediaQuery.of(context).size.width;
if (_width <= 0) _width = 1.0;
_backController = widget.onStartPopGesture();
}
void _handleUpdate(DragUpdateDetails details) {
final delta = details.primaryDelta ?? 0.0;
_backController?.dragUpdate(delta / _width);
}
void _handleEnd(DragEndDetails details) {
final velocity = details.velocity.pixelsPerSecond.dx / _width;
_backController?.dragEnd(velocity);
_backController = null;
}
void _handleCancel() {
_backController?.dragEnd(0.0);
_backController = null;
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
RightwardDragRecognizer:
GestureRecognizerFactoryWithHandlers<RightwardDragRecognizer>(
() => RightwardDragRecognizer(debugOwner: this),
(instance) {
instance
..onStart = _handleStart
..onUpdate = _handleUpdate
..onEnd = _handleEnd
..onCancel = _handleCancel;
},
),
},
child: widget.child,
);
}
}
class _SwipeBackController<T> {
_SwipeBackController({
required this.navigator,
required this.controller,
});
final NavigatorState navigator;
final AnimationController controller;
VoidCallback? _onEnd;
static const double _kMinFlingVelocity = 1.0;
void dragUpdate(double delta) {
controller.value -= delta;
}
void dragEnd(double velocity) {
const animationCurve = Curves.fastLinearToSlowEaseIn;
final bool animateForward;
if (velocity.abs() >= _kMinFlingVelocity) {
animateForward = velocity <= 0;
} else {
animateForward = controller.value > 0.5;
}
if (animateForward) {
final forwardMs = math.min(
lerpDouble(800, 0, controller.value)!.floor(),
300,
);
controller.animateTo(
1.0,
duration: Duration(milliseconds: forwardMs),
curve: animationCurve,
);
} else {
navigator.pop();
if (controller.isAnimating) {
final backMs = lerpDouble(0, 800, controller.value)!.floor();
controller.animateBack(
0.0,
duration: Duration(milliseconds: backMs),
curve: animationCurve,
);
}
}
if (controller.isAnimating) {
late AnimationStatusListener statusCb;
statusCb = (status) {
_onEnd?.call();
controller.removeStatusListener(statusCb);
};
controller.addStatusListener(statusCb);
} else {
_onEnd?.call();
}
}
}
+37 -46
View File
@@ -1,22 +1,19 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'rightward_drag_recognizer.dart';
class SwipeToPop extends StatefulWidget { class SwipeToPop extends StatefulWidget {
final Widget child; final Widget child;
final double edgeWidth;
final double popThreshold; final double popThreshold;
final double velocityThreshold; final double velocityThreshold;
final bool fullWidth;
final bool enabled; final bool enabled;
final VoidCallback? onPop; final VoidCallback? onPop;
const SwipeToPop({ const SwipeToPop({
super.key, super.key,
required this.child, required this.child,
this.edgeWidth = 28,
this.popThreshold = 0.35, this.popThreshold = 0.35,
this.velocityThreshold = 700, this.velocityThreshold = 700,
this.fullWidth = false,
this.enabled = true, this.enabled = true,
this.onPop, this.onPop,
}); });
@@ -28,6 +25,7 @@ class SwipeToPop extends StatefulWidget {
class _SwipeToPopState extends State<SwipeToPop> class _SwipeToPopState extends State<SwipeToPop>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final AnimationController _controller; late final AnimationController _controller;
double _width = 0;
@override @override
void initState() { void initState() {
@@ -53,17 +51,19 @@ class _SwipeToPopState extends State<SwipeToPop>
} }
void _onDragStart(DragStartDetails _) { void _onDragStart(DragStartDetails _) {
_width = context.size?.width ?? MediaQuery.of(context).size.width;
if (_width <= 0) _width = 1.0;
_controller.stop(); _controller.stop();
} }
void _onDragUpdate(DragUpdateDetails d, double width) { void _onDragUpdate(DragUpdateDetails d) {
if (width <= 0) return; final next =
final next = (_controller.value + d.delta.dx / width).clamp(0.0, 1.0); (_controller.value + (d.primaryDelta ?? 0.0) / _width).clamp(0.0, 1.0);
_controller.value = next; _controller.value = next;
} }
Future<void> _onDragEnd(DragEndDetails d, double width) async { Future<void> _onDragEnd(DragEndDetails d) async {
final velocity = d.primaryVelocity ?? 0; final velocity = d.velocity.pixelsPerSecond.dx;
final pastThreshold = _controller.value > widget.popThreshold || final pastThreshold = _controller.value > widget.popThreshold ||
velocity > widget.velocityThreshold; velocity > widget.velocityThreshold;
if (pastThreshold) { if (pastThreshold) {
@@ -96,44 +96,35 @@ class _SwipeToPopState extends State<SwipeToPop>
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final width = constraints.maxWidth; final width = constraints.maxWidth;
final gestureChild = GestureDetector( return RawGestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
dragStartBehavior: DragStartBehavior.down, gestures: <Type, GestureRecognizerFactory>{
onHorizontalDragStart: _onDragStart, RightwardDragRecognizer:
onHorizontalDragUpdate: (d) => _onDragUpdate(d, width), GestureRecognizerFactoryWithHandlers<RightwardDragRecognizer>(
onHorizontalDragEnd: (d) => _onDragEnd(d, width), () => RightwardDragRecognizer(debugOwner: this),
onHorizontalDragCancel: _onDragCancel, (instance) {
); instance
..onStart = _onDragStart
return Stack( ..onUpdate = _onDragUpdate
children: [ ..onEnd = _onDragEnd
Positioned.fill( ..onCancel = _onDragCancel;
child: AnimatedBuilder( },
animation: _controller,
builder: (context, child) {
final t = _controller.value;
return Transform.translate(
offset: Offset(t * width, 0),
child: Opacity(
opacity: (1.0 - t * 0.35).clamp(0.0, 1.0),
child: child,
),
);
},
child: widget.child,
),
), ),
if (widget.fullWidth) },
Positioned.fill(child: gestureChild) child: AnimatedBuilder(
else animation: _controller,
Positioned( builder: (context, child) {
left: 0, final t = _controller.value;
top: 0, return Transform.translate(
bottom: 0, offset: Offset(t * width, 0),
width: widget.edgeWidth, child: Opacity(
child: gestureChild, opacity: (1.0 - t * 0.35).clamp(0.0, 1.0),
), child: child,
], ),
);
},
child: widget.child,
),
); );
}, },
); );
@@ -0,0 +1,165 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:video_player/video_player.dart';
import '../../core/utils/media_cache.dart';
class VideoPlayerScreen extends StatefulWidget {
final String cacheName;
final String? url;
const VideoPlayerScreen({
super.key,
required this.cacheName,
this.url,
});
@override
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
VideoPlayerController? _controller;
bool _error = false;
double _progress = 0;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
File? file = await MediaCache.existing(widget.cacheName);
if (file == null && widget.url != null) {
file = await MediaCache.getOrDownload(
widget.cacheName,
widget.url!,
onProgress: (p) {
if (mounted) setState(() => _progress = p);
},
);
}
if (!mounted) return;
if (file == null) {
setState(() => _error = true);
return;
}
final controller = VideoPlayerController.file(file);
_controller = controller;
try {
await controller.initialize();
if (!mounted) return;
setState(() {});
controller.play();
controller.addListener(_onTick);
} catch (_) {
if (mounted) setState(() => _error = true);
}
}
void _onTick() {
if (mounted) setState(() {});
}
@override
void dispose() {
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
void _togglePlay() {
final c = _controller;
if (c == null || !c.value.isInitialized) return;
setState(() => c.value.isPlaying ? c.pause() : c.play());
}
@override
Widget build(BuildContext context) {
final c = _controller;
final ready = c != null && c.value.isInitialized;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Center(
child: _error
? const Icon(Symbols.error, color: Colors.white54, size: 64)
: ready
? AspectRatio(
aspectRatio: c.value.aspectRatio,
child: VideoPlayer(c),
)
: _buildLoading(),
),
if (ready)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _togglePlay,
child: AnimatedOpacity(
opacity: c.value.isPlaying ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: Center(
child: Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(Symbols.play_arrow,
color: Colors.white, size: 40),
),
),
),
),
),
if (ready)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: VideoProgressIndicator(
c,
allowScrubbing: true,
colors: const VideoProgressColors(playedColor: Colors.white),
),
),
Positioned(
top: MediaQuery.of(context).padding.top + 8,
left: 8,
child: IconButton(
icon: const Icon(Symbols.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
],
),
);
}
Widget _buildLoading() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: Colors.white,
value: _progress > 0 && _progress < 1 ? _progress : null,
),
if (_progress > 0 && _progress < 1) ...[
const SizedBox(height: 12),
Text(
'${(_progress * 100).round()}%',
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
],
);
}
}
+4 -1
View File
@@ -168,5 +168,8 @@
"editProfileSave": "Save", "editProfileSave": "Save",
"editProfileFirstName": "First name", "editProfileFirstName": "First name",
"editProfileLastName": "Last name", "editProfileLastName": "Last name",
"editProfileRemovePhoto": "Remove photo" "editProfileRemovePhoto": "Remove photo",
"registrationTitle": "Create your profile",
"registrationSubtitle": "Add your name and pick an avatar",
"registrationChooseAvatar": "Choose an avatar"
} }
+18
View File
@@ -1009,6 +1009,24 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Remove photo'** /// **'Remove photo'**
String get editProfileRemovePhoto; String get editProfileRemovePhoto;
/// No description provided for @registrationTitle.
///
/// In en, this message translates to:
/// **'Create your profile'**
String get registrationTitle;
/// No description provided for @registrationSubtitle.
///
/// In en, this message translates to:
/// **'Add your name and pick an avatar'**
String get registrationSubtitle;
/// No description provided for @registrationChooseAvatar.
///
/// In en, this message translates to:
/// **'Choose an avatar'**
String get registrationChooseAvatar;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+9
View File
@@ -478,4 +478,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get editProfileRemovePhoto => 'Remove photo'; String get editProfileRemovePhoto => 'Remove photo';
@override
String get registrationTitle => 'Create your profile';
@override
String get registrationSubtitle => 'Add your name and pick an avatar';
@override
String get registrationChooseAvatar => 'Choose an avatar';
} }
+9
View File
@@ -480,4 +480,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get editProfileRemovePhoto => 'Удалить фото'; String get editProfileRemovePhoto => 'Удалить фото';
@override
String get registrationTitle => 'Создание профиля';
@override
String get registrationSubtitle => 'Укажите имя и выберите аватар';
@override
String get registrationChooseAvatar => 'Выберите аватар';
} }
+4 -1
View File
@@ -168,5 +168,8 @@
"editProfileSave": "Сохранить", "editProfileSave": "Сохранить",
"editProfileFirstName": "Имя", "editProfileFirstName": "Имя",
"editProfileLastName": "Фамилия", "editProfileLastName": "Фамилия",
"editProfileRemovePhoto": "Удалить фото" "editProfileRemovePhoto": "Удалить фото",
"registrationTitle": "Создание профиля",
"registrationSubtitle": "Укажите имя и выберите аватар",
"registrationChooseAvatar": "Выберите аватар"
} }
+63 -14
View File
@@ -10,6 +10,7 @@ import 'package:m3e_collection/m3e_collection.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'backend/api.dart'; import 'backend/api.dart';
import 'core/cache/info_cache.dart';
import 'core/config/app_accent.dart'; import 'core/config/app_accent.dart';
import 'core/config/app_amoled.dart'; import 'core/config/app_amoled.dart';
import 'core/config/app_bubble_behavior.dart'; import 'core/config/app_bubble_behavior.dart';
@@ -18,6 +19,9 @@ import 'core/config/app_cache_extent.dart';
import 'core/config/app_fonts.dart'; import 'core/config/app_fonts.dart';
import 'core/config/app_message_actions_style.dart'; import 'core/config/app_message_actions_style.dart';
import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_swipe_back_desktop.dart';
import 'core/config/app_pranks.dart';
import 'core/config/app_stories.dart';
import 'core/config/app_media_cache.dart';
import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_mode.dart';
import 'core/config/app_theme_schedule.dart'; import 'core/config/app_theme_schedule.dart';
import 'backend/modules/account.dart'; import 'backend/modules/account.dart';
@@ -25,6 +29,7 @@ import 'backend/modules/chats.dart';
import 'backend/modules/contacts.dart'; import 'backend/modules/contacts.dart';
import 'backend/modules/file_uploader.dart'; import 'backend/modules/file_uploader.dart';
import 'backend/modules/messages.dart'; import 'backend/modules/messages.dart';
import 'backend/modules/polls.dart';
import 'core/push/push_service.dart'; import 'core/push/push_service.dart';
import 'core/storage/app_database.dart'; import 'core/storage/app_database.dart';
import 'core/transport/tls_config.dart'; import 'core/transport/tls_config.dart';
@@ -41,7 +46,10 @@ import 'frontend/widgets/theme_reveal.dart';
final api = Api(); final api = Api();
final accountModule = AccountModule(api); final accountModule = AccountModule(api);
final messagesModule = MessagesModule(api); final messagesModule = MessagesModule(api);
final pollsModule = PollsModule(api);
final fileUploader = FileUploader(api: api, messages: messagesModule); final fileUploader = FileUploader(api: api, messages: messagesModule);
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
RouteObserver<PageRoute<dynamic>>();
Future<Locale> _loadInitialLocale() async { Future<Locale> _loadInitialLocale() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -63,19 +71,38 @@ void main() async {
if (activeAccountId != null) { if (activeAccountId != null) {
await ContactsModule.primeCacheFromDb(activeAccountId); await ContactsModule.primeCacheFromDb(activeAccountId);
} }
attachInfoCacheApi(api);
ChatsModule.attachGlobalPushHandlers(api); ChatsModule.attachGlobalPushHandlers(api);
final packageInfoFuture = PackageInfo.fromPlatform();
final localeFuture = _loadInitialLocale();
final hapticsFuture = Haptics.load();
final prefsFuture = SharedPreferences.getInstance();
final accentFuture = AppAccent.load();
final bubbleShapeFuture = AppBubbleShape.load();
final bubbleBehaviorFuture = AppBubbleBehavior.load();
final cacheExtentFuture = AppCacheExtent.load();
final themeModeFuture = AppThemeModeConfig.load();
final amoledFuture = AppAmoled.load();
final themeScheduleFuture = AppThemeSchedule.load();
final messageActionsFuture = AppMessageActionsStyle.load();
final swipeBackFuture = AppSwipeBackDesktop.load();
final pranksFuture = AppPranks.load();
final storiesFuture = AppStories.load();
final cacheLimitFuture = AppMediaCacheLimit.load();
await api.connect(); await api.connect();
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await packageInfoFuture;
if (packageInfo.packageName == 'ru.oneme.app') { if (packageInfo.packageName == 'ru.oneme.app') {
await PushService.instance.init(api: api, account: accountModule); await PushService.instance.init(api: api, account: accountModule);
} }
final initialLocale = await _loadInitialLocale(); final initialLocale = await localeFuture;
await Haptics.load(); await hapticsFuture;
final prefs = await SharedPreferences.getInstance(); final prefs = await prefsFuture;
await FileHistoryCache.load(prefs); await FileHistoryCache.load(prefs);
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
@@ -85,15 +112,18 @@ void main() async {
final initialFontScale = AppFonts.clampScale( final initialFontScale = AppFonts.clampScale(
prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale, prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale,
); );
final initialAccentSeed = await AppAccent.load(); final initialAccentSeed = await accentFuture;
AppBubbleShape.current.value = await AppBubbleShape.load(); AppBubbleShape.current.value = await bubbleShapeFuture;
AppBubbleBehavior.current.value = await AppBubbleBehavior.load(); AppBubbleBehavior.current.value = await bubbleBehaviorFuture;
AppCacheExtent.current.value = await AppCacheExtent.load(); AppCacheExtent.current.value = await cacheExtentFuture;
AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); AppThemeModeConfig.current.value = await themeModeFuture;
AppAmoled.current.value = await AppAmoled.load(); AppAmoled.current.value = await amoledFuture;
AppThemeSchedule.current.value = await AppThemeSchedule.load(); AppThemeSchedule.current.value = await themeScheduleFuture;
AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); AppMessageActionsStyle.current.value = await messageActionsFuture;
AppSwipeBackDesktop.current.value = await AppSwipeBackDesktop.load(); AppSwipeBackDesktop.current.value = await swipeBackFuture;
AppPranks.current.value = await pranksFuture;
AppStories.current.value = await storiesFuture;
AppMediaCacheLimit.current.value = await cacheLimitFuture;
runApp( runApp(
KometApp( KometApp(
initialLocale: initialLocale, initialLocale: initialLocale,
@@ -626,6 +656,7 @@ class KometAppState extends State<KometApp>
theme: _lightTheme, theme: _lightTheme,
darkTheme: _darkTheme, darkTheme: _darkTheme,
navigatorKey: KometApp.navigatorKey, navigatorKey: KometApp.navigatorKey,
navigatorObservers: [appRouteObserver],
builder: (context, child) { builder: (context, child) {
return ValueListenableBuilder<double>( return ValueListenableBuilder<double>(
valueListenable: fontScale, valueListenable: fontScale,
@@ -683,8 +714,13 @@ class _StartupScreenState extends State<_StartupScreen> {
} }
Future<void> _tryAutoLogin() async { Future<void> _tryAutoLogin() async {
final accountId = await TokenStorage.getActiveAccountId(); int? accountId = await TokenStorage.getActiveAccountId();
if (accountId == null || await TokenStorage.readToken(accountId) == null) { if (accountId == null || await TokenStorage.readToken(accountId) == null) {
accountId = await _recoverActiveAccount();
}
if (accountId == null) {
_goToLogin(); _goToLogin();
return; return;
} }
@@ -701,6 +737,19 @@ class _StartupScreenState extends State<_StartupScreen> {
} }
} }
Future<int?> _recoverActiveAccount() async {
final profiles = await AppDatabase.loadAllProfiles();
for (final profile in profiles) {
if (await TokenStorage.readToken(profile.id) != null) {
await TokenStorage.setActiveAccount(profile.id);
await AppDatabase.setActiveAccount(profile.id);
await ContactsModule.primeCacheFromDb(profile.id);
return profile.id;
}
}
return null;
}
void _goToLogin() { void _goToLogin() {
if (mounted) { if (mounted) {
Navigator.pushReplacement( Navigator.pushReplacement(
+28
View File
@@ -7,6 +7,7 @@ enum AttachmentType {
location, location,
sticker, sticker,
control, control,
poll,
} }
abstract class MessageAttachment { abstract class MessageAttachment {
@@ -41,6 +42,8 @@ abstract class MessageAttachment {
return LocationAttachment.fromMap(map); return LocationAttachment.fromMap(map);
case 'CONTROL': case 'CONTROL':
return ControlAttachment.fromMap(map); return ControlAttachment.fromMap(map);
case 'POLL':
return PollAttachment.fromMap(map);
case 'SHARE': case 'SHARE':
return FileAttachment.fromMap(map); return FileAttachment.fromMap(map);
case 'INLINE_KEYBOARD': case 'INLINE_KEYBOARD':
@@ -471,6 +474,31 @@ class ControlAttachment extends MessageAttachment {
}; };
} }
class PollAttachment extends MessageAttachment {
final int pollId;
final String? title;
const PollAttachment({
required this.pollId,
this.title,
}) : super(type: AttachmentType.poll);
factory PollAttachment.fromMap(Map<String, dynamic> map) {
final id = map['pollId'] ?? map['id'];
return PollAttachment(
pollId: id is int ? id : int.tryParse(id?.toString() ?? '') ?? 0,
title: (map['title'] ?? map['question'])?.toString(),
);
}
@override
Map<String, dynamic> toMap() => {
'_type': 'POLL',
'pollId': pollId,
'title': title,
};
}
class ForwardedMessageAttachment extends MessageAttachment { class ForwardedMessageAttachment extends MessageAttachment {
final int originalSenderId; final int originalSenderId;
final String? originalSenderName; final String? originalSenderName;
+87
View File
@@ -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
View File
@@ -146,13 +146,21 @@ packages:
source: hosted source: hosted
version: "0.3.5+2" version: "0.3.5+2"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
dart_lz4: dart_lz4:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -421,6 +429,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" version: "1.0.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -446,7 +462,7 @@ packages:
source: hosted source: hosted
version: "0.2.1" version: "0.2.1"
image: image:
dependency: transitive dependency: "direct main"
description: description:
name: image name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
@@ -585,10 +601,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mobile_scanner: mobile_scanner:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -645,6 +661,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
open_filex:
dependency: "direct main"
description:
name: open_filex
sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900"
url: "https://pub.dev"
source: hosted
version: "4.7.0"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -670,7 +694,7 @@ packages:
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -958,10 +982,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.10"
timezone: timezone:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1002,6 +1026,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
url: "https://pub.dev"
source: hosted
version: "2.9.5"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
url: "https://pub.dev"
source: hosted
version: "2.9.7"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0"
url: "https://pub.dev"
source: hosted
version: "6.7.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
+5
View File
@@ -39,11 +39,13 @@ dependencies:
dart_lz4: ^1.0.0 dart_lz4: ^1.0.0
libcompress: ^1.0.0 libcompress: ^1.0.0
msgpack_dart: ^1.0.1 msgpack_dart: ^1.0.1
crypto: ^3.0.7
logger: ^2.6.2 logger: ^2.6.2
device_info_plus: 12.3.0 device_info_plus: 12.3.0
flutter_timezone: ^5.0.1 flutter_timezone: ^5.0.1
timezone: ^0.11.0 timezone: ^0.11.0
file_picker: ^8.0.0 file_picker: ^8.0.0
image: ^4.3.0
sqflite: ^2.4.2 sqflite: ^2.4.2
sqflite_common_ffi: ^2.4.0+2 sqflite_common_ffi: ^2.4.0+2
path: ^1.9.1 path: ^1.9.1
@@ -55,6 +57,9 @@ dependencies:
package_info_plus: ^9.0.1 package_info_plus: ^9.0.1
mobile_scanner: ^7.2.0 mobile_scanner: ^7.2.0
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
path_provider: ^2.1.4
open_filex: ^4.5.0
video_player: ^2.9.2
firebase_core: ^4.1.1 firebase_core: ^4.1.1
firebase_messaging: ^16.0.2 firebase_messaging: ^16.0.2
flutter_local_notifications: ^21.0.0 flutter_local_notifications: ^21.0.0