Merge pull request #23 from KometTeam/feature/FullStack
feat: чаты, группы, файлы, real-time + security-фиксы #17 → dev/0.5.0
This commit is contained in:
@@ -5,6 +5,7 @@ import '../core/config/config.dart';
|
||||
import '../core/config/countries.dart';
|
||||
import '../core/protocol/opcode_map.dart';
|
||||
import '../core/protocol/packet.dart';
|
||||
import '../core/storage/device_identity.dart';
|
||||
import '../core/storage/spoofing_service.dart';
|
||||
import '../core/transport/connection.dart';
|
||||
import '../core/transport/dispatcher.dart';
|
||||
@@ -170,7 +171,7 @@ class Api {
|
||||
String timezone = timeZoneName.identifier;
|
||||
String locale = 'ru';
|
||||
String deviceLocale = Platform.localeName.substring(0, 2);
|
||||
String deviceId = 'a1b2c3d4e5f6a7b8';
|
||||
String deviceId = await DeviceIdentity.deviceId();
|
||||
|
||||
if (Platform.isLinux) {
|
||||
final linuxInfo = await deviceInfo.linuxInfo;
|
||||
@@ -242,8 +243,8 @@ class Api {
|
||||
};
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000',
|
||||
'clientSessionId': 42,
|
||||
'mt_instanceid': await DeviceIdentity.instanceId(),
|
||||
'clientSessionId': DeviceIdentity.clientSessionId,
|
||||
'deviceId': deviceId,
|
||||
'userAgent': _userAgent,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../api.dart';
|
||||
import 'folders.dart';
|
||||
import 'messages.dart' show ContactCache;
|
||||
|
||||
Map<int, int> _parseParticipants(dynamic raw) {
|
||||
try {
|
||||
@@ -40,6 +47,8 @@ class CachedChat {
|
||||
final int seenTime;
|
||||
final Map<int, int> participants;
|
||||
final Set<String> options;
|
||||
final int? owner;
|
||||
final Set<int> admins;
|
||||
|
||||
CachedChat({
|
||||
required this.id,
|
||||
@@ -60,12 +69,22 @@ class CachedChat {
|
||||
required this.seenTime,
|
||||
required this.participants,
|
||||
this.options = const {},
|
||||
this.owner,
|
||||
this.admins = const {},
|
||||
}) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
|
||||
? lastMsgText.replaceAll('\n', ' ')
|
||||
: lastMsgText;
|
||||
|
||||
bool get isOfficial => options.contains('OFFICIAL');
|
||||
|
||||
bool iAmAdmin(int myId) => owner == myId || admins.contains(myId);
|
||||
|
||||
bool get isMuted {
|
||||
if (dontDisturbUntil == ChatsModule.muteOff) return false;
|
||||
if (dontDisturbUntil < 0) return true;
|
||||
return dontDisturbUntil > DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
|
||||
id: row['id'] as int,
|
||||
accountId: row['account_id'] as int,
|
||||
@@ -85,6 +104,8 @@ class CachedChat {
|
||||
seenTime: row['seen_time'] as int,
|
||||
participants: _parseParticipants(row['participants']),
|
||||
options: _decodeOptions(row['options']),
|
||||
owner: row['owner'] as int?,
|
||||
admins: _decodeAdmins(row['admins']),
|
||||
);
|
||||
|
||||
static Set<String> _decodeOptions(dynamic raw) {
|
||||
@@ -92,6 +113,15 @@ class CachedChat {
|
||||
return raw.split(',').where((s) => s.isNotEmpty).toSet();
|
||||
}
|
||||
|
||||
static Set<int> _decodeAdmins(dynamic raw) {
|
||||
if (raw is! String || raw.isEmpty) return const {};
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => int.tryParse(s.trim()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
@@ -111,10 +141,187 @@ class CachedChat {
|
||||
'seen_time': seenTime,
|
||||
'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))),
|
||||
'options': options.isEmpty ? null : options.join(','),
|
||||
'owner': owner,
|
||||
'admins': admins.isEmpty ? null : admins.join(','),
|
||||
};
|
||||
}
|
||||
|
||||
class ChatsModule {
|
||||
static const int muteOff = 0;
|
||||
static const int muteForever = -1;
|
||||
|
||||
static final ValueNotifier<int> chatsChanged = ValueNotifier(0);
|
||||
static void _bump() => chatsChanged.value = chatsChanged.value + 1;
|
||||
|
||||
static StreamSubscription<Packet>? _globalPushSub;
|
||||
|
||||
static void attachGlobalPushHandlers(Api api) {
|
||||
_globalPushSub?.cancel();
|
||||
_globalPushSub = api.pushStream.listen(_handleGlobalPush);
|
||||
}
|
||||
|
||||
static Future<void> _handleGlobalPush(Packet packet) async {
|
||||
switch (packet.opcode) {
|
||||
case Opcode.notifMark:
|
||||
await _handleNotifMark(packet);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _handleNotifMark(Packet packet) async {
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return;
|
||||
final chatId = payload['chatId'];
|
||||
if (chatId is! int) return;
|
||||
final userId = payload['userId'];
|
||||
if (userId is! int) return;
|
||||
final mark = payload['mark'];
|
||||
if (mark is! int) return;
|
||||
if (payload['setAsUnread'] == true) return;
|
||||
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
|
||||
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
if (rows.isEmpty) return;
|
||||
final cached = CachedChat.fromDbRow(rows.first);
|
||||
if (cached.participants[userId] == mark) return;
|
||||
cached.participants[userId] = mark;
|
||||
await AppDatabase.saveChats([cached.toDbRow()]);
|
||||
}
|
||||
|
||||
static final Set<int> _pendingContactUpdates = {};
|
||||
static Timer? _contactFlushTimer;
|
||||
static Future<void>? _contactFlushFuture;
|
||||
static const _contactFlushDelay = Duration(milliseconds: 250);
|
||||
|
||||
static void applyContactUpdate(int contactId) {
|
||||
_pendingContactUpdates.add(contactId);
|
||||
if (_contactFlushTimer != null) return;
|
||||
if (_contactFlushFuture != null) return;
|
||||
_contactFlushTimer = Timer(_contactFlushDelay, _kickFlush);
|
||||
}
|
||||
|
||||
static void _kickFlush() {
|
||||
_contactFlushTimer = null;
|
||||
if (_contactFlushFuture != null) return;
|
||||
_contactFlushFuture = _flushContactUpdates().whenComplete(() {
|
||||
_contactFlushFuture = null;
|
||||
if (_pendingContactUpdates.isNotEmpty) {
|
||||
_contactFlushTimer ??= Timer(_contactFlushDelay, _kickFlush);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _flushContactUpdates() async {
|
||||
if (_pendingContactUpdates.isEmpty) return;
|
||||
final ids = _pendingContactUpdates.toList();
|
||||
_pendingContactUpdates.clear();
|
||||
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
|
||||
final dialogRows = await AppDatabase.loadDialogChats(accountId);
|
||||
final byParticipant = <int, List<Map<String, dynamic>>>{};
|
||||
for (final row in dialogRows) {
|
||||
final cached = CachedChat.fromDbRow(row);
|
||||
for (final pid in cached.participants.keys) {
|
||||
if (pid == accountId) continue;
|
||||
byParticipant.putIfAbsent(pid, () => []).add(row);
|
||||
}
|
||||
}
|
||||
|
||||
final updates = <Map<String, dynamic>>[];
|
||||
for (final contactId in ids) {
|
||||
final name = ContactCache.get(contactId);
|
||||
if (name == null) continue;
|
||||
final avatar = ContactCache.getAvatar(contactId);
|
||||
final options = ContactCache.getOptions(contactId) ?? const <String>{};
|
||||
final affected = byParticipant[contactId];
|
||||
if (affected == null) continue;
|
||||
for (final row in affected) {
|
||||
final cached = CachedChat.fromDbRow(row);
|
||||
final sameTitle = cached.title == name;
|
||||
final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
|
||||
final sameOptions = cached.options.length == options.length &&
|
||||
cached.options.containsAll(options);
|
||||
if (sameTitle && sameAvatar && sameOptions) continue;
|
||||
final newRow = Map<String, dynamic>.from(row);
|
||||
newRow['title'] = name;
|
||||
newRow['icon_url'] = avatar;
|
||||
newRow['options'] = options.isEmpty ? null : options.join(',');
|
||||
updates.add(newRow);
|
||||
}
|
||||
}
|
||||
if (updates.isNotEmpty) {
|
||||
await AppDatabase.saveChats(updates);
|
||||
_bump();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<CachedChat?> cacheServerChat(
|
||||
Map<dynamic, dynamic> chat,
|
||||
int accountId, {
|
||||
Map<int, CachedChat>? preloadedExisting,
|
||||
}) async {
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
final id = chat['id'];
|
||||
Map<int, CachedChat> existing = const {};
|
||||
if (preloadedExisting != null) {
|
||||
existing = preloadedExisting;
|
||||
} else if (id is int) {
|
||||
final rows = await AppDatabase.loadChat(accountId, id);
|
||||
if (rows.isNotEmpty) {
|
||||
existing = {id: CachedChat.fromDbRow(rows.first)};
|
||||
}
|
||||
}
|
||||
final parsed = _parseChat(
|
||||
chat,
|
||||
accountId,
|
||||
accountId,
|
||||
const {},
|
||||
const {},
|
||||
const {},
|
||||
existing,
|
||||
cachedAt,
|
||||
);
|
||||
if (parsed == null) {
|
||||
logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
|
||||
return null;
|
||||
}
|
||||
final ex = existing[parsed.id];
|
||||
if (ex != null && _sameContent(ex, parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
await AppDatabase.saveChats([parsed.toDbRow()]);
|
||||
_bump();
|
||||
return parsed;
|
||||
}
|
||||
|
||||
static bool _sameContent(CachedChat a, CachedChat b) {
|
||||
if (a.title != b.title) return false;
|
||||
if (a.iconUrl != b.iconUrl) return false;
|
||||
if (a.owner != b.owner) return false;
|
||||
if (a.dontDisturbUntil != b.dontDisturbUntil) return false;
|
||||
if (a.favIndex != b.favIndex) return false;
|
||||
if (a.lastMsgId != b.lastMsgId) return false;
|
||||
if (a.lastMsgTime != b.lastMsgTime) return false;
|
||||
if (a.lastMsgText != b.lastMsgText) return false;
|
||||
if (a.lastMsgSenderId != b.lastMsgSenderId) return false;
|
||||
if (a.unreadCount != b.unreadCount) return false;
|
||||
if (a.lastEventTime != b.lastEventTime) return false;
|
||||
if (a.isOnline != b.isOnline) return false;
|
||||
if (a.seenTime != b.seenTime) return false;
|
||||
if (a.admins.length != b.admins.length) return false;
|
||||
if (!a.admins.containsAll(b.admins)) return false;
|
||||
if (a.options.length != b.options.length) return false;
|
||||
if (!a.options.containsAll(b.options)) return false;
|
||||
if (a.participants.length != b.participants.length) return false;
|
||||
for (final e in a.participants.entries) {
|
||||
if (b.participants[e.key] != e.value) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Парсит и кэширует чаты из payload opcode 19.
|
||||
///
|
||||
/// Для диалогов разрезолвит имя и аватар из списка [contacts] того же
|
||||
@@ -166,6 +373,7 @@ class ChatsModule {
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
await AppDatabase.saveChats(rows);
|
||||
_bump();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при синке: $e");
|
||||
@@ -274,6 +482,12 @@ class ChatsModule {
|
||||
if (config is Map) {
|
||||
favIndex = config['favIndex'] as int?;
|
||||
dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0;
|
||||
} else {
|
||||
final ex = existing[id];
|
||||
if (ex != null) {
|
||||
favIndex = ex.favIndex;
|
||||
dontDisturbUntil = ex.dontDisturbUntil;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +502,31 @@ class ChatsModule {
|
||||
}
|
||||
Map<int, int> participants = _parseParticipants(chat['participants']);
|
||||
|
||||
int? owner;
|
||||
final ownerRaw = chat['owner'];
|
||||
if (ownerRaw is int) {
|
||||
owner = ownerRaw;
|
||||
} else if (ownerRaw is String) {
|
||||
owner = int.tryParse(ownerRaw);
|
||||
}
|
||||
|
||||
Set<int> admins = const {};
|
||||
final adminsRaw = chat['admins'];
|
||||
if (adminsRaw is List) {
|
||||
admins = adminsRaw
|
||||
.map((e) => e is int ? e : int.tryParse(e.toString()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
} else {
|
||||
final adminParticipants = chat['adminParticipants'];
|
||||
if (adminParticipants is Map) {
|
||||
admins = adminParticipants.keys
|
||||
.map((k) => k is int ? k : int.tryParse(k.toString()))
|
||||
.whereType<int>()
|
||||
.toSet();
|
||||
}
|
||||
}
|
||||
|
||||
return CachedChat(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
@@ -307,6 +546,8 @@ class ChatsModule {
|
||||
seenTime: seenTime,
|
||||
participants: participants,
|
||||
options: options,
|
||||
owner: owner,
|
||||
admins: admins,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при парсинге чата: $e");
|
||||
@@ -355,4 +596,225 @@ class ChatsModule {
|
||||
});
|
||||
return packet.payload;
|
||||
}
|
||||
|
||||
static Future<CachedChat?> createGroupChat(
|
||||
Api api, {
|
||||
required String title,
|
||||
required List<int> userIds,
|
||||
bool notify = true,
|
||||
}) async {
|
||||
final payload = {
|
||||
'message': {
|
||||
'cid': DateTime.now().millisecondsSinceEpoch,
|
||||
'attaches': [
|
||||
{
|
||||
'_type': 'CONTROL',
|
||||
'event': 'new',
|
||||
'chatType': 'CHAT',
|
||||
'title': title,
|
||||
'userIds': userIds,
|
||||
},
|
||||
],
|
||||
},
|
||||
'notify': notify,
|
||||
};
|
||||
final packet = await api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!packet.isOk) {
|
||||
logger.w('createGroupChat: server error payload=${packet.payload}');
|
||||
return null;
|
||||
}
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
logger.w('createGroupChat: payload is not a Map: $data');
|
||||
return null;
|
||||
}
|
||||
final chat = data['chat'];
|
||||
if (chat is! Map) {
|
||||
logger.w('createGroupChat: response has no chat field: $data');
|
||||
return null;
|
||||
}
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) {
|
||||
logger.w('createGroupChat: no active account id');
|
||||
return null;
|
||||
}
|
||||
return cacheServerChat(chat, accountId);
|
||||
}
|
||||
|
||||
static Future<String?> requestChatPhotoUploadUrl(Api api) async {
|
||||
final packet = await api.sendRequest(Opcode.photoUpload, {'count': 1});
|
||||
if (!packet.isOk) return null;
|
||||
final data = packet.payload;
|
||||
if (data is! Map) return null;
|
||||
return data['url'] as String?;
|
||||
}
|
||||
|
||||
static Future<bool> setChatPhoto(
|
||||
Api api, {
|
||||
required int chatId,
|
||||
required String photoToken,
|
||||
}) async {
|
||||
final packet = await api.sendRequest(Opcode.chatUpdate, {
|
||||
'chatId': chatId,
|
||||
'photoToken': photoToken,
|
||||
});
|
||||
return packet.isOk;
|
||||
}
|
||||
|
||||
static Future<String?> togglePin(
|
||||
Api api, {
|
||||
required List<int> chatIds,
|
||||
required bool pin,
|
||||
}) async {
|
||||
if (chatIds.isEmpty) return null;
|
||||
try {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return 'Нет активного аккаунта';
|
||||
final folders = await FoldersModule.loadFolders(accountId);
|
||||
final allFolder = folders.firstWhere(
|
||||
FoldersModule.isAllChatsFolder,
|
||||
orElse: () => folders.isEmpty
|
||||
? throw StateError('Папка "Все" не найдена')
|
||||
: folders.first,
|
||||
);
|
||||
|
||||
final favorites = List<int>.from(allFolder.favorites ?? const []);
|
||||
if (pin) {
|
||||
for (final id in chatIds) {
|
||||
if (!favorites.contains(id)) favorites.add(id);
|
||||
}
|
||||
} else {
|
||||
favorites.removeWhere((id) => chatIds.contains(id));
|
||||
}
|
||||
|
||||
await FoldersModule.setFolderFavorites(api, accountId, allFolder, favorites);
|
||||
|
||||
final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds);
|
||||
final updates = <Map<String, dynamic>>[];
|
||||
for (final row in existingRows) {
|
||||
final id = row['id'] as int;
|
||||
final isFav = favorites.contains(id);
|
||||
final currentFav = row['fav_index'] as int?;
|
||||
final newFav = isFav
|
||||
? ((currentFav ?? 0) > 0 ? currentFav : favorites.indexOf(id) + 1)
|
||||
: 0;
|
||||
if (currentFav == newFav) continue;
|
||||
final newRow = Map<String, dynamic>.from(row);
|
||||
newRow['fav_index'] = newFav;
|
||||
updates.add(newRow);
|
||||
}
|
||||
if (updates.isNotEmpty) {
|
||||
await AppDatabase.saveChats(updates);
|
||||
_bump();
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
logger.w('togglePin: ${e.message}');
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
logger.w('togglePin: $e');
|
||||
return 'Не удалось изменить закрепление';
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> setChatMute(
|
||||
Api api, {
|
||||
required int chatId,
|
||||
required int dontDisturbUntil,
|
||||
}) async {
|
||||
try {
|
||||
await api.sendRequest(Opcode.config, {
|
||||
'settings': {
|
||||
'chats': {
|
||||
chatId: {'dontDisturbUntil': dontDisturbUntil},
|
||||
},
|
||||
},
|
||||
});
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
if (rows.isNotEmpty) {
|
||||
final row = Map<String, dynamic>.from(rows.first);
|
||||
row['dont_disturb_until'] = dontDisturbUntil;
|
||||
await AppDatabase.saveChats([row]);
|
||||
_bump();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
logger.w('setChatMute $chatId: ${e.message}');
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
logger.w('setChatMute $chatId: $e');
|
||||
return 'Не удалось изменить уведомления';
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> deleteChat(
|
||||
Api api, {
|
||||
required int chatId,
|
||||
required int lastEventTime,
|
||||
required bool forAll,
|
||||
}) async {
|
||||
try {
|
||||
await api.sendRequest(Opcode.chatDelete, {
|
||||
'chatId': chatId,
|
||||
'lastEventTime': lastEventTime,
|
||||
'forAll': forAll,
|
||||
});
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.deleteChat(chatId, accountId);
|
||||
_bump();
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
logger.w('deleteChat $chatId: ${e.message}');
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
logger.w('deleteChat $chatId: $e');
|
||||
return 'Не удалось удалить чат';
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CachedChat>> refreshChats(
|
||||
Api api,
|
||||
List<int> chatIds,
|
||||
) async {
|
||||
if (chatIds.isEmpty) return const [];
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.chatInfo, {
|
||||
'chatIds': chatIds,
|
||||
});
|
||||
final payload = packet.payload;
|
||||
if (payload is! Map) return const [];
|
||||
final list = payload['chats'];
|
||||
if (list is! List) return const [];
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return const [];
|
||||
final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds);
|
||||
final preloadedExisting = {
|
||||
for (final row in existingRows)
|
||||
row['id'] as int: CachedChat.fromDbRow(row),
|
||||
};
|
||||
final out = <CachedChat>[];
|
||||
for (final c in list) {
|
||||
if (c is Map) {
|
||||
final cached = await cacheServerChat(
|
||||
c,
|
||||
accountId,
|
||||
preloadedExisting: preloadedExisting,
|
||||
);
|
||||
if (cached != null) out.add(cached);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} on PacketError catch (e) {
|
||||
logger.w('refreshChats: ${e.message}');
|
||||
return const [];
|
||||
} catch (e) {
|
||||
logger.w('refreshChats: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert' show jsonDecode, utf8;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../api.dart';
|
||||
import '../../core/config/proxy_config.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/transport/proxy_connector.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'messages.dart';
|
||||
|
||||
sealed class UploadEvent {
|
||||
const UploadEvent();
|
||||
}
|
||||
|
||||
class UploadProgress extends UploadEvent {
|
||||
final int sent;
|
||||
final int total;
|
||||
const UploadProgress({required this.sent, required this.total});
|
||||
}
|
||||
|
||||
class UploadDone extends UploadEvent {
|
||||
final int fileId;
|
||||
final String? token;
|
||||
final String? url;
|
||||
final String filename;
|
||||
final int size;
|
||||
const UploadDone({
|
||||
required this.fileId,
|
||||
required this.filename,
|
||||
required this.size,
|
||||
this.token,
|
||||
this.url,
|
||||
});
|
||||
}
|
||||
|
||||
class UploadError extends UploadEvent {
|
||||
final String message;
|
||||
const UploadError(this.message);
|
||||
}
|
||||
|
||||
class FileUploader {
|
||||
final Api api;
|
||||
final MessagesModule messages;
|
||||
|
||||
FileUploader({required this.api, required this.messages});
|
||||
|
||||
Stream<UploadEvent> upload({
|
||||
required int chatId,
|
||||
required File file,
|
||||
required String filename,
|
||||
required int totalSize,
|
||||
Duration autoForceAfter = const Duration(seconds: 1),
|
||||
Duration overallTimeout = const Duration(minutes: 5),
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
}) {
|
||||
final ctrl = StreamController<UploadEvent>();
|
||||
var cancelled = false;
|
||||
Socket? socket;
|
||||
|
||||
ctrl.onCancel = () {
|
||||
cancelled = true;
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
Future<void> run() async {
|
||||
try {
|
||||
final info = await messages.requestUploadUrl();
|
||||
if (cancelled) return;
|
||||
if (info == null) {
|
||||
ctrl.add(const UploadError('no_upload_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(() async {
|
||||
try {
|
||||
await api.sendRequest(Opcode.msgTyping, {
|
||||
'chatId': chatId,
|
||||
'type': 'FILE',
|
||||
});
|
||||
} catch (_) {}
|
||||
}());
|
||||
|
||||
final uri = Uri.parse(info.url);
|
||||
socket = await _openSocket(uri);
|
||||
if (cancelled) return;
|
||||
|
||||
_writeHeaders(socket!, uri, filename, totalSize);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
final body = file.openRead().map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (stopwatch.elapsed >= progressThrottle) {
|
||||
ctrl.add(UploadProgress(sent: sent, total: totalSize));
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
await socket!.addStream(body);
|
||||
await socket!.flush();
|
||||
if (cancelled) return;
|
||||
ctrl.add(UploadProgress(sent: totalSize, total: totalSize));
|
||||
|
||||
final statusCode = await _readResponse(
|
||||
socket!,
|
||||
autoForceAfter: autoForceAfter,
|
||||
overallTimeout: overallTimeout,
|
||||
);
|
||||
try {
|
||||
socket!.destroy();
|
||||
} catch (_) {}
|
||||
if (cancelled) return;
|
||||
|
||||
if (statusCode != 200 && statusCode != 0) {
|
||||
ctrl.add(UploadError('http_$statusCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
final ok = await messages.sendFileMessage(
|
||||
chatId,
|
||||
info.fileId,
|
||||
token: info.token,
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!ok) {
|
||||
ctrl.add(const UploadError('send_failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
ctrl.add(UploadDone(
|
||||
fileId: info.fileId,
|
||||
token: info.token,
|
||||
url: info.url,
|
||||
filename: filename,
|
||||
size: totalSize,
|
||||
));
|
||||
} catch (e) {
|
||||
if (!cancelled) ctrl.add(UploadError(e.toString()));
|
||||
} finally {
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
await ctrl.close();
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(run());
|
||||
return ctrl.stream;
|
||||
}
|
||||
|
||||
Future<Socket> _openSocket(Uri uri) async {
|
||||
final proxySettings = await ProxyConfig.load();
|
||||
final base = proxySettings.isEnabled
|
||||
? await ProxyConnector(proxySettings).connect(uri.host, uri.port)
|
||||
: await Socket.connect(uri.host, uri.port);
|
||||
if (uri.scheme != 'https') return base;
|
||||
return SecureSocket.secure(
|
||||
base,
|
||||
host: uri.host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
}
|
||||
|
||||
void _writeHeaders(
|
||||
Socket socket,
|
||||
Uri uri,
|
||||
String filename,
|
||||
int total, {
|
||||
String contentType = 'application/x-binary; charset=x-user-defined',
|
||||
}) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: $contentType\r\n')
|
||||
..write('Content-Disposition: attachment; filename=$filename\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('Content-Range: bytes 0-${total - 1}/$total\r\n')
|
||||
..write('Content-Length: $total\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
}
|
||||
|
||||
Future<String?> uploadImage(Uri uri, Uint8List bytes, {String filename = 'avatar.jpg'}) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
socket = await _openSocket(uri);
|
||||
_writeImageHeaders(
|
||||
socket,
|
||||
uri,
|
||||
bytes.length,
|
||||
contentType: _contentTypeForFilename(filename),
|
||||
);
|
||||
socket.add(bytes);
|
||||
await socket.flush();
|
||||
|
||||
final response = await _readFullResponse(
|
||||
socket,
|
||||
timeout: const Duration(minutes: 2),
|
||||
);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) {
|
||||
logger.w('uploadImage: empty/timed-out response');
|
||||
return null;
|
||||
}
|
||||
final (status, body) = response;
|
||||
if (status != 200) {
|
||||
logger.w('uploadImage: status=$status body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}');
|
||||
return null;
|
||||
}
|
||||
final token = _parsePhotoToken(body);
|
||||
if (token == null) {
|
||||
logger.w('uploadImage: photoToken not found in body=${body.length > 200 ? '${body.substring(0, 200)}…' : body}');
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
logger.w('uploadImage: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: ${uri.host}\r\n')
|
||||
..write('Content-Type: $contentType\r\n')
|
||||
..write('Content-Length: $total\r\n')
|
||||
..write('Connection: keep-alive\r\n')
|
||||
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
||||
..write('\r\n');
|
||||
socket.add(utf8.encode(headers.toString()));
|
||||
}
|
||||
|
||||
String _contentTypeForFilename(String filename) {
|
||||
final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : '';
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'heic':
|
||||
case 'heif':
|
||||
return 'image/heic';
|
||||
case 'bmp':
|
||||
return 'image/bmp';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
default:
|
||||
return 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
Future<(int, String)?> _readFullResponse(
|
||||
Socket socket, {
|
||||
required Duration timeout,
|
||||
}) {
|
||||
final bytes = <int>[];
|
||||
final completer = Completer<(int, String)?>();
|
||||
Timer? timer;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
|
||||
void finish() {
|
||||
timer?.cancel();
|
||||
sub?.cancel();
|
||||
if (completer.isCompleted) return;
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) {
|
||||
completer.complete(null);
|
||||
return;
|
||||
}
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final lines = headerStr.split('\r\n');
|
||||
final parts = lines.first.split(' ');
|
||||
final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0;
|
||||
final chunked = lines.skip(1).any(
|
||||
(l) => l.toLowerCase().startsWith('transfer-encoding:') &&
|
||||
l.toLowerCase().contains('chunked'),
|
||||
);
|
||||
final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true);
|
||||
final body = chunked ? _decodeChunked(rawBody) : rawBody;
|
||||
completer.complete((status, body));
|
||||
}
|
||||
|
||||
void fail() {
|
||||
timer?.cancel();
|
||||
sub?.cancel();
|
||||
if (!completer.isCompleted) completer.complete(null);
|
||||
}
|
||||
|
||||
sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish);
|
||||
timer = Timer(timeout, fail);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
String _decodeChunked(String body) {
|
||||
final out = StringBuffer();
|
||||
var i = 0;
|
||||
while (i < body.length) {
|
||||
final lineEnd = body.indexOf('\r\n', i);
|
||||
if (lineEnd < 0) break;
|
||||
final sizeStr = body.substring(i, lineEnd).split(';').first.trim();
|
||||
if (sizeStr.isEmpty) {
|
||||
i = lineEnd + 2;
|
||||
continue;
|
||||
}
|
||||
final size = int.tryParse(sizeStr, radix: 16);
|
||||
if (size == null) break;
|
||||
if (size == 0) break;
|
||||
final dataStart = lineEnd + 2;
|
||||
if (dataStart + size > body.length) break;
|
||||
out.write(body.substring(dataStart, dataStart + size));
|
||||
i = dataStart + size;
|
||||
if (i + 2 <= body.length && body.substring(i, i + 2) == '\r\n') {
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
String? _parsePhotoToken(String body) {
|
||||
try {
|
||||
final json = jsonDecode(body);
|
||||
if (json is Map) {
|
||||
final photos = json['photos'];
|
||||
if (photos is Map) {
|
||||
for (final v in photos.values) {
|
||||
if (v is Map) {
|
||||
final token = v['token'];
|
||||
if (token is String && token.isNotEmpty) return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
final pt = json['photoToken'];
|
||||
if (pt is String && pt.isNotEmpty) return pt;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('parsePhotoToken: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> _readResponse(
|
||||
Socket socket, {
|
||||
required Duration autoForceAfter,
|
||||
required Duration overallTimeout,
|
||||
}) {
|
||||
final responseBytes = <int>[];
|
||||
final completer = Completer<int>();
|
||||
Timer? force;
|
||||
Timer? overall;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
|
||||
void finish(int code) {
|
||||
if (completer.isCompleted) return;
|
||||
force?.cancel();
|
||||
overall?.cancel();
|
||||
sub?.cancel();
|
||||
completer.complete(code);
|
||||
}
|
||||
|
||||
void fail(Object e) {
|
||||
if (completer.isCompleted) return;
|
||||
force?.cancel();
|
||||
overall?.cancel();
|
||||
sub?.cancel();
|
||||
completer.completeError(e);
|
||||
}
|
||||
|
||||
force = Timer(autoForceAfter, () => finish(0));
|
||||
|
||||
sub = socket.listen(
|
||||
responseBytes.addAll,
|
||||
onError: fail,
|
||||
onDone: () {
|
||||
final code = _parseHttpStatus(responseBytes);
|
||||
if (code == null) {
|
||||
fail(const SocketException('Не удалось прочитать заголовок ответа'));
|
||||
} else {
|
||||
finish(code);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
overall = Timer(overallTimeout, () => fail(TimeoutException('Тайм-аут загрузки')));
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
int? _parseHttpStatus(List<int> bytes) {
|
||||
final headerEnd = _findHeaderEnd(bytes);
|
||||
if (headerEnd == -1) return null;
|
||||
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final statusLine = headerStr.split('\r\n').first;
|
||||
final parts = statusLine.split(' ');
|
||||
if (parts.length < 2) return null;
|
||||
return int.tryParse(parts[1]);
|
||||
}
|
||||
|
||||
int _findHeaderEnd(List<int> bytes) {
|
||||
for (var i = 0; i < bytes.length - 3; i++) {
|
||||
if (bytes[i] == 0x0D &&
|
||||
bytes[i + 1] == 0x0A &&
|
||||
bytes[i + 2] == 0x0D &&
|
||||
bytes[i + 3] == 0x0A) {
|
||||
return i + 4;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,57 @@ class FoldersModule {
|
||||
await markFoldersListReady(accountId);
|
||||
}
|
||||
|
||||
static Future<ChatFolder?> setFolderFavorites(
|
||||
Api api,
|
||||
int accountId,
|
||||
ChatFolder folder,
|
||||
List<int> favorites,
|
||||
) async {
|
||||
final packet = await api.sendRequest(Opcode.foldersUpdate, {
|
||||
'id': folder.id,
|
||||
'title': folder.title,
|
||||
'include': folder.include ?? const [],
|
||||
'favorites': favorites,
|
||||
'filters': folder.filters,
|
||||
'options': folder.options ?? const [],
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw PacketError(messageFromErrorPayload(packet.payload));
|
||||
}
|
||||
final data = packet.payload;
|
||||
if (data is! Map) return null;
|
||||
final folderJson = data['folder'];
|
||||
if (folderJson is! Map) return null;
|
||||
final updated = ChatFolder.fromJson(
|
||||
folderJson is Map<String, dynamic>
|
||||
? folderJson
|
||||
: Map<String, dynamic>.from(folderJson),
|
||||
);
|
||||
|
||||
final currentRaw = await AppDatabase.getSyncValue(accountId, _syncKey);
|
||||
final snapshot = (currentRaw != null && currentRaw.isNotEmpty)
|
||||
? jsonDecode(currentRaw) as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
final existing = (snapshot['folders'] as List?)
|
||||
?.map((e) {
|
||||
final m = e is Map<String, dynamic>
|
||||
? e
|
||||
: Map<String, dynamic>.from(e as Map);
|
||||
return ChatFolder.fromJson(m);
|
||||
})
|
||||
.toList() ??
|
||||
<ChatFolder>[];
|
||||
final idx = existing.indexWhere((f) => f.id == updated.id);
|
||||
if (idx >= 0) {
|
||||
existing[idx] = updated;
|
||||
} else {
|
||||
existing.add(updated);
|
||||
}
|
||||
final order = snapshot['foldersOrder'] as List<dynamic>?;
|
||||
await _persist(accountId, existing, order);
|
||||
return updated;
|
||||
}
|
||||
|
||||
static Future<void> syncFromServer(Api api, int accountId) async {
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.foldersGet, {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'chats.dart' show ChatsModule;
|
||||
|
||||
class ContactCache {
|
||||
static final Map<int, String> _nameCache = {};
|
||||
@@ -20,6 +22,7 @@ class ContactCache {
|
||||
|
||||
static String? get(int id) => _nameCache[id];
|
||||
static String? getAvatar(int id) => _avatarCache[id];
|
||||
static Set<String>? getOptions(int id) => _optionsCache[id];
|
||||
static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false;
|
||||
}
|
||||
|
||||
@@ -55,27 +58,93 @@ class FileHistoryEntry {
|
||||
final int fileId;
|
||||
final String? url;
|
||||
final String? token;
|
||||
final String? filename;
|
||||
final int? size;
|
||||
final DateTime sentAt;
|
||||
|
||||
FileHistoryEntry({
|
||||
required this.fileId,
|
||||
this.url,
|
||||
this.token,
|
||||
this.filename,
|
||||
this.size,
|
||||
required this.sentAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'fileId': fileId,
|
||||
if (url != null) 'url': url,
|
||||
if (token != null) 'token': token,
|
||||
if (filename != null) 'filename': filename,
|
||||
if (size != null) 'size': size,
|
||||
'sentAt': sentAt.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
static FileHistoryEntry? fromJson(Map<String, dynamic> j) {
|
||||
final id = j['fileId'];
|
||||
final ts = j['sentAt'];
|
||||
if (id is! int || ts is! int) return null;
|
||||
return FileHistoryEntry(
|
||||
fileId: id,
|
||||
url: j['url'] as String?,
|
||||
token: j['token'] as String?,
|
||||
filename: j['filename'] as String?,
|
||||
size: j['size'] as int?,
|
||||
sentAt: DateTime.fromMillisecondsSinceEpoch(ts),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FileHistoryCache {
|
||||
static final List<FileHistoryEntry> _history = [];
|
||||
static const _prefKey = 'file_history_v1';
|
||||
static const _maxEntries = 50;
|
||||
|
||||
static List<FileHistoryEntry> get history => List.unmodifiable(_history);
|
||||
static final ValueNotifier<List<FileHistoryEntry>> notifier =
|
||||
ValueNotifier(const []);
|
||||
|
||||
static void add(FileHistoryEntry entry) {
|
||||
_history.insert(0, entry);
|
||||
if (_history.length > 50) _history.removeLast();
|
||||
static List<FileHistoryEntry> get history => notifier.value;
|
||||
static bool get isEmpty => notifier.value.isEmpty;
|
||||
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
static Future<void> load(SharedPreferences prefs) async {
|
||||
_prefs = prefs;
|
||||
final raw = prefs.getString(_prefKey);
|
||||
if (raw == null) return;
|
||||
try {
|
||||
final list = jsonDecode(raw);
|
||||
if (list is! List) return;
|
||||
final entries = <FileHistoryEntry>[];
|
||||
for (final e in list) {
|
||||
if (e is Map) {
|
||||
final entry = FileHistoryEntry.fromJson(Map<String, dynamic>.from(e));
|
||||
if (entry != null) entries.add(entry);
|
||||
}
|
||||
}
|
||||
notifier.value = entries;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static bool get isEmpty => _history.isEmpty;
|
||||
static void add(FileHistoryEntry entry) {
|
||||
final next = [entry, ...notifier.value.where((e) => e.fileId != entry.fileId)];
|
||||
if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length);
|
||||
notifier.value = next;
|
||||
_persist();
|
||||
}
|
||||
|
||||
static void remove(int fileId) {
|
||||
final next = notifier.value.where((e) => e.fileId != fileId).toList();
|
||||
if (next.length == notifier.value.length) return;
|
||||
notifier.value = next;
|
||||
_persist();
|
||||
}
|
||||
|
||||
static void _persist() {
|
||||
final prefs = _prefs;
|
||||
if (prefs == null) return;
|
||||
final encoded = jsonEncode(notifier.value.map((e) => e.toJson()).toList());
|
||||
prefs.setString(_prefKey, encoded);
|
||||
}
|
||||
}
|
||||
|
||||
class FileUploadInfo {
|
||||
@@ -393,6 +462,8 @@ class MessagesModule {
|
||||
int fileId, {
|
||||
String? token,
|
||||
bool notify = true,
|
||||
int maxAttempts = 5,
|
||||
Duration retryDelay = const Duration(seconds: 1),
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
@@ -411,8 +482,16 @@ class MessagesModule {
|
||||
'notify': notify,
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
return response.isOk;
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (response.isOk) return true;
|
||||
final err = response.payload is Map ? response.payload['error'] : null;
|
||||
if (err != 'attachment.not.ready' || attempt == maxAttempts - 1) {
|
||||
return false;
|
||||
}
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||
@@ -564,6 +643,7 @@ class MessagesModule {
|
||||
ContactCache.putOptions(contactId, rawOpts.whereType<String>().toSet());
|
||||
}
|
||||
|
||||
ChatsModule.applyContactUpdate(contactId);
|
||||
return fullName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum BubbleBehavior { mutable, immutable }
|
||||
|
||||
class AppBubbleBehavior {
|
||||
static const prefKey = 'app_bubble_behavior';
|
||||
static final ValueNotifier<BubbleBehavior> current = ValueNotifier(
|
||||
BubbleBehavior.mutable,
|
||||
);
|
||||
|
||||
static Future<BubbleBehavior> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getString(prefKey);
|
||||
return _parse(val);
|
||||
}
|
||||
|
||||
static Future<void> save(BubbleBehavior behavior) async {
|
||||
current.value = behavior;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefKey, behavior.name);
|
||||
}
|
||||
|
||||
static BubbleBehavior _parse(String? val) {
|
||||
if (val == BubbleBehavior.immutable.name) return BubbleBehavior.immutable;
|
||||
return BubbleBehavior.mutable;
|
||||
}
|
||||
|
||||
static String label(BubbleBehavior behavior) {
|
||||
switch (behavior) {
|
||||
case BubbleBehavior.mutable:
|
||||
return 'Изменяемая';
|
||||
case BubbleBehavior.immutable:
|
||||
return 'Неизменяемая';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ const int _maxDecompressedSize = 1048576; // 1 MB
|
||||
|
||||
/// Типы команд в протоколе
|
||||
abstract class CmdType {
|
||||
static const int request = 0; // запрос клиента
|
||||
static const int push = 1; // пуш от сервера
|
||||
static const int request = 0; // запрос клиента / пуш от сервера (направление определяет смысл)
|
||||
static const int push = 0; // пуш от сервера (имеет смысл только для incoming)
|
||||
|
||||
static const int ok = 1; // ответ: ок
|
||||
static const int notFound = 2; // ответ: не найдено
|
||||
|
||||
@@ -159,7 +159,7 @@ class AppDatabase {
|
||||
final dbPath = await getDatabasesPath();
|
||||
return openDatabase(
|
||||
join(dbPath, 'komet.db'),
|
||||
version: 9,
|
||||
version: 10,
|
||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, _) => _createTables(db),
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
@@ -201,6 +201,14 @@ class AppDatabase {
|
||||
'ALTER TABLE chats_cache ADD COLUMN options TEXT',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 10) {
|
||||
await db.execute(
|
||||
'ALTER TABLE chats_cache ADD COLUMN owner INTEGER',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE chats_cache ADD COLUMN admins TEXT',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -272,6 +280,8 @@ class AppDatabase {
|
||||
seen_time INTEGER NOT NULL DEFAULT 0,
|
||||
participants TEXT NOT NULL DEFAULT "",
|
||||
options TEXT,
|
||||
owner INTEGER,
|
||||
admins TEXT,
|
||||
PRIMARY KEY (id, account_id)
|
||||
)
|
||||
''';
|
||||
@@ -461,6 +471,51 @@ class AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<int?> findDialogChatByParticipant(int accountId, int contactId) async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'chats_cache',
|
||||
columns: ['id'],
|
||||
where: "account_id = ? AND type = 'DIALOG' AND participants LIKE ?",
|
||||
whereArgs: [accountId, '%"$contactId":%'],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['id'] as int?;
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadDialogChats(int accountId) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
'chats_cache',
|
||||
where: "account_id = ? AND type = 'DIALOG'",
|
||||
whereArgs: [accountId],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadChatsByIds(
|
||||
int accountId,
|
||||
List<int> ids,
|
||||
) async {
|
||||
if (ids.isEmpty) return const [];
|
||||
final db = await _instance;
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
return db.query(
|
||||
'chats_cache',
|
||||
where: 'account_id = ? AND id IN ($placeholders)',
|
||||
whereArgs: [accountId, ...ids],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> deleteChat(int chatId, int accountId) async {
|
||||
final db = await _instance;
|
||||
await db.delete(
|
||||
'chats_cache',
|
||||
where: 'id = ? AND account_id = ?',
|
||||
whereArgs: [chatId, accountId],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> clearChatsCache(int accountId) async {
|
||||
final db = await _instance;
|
||||
await db.delete(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract class DeviceIdentity {
|
||||
static const String _instanceIdKey = 'mt_instance_id';
|
||||
static const String _deviceIdKey = 'device_id_local';
|
||||
|
||||
static final Random _rng = Random.secure();
|
||||
static int? _clientSessionId;
|
||||
|
||||
static int get clientSessionId =>
|
||||
_clientSessionId ??= _rng.nextInt(0x7FFFFFFF) + 1;
|
||||
|
||||
static Future<String> instanceId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final existing = prefs.getString(_instanceIdKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final generated = _uuidV4();
|
||||
await prefs.setString(_instanceIdKey, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
static Future<String> deviceId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final existing = prefs.getString(_deviceIdKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final generated = _hex(8);
|
||||
await prefs.setString(_deviceIdKey, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
static String _hex(int bytes) {
|
||||
final sb = StringBuffer();
|
||||
for (var i = 0; i < bytes; i++) {
|
||||
sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static String _uuidV4() {
|
||||
final b = List<int>.generate(16, (_) => _rng.nextInt(256));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
String h(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||
return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-'
|
||||
'${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}';
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'dart:typed_data';
|
||||
import '../config/proxy_config.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'proxy_connector.dart';
|
||||
import 'tls_config.dart';
|
||||
import 'vpn_bypass.dart';
|
||||
|
||||
enum SocketState { disconnected, connecting, connected }
|
||||
@@ -12,8 +13,8 @@ enum SocketState { disconnected, connecting, connected }
|
||||
/// Обёртка над TCP + TLS сокетом.
|
||||
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
|
||||
class Connection {
|
||||
RawSecureSocket? _socket;
|
||||
StreamSubscription<RawSocketEvent>? _subscription;
|
||||
SecureSocket? _socket;
|
||||
StreamSubscription<Uint8List>? _subscription;
|
||||
SocketState _state = SocketState.disconnected;
|
||||
|
||||
final _dataController = StreamController<Uint8List>.broadcast();
|
||||
@@ -62,18 +63,7 @@ class Connection {
|
||||
logger.i('Подключено к $host:$port');
|
||||
|
||||
_subscription = _socket!.listen(
|
||||
(event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final data = _socket?.read();
|
||||
if (data != null) {
|
||||
_dataController.add(data);
|
||||
}
|
||||
} else if (event == RawSocketEvent.readClosed ||
|
||||
event == RawSocketEvent.closed) {
|
||||
logger.w('Сокет закрыт сервером');
|
||||
disconnect();
|
||||
}
|
||||
},
|
||||
(data) => _dataController.add(data),
|
||||
onError: (Object error) {
|
||||
logger.e('Ошибка сокета: $error');
|
||||
disconnect();
|
||||
@@ -90,34 +80,41 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
Future<RawSecureSocket> _openSecureSocket(
|
||||
Future<SecureSocket> _openSecureSocket(
|
||||
String host,
|
||||
int port,
|
||||
ProxySettings proxySettings, {
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
RawSocket rawSocket;
|
||||
Socket socket;
|
||||
if (proxySettings.isEnabled) {
|
||||
final connector = ProxyConnector(proxySettings);
|
||||
rawSocket = await connector.connect(host, port);
|
||||
socket = await connector.connect(host, port);
|
||||
logger.i('Подключено через прокси ${proxySettings.type.name}');
|
||||
} else {
|
||||
rawSocket = timeout == null
|
||||
? await RawSocket.connect(host, port)
|
||||
: await RawSocket.connect(host, port, timeout: timeout);
|
||||
socket = timeout == null
|
||||
? await Socket.connect(host, port)
|
||||
: await Socket.connect(host, port, timeout: timeout);
|
||||
}
|
||||
return RawSecureSocket.secure(
|
||||
rawSocket,
|
||||
host: host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||
if (allowInsecure) {
|
||||
logger.w(
|
||||
'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM',
|
||||
);
|
||||
return SecureSocket.secure(
|
||||
socket,
|
||||
host: host,
|
||||
onBadCertificate: (_) => true,
|
||||
);
|
||||
}
|
||||
return SecureSocket.secure(socket, host: host);
|
||||
}
|
||||
|
||||
void write(Uint8List data) {
|
||||
if (_socket == null || !isConnected) {
|
||||
throw StateError('Нельзя писать: сокет не подключён');
|
||||
}
|
||||
_socket!.write(data);
|
||||
_socket!.add(data);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
@@ -128,7 +125,7 @@ class Connection {
|
||||
|
||||
if (socket != null) {
|
||||
try {
|
||||
socket.close();
|
||||
await socket.close();
|
||||
} catch (e) {
|
||||
logger.w('Ошибка при закрытии сокета: $e');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import '../protocol/packet.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
import '../utils/log_redact.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
typedef PacketHandler = void Function(Packet packet);
|
||||
@@ -53,12 +54,8 @@ class PacketDispatcher {
|
||||
if (packet.cmd == CmdType.ok ||
|
||||
packet.cmd == CmdType.error ||
|
||||
packet.cmd == CmdType.notFound) {
|
||||
final payloadStr = packet.payload.toString();
|
||||
final displayPayload = packet.opcode == Opcode.login && payloadStr.length > 50
|
||||
? '${payloadStr.substring(0, 50)}...'
|
||||
: payloadStr;
|
||||
logger.i(
|
||||
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $displayPayload}',
|
||||
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}',
|
||||
);
|
||||
|
||||
final completer = _pendingRequests.remove(packet.seq);
|
||||
@@ -84,7 +81,7 @@ class PacketDispatcher {
|
||||
}
|
||||
} else if (packet.isPush) {
|
||||
logger.i(
|
||||
'<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}',
|
||||
'<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}',
|
||||
);
|
||||
_pushHandlers[packet.opcode]?.call(packet);
|
||||
_pushController.add(packet);
|
||||
|
||||
@@ -6,28 +6,25 @@ import 'dart:typed_data';
|
||||
import '../config/proxy_config.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Устанавливает TCP-соединение через SOCKS5 или HTTP CONNECT прокси.
|
||||
/// Возвращает [RawSocket], который никогда не слушался —
|
||||
/// его можно передать в [RawSecureSocket.secure].
|
||||
class ProxyConnector {
|
||||
final ProxySettings settings;
|
||||
|
||||
ProxyConnector(this.settings);
|
||||
|
||||
Future<RawSocket> connect(String targetHost, int targetPort) async {
|
||||
Future<Socket> connect(String targetHost, int targetPort) async {
|
||||
switch (settings.type) {
|
||||
case ProxyType.socks5:
|
||||
return _connectSocks5(targetHost, targetPort);
|
||||
case ProxyType.httpConnect:
|
||||
return _connectHttpConnect(targetHost, targetPort);
|
||||
case ProxyType.none:
|
||||
return RawSocket.connect(targetHost, targetPort);
|
||||
return Socket.connect(targetHost, targetPort);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SOCKS5 (RFC 1928) ──────────────────────────────────────────────────
|
||||
|
||||
Future<RawSocket> _connectSocks5(String targetHost, int targetPort) async {
|
||||
Future<Socket> _connectSocks5(String targetHost, int targetPort) async {
|
||||
final proxySocket = await RawSocket.connect(settings.host, settings.port);
|
||||
logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}');
|
||||
|
||||
@@ -128,7 +125,7 @@ class ProxyConnector {
|
||||
|
||||
// ── HTTP CONNECT ────────────────────────────────────────────────────────
|
||||
|
||||
Future<RawSocket> _connectHttpConnect(
|
||||
Future<Socket> _connectHttpConnect(
|
||||
String targetHost,
|
||||
int targetPort,
|
||||
) async {
|
||||
@@ -194,19 +191,13 @@ class ProxyConnector {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Мост: создаём свежий сокет и проксируем через loopback ─────────────
|
||||
|
||||
/// После handshake proxy-сокет уже прослушан (single-subscription).
|
||||
/// Создаём пару локальных сокетов через loopback и проксируем данные
|
||||
/// между прокси-сокетом и одним концом. Второй конец возвращаем —
|
||||
/// он «свежий» и его можно передать в [RawSecureSocket.secure].
|
||||
Future<RawSocket> _bridgeToFreshSocket(
|
||||
Future<Socket> _bridgeToFreshSocket(
|
||||
RawSocket proxySocket,
|
||||
_RawSocketIO io,
|
||||
) async {
|
||||
RawServerSocket? server;
|
||||
ServerSocket? server;
|
||||
try {
|
||||
server = await RawServerSocket.bind(
|
||||
server = await ServerSocket.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
);
|
||||
@@ -215,31 +206,36 @@ class ProxyConnector {
|
||||
proxySocket.close();
|
||||
rethrow;
|
||||
}
|
||||
final clientSide = await RawSocket.connect(
|
||||
final clientFuture = Socket.connect(
|
||||
InternetAddress.loopbackIPv4,
|
||||
server.port,
|
||||
);
|
||||
final serverSide = await server.first;
|
||||
final clientSide = await clientFuture;
|
||||
await server.close();
|
||||
|
||||
// proxy → local (через уже имеющуюся подписку _RawSocketIO)
|
||||
io.onData = (data) {
|
||||
serverSide.write(data);
|
||||
serverSide.add(data);
|
||||
};
|
||||
io.onClosed = () {
|
||||
serverSide.shutdown(SocketDirection.send);
|
||||
serverSide.close();
|
||||
};
|
||||
|
||||
// local → proxy
|
||||
serverSide.listen((event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final data = serverSide.read();
|
||||
if (data != null) proxySocket.write(data);
|
||||
} else if (event == RawSocketEvent.readClosed ||
|
||||
event == RawSocketEvent.closed) {
|
||||
serverSide.listen(
|
||||
(data) {
|
||||
unawaited(io.write(data).catchError((Object _) {
|
||||
try {
|
||||
serverSide.destroy();
|
||||
} catch (_) {}
|
||||
}));
|
||||
},
|
||||
onError: (Object _) {
|
||||
proxySocket.shutdown(SocketDirection.send);
|
||||
}
|
||||
});
|
||||
},
|
||||
onDone: () {
|
||||
proxySocket.shutdown(SocketDirection.send);
|
||||
},
|
||||
);
|
||||
|
||||
// Сливаем данные, буферизованные во время handshake
|
||||
io.flushBuffered();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../protocol/packet.dart';
|
||||
import '../utils/log_redact.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'connection.dart';
|
||||
|
||||
@@ -17,7 +18,7 @@ class PacketSender {
|
||||
final data = packPacket(opcode, payload, seq: seq);
|
||||
connection.write(data);
|
||||
logger.i(
|
||||
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: $payload}',
|
||||
'=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${redactForLog(payload)}}',
|
||||
);
|
||||
return seq;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract class TlsConfig {
|
||||
static const String prefKey = 'dev_tls_insecure';
|
||||
|
||||
static Future<bool> isInsecureAllowed() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setInsecureAllowed(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../config/app_bubble_behavior.dart';
|
||||
import '../config/app_bubble_shape.dart';
|
||||
|
||||
const double kBubbleBigRadius = 20;
|
||||
const double kBubbleSmallRadius = 4;
|
||||
|
||||
const Radius _big = Radius.circular(kBubbleBigRadius);
|
||||
const Radius _small = Radius.circular(kBubbleSmallRadius);
|
||||
|
||||
BorderRadius computeBubbleRadius({
|
||||
required bool isMe,
|
||||
required bool isTop,
|
||||
required bool isBottom,
|
||||
required BubbleStyle style,
|
||||
required BubbleBehavior behavior,
|
||||
bool hasPhotoWithCaption = false,
|
||||
bool hasMultiplePhotosNoCaption = false,
|
||||
}) {
|
||||
final isSingle = isTop && isBottom;
|
||||
|
||||
if (hasPhotoWithCaption && (isTop || isBottom)) {
|
||||
return BorderRadius.only(
|
||||
topLeft: _big,
|
||||
topRight: _big,
|
||||
bottomLeft: isMe ? _big : _small,
|
||||
bottomRight: _small,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasMultiplePhotosNoCaption && isBottom) {
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? _big : _small,
|
||||
topRight: _small,
|
||||
bottomLeft: isMe ? _big : _small,
|
||||
bottomRight: isMe ? _small : _big,
|
||||
);
|
||||
}
|
||||
|
||||
final base = style == BubbleStyle.desktop ? _small : _big;
|
||||
Radius tl = base, tr = base, bl = base, br = base;
|
||||
|
||||
if (behavior == BubbleBehavior.immutable || isSingle) {
|
||||
return BorderRadius.only(
|
||||
topLeft: tl,
|
||||
topRight: tr,
|
||||
bottomLeft: bl,
|
||||
bottomRight: br,
|
||||
);
|
||||
}
|
||||
|
||||
if (isTop) {
|
||||
if (isMe) {
|
||||
br = _small;
|
||||
} else {
|
||||
bl = _small;
|
||||
}
|
||||
} else if (isBottom) {
|
||||
if (isMe) {
|
||||
tr = _small;
|
||||
} else {
|
||||
tl = _small;
|
||||
}
|
||||
} else {
|
||||
if (isMe) {
|
||||
tr = _small;
|
||||
br = _small;
|
||||
} else {
|
||||
tl = _small;
|
||||
bl = _small;
|
||||
}
|
||||
}
|
||||
|
||||
return BorderRadius.only(
|
||||
topLeft: tl,
|
||||
topRight: tr,
|
||||
bottomLeft: bl,
|
||||
bottomRight: br,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const _redacted = '***';
|
||||
|
||||
const _sensitiveSubstrings = ['password', 'token', 'phone', 'secret'];
|
||||
|
||||
const _sensitiveExact = {
|
||||
'code',
|
||||
'verifycode',
|
||||
'smscode',
|
||||
'otp',
|
||||
'hint',
|
||||
'pin',
|
||||
'qrlink',
|
||||
'text',
|
||||
'msisdn',
|
||||
};
|
||||
|
||||
bool _isSensitiveKey(Object? key) {
|
||||
if (key is! String) return false;
|
||||
final k = key.toLowerCase();
|
||||
if (_sensitiveExact.contains(k)) return true;
|
||||
for (final s in _sensitiveSubstrings) {
|
||||
if (k.contains(s)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
dynamic redactForLog(dynamic value) {
|
||||
if (value is Map) {
|
||||
final out = {};
|
||||
value.forEach((k, v) {
|
||||
out[k] = _isSensitiveKey(k) ? _redacted : redactForLog(v);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(redactForLog).toList();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
@@ -47,6 +48,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
|
||||
int _myId = 0;
|
||||
bool _isLoading = true;
|
||||
bool _extraContactExpanded = false;
|
||||
Map<String, dynamic>? _chatData;
|
||||
String _selectedTab = '';
|
||||
bool _descExpanded = false;
|
||||
@@ -56,6 +58,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
Map<String, dynamic>? _contactData;
|
||||
int? _seenTime;
|
||||
bool _isOnline = false;
|
||||
int _presenceStatus = 0;
|
||||
bool _isBot = false;
|
||||
|
||||
// CHAT
|
||||
@@ -142,7 +145,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
final p = presence?[_otherId.toString()] ?? presence?[_otherId];
|
||||
if (p is Map) {
|
||||
_seenTime = p['seen'] as int?;
|
||||
_isOnline = ((p['status'] as int?) ?? 0) > 0;
|
||||
final st = (p['status'] as int?) ?? 0;
|
||||
_presenceStatus = st;
|
||||
_isOnline = st == 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +186,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
_onlineCount = 0;
|
||||
_members = memberIds.map((id) {
|
||||
final pres = presenceMap[id];
|
||||
final online = ((pres?['status'] as int?) ?? 0) > 0;
|
||||
final online = (pres?['status'] as int?) == 1;
|
||||
if (online) _onlineCount++;
|
||||
final isAdmin =
|
||||
admins.containsKey(id.toString()) || admins.containsKey(id);
|
||||
@@ -326,7 +331,10 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
case 'DIALOG':
|
||||
if (_isBot) return 'Бот';
|
||||
if (_isOnline) return 'В сети';
|
||||
if (_seenTime != null) return _formatLastSeen(_seenTime!);
|
||||
if (_presenceStatus == 3) return 'был(-а) недавно';
|
||||
if (_seenTime != null && _seenTime! > 0) {
|
||||
return 'был(-а) ${_formatLastSeen(_seenTime!)}';
|
||||
}
|
||||
return '';
|
||||
case 'CHAT':
|
||||
final total =
|
||||
@@ -921,34 +929,121 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13));
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < rows.length; i++) ...[
|
||||
_infoRow(cs, rows[i].label, rows[i].value),
|
||||
if (i < rows.length - 1)
|
||||
Divider(
|
||||
height: 10,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
final extraRows = _buildExtraContactRows();
|
||||
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < rows.length; i++) ...[
|
||||
_infoRow(
|
||||
cs,
|
||||
rows[i].label,
|
||||
rows[i].value,
|
||||
trailing: _trailingFor(rows[i].label, cs),
|
||||
),
|
||||
if (i < rows.length - 1 || (_extraContactExpanded && extraRows.isNotEmpty))
|
||||
Divider(
|
||||
height: 10,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
],
|
||||
if (_extraContactExpanded)
|
||||
for (int i = 0; i < extraRows.length; i++) ...[
|
||||
_infoRow(cs, extraRows[i].label, extraRows[i].value),
|
||||
if (i < extraRows.length - 1)
|
||||
Divider(
|
||||
height: 10,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(ColorScheme cs, String label, String value) {
|
||||
List<({String label, String value})> _buildExtraContactRows() {
|
||||
final c = _contactData;
|
||||
if (c == null) return const [];
|
||||
final rows = <({String label, String value})>[];
|
||||
final reg = c['registrationTime'];
|
||||
if (reg is int && reg > 0) {
|
||||
rows.add((label: 'Регистрация', value: _formatTs(reg)));
|
||||
}
|
||||
final upd = c['updateTime'];
|
||||
if (upd is int && upd > 0) {
|
||||
rows.add((label: 'Обновлён', value: _formatTs(upd)));
|
||||
}
|
||||
final country = c['country'];
|
||||
if (country is String && country.isNotEmpty) {
|
||||
rows.add((label: 'Страна', value: country));
|
||||
}
|
||||
final gender = c['gender'];
|
||||
if (gender is int) {
|
||||
final g = gender == 1 ? 'Мужской' : (gender == 2 ? 'Женский' : null);
|
||||
if (g != null) rows.add((label: 'Пол', value: g));
|
||||
}
|
||||
final phone = c['phone'];
|
||||
if (phone is int && phone > 0) {
|
||||
rows.add((label: 'Телефон', value: '+$phone'));
|
||||
} else if (phone is String && phone.isNotEmpty && phone != '***') {
|
||||
rows.add((label: 'Телефон', value: phone));
|
||||
}
|
||||
final accStatus = c['accountStatus'];
|
||||
if (accStatus is int && accStatus != 0) {
|
||||
rows.add((label: 'Статус аккаунта', value: accStatus.toString()));
|
||||
}
|
||||
final opts = c['options'];
|
||||
if (opts is List && opts.isNotEmpty) {
|
||||
rows.add((label: 'Флаги', value: opts.whereType<String>().join(', ')));
|
||||
}
|
||||
final link = c['link'];
|
||||
if (link is String && link.isNotEmpty) {
|
||||
rows.add((label: 'Ссылка', value: link));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
Widget? _trailingFor(String label, ColorScheme cs) {
|
||||
if (label != 'ID чата') return null;
|
||||
if (widget.chatType != 'DIALOG') return null;
|
||||
if (_contactData == null) return null;
|
||||
return IconButton(
|
||||
tooltip: _extraContactExpanded ? 'Скрыть' : 'Подробнее',
|
||||
icon: AnimatedRotation(
|
||||
turns: _extraContactExpanded ? 0.125 : 0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
child: Icon(Symbols.add_circle, color: cs.primary, size: 22),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: () => setState(() => _extraContactExpanded = !_extraContactExpanded),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(ColorScheme cs, String label, String value, {Widget? trailing}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500)),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -986,8 +1081,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
|
||||
|
||||
// ─── HELPERS ─────────────────────────────────────────────────────────────
|
||||
|
||||
String _formatLastSeen(int ms) {
|
||||
final diff = DateTime.now().millisecondsSinceEpoch - ms;
|
||||
String _formatLastSeen(int secondsSinceEpoch) {
|
||||
final diff = DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000;
|
||||
if (diff < 60000) return 'только что';
|
||||
if (diff < 3600000) return '${diff ~/ 60000} мин назад';
|
||||
if (diff < 86400000) return '${diff ~/ 3600000} ч назад';
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'dart:math';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'create_group_flow.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
@@ -60,6 +62,8 @@ class ChatListScreen extends StatefulWidget {
|
||||
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||
}
|
||||
|
||||
enum _DeleteKind { personalLike, ownerGroup, blocked }
|
||||
|
||||
class _ChatListScreenState extends State<ChatListScreen>
|
||||
with TickerProviderStateMixin {
|
||||
String? _selectedFolderId;
|
||||
@@ -167,6 +171,216 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
});
|
||||
}
|
||||
|
||||
List<CachedChat> _selectedChatObjects() {
|
||||
if (_selectedChats.isEmpty) return const [];
|
||||
final ids = _selectedChats;
|
||||
return _chats.where((c) => ids.contains(c.id.toString())).toList();
|
||||
}
|
||||
|
||||
_DeleteKind _categorizeChat(CachedChat c, int myId) {
|
||||
if (c.type == 'DIALOG') return _DeleteKind.personalLike;
|
||||
if (c.iAmAdmin(myId)) return _DeleteKind.ownerGroup;
|
||||
return _DeleteKind.blocked;
|
||||
}
|
||||
|
||||
_DeleteKind? _selectionDeleteCategoryFor(List<CachedChat> selected) {
|
||||
if (_sessionState != SessionState.online) return null;
|
||||
final myId = _profile?.id;
|
||||
if (myId == null) return null;
|
||||
if (selected.isEmpty) return null;
|
||||
final cats = selected.map((c) => _categorizeChat(c, myId)).toSet();
|
||||
if (cats.contains(_DeleteKind.blocked)) return null;
|
||||
if (cats.length > 1) return null;
|
||||
return cats.single;
|
||||
}
|
||||
|
||||
Future<void> _onPinTap() async {
|
||||
final selected = _selectedChatObjects();
|
||||
if (selected.isEmpty) return;
|
||||
final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0);
|
||||
final err = await ChatsModule.togglePin(
|
||||
api,
|
||||
chatIds: selected.map((c) => c.id).toList(),
|
||||
pin: !anyPinned,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (err != null) showCustomNotification(context, err);
|
||||
_clearSelection();
|
||||
}
|
||||
|
||||
Future<void> _onMuteTap() async {
|
||||
final selected = _selectedChatObjects();
|
||||
if (selected.isEmpty) return;
|
||||
final anyMuted = selected.any((c) => c.isMuted);
|
||||
final targetDDU = anyMuted ? ChatsModule.muteOff : ChatsModule.muteForever;
|
||||
|
||||
final errors = <String>[];
|
||||
for (final c in selected) {
|
||||
final err = await ChatsModule.setChatMute(
|
||||
api,
|
||||
chatId: c.id,
|
||||
dontDisturbUntil: targetDDU,
|
||||
);
|
||||
if (err != null) errors.add(err);
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (errors.isNotEmpty) {
|
||||
showCustomNotification(
|
||||
context,
|
||||
errors.length == 1
|
||||
? errors.first
|
||||
: 'Не удалось изменить ${errors.length} чат(ов): ${errors.first}',
|
||||
);
|
||||
}
|
||||
_clearSelection();
|
||||
}
|
||||
|
||||
Future<void> _onDeleteTap() async {
|
||||
final selectedBefore = _selectedChatObjects();
|
||||
if (selectedBefore.isEmpty) return;
|
||||
final myId = _profile?.id;
|
||||
if (myId == null) return;
|
||||
|
||||
await ChatsModule.refreshChats(api, selectedBefore.map((c) => c.id).toList());
|
||||
if (!mounted) return;
|
||||
|
||||
final selectedAfter = _selectedChatObjects();
|
||||
if (selectedAfter.isEmpty) return;
|
||||
final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet();
|
||||
if (cats.contains(_DeleteKind.blocked) || cats.length > 1) {
|
||||
showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз');
|
||||
return;
|
||||
}
|
||||
final kind = cats.single;
|
||||
|
||||
final confirmed = await _showDeleteConfirmDialog(selectedAfter, kind);
|
||||
if (!mounted || confirmed != true) return;
|
||||
|
||||
final errors = <String>[];
|
||||
for (final c in selectedAfter) {
|
||||
final forAll = kind == _DeleteKind.ownerGroup;
|
||||
final err = await ChatsModule.deleteChat(
|
||||
api,
|
||||
chatId: c.id,
|
||||
lastEventTime: c.lastEventTime,
|
||||
forAll: forAll,
|
||||
);
|
||||
if (err != null) errors.add(err);
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (errors.isNotEmpty) {
|
||||
final msg = errors.length == 1
|
||||
? errors.first
|
||||
: 'Не удалось удалить ${errors.length} чат(ов): ${errors.first}';
|
||||
showCustomNotification(context, msg);
|
||||
}
|
||||
_clearSelection();
|
||||
}
|
||||
|
||||
Future<bool?> _showDeleteConfirmDialog(
|
||||
List<CachedChat> selected,
|
||||
_DeleteKind kind,
|
||||
) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final count = selected.length;
|
||||
final single = count == 1 ? selected.first : null;
|
||||
|
||||
String title;
|
||||
String body;
|
||||
String primaryLabel;
|
||||
switch (kind) {
|
||||
case _DeleteKind.personalLike:
|
||||
title = single != null
|
||||
? 'Удалить чат с ${single.title ?? ''}?'
|
||||
: 'Удалить $count чатов?';
|
||||
body = 'Восстановить переписку не получится';
|
||||
primaryLabel = count == 1 ? 'Удалить чат' : 'Удалить';
|
||||
case _DeleteKind.ownerGroup:
|
||||
title = single != null
|
||||
? 'Хотите удалить чат «${single.title ?? ''}»?'
|
||||
: 'Удалить $count групп у всех?';
|
||||
body = single != null
|
||||
? 'Передайте права владельца, чтобы остальные участники могли продолжить общение'
|
||||
: 'Действие нельзя отменить';
|
||||
primaryLabel = count == 1 ? 'Удалить чат у всех' : 'Удалить у всех';
|
||||
case _DeleteKind.blocked:
|
||||
return Future.value(false);
|
||||
}
|
||||
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
body,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (kind == _DeleteKind.ownerGroup && single != null) ...[
|
||||
Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
'Передать права и выйти',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface.withValues(alpha: 0.4),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx, true),
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
primaryLabel,
|
||||
style: TextStyle(
|
||||
color: cs.onError,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _isInitialLoading = true;
|
||||
DateTime _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@@ -244,9 +458,14 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
_reloadChatsAndFolders();
|
||||
}
|
||||
});
|
||||
ChatsModule.chatsChanged.addListener(_onChatsChanged);
|
||||
_reloadChatsAndFolders();
|
||||
}
|
||||
|
||||
void _onChatsChanged() {
|
||||
if (mounted) _reloadChatsAndFolders();
|
||||
}
|
||||
|
||||
Future<void> _reloadChatsAndFolders() async {
|
||||
final p = await AppDatabase.loadActiveProfile();
|
||||
if (p == null) {
|
||||
@@ -387,7 +606,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
if (!mounted) return;
|
||||
_contactRebuildTimer?.cancel();
|
||||
_contactRebuildTimer = Timer(const Duration(milliseconds: 120), () {
|
||||
if (mounted) setState(() {});
|
||||
if (!mounted) return;
|
||||
_cachedChatsBody = null;
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -700,6 +921,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ChatsModule.chatsChanged.removeListener(_onChatsChanged);
|
||||
_loginSub?.cancel();
|
||||
_stateSub?.cancel();
|
||||
_fabController.dispose();
|
||||
@@ -1146,7 +1368,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
avatar ?? "",
|
||||
isOnline: chat.isOnline,
|
||||
unreadCount: chat.unreadCount,
|
||||
isMuted: chat.dontDisturbUntil > 0,
|
||||
isMuted: chat.isMuted,
|
||||
isVerified: isVerified,
|
||||
isPinned: isPinned,
|
||||
chatType: "DIALOG",
|
||||
@@ -1176,7 +1398,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
: '',
|
||||
isOnline: chat.isOnline,
|
||||
unreadCount: chat.unreadCount,
|
||||
isMuted: chat.dontDisturbUntil > 0,
|
||||
isMuted: chat.isMuted,
|
||||
isVerified: chat.isOfficial,
|
||||
isPinned: isPinned,
|
||||
chatType: chat.type,
|
||||
@@ -1621,36 +1843,53 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_selectedChats.length.toString(),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: Builder(builder: (_) {
|
||||
final selected = _selectedChatObjects();
|
||||
final deleteCategory = _selectionDeleteCategoryFor(selected);
|
||||
final anyMuted = selected.any((c) => c.isMuted);
|
||||
final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.archive, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.volume_off, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_selectedChats.length.toString(),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (deleteCategory != null)
|
||||
IconButton(
|
||||
icon: Icon(Symbols.delete, color: cs.onSurface),
|
||||
onPressed: _onDeleteTap,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.archive, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
anyPinned ? Symbols.keep_off : Symbols.keep,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
onPressed: selected.isEmpty ? null : _onPinTap,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
anyMuted ? Symbols.volume_up : Symbols.volume_off,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
onPressed: selected.isEmpty ? null : _onMuteTap,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2086,7 +2325,14 @@ Navigator.push(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_buildFabMenuItem(Symbols.group_add, 'Создать группу'),
|
||||
_buildFabMenuItem(
|
||||
Symbols.group_add,
|
||||
'Создать группу',
|
||||
onTap: () {
|
||||
_toggleFab();
|
||||
showCreateGroupFlow(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildFabMenuItem(Symbols.campaign, 'Создать канал'),
|
||||
const SizedBox(height: 4),
|
||||
@@ -2095,7 +2341,7 @@ Navigator.push(
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFabMenuItem(IconData icon, String title) {
|
||||
Widget _buildFabMenuItem(IconData icon, String title, {VoidCallback? onTap}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: 220,
|
||||
@@ -2111,9 +2357,7 @@ Navigator.push(
|
||||
],
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Action logic here
|
||||
},
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,596 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../backend/modules/contacts.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
const int _maxAvatarBytes = 8 * 1024 * 1024;
|
||||
|
||||
Future<void> showCreateGroupFlow(BuildContext context) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => const _CreateGroupFlow(),
|
||||
);
|
||||
}
|
||||
|
||||
enum _Step { pickParticipants, groupDetails }
|
||||
|
||||
class _CreateGroupFlow extends StatefulWidget {
|
||||
const _CreateGroupFlow();
|
||||
|
||||
@override
|
||||
State<_CreateGroupFlow> createState() => _CreateGroupFlowState();
|
||||
}
|
||||
|
||||
class _CreateGroupFlowState extends State<_CreateGroupFlow> {
|
||||
_Step _step = _Step.pickParticipants;
|
||||
List<CachedContact> _all = [];
|
||||
final List<CachedContact> _selected = [];
|
||||
bool _loading = true;
|
||||
final TextEditingController _search = TextEditingController();
|
||||
final TextEditingController _title = TextEditingController();
|
||||
File? _avatar;
|
||||
bool _creating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadContacts();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
_title.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadContacts() async {
|
||||
try {
|
||||
final myId = await TokenStorage.getActiveAccountId();
|
||||
if (myId == null) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
final list = await ContactsModule.getContacts(myId);
|
||||
list.removeWhere((c) => c.id == myId);
|
||||
list.sort((a, b) => _displayName(a).toLowerCase().compareTo(_displayName(b).toLowerCase()));
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_all = list;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggle(CachedContact c) {
|
||||
setState(() {
|
||||
final idx = _selected.indexWhere((x) => x.id == c.id);
|
||||
if (idx >= 0) {
|
||||
_selected.removeAt(idx);
|
||||
} else {
|
||||
_selected.add(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isSelected(int id) => _selected.any((c) => c.id == id);
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
if (_creating) return;
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.image);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final path = result.files.first.path;
|
||||
if (path == null) return;
|
||||
final file = File(path);
|
||||
final size = await file.length();
|
||||
if (size > _maxAvatarBytes) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)');
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _avatar = file);
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final title = _title.text.trim();
|
||||
if (title.isEmpty || _creating) return;
|
||||
setState(() => _creating = true);
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
try {
|
||||
final chat = await ChatsModule.createGroupChat(
|
||||
api,
|
||||
title: title,
|
||||
userIds: _selected.map((c) => c.id).toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (chat == null) {
|
||||
showCustomNotification(context, 'Не удалось создать группу');
|
||||
setState(() => _creating = false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_avatar != null) {
|
||||
final url = await ChatsModule.requestChatPhotoUploadUrl(api);
|
||||
if (url != null) {
|
||||
final bytes = await _avatar!.readAsBytes();
|
||||
final token = await fileUploader.uploadImage(
|
||||
Uri.parse(url),
|
||||
bytes,
|
||||
filename: _avatar!.uri.pathSegments.last,
|
||||
);
|
||||
if (token != null) {
|
||||
await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token);
|
||||
} else if (mounted) {
|
||||
showCustomNotification(context, 'Не удалось загрузить аватарку');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
navigator.pop();
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
chatId: chat.id,
|
||||
name: chat.title ?? title,
|
||||
imageUrl: chat.iconUrl ?? '',
|
||||
chatType: chat.type,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
setState(() => _creating = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _displayName(CachedContact c) {
|
||||
final last = c.lastName ?? '';
|
||||
return last.isEmpty ? c.firstName : '${c.firstName} $last';
|
||||
}
|
||||
|
||||
String _statusText(CachedContact c) => c.isBot ? 'Бот' : 'Был(-а) недавно';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final viewInsets = MediaQuery.of(context).viewInsets;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, anim) {
|
||||
final offset = child.key == const ValueKey(_Step.pickParticipants)
|
||||
? Offset(-0.05, 0)
|
||||
: Offset(0.05, 0);
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(begin: offset, end: Offset.zero).animate(anim),
|
||||
child: FadeTransition(opacity: anim, child: child),
|
||||
);
|
||||
},
|
||||
child: _step == _Step.pickParticipants
|
||||
? KeyedSubtree(
|
||||
key: const ValueKey(_Step.pickParticipants),
|
||||
child: _buildPickerStep(),
|
||||
)
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey(_Step.groupDetails),
|
||||
child: _buildDetailsStep(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPickerStep() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final query = _search.text.trim().toLowerCase();
|
||||
final filtered = query.isEmpty
|
||||
? _all
|
||||
: _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList();
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Выберите участников',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_selected.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final c in _selected)
|
||||
_SelectedChip(
|
||||
contact: c,
|
||||
label: _displayName(c),
|
||||
onRemove: () => _toggle(c),
|
||||
cs: cs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: TextField(
|
||||
controller: _search,
|
||||
onChanged: (_) => setState(() {}),
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Найти по имени',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
prefixIcon: Icon(Symbols.search, color: cs.onSurfaceVariant, size: 20),
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: _loading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final c = filtered[i];
|
||||
final picked = _isSelected(c.id);
|
||||
final dim = c.isBot;
|
||||
return InkWell(
|
||||
onTap: () => _toggle(c),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_Avatar(contact: c, size: 40, cs: cs),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_displayName(c),
|
||||
style: TextStyle(
|
||||
color: dim
|
||||
? cs.onSurface.withValues(alpha: 0.5)
|
||||
: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_statusText(c),
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (picked)
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Symbols.check, color: cs.onPrimary, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Далее',
|
||||
filled: true,
|
||||
onTap: () => setState(() => _step = _Step.groupDetails),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailsStep() {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final canCreate = _title.text.trim().isNotEmpty && !_creating;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _creating
|
||||
? null
|
||||
: () => setState(() => _step = _Step.pickParticipants),
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Создать группу',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _creating ? null : () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _avatar != null
|
||||
? Image.file(_avatar!, fit: BoxFit.cover)
|
||||
: Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _title,
|
||||
onChanged: (_) => setState(() {}),
|
||||
enabled: !_creating,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Название группы',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: 'Отменить',
|
||||
filled: false,
|
||||
onTap: _creating ? null : () => Navigator.pop(context),
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SheetButton(
|
||||
label: _creating ? 'Создаю...' : 'Создать',
|
||||
filled: true,
|
||||
onTap: canCreate ? _create : null,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final CachedContact contact;
|
||||
final double size;
|
||||
final ColorScheme cs;
|
||||
const _Avatar({required this.contact, required this.size, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = contact.baseUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
return ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => _initials(cs, size),
|
||||
errorWidget: (_, _, _) => _initials(cs, size),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _initials(cs, size);
|
||||
}
|
||||
|
||||
Widget _initials(ColorScheme cs, double size) {
|
||||
final initial = contact.firstName.isNotEmpty
|
||||
? contact.firstName[0].toUpperCase()
|
||||
: '?';
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(color: cs.primaryContainer, shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectedChip extends StatelessWidget {
|
||||
final CachedContact contact;
|
||||
final String label;
|
||||
final VoidCallback onRemove;
|
||||
final ColorScheme cs;
|
||||
const _SelectedChip({
|
||||
required this.contact,
|
||||
required this.label,
|
||||
required this.onRemove,
|
||||
required this.cs,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 12, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Avatar(contact: contact, size: 24, cs: cs),
|
||||
const SizedBox(width: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 140),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetButton extends StatelessWidget {
|
||||
final String label;
|
||||
final bool filled;
|
||||
final VoidCallback? onTap;
|
||||
final ColorScheme cs;
|
||||
const _SheetButton({
|
||||
required this.label,
|
||||
required this.filled,
|
||||
required this.onTap,
|
||||
required this.cs,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = onTap == null;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
? (disabled ? cs.primary.withValues(alpha: 0.4) : cs.primary)
|
||||
: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: filled
|
||||
? cs.onPrimary
|
||||
: (disabled
|
||||
? cs.onSurface.withValues(alpha: 0.4)
|
||||
: cs.onSurface),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
import '../chats/chat_screen.dart';
|
||||
|
||||
class ContactProfileScreen extends StatefulWidget {
|
||||
final int contactId;
|
||||
final String? initialName;
|
||||
final String? initialAvatarUrl;
|
||||
|
||||
const ContactProfileScreen({
|
||||
super.key,
|
||||
required this.contactId,
|
||||
this.initialName,
|
||||
this.initialAvatarUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ContactProfileScreen> createState() => _ContactProfileScreenState();
|
||||
}
|
||||
|
||||
class _ContactProfileScreenState extends State<ContactProfileScreen> {
|
||||
bool _loading = true;
|
||||
Map<String, dynamic>? _contact;
|
||||
int? _seenTime;
|
||||
int _presenceStatus = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}),
|
||||
api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
final infoPacket = results[0];
|
||||
if (infoPacket.isOk) {
|
||||
final contacts = (infoPacket.payload as Map?)?['contacts'] as List?;
|
||||
if (contacts != null && contacts.isNotEmpty) {
|
||||
_contact = Map<String, dynamic>.from(contacts.first as Map);
|
||||
}
|
||||
}
|
||||
final presencePacket = results[1];
|
||||
if (presencePacket.isOk) {
|
||||
final presence = (presencePacket.payload as Map?)?['presence'] as Map?;
|
||||
final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId];
|
||||
if (p is Map) {
|
||||
_seenTime = p['seen'] as int?;
|
||||
_presenceStatus = (p['status'] as int?) ?? 0;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _displayName() {
|
||||
final c = _contact;
|
||||
if (c != null) {
|
||||
final names = c['names'];
|
||||
if (names is List && names.isNotEmpty) {
|
||||
final n = names.first;
|
||||
if (n is Map) {
|
||||
final full = n['name']?.toString();
|
||||
if (full != null && full.isNotEmpty) return full;
|
||||
final first = n['firstName']?.toString() ?? '';
|
||||
final last = n['lastName']?.toString() ?? '';
|
||||
final combined = '$first $last'.trim();
|
||||
if (combined.isNotEmpty) return combined;
|
||||
}
|
||||
}
|
||||
}
|
||||
return widget.initialName ?? 'User #${widget.contactId}';
|
||||
}
|
||||
|
||||
String? _avatarUrl() {
|
||||
return (_contact?['baseUrl'] as String?) ?? widget.initialAvatarUrl;
|
||||
}
|
||||
|
||||
Set<String> _options() {
|
||||
final raw = _contact?['options'];
|
||||
if (raw is List) return raw.whereType<String>().toSet();
|
||||
return const {};
|
||||
}
|
||||
|
||||
bool get _isBot => _options().contains('BOT');
|
||||
bool get _isVerified => _options().contains('OFFICIAL');
|
||||
|
||||
String _subtitle() {
|
||||
if (_isBot) return 'Бот';
|
||||
if (_presenceStatus == 1) return 'В сети';
|
||||
if (_presenceStatus == 3) return 'Был(-а) недавно';
|
||||
if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!);
|
||||
return '';
|
||||
}
|
||||
|
||||
String _formatLastSeen(int secondsSinceEpoch) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
if (diff.inMinutes < 2) return 'Был(-а) только что';
|
||||
if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад';
|
||||
if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад';
|
||||
if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад';
|
||||
return 'Был(-а) ${_formatDate(dt)}';
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
const months = [
|
||||
'янв', 'фев', 'мар', 'апр', 'мая', 'июн',
|
||||
'июл', 'авг', 'сен', 'окт', 'ноя', 'дек',
|
||||
];
|
||||
return '${dt.day} ${months[dt.month - 1]} ${dt.year}';
|
||||
}
|
||||
|
||||
String _formatDateTime(int msSinceEpoch) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch);
|
||||
final hh = dt.hour.toString().padLeft(2, '0');
|
||||
final mm = dt.minute.toString().padLeft(2, '0');
|
||||
return '${_formatDate(dt)}, $hh:$mm';
|
||||
}
|
||||
|
||||
String? _formatPhone(dynamic raw) {
|
||||
String? digits;
|
||||
if (raw is int && raw > 0) {
|
||||
digits = raw.toString();
|
||||
} else if (raw is String && raw.isNotEmpty && raw != '***') {
|
||||
digits = raw.replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.isEmpty) return null;
|
||||
}
|
||||
if (digits == null) return null;
|
||||
if (digits.length == 11 && digits.startsWith('7')) {
|
||||
final p = digits;
|
||||
return '+${p[0]} (${p.substring(1, 4)}) ${p.substring(4, 7)}-${p.substring(7, 9)}-${p.substring(9)}';
|
||||
}
|
||||
return '+$digits';
|
||||
}
|
||||
|
||||
String? _formatGender(dynamic raw) {
|
||||
if (raw is! int) return null;
|
||||
switch (raw) {
|
||||
case 1:
|
||||
return 'Мужской';
|
||||
case 2:
|
||||
return 'Женский';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openChat() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return;
|
||||
final existing = await AppDatabase.findDialogChatByParticipant(
|
||||
accountId,
|
||||
widget.contactId,
|
||||
);
|
||||
final chatId = existing ?? (accountId ^ widget.contactId);
|
||||
if (!mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatScreen(
|
||||
chatId: chatId,
|
||||
name: _displayName(),
|
||||
imageUrl: _avatarUrl() ?? '',
|
||||
chatType: 'DIALOG',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildBody(cs),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(ColorScheme cs) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
floating: true,
|
||||
leading: IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAvatar(cs),
|
||||
const SizedBox(height: 14),
|
||||
_buildNameRow(cs),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_subtitle(),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildActions(cs),
|
||||
const SizedBox(height: 16),
|
||||
_buildInfoCard(cs),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(ColorScheme cs) {
|
||||
final url = _avatarUrl();
|
||||
return Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.primaryContainer,
|
||||
),
|
||||
child: (url != null && url.isNotEmpty)
|
||||
? ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, _, _) => _avatarLetters(cs),
|
||||
),
|
||||
)
|
||||
: _avatarLetters(cs),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _avatarLetters(ColorScheme cs) {
|
||||
final name = _displayName();
|
||||
return Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNameRow(ColorScheme cs) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
_displayName(),
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (_isVerified) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Symbols.verified,
|
||||
color: cs.primary,
|
||||
size: 20,
|
||||
fill: 1,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions(ColorScheme cs) {
|
||||
final actions = <({IconData icon, String label, VoidCallback? onTap})>[
|
||||
(icon: Symbols.chat_bubble, label: 'Чат', onTap: _openChat),
|
||||
(icon: Symbols.notifications, label: 'Звук', onTap: null),
|
||||
if (!_isBot)
|
||||
(icon: Symbols.call, label: 'Звонок', onTap: null),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < actions.length; i++) ...[
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: actions[i].onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(actions[i].icon, color: cs.primary, size: 22),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
actions[i].label,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i < actions.length - 1) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(ColorScheme cs) {
|
||||
final c = _contact;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
|
||||
final rows = <Widget>[];
|
||||
|
||||
final phoneStr = _formatPhone(c['phone']);
|
||||
if (phoneStr != null) {
|
||||
rows.add(_infoRow(cs, Symbols.phone, 'Телефон', phoneStr));
|
||||
}
|
||||
|
||||
final country = c['country'] as String?;
|
||||
if (country != null && country.isNotEmpty) {
|
||||
rows.add(_infoRow(cs, Symbols.public, 'Страна', country));
|
||||
}
|
||||
|
||||
final genderStr = _formatGender(c['gender']);
|
||||
if (genderStr != null) {
|
||||
rows.add(_infoRow(cs, Symbols.wc, 'Пол', genderStr));
|
||||
}
|
||||
|
||||
final regTime = c['registrationTime'] as int?;
|
||||
if (regTime != null && regTime > 0) {
|
||||
rows.add(_infoRow(cs, Symbols.event, 'Регистрация', _formatDateTime(regTime)));
|
||||
}
|
||||
|
||||
final updateTime = c['updateTime'] as int?;
|
||||
if (updateTime != null && updateTime > 0) {
|
||||
rows.add(_infoRow(cs, Symbols.update, 'Обновлён', _formatDateTime(updateTime)));
|
||||
}
|
||||
|
||||
final accountStatus = c['accountStatus'];
|
||||
if (accountStatus is int && accountStatus != 0) {
|
||||
rows.add(_infoRow(cs, Symbols.account_circle, 'Статус аккаунта', accountStatus.toString()));
|
||||
}
|
||||
|
||||
final desc = (c['description'] as String?)?.trim();
|
||||
if (desc != null && desc.isNotEmpty) {
|
||||
rows.add(_infoRow(cs, Symbols.info, 'Описание', desc, multiline: true));
|
||||
}
|
||||
|
||||
final link = c['link'] as String?;
|
||||
if (link != null && link.isNotEmpty) {
|
||||
rows.add(_infoRow(cs, Symbols.link, 'Ссылка', link));
|
||||
}
|
||||
|
||||
final webApp = c['webApp'] as String?;
|
||||
if (webApp != null && webApp.isNotEmpty) {
|
||||
rows.add(_infoRow(cs, Symbols.web, 'Web app', webApp));
|
||||
}
|
||||
|
||||
final opts = _options();
|
||||
if (opts.isNotEmpty) {
|
||||
rows.add(_infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true));
|
||||
}
|
||||
|
||||
rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString()));
|
||||
|
||||
if (rows.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < rows.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
rows[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(
|
||||
ColorScheme cs,
|
||||
IconData icon,
|
||||
String label,
|
||||
String value, {
|
||||
bool multiline = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: cs.onSurfaceVariant, size: 20),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 14),
|
||||
maxLines: multiline ? null : 1,
|
||||
overflow: multiline ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../backend/modules/contacts.dart';
|
||||
import '../../../main.dart';
|
||||
import 'contact_profile_screen.dart';
|
||||
|
||||
class ContactsTab extends StatefulWidget {
|
||||
const ContactsTab({super.key});
|
||||
@@ -21,6 +25,19 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
_loadContacts();
|
||||
}
|
||||
|
||||
Future<void> _openSearchById() async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => const _SearchContactSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadContacts() async {
|
||||
final p = await AppDatabase.loadActiveProfile();
|
||||
if (p == null) {
|
||||
@@ -67,7 +84,16 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Open contact details or chat
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContactProfileScreen(
|
||||
contactId: contact.id,
|
||||
initialName: nameToDisplay,
|
||||
initialAvatarUrl: contact.baseUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
@@ -182,7 +208,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.search, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
onPressed: _openSearchById,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -216,3 +242,182 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchContactSheet extends StatefulWidget {
|
||||
const _SearchContactSheet();
|
||||
|
||||
@override
|
||||
State<_SearchContactSheet> createState() => _SearchContactSheetState();
|
||||
}
|
||||
|
||||
class _SearchContactSheetState extends State<_SearchContactSheet> {
|
||||
final _controller = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final raw = _controller.text.trim();
|
||||
final id = int.tryParse(raw);
|
||||
if (id == null) {
|
||||
setState(() => _error = 'Введите числовой ID');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final packet = await api.sendRequest(Opcode.contactInfo, {
|
||||
'contactIds': [id],
|
||||
});
|
||||
final contacts = (packet.payload as Map?)?['contacts'] as List?;
|
||||
if (contacts == null || contacts.isEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Контакт с таким ID не найден';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final raw = Map<String, dynamic>.from(contacts.first as Map);
|
||||
String? name;
|
||||
final namesRaw = raw['names'];
|
||||
if (namesRaw is List && namesRaw.isNotEmpty) {
|
||||
final n = namesRaw.first;
|
||||
if (n is Map) name = n['name']?.toString();
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContactProfileScreen(
|
||||
contactId: id,
|
||||
initialName: name,
|
||||
initialAvatarUrl: raw['baseUrl'] as String?,
|
||||
),
|
||||
),
|
||||
);
|
||||
} on PacketError catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e.message;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Ошибка: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final viewInsets = MediaQuery.of(context).viewInsets;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Поиск по ID',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: !_loading,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) {
|
||||
if (_error != null) setState(() => _error = null);
|
||||
},
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите ID контакта',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16),
|
||||
prefixIcon: Icon(Symbols.tag, color: cs.onSurfaceVariant, size: 20),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.errorContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.error_outline, size: 18, color: cs.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: cs.onErrorContainer, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Найти'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:m3e_collection/m3e_collection.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../core/config/app_bubble_behavior.dart';
|
||||
import '../../../core/config/app_bubble_shape.dart';
|
||||
import '../../../core/utils/bubble_radius.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../main.dart';
|
||||
|
||||
@@ -70,6 +72,11 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
|
||||
AppBubbleShape.save(style);
|
||||
}
|
||||
|
||||
void _onBehaviorChanged(BubbleBehavior behavior) {
|
||||
Haptics.selection();
|
||||
AppBubbleBehavior.save(behavior);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -98,6 +105,8 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_BubbleShapeCard(onChanged: _onStyleChanged),
|
||||
const SizedBox(height: 12),
|
||||
_BubbleBehaviorCard(onChanged: _onBehaviorChanged),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -168,55 +177,85 @@ class _PreviewSectionState extends State<_PreviewSection> {
|
||||
class _ChatPreview extends StatelessWidget {
|
||||
const _ChatPreview();
|
||||
|
||||
static const _messages = <_PreviewMsg>[
|
||||
_PreviewMsg('Привет!', true, true, false),
|
||||
_PreviewMsg('Как тебе?', true, false, true),
|
||||
_PreviewMsg('Привет!', false, true, false),
|
||||
_PreviewMsg('хм...', false, false, false),
|
||||
_PreviewMsg('Вполне неплохо!', false, false, true),
|
||||
];
|
||||
|
||||
BorderRadius _radiusFor(
|
||||
_PreviewMsg msg,
|
||||
BubbleStyle style,
|
||||
BubbleBehavior behavior,
|
||||
) {
|
||||
return computeBubbleRadius(
|
||||
isMe: msg.isMe,
|
||||
isTop: msg.isTop,
|
||||
isBottom: msg.isBottom,
|
||||
style: style,
|
||||
behavior: behavior,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return ValueListenableBuilder<BubbleStyle>(
|
||||
valueListenable: AppBubbleShape.current,
|
||||
builder: (context, style, _) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_PreviewBubble(text: 'Как тебе?', isMe: true, style: style),
|
||||
const SizedBox(height: 6),
|
||||
_PreviewBubble(text: 'отлично выглядит!', isMe: false, style: style),
|
||||
],
|
||||
),
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(
|
||||
[AppBubbleShape.current, AppBubbleBehavior.current],
|
||||
),
|
||||
builder: (context, _) {
|
||||
final style = AppBubbleShape.current.value;
|
||||
final behavior = AppBubbleBehavior.current.value;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < _messages.length; i++) ...[
|
||||
if (i > 0)
|
||||
SizedBox(height: _messages[i].isTop ? 8 : 2),
|
||||
_PreviewBubble(
|
||||
text: _messages[i].text,
|
||||
isMe: _messages[i].isMe,
|
||||
radius: _radiusFor(_messages[i], style, behavior),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PreviewMsg {
|
||||
final String text;
|
||||
final bool isMe;
|
||||
final bool isTop;
|
||||
final bool isBottom;
|
||||
const _PreviewMsg(this.text, this.isMe, this.isTop, this.isBottom);
|
||||
}
|
||||
|
||||
class _PreviewBubble extends StatelessWidget {
|
||||
final String text;
|
||||
final bool isMe;
|
||||
final BubbleStyle style;
|
||||
final BorderRadius radius;
|
||||
|
||||
const _PreviewBubble({
|
||||
required this.text,
|
||||
required this.isMe,
|
||||
required this.style,
|
||||
required this.radius,
|
||||
});
|
||||
|
||||
BorderRadius get _radius {
|
||||
const big = Radius.circular(20);
|
||||
const small = Radius.circular(4);
|
||||
final outside = style == BubbleStyle.mobile ? big : small;
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? outside : big,
|
||||
topRight: isMe ? big : outside,
|
||||
bottomLeft: isMe ? outside : big,
|
||||
bottomRight: isMe ? big : outside,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -230,7 +269,7 @@ class _PreviewBubble extends StatelessWidget {
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: _radius),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: radius),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(color: fg, fontSize: 15, height: 1.3),
|
||||
@@ -444,6 +483,67 @@ class _BubbleShapeCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _BubbleBehaviorCard extends StatelessWidget {
|
||||
final ValueChanged<BubbleBehavior> onChanged;
|
||||
|
||||
const _BubbleBehaviorCard({required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Поведение сообщения',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Меняется ли форма пузыря по соседям в группе',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<BubbleBehavior>(
|
||||
valueListenable: AppBubbleBehavior.current,
|
||||
builder: (context, current, _) {
|
||||
return SegmentedButton<BubbleBehavior>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: BubbleBehavior.mutable,
|
||||
label: Text('Изменяемая'),
|
||||
icon: Icon(Symbols.auto_fix),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: BubbleBehavior.immutable,
|
||||
label: Text('Неизменяемая'),
|
||||
icon: Icon(Symbols.lock),
|
||||
),
|
||||
],
|
||||
selected: {current},
|
||||
onSelectionChanged: (set) {
|
||||
if (set.isNotEmpty) onChanged(set.first);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HueStripPicker extends StatelessWidget {
|
||||
final Color color;
|
||||
final ValueChanged<Color> onChanged;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../backend/modules/chats.dart';
|
||||
import '../../../core/protocol/opcode_map.dart';
|
||||
import '../../../core/protocol/packet.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class DebugMenuScreen extends StatefulWidget {
|
||||
const DebugMenuScreen({super.key});
|
||||
@@ -13,8 +18,10 @@ class DebugMenuScreen extends StatefulWidget {
|
||||
|
||||
class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
final _idController = TextEditingController();
|
||||
String? _searchResult;
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
final List<_SearchHit> _hits = [];
|
||||
final Map<String, String> _errors = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -27,26 +34,57 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
if (id == null) return;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_searchResult = null;
|
||||
_hasSearched = true;
|
||||
_hits.clear();
|
||||
_errors.clear();
|
||||
});
|
||||
try {
|
||||
final result = await ChatsModule.searchById(api, id);
|
||||
logger.i('searchById result: $result');
|
||||
if (!mounted) return;
|
||||
if (result is Map && result.containsKey('error')) {
|
||||
final errorMsg = result['localizedMessage'] ?? result['message'] ?? result['error'] ?? 'Error';
|
||||
setState(() => _searchResult = 'Error: $errorMsg');
|
||||
} else if (result is Map) {
|
||||
setState(() => _searchResult = result.toString());
|
||||
} else {
|
||||
setState(() => _searchResult = result?.toString() ?? 'null');
|
||||
|
||||
Future<void> tryProbe(String label, Future<dynamic> Function() probe) async {
|
||||
try {
|
||||
final res = await probe();
|
||||
logger.i('debug-search $label($id): $res');
|
||||
if (res is Map) _extractHits(label, res);
|
||||
} on PacketError catch (e) {
|
||||
_errors[label] = e.message;
|
||||
} catch (e) {
|
||||
_errors[label] = e.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _searchResult = 'Exception: $e');
|
||||
}
|
||||
|
||||
await Future.wait([
|
||||
tryProbe('contactInfo', () async {
|
||||
final p = await api.sendRequest(Opcode.contactInfo, {'contactIds': [id]});
|
||||
return p.payload;
|
||||
}),
|
||||
tryProbe('chatInfo', () async {
|
||||
final p = await api.sendRequest(Opcode.chatInfo, {'chatIds': [id]});
|
||||
return p.payload;
|
||||
}),
|
||||
tryProbe('publicSearch', () => ChatsModule.searchById(api, id)),
|
||||
]);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isSearching = false);
|
||||
}
|
||||
|
||||
void _extractHits(String source, Map raw) {
|
||||
final contacts = raw['contacts'];
|
||||
if (contacts is List) {
|
||||
for (final c in contacts) {
|
||||
if (c is Map) {
|
||||
final hit = _SearchHit.fromContact(source, c);
|
||||
if (hit != null) _hits.add(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
final chats = raw['chats'];
|
||||
if (chats is List) {
|
||||
for (final c in chats) {
|
||||
if (c is Map) {
|
||||
final hit = _SearchHit.fromChat(source, c);
|
||||
if (hit != null) _hits.add(hit);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSearching = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +264,74 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: appState == null
|
||||
? const SizedBox.shrink()
|
||||
: ValueListenableBuilder<bool>(
|
||||
valueListenable: appState.tlsInsecureEnabled,
|
||||
builder: (context, insecureOn, _) {
|
||||
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.gpp_bad,
|
||||
color: cs.onSurfaceVariant,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Отключить проверку TLS',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Принимать любой сертификат сервера. '
|
||||
'Только для отладки через MitM-прокси — '
|
||||
'соединение становится уязвимым к '
|
||||
'перехвату трафика',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: insecureOn,
|
||||
onChanged: (v) {
|
||||
appState.setTlsInsecureEnabled(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
@@ -239,13 +345,21 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Поиск по ID (opcode 60)',
|
||||
'Поиск по ID',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Параллельно: contactInfo (32) + chatInfo (48) + publicSearch (60)',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
@@ -254,7 +368,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
controller: _idController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Введите user ID',
|
||||
hintText: 'Введите ID',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -281,27 +395,24 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_searchResult != null) ...[
|
||||
if (_hasSearched && !_isSearching) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
child: SingleChildScrollView(
|
||||
if (_hits.isEmpty && _errors.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
_searchResult!,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
'Ничего не найдено',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final hit in _hits) ...[
|
||||
_SearchResultCard(hit: hit),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
for (final entry in _errors.entries) ...[
|
||||
_ErrorChip(label: entry.key, message: entry.value),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -314,4 +425,327 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _HitKind { dialog, chat, channel, bot, official, contact, user, unknown }
|
||||
|
||||
class _SearchHit {
|
||||
final String source;
|
||||
final int id;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? avatarUrl;
|
||||
final List<_HitKind> badges;
|
||||
final bool isChatEntity;
|
||||
|
||||
_SearchHit({
|
||||
required this.source,
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.avatarUrl,
|
||||
required this.badges,
|
||||
required this.isChatEntity,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
static _SearchHit? fromContact(String source, Map raw) {
|
||||
final id = raw['id'];
|
||||
if (id is! int) return null;
|
||||
final namesRaw = raw['names'];
|
||||
String title = 'User #$id';
|
||||
if (namesRaw is List && namesRaw.isNotEmpty) {
|
||||
final n = namesRaw.first;
|
||||
if (n is Map) {
|
||||
final full = n['name']?.toString();
|
||||
if (full != null && full.isNotEmpty) title = full;
|
||||
}
|
||||
}
|
||||
final opts = (raw['options'] is List)
|
||||
? (raw['options'] as List).whereType<String>().toSet()
|
||||
: <String>{};
|
||||
final badges = <_HitKind>[];
|
||||
if (opts.contains('BOT')) badges.add(_HitKind.bot);
|
||||
if (opts.contains('OFFICIAL')) badges.add(_HitKind.official);
|
||||
if (badges.isEmpty) badges.add(_HitKind.contact);
|
||||
return _SearchHit(
|
||||
source: source,
|
||||
id: id,
|
||||
title: title,
|
||||
subtitle: (raw['description'] as String?)?.trim().isNotEmpty == true
|
||||
? raw['description'] as String
|
||||
: (raw['phone'] != null ? 'Телефон скрыт' : null),
|
||||
avatarUrl: raw['baseUrl'] as String?,
|
||||
badges: badges,
|
||||
isChatEntity: false,
|
||||
);
|
||||
}
|
||||
|
||||
static _SearchHit? fromChat(String source, Map raw) {
|
||||
final id = raw['id'];
|
||||
if (id is! int) return null;
|
||||
final type = (raw['type'] as String?) ?? 'CHAT';
|
||||
final title = (raw['title'] as String?) ?? 'Chat #$id';
|
||||
final pCount = raw['participantsCount'] as int?;
|
||||
final badges = <_HitKind>[];
|
||||
switch (type) {
|
||||
case 'DIALOG':
|
||||
badges.add(_HitKind.dialog);
|
||||
case 'CHANNEL':
|
||||
badges.add(_HitKind.channel);
|
||||
case 'CHAT':
|
||||
badges.add(_HitKind.chat);
|
||||
default:
|
||||
badges.add(_HitKind.unknown);
|
||||
}
|
||||
final opts = raw['options'];
|
||||
if (opts is Map && opts['OFFICIAL'] == true) {
|
||||
badges.add(_HitKind.official);
|
||||
}
|
||||
String? subtitle;
|
||||
if (type == 'CHANNEL') {
|
||||
subtitle = pCount != null ? 'Канал · $pCount подписч.' : 'Канал';
|
||||
} else if (type == 'CHAT') {
|
||||
subtitle = pCount != null ? 'Группа · $pCount участн.' : 'Группа';
|
||||
} else {
|
||||
subtitle = 'Диалог';
|
||||
}
|
||||
return _SearchHit(
|
||||
source: source,
|
||||
id: id,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
avatarUrl: raw['baseIconUrl'] as String?,
|
||||
badges: badges,
|
||||
isChatEntity: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchResultCard extends StatelessWidget {
|
||||
final _SearchHit hit;
|
||||
const _SearchResultCard({required this.hit});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_HitAvatar(hit: hit, cs: cs),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
hit.title,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
for (final b in hit.badges) ...[
|
||||
const SizedBox(width: 6),
|
||||
_BadgeChip(kind: b, cs: cs),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (hit.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hit.subtitle!,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'id: ${hit.id}',
|
||||
style: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'via ${hit.source}',
|
||||
style: TextStyle(color: cs.outline, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Скопировать id',
|
||||
icon: Icon(Symbols.content_copy, size: 18, color: cs.onSurfaceVariant),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: hit.id.toString()));
|
||||
if (context.mounted) {
|
||||
showCustomNotification(context, 'id скопирован');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HitAvatar extends StatelessWidget {
|
||||
final _SearchHit hit;
|
||||
final ColorScheme cs;
|
||||
const _HitAvatar({required this.hit, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const size = 44.0;
|
||||
final url = hit.avatarUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
return ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => _fallback(),
|
||||
errorWidget: (_, _, _) => _fallback(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _fallback();
|
||||
}
|
||||
|
||||
Widget _fallback() {
|
||||
final initial = hit.title.isNotEmpty ? hit.title[0].toUpperCase() : '?';
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BadgeChip extends StatelessWidget {
|
||||
final _HitKind kind;
|
||||
final ColorScheme cs;
|
||||
const _BadgeChip({required this.kind, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String label;
|
||||
Color bg;
|
||||
Color fg;
|
||||
switch (kind) {
|
||||
case _HitKind.bot:
|
||||
label = 'Bot';
|
||||
bg = cs.tertiaryContainer;
|
||||
fg = cs.onTertiaryContainer;
|
||||
case _HitKind.official:
|
||||
label = '✓';
|
||||
bg = cs.primary;
|
||||
fg = cs.onPrimary;
|
||||
case _HitKind.contact:
|
||||
label = 'Контакт';
|
||||
bg = cs.surface;
|
||||
fg = cs.onSurfaceVariant;
|
||||
case _HitKind.user:
|
||||
label = 'User';
|
||||
bg = cs.surface;
|
||||
fg = cs.onSurfaceVariant;
|
||||
case _HitKind.dialog:
|
||||
label = 'Диалог';
|
||||
bg = cs.secondaryContainer;
|
||||
fg = cs.onSecondaryContainer;
|
||||
case _HitKind.chat:
|
||||
label = 'Группа';
|
||||
bg = cs.secondaryContainer;
|
||||
fg = cs.onSecondaryContainer;
|
||||
case _HitKind.channel:
|
||||
label = 'Канал';
|
||||
bg = cs.tertiaryContainer;
|
||||
fg = cs.onTertiaryContainer;
|
||||
case _HitKind.unknown:
|
||||
label = '?';
|
||||
bg = cs.surface;
|
||||
fg = cs.onSurfaceVariant;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorChip extends StatelessWidget {
|
||||
final String label;
|
||||
final String message;
|
||||
const _ErrorChip({required this.label, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.errorContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.error_outline, size: 16, color: cs.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$label: $message',
|
||||
style: TextStyle(
|
||||
color: cs.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../core/utils/haptics.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../auth/login_screen.dart';
|
||||
import '../auth/proxy_settings_sheet.dart';
|
||||
import 'customization_screen.dart';
|
||||
import 'performance_screen.dart';
|
||||
@@ -26,6 +28,8 @@ class SettingsTab extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsTabState extends State<SettingsTab> {
|
||||
static const bool _showLogoutButton = false;
|
||||
|
||||
ProfileData? _profile;
|
||||
bool _isPhoneVisible = false;
|
||||
String? _appVersionLabel;
|
||||
@@ -94,6 +98,83 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
if (mounted) setState(() => _hapticsEnabled = value);
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout() async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final confirmed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Выйти из аккаунта?',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Сессия будет сброшена. Локальный кеш сохранится — войдёшь снова в этот же аккаунт.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: cs.error,
|
||||
foregroundColor: cs.onError,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: const Text('Выйти'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
await _doLogout();
|
||||
}
|
||||
|
||||
Future<void> _doLogout() async {
|
||||
final navState = KometApp.navigatorKey.currentState;
|
||||
try {
|
||||
await api.disconnect();
|
||||
} catch (_) {}
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await TokenStorage.deleteToken(accountId);
|
||||
}
|
||||
try {
|
||||
await api.connect();
|
||||
} catch (_) {}
|
||||
if (navState != null) {
|
||||
await navState.pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -426,31 +507,51 @@ child: _buildSection(
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: _PhoneSpoiler(
|
||||
text: phone,
|
||||
isVisible: _isPhoneVisible,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.5,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: _PhoneSpoiler(
|
||||
text: phone,
|
||||
isVisible: _isPhoneVisible,
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_isPhoneVisible ? Symbols.visibility : Symbols.visibility_off,
|
||||
size: 14,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_showLogoutButton)
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
tooltip: 'Выйти',
|
||||
icon: Icon(
|
||||
Symbols.logout,
|
||||
color: cs.error,
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
onPressed: _confirmLogout,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_isPhoneVisible ? Symbols.visibility : Symbols.visibility_off,
|
||||
size: 14,
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert' show utf8;
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:komet/backend/modules/messages.dart' show FileHistoryCache, FileHistoryEntry;
|
||||
import 'package:komet/core/config/proxy_config.dart';
|
||||
import 'package:komet/core/protocol/opcode_map.dart';
|
||||
import 'package:komet/core/protocol/packet.dart';
|
||||
import 'package:komet/core/transport/proxy_connector.dart';
|
||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||
import 'package:komet/main.dart' show api, messagesModule;
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class AttachmentPanel extends StatefulWidget {
|
||||
final int chatId;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onPickFile;
|
||||
final Future<bool> Function(int fileId) onSendById;
|
||||
|
||||
const AttachmentPanel({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.onClose,
|
||||
required this.onPickFile,
|
||||
required this.onSendById,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -29,259 +20,21 @@ class AttachmentPanel extends StatefulWidget {
|
||||
|
||||
class _AttachmentPanelState extends State<AttachmentPanel> {
|
||||
final TextEditingController _fileIdController = TextEditingController();
|
||||
bool _isUploading = false;
|
||||
bool _sendingById = false;
|
||||
|
||||
Future<void> _pickAndUploadFile() async {
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final file = result.files.first;
|
||||
if (file.path == null) return;
|
||||
|
||||
setState(() => _isUploading = true);
|
||||
|
||||
try {
|
||||
final uploadInfo = await messagesModule.requestUploadUrl();
|
||||
if (uploadInfo == null) {
|
||||
if (mounted) showCustomNotification(context, 'Не удалось получить ссылку');
|
||||
return;
|
||||
}
|
||||
|
||||
await api.sendRequest(Opcode.msgTyping, {
|
||||
'chatId': widget.chatId,
|
||||
'type': 'FILE',
|
||||
});
|
||||
|
||||
final uri = Uri.parse(uploadInfo.url);
|
||||
final fileBytes = await File(file.path!).readAsBytes();
|
||||
final proxySettings = await ProxyConfig.load();
|
||||
|
||||
int statusCode;
|
||||
if (proxySettings.isEnabled) {
|
||||
final connector = ProxyConnector(proxySettings);
|
||||
final proxySocket = await connector.connect(uri.host, uri.port);
|
||||
final socket = uri.scheme == 'https'
|
||||
? await RawSecureSocket.secure(
|
||||
proxySocket,
|
||||
host: uri.host,
|
||||
onBadCertificate: (_) => true,
|
||||
)
|
||||
: proxySocket;
|
||||
statusCode = await _rawPost(socket, uri, fileBytes, file.name);
|
||||
} else {
|
||||
final socket = await RawSocket.connect(uri.host, uri.port);
|
||||
final secureSocket = uri.scheme == 'https'
|
||||
? await RawSecureSocket.secure(
|
||||
socket,
|
||||
host: uri.host,
|
||||
onBadCertificate: (_) => true,
|
||||
)
|
||||
: socket;
|
||||
statusCode = await _rawPost(secureSocket, uri, fileBytes, file.name);
|
||||
}
|
||||
|
||||
if (statusCode != 200) {
|
||||
if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for notifAttach push
|
||||
final pushCompleter = Completer<void>();
|
||||
void Function(Packet)? pushHandler;
|
||||
pushHandler = (Packet packet) {
|
||||
final payload = packet.payload;
|
||||
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
|
||||
api.unregisterPushHandler(Opcode.notifAttach);
|
||||
pushCompleter.complete();
|
||||
}
|
||||
};
|
||||
api.registerPushHandler(Opcode.notifAttach, (Packet p) => pushHandler!(p));
|
||||
|
||||
await pushCompleter.future.timeout(
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () {
|
||||
api.unregisterPushHandler(Opcode.notifAttach);
|
||||
throw TimeoutException('Тайм-аут подтверждения загрузки');
|
||||
},
|
||||
);
|
||||
|
||||
// Retry loop: server may say "attachment in progress" (cmd=3)
|
||||
for (var attempt = 0; attempt < 5; attempt++) {
|
||||
final sent = await messagesModule.sendFileMessage(
|
||||
widget.chatId,
|
||||
uploadInfo.fileId,
|
||||
token: uploadInfo.token,
|
||||
);
|
||||
|
||||
// Listen for push again (another notifAttach may come)
|
||||
final msgCompleter = Completer<bool>();
|
||||
void Function(Packet)? msgHandler;
|
||||
msgHandler = (Packet packet) {
|
||||
final payload = packet.payload;
|
||||
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
|
||||
api.unregisterPushHandler(Opcode.notifAttach);
|
||||
msgCompleter.complete(true);
|
||||
}
|
||||
};
|
||||
api.registerPushHandler(Opcode.notifAttach, (Packet p) => msgHandler!(p));
|
||||
|
||||
final pushFuture = msgCompleter.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
api.unregisterPushHandler(Opcode.notifAttach);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
final pushReceived = await pushFuture;
|
||||
if (pushReceived && sent) {
|
||||
FileHistoryCache.add(FileHistoryEntry(
|
||||
fileId: uploadInfo.fileId,
|
||||
url: uploadInfo.url,
|
||||
token: uploadInfo.token,
|
||||
sentAt: DateTime.now(),
|
||||
));
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Файл отправлен');
|
||||
widget.onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If push was received, check if message was sent
|
||||
if (pushReceived) {
|
||||
FileHistoryCache.add(FileHistoryEntry(
|
||||
fileId: uploadInfo.fileId,
|
||||
url: uploadInfo.url,
|
||||
token: uploadInfo.token,
|
||||
sentAt: DateTime.now(),
|
||||
));
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Файл отправлен');
|
||||
widget.onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sent) {
|
||||
// msgSend failed, maybe server still processing — wait and retry
|
||||
await Future.delayed(Duration(seconds: 1 + attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sent ok, no push received (already processed earlier)
|
||||
FileHistoryCache.add(FileHistoryEntry(
|
||||
fileId: uploadInfo.fileId,
|
||||
url: uploadInfo.url,
|
||||
token: uploadInfo.token,
|
||||
sentAt: DateTime.now(),
|
||||
));
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Файл отправлен');
|
||||
widget.onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) showCustomNotification(context, 'Не удалось отправить сообщение');
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isUploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _rawPost(RawSocket socket, Uri uri, List<int> body, String filename) async {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final host = uri.host;
|
||||
final total = body.length;
|
||||
|
||||
final request = StringBuffer()
|
||||
..write('POST $path HTTP/1.1\r\n')
|
||||
..write('Host: $host\r\n')
|
||||
..write('Content-Type: application/x-binary; charset=x-user-defined\r\n')
|
||||
..write('Content-Disposition: attachment; filename=$filename\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('Content-Range: bytes 0-${total - 1}/$total\r\n')
|
||||
..write('Content-Length: $total\r\n')
|
||||
..write('\r\n');
|
||||
|
||||
final requestBytes = utf8.encode(request.toString());
|
||||
final allBytes = <int>[...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))];
|
||||
socket.write(Uint8List.fromList(allBytes));
|
||||
|
||||
final responseBytes = <int>[];
|
||||
final completer = Completer<int>();
|
||||
Timer? timer;
|
||||
|
||||
socket.listen((event) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final data = socket.read();
|
||||
if (data != null) responseBytes.addAll(data);
|
||||
} else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) {
|
||||
timer?.cancel();
|
||||
if (responseBytes.isEmpty) {
|
||||
completer.completeError(const SocketException('Пустой ответ сервера'));
|
||||
return;
|
||||
}
|
||||
final headerEnd = _findHeaderEnd(responseBytes);
|
||||
if (headerEnd == -1) {
|
||||
completer.completeError(const SocketException('Не удалось прочитать заголовок ответа'));
|
||||
return;
|
||||
}
|
||||
final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true);
|
||||
final statusLine = headerStr.split('\r\n').first;
|
||||
debugPrint('HTTP Response: $statusLine');
|
||||
final parts = statusLine.split(' ');
|
||||
completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0);
|
||||
}
|
||||
}, onError: (e) {
|
||||
timer?.cancel();
|
||||
completer.completeError(e);
|
||||
});
|
||||
|
||||
timer = Timer(const Duration(minutes: 5), () {
|
||||
socket.close();
|
||||
completer.completeError(TimeoutException('Тайм-аут загрузки'));
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
int _findHeaderEnd(List<int> bytes) {
|
||||
for (var i = 0; i < bytes.length - 3; i++) {
|
||||
if (bytes[i] == 0x0D && bytes[i + 1] == 0x0A &&
|
||||
bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A) {
|
||||
return i + 4;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
Future<void> _uploadByFileId() async {
|
||||
final fileIdStr = _fileIdController.text.trim();
|
||||
if (fileIdStr.isEmpty) return;
|
||||
final fileId = int.tryParse(fileIdStr);
|
||||
if (fileId == null) {
|
||||
if (mounted) showCustomNotification(context, 'Неверный fileId');
|
||||
Future<void> _sendById() async {
|
||||
final s = _fileIdController.text.trim();
|
||||
if (s.isEmpty) return;
|
||||
final id = int.tryParse(s);
|
||||
if (id == null) {
|
||||
showCustomNotification(context, 'Неверный fileId');
|
||||
return;
|
||||
}
|
||||
setState(() => _isUploading = true);
|
||||
try {
|
||||
final sent = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
||||
if (sent) {
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Файл отправлен');
|
||||
widget.onClose();
|
||||
}
|
||||
} else {
|
||||
if (mounted) showCustomNotification(context, 'Ошибка отправки');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isUploading = false);
|
||||
}
|
||||
setState(() => _sendingById = true);
|
||||
final ok = await widget.onSendById(id);
|
||||
if (!mounted) return;
|
||||
setState(() => _sendingById = false);
|
||||
if (ok) _fileIdController.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -293,34 +46,23 @@ class _AttachmentPanelState extends State<AttachmentPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dy > 300) widget.onClose();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Stack(children: [
|
||||
Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
const SizedBox(height: 40),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
child: Row(children: [
|
||||
Expanded(child: _buildButton(
|
||||
label: 'Выбрать из файла',
|
||||
icon: Symbols.folder_open,
|
||||
filled: true,
|
||||
onTap: _isUploading ? null : _pickAndUploadFile,
|
||||
onTap: _sendingById ? null : widget.onPickFile,
|
||||
cs: cs,
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
@@ -328,80 +70,43 @@ class _AttachmentPanelState extends State<AttachmentPanel> {
|
||||
label: 'Отправить по id',
|
||||
icon: null,
|
||||
filled: false,
|
||||
onTap: _isUploading ? null : _uploadByFileId,
|
||||
onTap: _sendingById ? null : _sendById,
|
||||
cs: cs,
|
||||
)),
|
||||
]),
|
||||
),
|
||||
if (_isUploading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextField(
|
||||
controller: _fileIdController,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 14),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'fileId...',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('История', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextField(
|
||||
controller: _fileIdController,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 14),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'fileId...',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (FileHistoryCache.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('история пуста...', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 100,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: FileHistoryCache.history.length,
|
||||
itemBuilder: (ctx, idx) {
|
||||
final e = FileHistoryCache.history[idx];
|
||||
return Container(
|
||||
width: 72,
|
||||
margin: const EdgeInsets.only(right: 8, bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Center(child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.description, color: cs.onSurfaceVariant, size: 28),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('${e.fileId}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
]),
|
||||
),
|
||||
Positioned(
|
||||
left: 6,
|
||||
top: 6,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onClose,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Symbols.close, color: cs.onSurfaceVariant, size: 22),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:komet/main.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../backend/modules/messages.dart';
|
||||
import '../../core/config/app_bubble_behavior.dart';
|
||||
import '../../core/config/app_bubble_shape.dart';
|
||||
import '../../core/utils/bubble_radius.dart';
|
||||
import '../../core/utils/haptics.dart';
|
||||
import '../../models/attachment.dart';
|
||||
|
||||
@@ -196,71 +198,21 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
BorderRadius _borderRadiusFor(
|
||||
BubbleStyle bubbleStyle,
|
||||
BubbleBehavior bubbleBehavior,
|
||||
BubbleShape shape,
|
||||
bool hasPhotoWithCaption,
|
||||
bool hasMultiplePhotosNoCaption,
|
||||
) {
|
||||
final outsideRadius =
|
||||
bubbleStyle == BubbleStyle.mobile ? _bigRadius : _smallRadius;
|
||||
|
||||
if (hasPhotoWithCaption &&
|
||||
(shape == BubbleShape.singleTop ||
|
||||
shape == BubbleShape.singleMiddle ||
|
||||
shape == BubbleShape.singleBottom)) {
|
||||
return BorderRadius.only(
|
||||
topLeft: _bigRadius,
|
||||
topRight: _bigRadius,
|
||||
bottomLeft: isMe ? _bigRadius : _smallRadius,
|
||||
bottomRight: _smallRadius,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasMultiplePhotosNoCaption &&
|
||||
(shape == BubbleShape.singleBottom ||
|
||||
shape == BubbleShape.singleMiddle)) {
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? _bigRadius : _smallRadius,
|
||||
topRight: _smallRadius,
|
||||
bottomLeft: isMe ? _bigRadius : _smallRadius,
|
||||
bottomRight: isMe ? _smallRadius : _bigRadius,
|
||||
);
|
||||
}
|
||||
|
||||
Radius cornerTL = isMe ? outsideRadius : _bigRadius;
|
||||
Radius cornerTR = isMe ? _bigRadius : outsideRadius;
|
||||
Radius cornerBL = isMe ? outsideRadius : _bigRadius;
|
||||
Radius cornerBR = isMe ? _bigRadius : outsideRadius;
|
||||
|
||||
switch (shape) {
|
||||
case BubbleShape.singleTop:
|
||||
if (isMe) {
|
||||
cornerBR = _smallRadius;
|
||||
} else {
|
||||
cornerBL = _smallRadius;
|
||||
}
|
||||
case BubbleShape.singleBottom:
|
||||
if (isMe) {
|
||||
cornerTR = _smallRadius;
|
||||
} else {
|
||||
cornerTL = _smallRadius;
|
||||
}
|
||||
case BubbleShape.singleMiddle:
|
||||
break;
|
||||
case BubbleShape.groupedMiddle:
|
||||
if (isMe) {
|
||||
cornerTR = _smallRadius;
|
||||
cornerBR = _smallRadius;
|
||||
} else {
|
||||
cornerTL = _smallRadius;
|
||||
cornerBL = _smallRadius;
|
||||
}
|
||||
}
|
||||
|
||||
return BorderRadius.only(
|
||||
topLeft: cornerTL,
|
||||
topRight: cornerTR,
|
||||
bottomLeft: cornerBL,
|
||||
bottomRight: cornerBR,
|
||||
final isTop = shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle;
|
||||
final isBottom = shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle;
|
||||
return computeBubbleRadius(
|
||||
isMe: isMe,
|
||||
isTop: isTop,
|
||||
isBottom: isBottom,
|
||||
style: bubbleStyle,
|
||||
behavior: bubbleBehavior,
|
||||
hasPhotoWithCaption: hasPhotoWithCaption,
|
||||
hasMultiplePhotosNoCaption: hasMultiplePhotosNoCaption,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,9 +304,11 @@ class MessageBubble extends StatelessWidget {
|
||||
radius: 15,
|
||||
backgroundColor: Color(0x00000000),
|
||||
),
|
||||
ValueListenableBuilder<BubbleStyle>(
|
||||
valueListenable: AppBubbleShape.current,
|
||||
builder: (context, bubbleStyle, child) {
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge(
|
||||
[AppBubbleShape.current, AppBubbleBehavior.current],
|
||||
),
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.75,
|
||||
@@ -364,7 +318,8 @@ class MessageBubble extends StatelessWidget {
|
||||
? cs.primaryContainer
|
||||
: cs.surfaceContainerHighest,
|
||||
borderRadius: _borderRadiusFor(
|
||||
bubbleStyle,
|
||||
AppBubbleShape.current.value,
|
||||
AppBubbleBehavior.current.value,
|
||||
shape,
|
||||
hasPhotoCap,
|
||||
hasMultiPhotos,
|
||||
@@ -1126,77 +1081,86 @@ class MessageBubble extends StatelessWidget {
|
||||
final size = (file as dynamic).size as int? ?? 0;
|
||||
final sizeStr = _formatFileSize(size);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(
|
||||
return IntrinsicWidth(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||
: ctx.cs.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Symbols.description,
|
||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: ctx.text,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||
: ctx.cs.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
sizeStr,
|
||||
style: TextStyle(
|
||||
color: ctx.dim,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
child: Icon(
|
||||
Symbols.description,
|
||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
color: ctx.text,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
sizeStr,
|
||||
style: TextStyle(
|
||||
color: ctx.dim,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||
: ctx.cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Symbols.download,
|
||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: isMe
|
||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||
: ctx.cs.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Symbols.download,
|
||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildMeta(ctx),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,18 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'backend/api.dart';
|
||||
import 'core/config/app_accent.dart';
|
||||
import 'core/config/app_bubble_behavior.dart';
|
||||
import 'core/config/app_bubble_shape.dart';
|
||||
import 'core/config/app_cache_extent.dart';
|
||||
import 'core/config/app_fonts.dart';
|
||||
import 'backend/modules/account.dart';
|
||||
import 'backend/modules/chats.dart';
|
||||
import 'backend/modules/contacts.dart';
|
||||
import 'backend/modules/file_uploader.dart';
|
||||
import 'backend/modules/messages.dart';
|
||||
import 'core/push/push_service.dart';
|
||||
import 'core/storage/app_database.dart';
|
||||
import 'core/transport/tls_config.dart';
|
||||
import 'core/transport/vpn_bypass.dart';
|
||||
import 'core/storage/token_storage.dart';
|
||||
import 'core/utils/haptics.dart';
|
||||
@@ -28,6 +32,7 @@ import 'frontend/widgets/custom_notification.dart';
|
||||
final api = Api();
|
||||
final accountModule = AccountModule(api);
|
||||
final messagesModule = MessagesModule(api);
|
||||
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
||||
|
||||
Future<Locale> _loadInitialLocale() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -49,6 +54,7 @@ void main() async {
|
||||
if (activeAccountId != null) {
|
||||
await ContactsModule.primeCacheFromDb(activeAccountId);
|
||||
}
|
||||
ChatsModule.attachGlobalPushHandlers(api);
|
||||
await api.connect();
|
||||
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
@@ -61,8 +67,10 @@ void main() async {
|
||||
await Haptics.load();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await FileHistoryCache.load(prefs);
|
||||
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
||||
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
||||
final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false;
|
||||
final initialFontId =
|
||||
prefs.getString(AppFonts.prefKey) ?? AppFonts.fallback.id;
|
||||
final initialFontScale = AppFonts.clampScale(
|
||||
@@ -70,12 +78,14 @@ void main() async {
|
||||
);
|
||||
final initialAccentSeed = await AppAccent.load();
|
||||
AppBubbleShape.current.value = await AppBubbleShape.load();
|
||||
AppBubbleBehavior.current.value = await AppBubbleBehavior.load();
|
||||
AppCacheExtent.current.value = await AppCacheExtent.load();
|
||||
runApp(
|
||||
KometApp(
|
||||
initialLocale: initialLocale,
|
||||
initialFpsOverlay: initialFpsOverlay,
|
||||
initialVpnBypass: initialVpnBypass,
|
||||
initialTlsInsecure: initialTlsInsecure,
|
||||
initialFontId: initialFontId,
|
||||
initialFontScale: initialFontScale,
|
||||
initialAccentSeed: initialAccentSeed,
|
||||
@@ -89,6 +99,7 @@ class KometApp extends StatefulWidget {
|
||||
required this.initialLocale,
|
||||
this.initialFpsOverlay = false,
|
||||
this.initialVpnBypass = false,
|
||||
this.initialTlsInsecure = false,
|
||||
required this.initialFontId,
|
||||
required this.initialFontScale,
|
||||
this.initialAccentSeed,
|
||||
@@ -97,6 +108,7 @@ class KometApp extends StatefulWidget {
|
||||
final Locale initialLocale;
|
||||
final bool initialFpsOverlay;
|
||||
final bool initialVpnBypass;
|
||||
final bool initialTlsInsecure;
|
||||
final String initialFontId;
|
||||
final double initialFontScale;
|
||||
final Color? initialAccentSeed;
|
||||
@@ -130,6 +142,9 @@ class KometAppState extends State<KometApp> {
|
||||
late final ValueNotifier<bool> vpnBypassEnabled = ValueNotifier(
|
||||
widget.initialVpnBypass,
|
||||
);
|
||||
late final ValueNotifier<bool> tlsInsecureEnabled = ValueNotifier(
|
||||
widget.initialTlsInsecure,
|
||||
);
|
||||
late final ValueNotifier<double> fontScale = ValueNotifier(
|
||||
widget.initialFontScale,
|
||||
);
|
||||
@@ -216,6 +231,7 @@ class KometAppState extends State<KometApp> {
|
||||
_profileUpdateController.close();
|
||||
fpsOverlayEnabled.dispose();
|
||||
vpnBypassEnabled.dispose();
|
||||
tlsInsecureEnabled.dispose();
|
||||
fontScale.dispose();
|
||||
accentSeed.dispose();
|
||||
super.dispose();
|
||||
@@ -235,6 +251,12 @@ class KometAppState extends State<KometApp> {
|
||||
await prefs.setBool(VpnBypassService.prefKey, value);
|
||||
}
|
||||
|
||||
Future<void> setTlsInsecureEnabled(bool value) async {
|
||||
if (tlsInsecureEnabled.value == value) return;
|
||||
tlsInsecureEnabled.value = value;
|
||||
await TlsConfig.setInsecureAllowed(value);
|
||||
}
|
||||
|
||||
Future<void> applyLocale(Locale locale) async {
|
||||
if (!AppLocalizations.supportedLocales.any(
|
||||
(l) => l.languageCode == locale.languageCode,
|
||||
|
||||
Reference in New Issue
Block a user