Merge pull request #23 from KometTeam/feature/FullStack

feat: чаты, группы, файлы, real-time + security-фиксы #17 → dev/0.5.0
This commit is contained in:
klockky
2026-05-18 08:40:21 +03:00
committed by GitHub
28 changed files with 4727 additions and 825 deletions
+4 -3
View File
@@ -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,
};
+462
View File
@@ -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 [];
}
}
}
+424
View File
@@ -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;
}
}
+51
View File
@@ -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, {
+88 -8
View File
@@ -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;
}
}
+37
View File
@@ -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 'Неизменяемая';
}
}
}
+2 -2
View File
@@ -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; // ответ: не найдено
+56 -1
View File
@@ -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(
+49
View File
@@ -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)}';
}
}
+24 -27
View File
@@ -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');
}
+3 -6
View File
@@ -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);
+25 -29
View File
@@ -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();
+2 -1
View File
@@ -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;
}
+15
View File
@@ -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);
}
}
+81
View File
@@ -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,
);
}
+39
View File
@@ -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;
}
+121 -26
View File
@@ -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} ч назад';
+281 -37
View File
@@ -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,
),
],
),
),
],
),
);
}
}
+207 -2
View File
@@ -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,
),
),
],
),
);
}
}
+122 -21
View File
@@ -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),
),
],
),
],
+56 -351
View File
@@ -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),
),
),
),
]),
);
}
+91 -127
View File
@@ -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),
],
),
),
);
}
+22
View File
@@ -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,