From c4eaae501b827536d4a54f0d830c0499b18fe88c Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 16 May 2026 23:36:28 +0300 Subject: [PATCH 1/7] fix(security): close critical findings #1-3 from issue #17 - TLS: validate cert chain by default; debug-menu toggle to disable - Logs: redact secrets in sender/dispatcher payloads - Identity: per-install mt_instanceid/deviceId, per-launch clientSessionId --- lib/backend/api.dart | 7 +- lib/core/storage/device_identity.dart | 49 +++++++++++++ lib/core/transport/connection.dart | 18 +++-- lib/core/transport/dispatcher.dart | 9 +-- lib/core/transport/sender.dart | 3 +- lib/core/transport/tls_config.dart | 15 ++++ lib/core/utils/log_redact.dart | 39 +++++++++++ .../screens/profile/debug_menu_screen.dart | 68 +++++++++++++++++++ lib/main.dart | 15 ++++ 9 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 lib/core/storage/device_identity.dart create mode 100644 lib/core/transport/tls_config.dart create mode 100644 lib/core/utils/log_redact.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index eba505c..c767808 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -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 = { - 'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000', - 'clientSessionId': 42, + 'mt_instanceid': await DeviceIdentity.instanceId(), + 'clientSessionId': DeviceIdentity.clientSessionId, 'deviceId': deviceId, 'userAgent': _userAgent, }; diff --git a/lib/core/storage/device_identity.dart b/lib/core/storage/device_identity.dart new file mode 100644 index 0000000..56bcc13 --- /dev/null +++ b/lib/core/storage/device_identity.dart @@ -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 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 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.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)}'; + } +} diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index cd0ea40..cb05ae4 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -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 } @@ -106,11 +107,18 @@ class Connection { ? await RawSocket.connect(host, port) : await RawSocket.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 RawSecureSocket.secure( + rawSocket, + host: host, + onBadCertificate: (_) => true, + ); + } + return RawSecureSocket.secure(rawSocket, host: host); } void write(Uint8List data) { diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index a97b3c8..89c6237 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -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); diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index aaa3187..0c2451a 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -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; } diff --git a/lib/core/transport/tls_config.dart b/lib/core/transport/tls_config.dart new file mode 100644 index 0000000..50d8639 --- /dev/null +++ b/lib/core/transport/tls_config.dart @@ -0,0 +1,15 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +abstract class TlsConfig { + static const String prefKey = 'dev_tls_insecure'; + + static Future isInsecureAllowed() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? false; + } + + static Future setInsecureAllowed(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/core/utils/log_redact.dart b/lib/core/utils/log_redact.dart new file mode 100644 index 0000000..3ad73e8 --- /dev/null +++ b/lib/core/utils/log_redact.dart @@ -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; +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index dc7de36..ffac3c3 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -226,6 +226,74 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: appState == null + ? const SizedBox.shrink() + : ValueListenableBuilder( + 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), diff --git a/lib/main.dart b/lib/main.dart index 37d7504..59eb8ae 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'backend/modules/contacts.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'; @@ -63,6 +64,7 @@ void main() async { final prefs = await SharedPreferences.getInstance(); 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( @@ -76,6 +78,7 @@ void main() async { initialLocale: initialLocale, initialFpsOverlay: initialFpsOverlay, initialVpnBypass: initialVpnBypass, + initialTlsInsecure: initialTlsInsecure, initialFontId: initialFontId, initialFontScale: initialFontScale, initialAccentSeed: initialAccentSeed, @@ -89,6 +92,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 +101,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 +135,9 @@ class KometAppState extends State { late final ValueNotifier vpnBypassEnabled = ValueNotifier( widget.initialVpnBypass, ); + late final ValueNotifier tlsInsecureEnabled = ValueNotifier( + widget.initialTlsInsecure, + ); late final ValueNotifier fontScale = ValueNotifier( widget.initialFontScale, ); @@ -216,6 +224,7 @@ class KometAppState extends State { _profileUpdateController.close(); fpsOverlayEnabled.dispose(); vpnBypassEnabled.dispose(); + tlsInsecureEnabled.dispose(); fontScale.dispose(); accentSeed.dispose(); super.dispose(); @@ -235,6 +244,12 @@ class KometAppState extends State { await prefs.setBool(VpnBypassService.prefKey, value); } + Future setTlsInsecureEnabled(bool value) async { + if (tlsInsecureEnabled.value == value) return; + tlsInsecureEnabled.value = value; + await TlsConfig.setInsecureAllowed(value); + } + Future applyLocale(Locale locale) async { if (!AppLocalizations.supportedLocales.any( (l) => l.languageCode == locale.languageCode, From d3f9b10d0135eec626a31af85628dc7f96f32a20 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 17 May 2026 16:11:30 +0700 Subject: [PATCH 2/7] =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2,=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D1=81=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/file_uploader.dart | 249 +++++++ lib/backend/modules/messages.dart | 93 ++- lib/core/transport/connection.dart | 39 +- lib/core/transport/proxy_connector.dart | 54 +- lib/frontend/screens/chats/chat_screen.dart | 718 +++++++++++++++++--- lib/frontend/widgets/attachment_panel.dart | 407 ++--------- lib/frontend/widgets/message_bubble.dart | 133 ++-- lib/main.dart | 3 + 8 files changed, 1114 insertions(+), 582 deletions(-) create mode 100644 lib/backend/modules/file_uploader.dart diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart new file mode 100644 index 0000000..c6e7903 --- /dev/null +++ b/lib/backend/modules/file_uploader.dart @@ -0,0 +1,249 @@ +import 'dart:async'; +import 'dart:convert' show utf8; +import 'dart:io'; + +import '../api.dart'; +import '../../core/config/proxy_config.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/transport/proxy_connector.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 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(); + var cancelled = false; + Socket? socket; + + ctrl.onCancel = () { + cancelled = true; + try { + socket?.destroy(); + } catch (_) {} + }; + + Future 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 _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) { + 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: 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'); + socket.add(utf8.encode(headers.toString())); + } + + Future _readResponse( + Socket socket, { + required Duration autoForceAfter, + required Duration overallTimeout, + }) { + final responseBytes = []; + final completer = Completer(); + Timer? force; + Timer? overall; + StreamSubscription>? 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 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 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; + } +} diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 6f172ae..2c0a718 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1,5 +1,6 @@ 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'; @@ -55,27 +56,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 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 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 _history = []; + static const _prefKey = 'file_history_v1'; + static const _maxEntries = 50; - static List get history => List.unmodifiable(_history); + static final ValueNotifier> notifier = + ValueNotifier(const []); - static void add(FileHistoryEntry entry) { - _history.insert(0, entry); - if (_history.length > 50) _history.removeLast(); + static List get history => notifier.value; + static bool get isEmpty => notifier.value.isEmpty; + + static SharedPreferences? _prefs; + + static Future 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 = []; + for (final e in list) { + if (e is Map) { + final entry = FileHistoryEntry.fromJson(Map.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 +460,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 +480,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 downloadPhoto(String baseUrl, String photoToken) async { diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index cb05ae4..9aa8869 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -13,8 +13,8 @@ enum SocketState { disconnected, connecting, connected } /// Обёртка над TCP + TLS сокетом. /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. class Connection { - RawSecureSocket? _socket; - StreamSubscription? _subscription; + SecureSocket? _socket; + StreamSubscription? _subscription; SocketState _state = SocketState.disconnected; final _dataController = StreamController.broadcast(); @@ -63,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(); @@ -91,41 +80,41 @@ class Connection { } } - Future _openSecureSocket( + Future _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); } final allowInsecure = await TlsConfig.isInsecureAllowed(); if (allowInsecure) { logger.w( 'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM', ); - return RawSecureSocket.secure( - rawSocket, + return SecureSocket.secure( + socket, host: host, onBadCertificate: (_) => true, ); } - return RawSecureSocket.secure(rawSocket, host: host); + return SecureSocket.secure(socket, host: host); } void write(Uint8List data) { if (_socket == null || !isConnected) { throw StateError('Нельзя писать: сокет не подключён'); } - _socket!.write(data); + _socket!.add(data); } Future disconnect() async { @@ -136,7 +125,7 @@ class Connection { if (socket != null) { try { - socket.close(); + await socket.close(); } catch (e) { logger.w('Ошибка при закрытии сокета: $e'); } diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart index 90d4df6..71108cc 100644 --- a/lib/core/transport/proxy_connector.dart +++ b/lib/core/transport/proxy_connector.dart @@ -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 connect(String targetHost, int targetPort) async { + Future 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 _connectSocks5(String targetHost, int targetPort) async { + Future _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 _connectHttpConnect( + Future _connectHttpConnect( String targetHost, int targetPort, ) async { @@ -194,19 +191,13 @@ class ProxyConnector { } } - // ── Мост: создаём свежий сокет и проксируем через loopback ───────────── - - /// После handshake proxy-сокет уже прослушан (single-subscription). - /// Создаём пару локальных сокетов через loopback и проксируем данные - /// между прокси-сокетом и одним концом. Второй конец возвращаем — - /// он «свежий» и его можно передать в [RawSecureSocket.secure]. - Future _bridgeToFreshSocket( + Future _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(); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index b865f9c..fbaa3a0 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,9 +1,13 @@ import 'dart:async'; +import 'dart:io' show File; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -15,6 +19,22 @@ import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/attachment_panel.dart'; +class _UploadStatus { + final bool active; + final int sent; + final int total; + + const _UploadStatus({ + this.active = false, + this.sent = 0, + this.total = 0, + }); + + bool get awaitingResponse => active && total > 0 && sent >= total; + double? get progressValue => + (!active || total == 0 || awaitingResponse) ? null : sent / total; +} + class _DateSeparatorItem { final DateTime date; final GlobalKey key; @@ -53,6 +73,12 @@ class _ChatScreenState extends State final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; final ValueNotifier _showAttachmentPanel = ValueNotifier(false); + final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); + StreamSubscription? _uploadSub; + int _tempIdCounter = 0; + late final AnimationController _attachAnim; + + String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; late AnimationController _shimmerController; List _messages = []; int _myId = 0; @@ -75,6 +101,12 @@ class _ChatScreenState extends State vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(); + _attachAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 320), + reverseDuration: const Duration(milliseconds: 240), + ); + _showAttachmentPanel.addListener(_onAttachPanelToggle); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), @@ -149,7 +181,11 @@ class _ChatScreenState extends State _floatingDateAnimController.dispose(); _floatingDate.dispose(); _hasText.dispose(); + _showAttachmentPanel.removeListener(_onAttachPanelToggle); _showAttachmentPanel.dispose(); + _uploadSub?.cancel(); + _uploadStatus.dispose(); + _attachAnim.dispose(); _messageController.dispose(); _scrollController.dispose(); _shimmerController.dispose(); @@ -163,6 +199,14 @@ class _ChatScreenState extends State } } + void _onAttachPanelToggle() { + if (_showAttachmentPanel.value) { + _attachAnim.forward(); + } else { + _attachAnim.reverse(); + } + } + String? _effectiveStatus(CachedMessage msg) { if (msg.senderId != _myId) return null; if (msg.status == 'sending' || msg.status == 'error') return msg.status; @@ -182,7 +226,7 @@ class _ChatScreenState extends State final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; - final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final tempId = _nextTempId(); final now = DateTime.now().millisecondsSinceEpoch; try { @@ -555,33 +599,37 @@ class _ChatScreenState extends State ], ), )), - body: Stack( + body: Column( children: [ - Column( - children: [ - Expanded( - child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() - : _buildMessagesList(), - ), - _buildInputArea(context), - ], + Expanded( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), ), - ValueListenableBuilder( - valueListenable: _showAttachmentPanel, - builder: (context, open, _) { - if (!open) return const SizedBox.shrink(); - return Positioned( - left: 0, - right: 0, - bottom: 0, - child: AttachmentPanel( - chatId: widget.chatId, - onClose: () => _showAttachmentPanel.value = false, + AnimatedBuilder( + animation: _attachAnim, + builder: (context, _) { + if (_attachAnim.value == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: Curves.easeOutCubic.transform(_attachAnim.value), + child: Opacity( + opacity: Curves.easeOut.transform(_attachAnim.value), + child: AttachmentPanel( + onClose: () => _showAttachmentPanel.value = false, + onPickFile: _pickAndUploadFile, + onSendById: _sendFileById, + ), + ), + ), ), ); }, ), + _buildInputArea(context), ], ), ); @@ -843,74 +891,147 @@ class _ChatScreenState extends State width: 0.5, ), ), - padding: const EdgeInsets.symmetric(horizontal: 14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + clipBehavior: Clip.hardEdge, + child: Stack( + alignment: Alignment.center, children: [ - Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), - const SizedBox(width: 12), - Expanded( - child: Focus( - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.enter && - !HardwareKeyboard.instance.isShiftPressed) { - if (_hasText.value) _sendMessage(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: TextField( - controller: _messageController, - style: TextStyle(color: cs.onSurface, fontSize: 16), - maxLines: null, - keyboardType: TextInputType.multiline, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - hintText: 'Message', - hintStyle: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 16, + AnimatedBuilder( + animation: _attachAnim, + builder: (context, child) { + final t = _attachAnim.value; + return IgnorePointer( + ignoring: t > 0.5, + child: Opacity(opacity: (1 - t).clamp(0.0, 1.0), child: child), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), + const SizedBox(width: 12), + Expanded( + child: Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + if (_hasText.value) _sendMessage(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _messageController, + style: TextStyle(color: cs.onSurface, fontSize: 16), + maxLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: 'Message', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + ), + ), + ), + ), ), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 14, + _AttachButton( + hasText: _hasText, + panelOpen: _showAttachmentPanel, + uploadStatus: _uploadStatus, + mutedIcon: mutedIcon, + cs: cs, ), - ), + ], ), ), ), - _AttachButton( - hasText: _hasText, - panelOpen: _showAttachmentPanel, - mutedIcon: mutedIcon, - cs: cs, + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SizedBox( + height: 54, + child: AnimatedBuilder( + animation: _attachAnim, + builder: (context, child) { + final t = _attachAnim.value; + return IgnorePointer( + ignoring: t < 0.5, + child: Opacity(opacity: t.clamp(0.0, 1.0), child: child), + ); + }, + child: _HistoryStrip( + anim: _attachAnim, + cs: cs, + onTapEntry: _sendHistoryFile, + ), + ), + ), ), ], ), ), ), - const SizedBox(width: 8), - ValueListenableBuilder( - valueListenable: _hasText, - builder: (context, hasText, _) => Container( - width: 54, - height: 54, - alignment: Alignment.center, - decoration: BoxDecoration( - color: hasText ? cs.primary : cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: GestureDetector( - onTap: hasText ? _sendMessage : null, - child: Icon( - hasText ? Symbols.send : Symbols.mic, - color: hasText ? cs.onPrimary : cs.onSurface, - size: 24, - weight: 400, + AnimatedBuilder( + animation: _attachAnim, + builder: (context, child) { + final t = _attachAnim.value; + return ClipRect( + child: Align( + alignment: Alignment.centerLeft, + widthFactor: (1 - t).clamp(0.0, 1.0), + child: child, ), - ), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + AnimatedBuilder( + animation: _attachAnim, + builder: (context, child) { + final t = _attachAnim.value; + return Transform.translate( + offset: Offset(t * 80, 0), + child: Opacity( + opacity: (1 - t * 1.5).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: ValueListenableBuilder( + valueListenable: _hasText, + builder: (context, hasText, _) => Container( + width: 54, + height: 54, + alignment: Alignment.center, + decoration: BoxDecoration( + color: hasText ? cs.primary : cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: GestureDetector( + onTap: hasText ? _sendMessage : null, + child: Icon( + hasText ? Symbols.send : Symbols.mic, + color: hasText ? cs.onPrimary : cs.onSurface, + size: 24, + weight: 400, + ), + ), + ), + ), + ), + ], ), ), ], @@ -918,26 +1039,204 @@ class _ChatScreenState extends State ), ); } + + String _addOptimisticFileMessage(FileAttachment attachment) { + final now = DateTime.now().millisecondsSinceEpoch; + final tempId = _nextTempId(); + final msg = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + time: now, + status: 'sending', + attachments: [attachment], + ); + setState(() { + _lastSentId = tempId; + _messages.add(msg); + }); + Haptics.send(); + _scrollToBottom(); + return tempId; + } + + void _updateFileMessageStatus( + String tempId, + String status, { + FileAttachment? attachment, + }) { + if (!mounted) return; + final idx = _messages.indexWhere((m) => m.id == tempId); + if (idx == -1) return; + final old = _messages[idx]; + setState(() { + _messages[idx] = CachedMessage( + id: tempId, + accountId: old.accountId, + chatId: old.chatId, + senderId: old.senderId, + text: old.text, + time: old.time, + status: status, + payload: old.payload, + attachments: attachment != null ? [attachment] : old.attachments, + ); + }); + } + + Future _sendHistoryFile(FileHistoryEntry entry) async { + final tempId = _addOptimisticFileMessage(FileAttachment( + fileId: entry.fileId, + fileToken: entry.token, + name: entry.filename, + size: entry.size, + )); + _showAttachmentPanel.value = false; + try { + final ok = await messagesModule.sendFileMessage( + widget.chatId, + entry.fileId, + token: entry.token, + ); + _updateFileMessageStatus(tempId, ok ? 'sent' : 'error'); + } catch (_) { + _updateFileMessageStatus(tempId, 'error'); + } + } + + Future _sendFileById(int fileId) async { + final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId)); + try { + final ok = await messagesModule.sendFileMessage(widget.chatId, fileId); + if (!mounted) return ok; + if (ok) { + FileHistoryCache.add(FileHistoryEntry( + fileId: fileId, + sentAt: DateTime.now(), + )); + _updateFileMessageStatus(tempId, 'sent'); + _showAttachmentPanel.value = false; + } else { + _updateFileMessageStatus(tempId, 'error'); + showCustomNotification(context, 'Ошибка отправки'); + } + return ok; + } catch (e) { + _updateFileMessageStatus(tempId, 'error'); + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + return false; + } + } + + Future _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; + + _showAttachmentPanel.value = false; + _uploadStatus.value = _UploadStatus(active: true, total: file.size); + + final tempId = _addOptimisticFileMessage(FileAttachment( + name: file.name, + size: file.size, + )); + + _uploadSub?.cancel(); + _uploadSub = fileUploader + .upload( + chatId: widget.chatId, + file: File(file.path!), + filename: file.name, + totalSize: file.size, + ) + .listen( + (event) { + if (!mounted) return; + switch (event) { + case UploadProgress(:final sent, :final total): + _uploadStatus.value = _UploadStatus(active: true, sent: sent, total: total); + case UploadDone(:final fileId, :final token, :final url): + FileHistoryCache.add(FileHistoryEntry( + fileId: fileId, + url: url, + token: token, + filename: file.name, + size: file.size, + sentAt: DateTime.now(), + )); + _updateFileMessageStatus( + tempId, + 'sent', + attachment: FileAttachment( + fileId: fileId, + fileToken: token, + name: file.name, + size: file.size, + ), + ); + case UploadError(:final message): + showCustomNotification(context, 'Ошибка: $message'); + _updateFileMessageStatus(tempId, 'error'); + } + }, + onDone: () { + if (!mounted) return; + final inFlight = _messages.firstWhere( + (m) => m.id == tempId, + orElse: () => CachedMessage( + id: '', accountId: 0, chatId: 0, senderId: 0, time: 0, + ), + ); + if (inFlight.id == tempId && inFlight.status == 'sending') { + _updateFileMessageStatus(tempId, 'error'); + } + _uploadStatus.value = const _UploadStatus(); + _uploadSub = null; + }, + onError: (Object e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + _updateFileMessageStatus(tempId, 'error'); + _uploadStatus.value = const _UploadStatus(); + _uploadSub = null; + }, + ); + } } class _AttachButton extends StatelessWidget { final ValueNotifier hasText; final ValueNotifier panelOpen; + final ValueNotifier<_UploadStatus> uploadStatus; final Color mutedIcon; final ColorScheme cs; const _AttachButton({ required this.hasText, required this.panelOpen, + required this.uploadStatus, required this.mutedIcon, required this.cs, }); @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: hasText, - builder: (context, isText, _) { + return ListenableBuilder( + listenable: Listenable.merge([hasText, panelOpen, uploadStatus]), + builder: (context, _) { + final isText = hasText.value; + final open = panelOpen.value; + final status = uploadStatus.value; + final iconColor = status.awaitingResponse + ? cs.primary + : (status.active || open + ? cs.onSurfaceVariant.withValues(alpha: 0.5) + : mutedIcon); + final onTap = (isText || status.active || open) + ? null + : () => panelOpen.value = true; return AnimatedContainer( duration: const Duration(milliseconds: 200), width: isText ? 0 : 36, @@ -946,34 +1245,31 @@ class _AttachButton extends StatelessWidget { opacity: isText ? 0 : 1, child: isText ? const SizedBox.shrink() - : ValueListenableBuilder( - valueListenable: panelOpen, - builder: (context, open, _) => GestureDetector( - onTap: open ? null : () => panelOpen.value = true, - child: Padding( - padding: const EdgeInsets.only(left: 12), - child: Stack( - alignment: Alignment.center, - children: [ - if (open) - SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), + : GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Stack( + alignment: Alignment.center, + children: [ + if (status.active) + SizedBox( + width: 30, + height: 30, + child: CircularProgressIndicator( + strokeWidth: 2, + value: status.progressValue, + color: cs.primary, ), - Icon( - Symbols.attachment, - color: open - ? cs.onSurfaceVariant.withValues(alpha: 0.3) - : mutedIcon, - size: 24, - weight: 400, ), - ], - ), + Icon( + Symbols.attachment, + color: iconColor, + size: 22, + weight: 400, + ), + ], ), ), ), @@ -984,6 +1280,214 @@ class _AttachButton extends StatelessWidget { } } +class _HistoryStrip extends StatelessWidget { + final Animation anim; + final ColorScheme cs; + final Future Function(FileHistoryEntry entry) onTapEntry; + + const _HistoryStrip({ + required this.anim, + required this.cs, + required this.onTapEntry, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FileHistoryCache.notifier, + builder: (context, history, _) { + if (history.isEmpty) { + return Center( + child: AnimatedBuilder( + animation: anim, + builder: (context, _) { + final v = anim.value.clamp(0.0, 1.0); + return Opacity( + opacity: v, + child: Text( + 'история пуста...', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ); + }, + ), + ); + } + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: history.length, + itemBuilder: (ctx, idx) { + final e = history[idx]; + final startInterval = (idx * 0.05).clamp(0.0, 0.45); + return AnimatedBuilder( + animation: anim, + builder: (context, child) { + final raw = ((anim.value - startInterval) / 0.45).clamp(0.0, 1.0); + final v = Curves.easeOutCubic.transform(raw); + return Opacity( + opacity: v, + child: Transform.translate( + offset: Offset(-14 * (1 - v), 0), + child: child, + ), + ); + }, + child: Container( + width: 54, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), + ), + child: Stack(children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTapEntry(e), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _iconForFilename(e.filename), + color: cs.onSurfaceVariant, + size: 22, + ), + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Text( + _labelForEntry(e), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + Positioned( + top: -2, + right: -2, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => FileHistoryCache.remove(e.fileId), + child: Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Icon( + Symbols.close, + size: 12, + color: cs.onSurfaceVariant, + ), + ), + ), + ), + ]), + ), + ); + }, + ); + }, + ); + } +} + +String _labelForEntry(FileHistoryEntry e) { + final n = e.filename; + if (n == null || n.isEmpty) return e.fileId.toString(); + final lastDot = n.lastIndexOf('.'); + return lastDot > 0 ? n.substring(0, lastDot) : n; +} + +IconData _iconForFilename(String? name) { + if (name == null || !name.contains('.')) return Symbols.description; + final ext = name.split('.').last.toLowerCase(); + switch (ext) { + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'webp': + case 'bmp': + case 'heic': + case 'heif': + return Symbols.image; + case 'mp4': + case 'mov': + case 'avi': + case 'mkv': + case 'webm': + case '3gp': + return Symbols.movie; + case 'mp3': + case 'wav': + case 'ogg': + case 'flac': + case 'm4a': + case 'aac': + return Symbols.audio_file; + case 'pdf': + return Symbols.picture_as_pdf; + case 'zip': + case 'rar': + case '7z': + case 'tar': + case 'gz': + return Symbols.folder_zip; + case 'doc': + case 'docx': + case 'txt': + case 'rtf': + case 'odt': + case 'md': + return Symbols.article; + case 'xls': + case 'xlsx': + case 'csv': + return Symbols.table_chart; + case 'ppt': + case 'pptx': + return Symbols.slideshow; + case 'dart': + case 'js': + case 'ts': + case 'py': + case 'java': + case 'kt': + case 'swift': + case 'cpp': + case 'c': + case 'h': + case 'rs': + case 'go': + case 'rb': + case 'php': + case 'html': + case 'css': + case 'json': + case 'xml': + case 'yaml': + case 'yml': + return Symbols.code; + default: + return Symbols.description; + } +} + class _SentMessageAnimation extends StatefulWidget { final Widget child; final VoidCallback onComplete; diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart index ffe3533..afed185 100644 --- a/lib/frontend/widgets/attachment_panel.dart +++ b/lib/frontend/widgets/attachment_panel.dart @@ -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 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 { final TextEditingController _fileIdController = TextEditingController(); - bool _isUploading = false; + bool _sendingById = false; - Future _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 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(); - 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 _rawPost(RawSocket socket, Uri uri, List 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 = [...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))]; - socket.write(Uint8List.fromList(allBytes)); - - final responseBytes = []; - final completer = Completer(); - 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 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 _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 _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 { @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 { 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), + ), + ), + ), + ]), ); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index aa2348f..cc8c460 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1126,77 +1126,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), ], ), + ), ); } diff --git a/lib/main.dart b/lib/main.dart index 59eb8ae..1f4c250 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'backend/modules/account.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'; @@ -29,6 +30,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 _loadInitialLocale() async { final prefs = await SharedPreferences.getInstance(); @@ -62,6 +64,7 @@ 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; From 2150c46508be363129518febe2126dafd40a26e5 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 17 May 2026 17:05:28 +0700 Subject: [PATCH 3/7] =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 84 +++ lib/backend/modules/file_uploader.dart | 98 ++- .../screens/chats/chat_list_screen.dart | 22 +- .../screens/chats/create_group_flow.dart | 594 ++++++++++++++++++ 4 files changed, 790 insertions(+), 8 deletions(-) create mode 100644 lib/frontend/screens/chats/create_group_flow.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 44bf89b..ecf4786 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1,7 +1,10 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../api.dart'; @@ -115,6 +118,34 @@ class CachedChat { } class ChatsModule { + static final ValueNotifier chatsChanged = ValueNotifier(0); + static void _bump() => chatsChanged.value = chatsChanged.value + 1; + + static Future cacheServerChat( + Map chat, + int accountId, + ) async { + final cachedAt = DateTime.now().millisecondsSinceEpoch; + final existingRows = await AppDatabase.loadChats(accountId); + final existing = { + for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row), + }; + final parsed = _parseChat( + chat, + accountId, + accountId, + const {}, + const {}, + const {}, + existing, + cachedAt, + ); + if (parsed == null) return null; + await AppDatabase.saveChats([parsed.toDbRow()]); + _bump(); + return parsed; + } + /// Парсит и кэширует чаты из payload opcode 19. /// /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же @@ -166,6 +197,7 @@ class ChatsModule { if (rows.isNotEmpty) { await AppDatabase.saveChats(rows); + _bump(); } } catch (e) { logger.e("Ошибка при синке: $e"); @@ -355,4 +387,56 @@ class ChatsModule { }); return packet.payload; } + + static Future createGroupChat( + Api api, { + required String title, + required List 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) return null; + final data = packet.payload; + if (data is! Map) return null; + final chat = data['chat']; + if (chat is! Map) return null; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + return cacheServerChat(chat, accountId); + } + + static Future 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 setChatPhoto( + Api api, { + required int chatId, + required String photoToken, + }) async { + final packet = await api.sendRequest(Opcode.chatUpdate, { + 'chatId': chatId, + 'photoToken': photoToken, + }); + return packet.isOk; + } } diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index c6e7903..9b9193f 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -1,6 +1,7 @@ import 'dart:async'; -import 'dart:convert' show utf8; +import 'dart:convert' show jsonDecode, utf8; import 'dart:io'; +import 'dart:typed_data'; import '../api.dart'; import '../../core/config/proxy_config.dart'; @@ -163,12 +164,18 @@ class FileUploader { ); } - void _writeHeaders(Socket socket, Uri uri, String filename, int total) { + 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: application/x-binary; charset=x-user-defined\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') @@ -178,6 +185,91 @@ class FileUploader { socket.add(utf8.encode(headers.toString())); } + Future uploadImage(Uri uri, Uint8List bytes, {String filename = 'avatar.jpg'}) async { + Socket? socket; + try { + socket = await _openSocket(uri); + _writeHeaders(socket, uri, filename, bytes.length, contentType: 'image/jpeg'); + socket.add(bytes); + await socket.flush(); + + final response = await _readFullResponse( + socket, + timeout: const Duration(minutes: 2), + ); + try { + socket.destroy(); + } catch (_) {} + + if (response == null) return null; + final (status, body) = response; + if (status != 200) return null; + return _parsePhotoToken(body); + } catch (_) { + try { + socket?.destroy(); + } catch (_) {} + return null; + } + } + + Future<(int, String)?> _readFullResponse( + Socket socket, { + required Duration timeout, + }) { + final bytes = []; + final completer = Completer<(int, String)?>(); + Timer? timer; + StreamSubscription>? 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 statusLine = headerStr.split('\r\n').first; + final parts = statusLine.split(' '); + final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; + final body = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true); + 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? _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 (_) {} + return null; + } + Future _readResponse( Socket socket, { required Duration autoForceAfter, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index ab2714d..53a28d3 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -7,6 +7,7 @@ import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; +import 'create_group_flow.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -244,9 +245,14 @@ class _ChatListScreenState extends State _reloadChatsAndFolders(); } }); + ChatsModule.chatsChanged.addListener(_onChatsChanged); _reloadChatsAndFolders(); } + void _onChatsChanged() { + if (mounted) _reloadChatsAndFolders(); + } + Future _reloadChatsAndFolders() async { final p = await AppDatabase.loadActiveProfile(); if (p == null) { @@ -700,6 +706,7 @@ class _ChatListScreenState extends State @override void dispose() { + ChatsModule.chatsChanged.removeListener(_onChatsChanged); _loginSub?.cancel(); _stateSub?.cancel(); _fabController.dispose(); @@ -2086,7 +2093,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 +2109,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 +2125,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), diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart new file mode 100644 index 0000000..d651d85 --- /dev/null +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -0,0 +1,594 @@ +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'; + +Future showCreateGroupFlow(BuildContext context) async { + final cs = Theme.of(context).colorScheme; + final selected = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => const _ParticipantsPickerSheet(), + ); + if (selected == null) return; + if (!context.mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => _GroupDetailsSheet(participants: selected), + ); +} + +class _ParticipantsPickerSheet extends StatefulWidget { + const _ParticipantsPickerSheet(); + + @override + State<_ParticipantsPickerSheet> createState() => _ParticipantsPickerSheetState(); +} + +class _ParticipantsPickerSheetState extends State<_ParticipantsPickerSheet> { + final TextEditingController _search = TextEditingController(); + List _all = []; + final Set _selectedIds = {}; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() 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); + } + } + + String _displayName(CachedContact c) { + final last = c.lastName ?? ''; + return last.isEmpty ? c.firstName : '${c.firstName} $last'; + } + + String _statusText(CachedContact c) { + if (c.isBot) return 'Бот'; + return 'Был(-а) недавно'; + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final viewInsets = MediaQuery.of(context).viewInsets; + final query = _search.text.trim().toLowerCase(); + final filtered = query.isEmpty + ? _all + : _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList(); + final selected = _all.where((c) => _selectedIds.contains(c.id)).toList(); + + return Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85), + child: 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: () => setState(() => _selectedIds.remove(c.id)), + 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 = _selectedIds.contains(c.id); + final dim = c.isBot; + return InkWell( + onTap: () { + setState(() { + if (picked) { + _selectedIds.remove(c.id); + } else { + _selectedIds.add(c.id); + } + }); + }, + 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: () { + final picked = _all + .where((c) => _selectedIds.contains(c.id)) + .toList(); + Navigator.pop(context, picked); + }, + cs: cs, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _GroupDetailsSheet extends StatefulWidget { + final List participants; + const _GroupDetailsSheet({required this.participants}); + + @override + State<_GroupDetailsSheet> createState() => _GroupDetailsSheetState(); +} + +class _GroupDetailsSheetState extends State<_GroupDetailsSheet> { + final TextEditingController _title = TextEditingController(); + File? _avatar; + bool _creating = false; + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + Future _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; + setState(() => _avatar = File(path)); + } + + Future _create() async { + final title = _title.text.trim(); + if (title.isEmpty || _creating) return; + setState(() => _creating = true); + try { + final chat = await ChatsModule.createGroupChat( + api, + title: title, + userIds: widget.participants.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); + if (token != null) { + await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); + } + } + } + + if (!mounted) return; + Navigator.pop(context); + Navigator.push( + context, + 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); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final viewInsets = MediaQuery.of(context).viewInsets; + final canCreate = _title.text.trim().isNotEmpty && !_creating; + return Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 4), + child: Row( + children: [ + IconButton( + onPressed: _creating ? null : () => Navigator.pop(context), + 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); + Navigator.maybePop(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, + ), + ), + ), + ); + } +} From 80b297f7b3e9d239601cbd7652e7461074be6118 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 17 May 2026 18:29:28 +0700 Subject: [PATCH 4/7] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B1=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B2=20chats=D1=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 200 ++++- lib/backend/modules/file_uploader.dart | 101 ++- lib/backend/modules/messages.dart | 3 + lib/core/storage/app_database.dart | 33 +- .../screens/chats/chat_list_screen.dart | 186 ++++- .../screens/chats/create_group_flow.dart | 682 +++++++++--------- 6 files changed, 841 insertions(+), 364 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index ecf4786..308b8a2 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1,12 +1,15 @@ +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 'messages.dart' show ContactCache; Map _parseParticipants(dynamic raw) { try { @@ -43,6 +46,8 @@ class CachedChat { final int seenTime; final Map participants; final Set options; + final int? owner; + final Set admins; CachedChat({ required this.id, @@ -63,12 +68,16 @@ 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); + factory CachedChat.fromDbRow(Map row) => CachedChat( id: row['id'] as int, accountId: row['account_id'] as int, @@ -88,6 +97,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 _decodeOptions(dynamic raw) { @@ -95,6 +106,15 @@ class CachedChat { return raw.split(',').where((s) => s.isNotEmpty).toSet(); } + static Set _decodeAdmins(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw + .split(',') + .map((s) => int.tryParse(s.trim())) + .whereType() + .toSet(); + } + Map toDbRow() => { 'id': id, 'account_id': accountId, @@ -114,6 +134,8 @@ 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(','), }; } @@ -121,15 +143,68 @@ class ChatsModule { static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; + static final Set _pendingContactUpdates = {}; + static Timer? _contactFlushTimer; + static const _contactFlushDelay = Duration(milliseconds: 250); + + static void applyContactUpdate(int contactId) { + _pendingContactUpdates.add(contactId); + _contactFlushTimer ??= Timer(_contactFlushDelay, _flushContactUpdates); + } + + static Future _flushContactUpdates() async { + _contactFlushTimer = null; + if (_pendingContactUpdates.isEmpty) return; + final ids = _pendingContactUpdates.toList(); + _pendingContactUpdates.clear(); + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final updates = >[]; + 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 {}; + + final rows = await AppDatabase.findDialogChatsByParticipant( + accountId, + contactId, + ); + for (final row in rows) { + 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.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 cacheServerChat( Map chat, int accountId, ) async { final cachedAt = DateTime.now().millisecondsSinceEpoch; - final existingRows = await AppDatabase.loadChats(accountId); - final existing = { - for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row), - }; + final id = chat['id']; + Map existing = const {}; + 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, @@ -140,7 +215,10 @@ class ChatsModule { existing, cachedAt, ); - if (parsed == null) return null; + if (parsed == null) { + logger.w('cacheServerChat: parse returned null for chat=${chat['id']}'); + return null; + } await AppDatabase.saveChats([parsed.toDbRow()]); _bump(); return parsed; @@ -306,6 +384,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; + } } @@ -320,6 +404,31 @@ class ChatsModule { } Map 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 admins = const {}; + final adminsRaw = chat['admins']; + if (adminsRaw is List) { + admins = adminsRaw + .map((e) => e is int ? e : int.tryParse(e.toString())) + .whereType() + .toSet(); + } else { + final adminParticipants = chat['adminParticipants']; + if (adminParticipants is Map) { + admins = adminParticipants.keys + .map((k) => k is int ? k : int.tryParse(k.toString())) + .whereType() + .toSet(); + } + } + return CachedChat( id: id, accountId: accountId, @@ -339,6 +448,8 @@ class ChatsModule { seenTime: seenTime, participants: participants, options: options, + owner: owner, + admins: admins, ); } catch (e) { logger.e("Ошибка при парсинге чата: $e"); @@ -410,13 +521,25 @@ class ChatsModule { 'notify': notify, }; final packet = await api.sendRequest(Opcode.msgSend, payload); - if (!packet.isOk) return null; + if (!packet.isOk) { + logger.w('createGroupChat: server error payload=${packet.payload}'); + return null; + } final data = packet.payload; - if (data is! Map) return null; + if (data is! Map) { + logger.w('createGroupChat: payload is not a Map: $data'); + return null; + } final chat = data['chat']; - if (chat is! Map) return null; + if (chat is! Map) { + logger.w('createGroupChat: response has no chat field: $data'); + return null; + } final accountId = await TokenStorage.getActiveAccountId(); - if (accountId == null) return null; + if (accountId == null) { + logger.w('createGroupChat: no active account id'); + return null; + } return cacheServerChat(chat, accountId); } @@ -439,4 +562,63 @@ class ChatsModule { }); return packet.isOk; } + + static Future 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> refreshChats( + Api api, + List 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 out = []; + for (final c in list) { + if (c is Map) { + final cached = await cacheServerChat(c, accountId); + 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 []; + } + } } diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 9b9193f..2aae308 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -7,6 +7,7 @@ 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 { @@ -189,7 +190,12 @@ class FileUploader { Socket? socket; try { socket = await _openSocket(uri); - _writeHeaders(socket, uri, filename, bytes.length, contentType: 'image/jpeg'); + _writeImageHeaders( + socket, + uri, + bytes.length, + contentType: _contentTypeForFilename(filename), + ); socket.add(bytes); await socket.flush(); @@ -201,11 +207,22 @@ class FileUploader { socket.destroy(); } catch (_) {} - if (response == null) return null; + if (response == null) { + logger.w('uploadImage: empty/timed-out response'); + return null; + } final (status, body) = response; - if (status != 200) return null; - return _parsePhotoToken(body); - } catch (_) { + 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 (_) {} @@ -213,6 +230,40 @@ class FileUploader { } } + 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, @@ -232,10 +283,15 @@ class FileUploader { return; } final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true); - final statusLine = headerStr.split('\r\n').first; - final parts = statusLine.split(' '); + final lines = headerStr.split('\r\n'); + final parts = lines.first.split(' '); final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; - final body = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true); + 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)); } @@ -250,6 +306,31 @@ class FileUploader { 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); @@ -266,7 +347,9 @@ class FileUploader { final pt = json['photoToken']; if (pt is String && pt.isNotEmpty) return pt; } - } catch (_) {} + } catch (e) { + logger.w('parsePhotoToken: $e'); + } return null; } diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 2c0a718..e64996e 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -5,6 +5,7 @@ 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 _nameCache = {}; @@ -21,6 +22,7 @@ class ContactCache { static String? get(int id) => _nameCache[id]; static String? getAvatar(int id) => _avatarCache[id]; + static Set? getOptions(int id) => _optionsCache[id]; static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; } @@ -641,6 +643,7 @@ class MessagesModule { ContactCache.putOptions(contactId, rawOpts.whereType().toSet()); } + ChatsModule.applyContactUpdate(contactId); return fullName; } } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7f7ff13..06dab07 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -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,27 @@ class AppDatabase { ); } + static Future>> findDialogChatsByParticipant( + int accountId, + int contactId, + ) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: "account_id = ? AND type = 'DIALOG' AND participants LIKE ?", + whereArgs: [accountId, '%"$contactId":%'], + ); + } + + static Future 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 clearChatsCache(int accountId) async { final db = await _instance; await db.delete( diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 53a28d3..1597438 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,6 +8,7 @@ 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'; @@ -61,6 +62,8 @@ class ChatListScreen extends StatefulWidget { State createState() => _ChatListScreenState(); } +enum _DeleteKind { personalLike, ownerGroup, blocked } + class _ChatListScreenState extends State with TickerProviderStateMixin { String? _selectedFolderId; @@ -168,6 +171,176 @@ class _ChatListScreenState extends State }); } + List _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? _selectionDeleteCategory() { + if (_sessionState != SessionState.online) return null; + final myId = _profile?.id; + if (myId == null) return null; + final selected = _selectedChatObjects(); + 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 _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 = []; + 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 _showDeleteConfirmDialog( + List 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( + 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); @@ -393,7 +566,9 @@ class _ChatListScreenState extends State if (!mounted) return; _contactRebuildTimer?.cancel(); _contactRebuildTimer = Timer(const Duration(milliseconds: 120), () { - if (mounted) setState(() {}); + if (!mounted) return; + _cachedChatsBody = null; + setState(() {}); }); } @@ -1644,10 +1819,11 @@ class _ChatListScreenState extends State ), ), const Spacer(), - IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: () {}, - ), + if (_selectionDeleteCategory() != null) + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: _onDeleteTap, + ), IconButton( icon: Icon(Symbols.archive, color: cs.onSurface), onPressed: () {}, diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index d651d85..fed22a6 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -12,19 +12,10 @@ import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import 'chat_screen.dart'; +const int _maxAvatarBytes = 8 * 1024 * 1024; + Future showCreateGroupFlow(BuildContext context) async { final cs = Theme.of(context).colorScheme; - final selected = await showModalBottomSheet>( - context: context, - isScrollControlled: true, - backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (_) => const _ParticipantsPickerSheet(), - ); - if (selected == null) return; - if (!context.mounted) return; await showModalBottomSheet( context: context, isScrollControlled: true, @@ -32,30 +23,43 @@ Future showCreateGroupFlow(BuildContext context) async { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), - builder: (_) => _GroupDetailsSheet(participants: selected), + builder: (_) => const _CreateGroupFlow(), ); } -class _ParticipantsPickerSheet extends StatefulWidget { - const _ParticipantsPickerSheet(); +enum _Step { pickParticipants, groupDetails } + +class _CreateGroupFlow extends StatefulWidget { + const _CreateGroupFlow(); @override - State<_ParticipantsPickerSheet> createState() => _ParticipantsPickerSheetState(); + State<_CreateGroupFlow> createState() => _CreateGroupFlowState(); } -class _ParticipantsPickerSheetState extends State<_ParticipantsPickerSheet> { - final TextEditingController _search = TextEditingController(); +class _CreateGroupFlowState extends State<_CreateGroupFlow> { + _Step _step = _Step.pickParticipants; List _all = []; - final Set _selectedIds = {}; + final List _selected = []; bool _loading = true; + final TextEditingController _search = TextEditingController(); + final TextEditingController _title = TextEditingController(); + File? _avatar; + bool _creating = false; @override void initState() { super.initState(); - _load(); + _loadContacts(); } - Future _load() async { + @override + void dispose() { + _search.dispose(); + _title.dispose(); + super.dispose(); + } + + Future _loadContacts() async { try { final myId = await TokenStorage.getActiveAccountId(); if (myId == null) { @@ -75,223 +79,18 @@ class _ParticipantsPickerSheetState extends State<_ParticipantsPickerSheet> { } } - String _displayName(CachedContact c) { - final last = c.lastName ?? ''; - return last.isEmpty ? c.firstName : '${c.firstName} $last'; + void _toggle(CachedContact c) { + setState(() { + final idx = _selected.indexWhere((x) => x.id == c.id); + if (idx >= 0) { + _selected.removeAt(idx); + } else { + _selected.add(c); + } + }); } - String _statusText(CachedContact c) { - if (c.isBot) return 'Бот'; - return 'Был(-а) недавно'; - } - - @override - void dispose() { - _search.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final viewInsets = MediaQuery.of(context).viewInsets; - final query = _search.text.trim().toLowerCase(); - final filtered = query.isEmpty - ? _all - : _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList(); - final selected = _all.where((c) => _selectedIds.contains(c.id)).toList(); - - return Padding( - padding: EdgeInsets.only(bottom: viewInsets.bottom), - child: SafeArea( - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85), - child: 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: () => setState(() => _selectedIds.remove(c.id)), - 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 = _selectedIds.contains(c.id); - final dim = c.isBot; - return InkWell( - onTap: () { - setState(() { - if (picked) { - _selectedIds.remove(c.id); - } else { - _selectedIds.add(c.id); - } - }); - }, - 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: () { - final picked = _all - .where((c) => _selectedIds.contains(c.id)) - .toList(); - Navigator.pop(context, picked); - }, - cs: cs, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class _GroupDetailsSheet extends StatefulWidget { - final List participants; - const _GroupDetailsSheet({required this.participants}); - - @override - State<_GroupDetailsSheet> createState() => _GroupDetailsSheetState(); -} - -class _GroupDetailsSheetState extends State<_GroupDetailsSheet> { - final TextEditingController _title = TextEditingController(); - File? _avatar; - bool _creating = false; - - @override - void dispose() { - _title.dispose(); - super.dispose(); - } + bool _isSelected(int id) => _selected.any((c) => c.id == id); Future _pickAvatar() async { if (_creating) return; @@ -299,18 +98,27 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> { if (result == null || result.files.isEmpty) return; final path = result.files.first.path; if (path == null) return; - setState(() => _avatar = File(path)); + 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 _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: widget.participants.map((c) => c.id).toList(), + userIds: _selected.map((c) => c.id).toList(), ); if (!mounted) return; if (chat == null) { @@ -323,17 +131,22 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> { final url = await ChatsModule.requestChatPhotoUploadUrl(api); if (url != null) { final bytes = await _avatar!.readAsBytes(); - final token = await fileUploader.uploadImage(Uri.parse(url), bytes); + 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(context); - Navigator.push( - context, + navigator.pop(); + navigator.push( MaterialPageRoute( builder: (_) => ChatScreen( chatId: chat.id, @@ -351,113 +164,304 @@ class _GroupDetailsSheetState extends State<_GroupDetailsSheet> { } } + 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 cs = Theme.of(context).colorScheme; final viewInsets = MediaQuery.of(context).viewInsets; - final canCreate = _title.text.trim().isNotEmpty && !_creating; return Padding( padding: EdgeInsets.only(bottom: viewInsets.bottom), child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 12, 8, 4), - child: Row( - children: [ - IconButton( - onPressed: _creating ? null : () => Navigator.pop(context), - icon: Icon(Symbols.arrow_back, color: cs.onSurfaceVariant), + 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(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(), ), - Expanded( - child: Text( - 'Создать группу', - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - ), - IconButton( - onPressed: _creating - ? null - : () { - Navigator.pop(context); - Navigator.maybePop(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, - ), - ), - ], - ), - ), - ], + ), ), ), ); } + + 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 { @@ -570,9 +574,7 @@ class _SheetButton extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( color: filled - ? (disabled - ? cs.primary.withValues(alpha: 0.4) - : cs.primary) + ? (disabled ? cs.primary.withValues(alpha: 0.4) : cs.primary) : cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(22), ), From 7fd0206c34939d36529c7f44a793619e508c20e2 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 17 May 2026 19:49:29 +0700 Subject: [PATCH 5/7] =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=80=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D0=BC=D1=8C=D1=8E=D1=82=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 184 ++++++++++++++++-- lib/backend/modules/folders.dart | 51 +++++ lib/core/config/app_bubble_behavior.dart | 37 ++++ lib/core/storage/app_database.dart | 23 ++- lib/core/utils/bubble_radius.dart | 81 ++++++++ .../screens/chats/chat_list_screen.dart | 122 ++++++++---- .../screens/profile/appearance_screen.dart | 164 +++++++++++++--- lib/frontend/widgets/message_bubble.dart | 85 ++------ lib/main.dart | 2 + 9 files changed, 601 insertions(+), 148 deletions(-) create mode 100644 lib/core/config/app_bubble_behavior.dart create mode 100644 lib/core/utils/bubble_radius.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 308b8a2..b1f4359 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -9,6 +9,7 @@ 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 _parseParticipants(dynamic raw) { @@ -78,6 +79,12 @@ class CachedChat { 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 row) => CachedChat( id: row['id'] as int, accountId: row['account_id'] as int, @@ -140,20 +147,36 @@ class CachedChat { } class ChatsModule { + static const int muteOff = 0; + static const int muteForever = -1; + static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; static final Set _pendingContactUpdates = {}; static Timer? _contactFlushTimer; + static Future? _contactFlushFuture; static const _contactFlushDelay = Duration(milliseconds: 250); static void applyContactUpdate(int contactId) { _pendingContactUpdates.add(contactId); - _contactFlushTimer ??= Timer(_contactFlushDelay, _flushContactUpdates); + 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 _flushContactUpdates() async { - _contactFlushTimer = null; if (_pendingContactUpdates.isEmpty) return; final ids = _pendingContactUpdates.toList(); _pendingContactUpdates.clear(); @@ -161,18 +184,25 @@ class ChatsModule { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; + final dialogRows = await AppDatabase.loadDialogChats(accountId); + final byParticipant = >>{}; + 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 = >[]; 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 {}; - - final rows = await AppDatabase.findDialogChatsByParticipant( - accountId, - contactId, - ); - for (final row in rows) { + 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 ?? ''); @@ -194,12 +224,15 @@ class ChatsModule { static Future cacheServerChat( Map chat, - int accountId, - ) async { + int accountId, { + Map? preloadedExisting, + }) async { final cachedAt = DateTime.now().millisecondsSinceEpoch; final id = chat['id']; Map existing = const {}; - if (id is int) { + 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)}; @@ -219,11 +252,40 @@ class ChatsModule { 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] того же @@ -563,6 +625,95 @@ class ChatsModule { return packet.isOk; } + static Future togglePin( + Api api, { + required List 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.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 = >[]; + 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.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 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.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 deleteChat( Api api, { required int chatId, @@ -605,10 +756,19 @@ class ChatsModule { 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 = []; for (final c in list) { if (c is Map) { - final cached = await cacheServerChat(c, accountId); + final cached = await cacheServerChat( + c, + accountId, + preloadedExisting: preloadedExisting, + ); if (cached != null) out.add(cached); } } diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart index 7f66fd2..b2856d8 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -179,6 +179,57 @@ class FoldersModule { await markFoldersListReady(accountId); } + static Future setFolderFavorites( + Api api, + int accountId, + ChatFolder folder, + List 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 + ? folderJson + : Map.from(folderJson), + ); + + final currentRaw = await AppDatabase.getSyncValue(accountId, _syncKey); + final snapshot = (currentRaw != null && currentRaw.isNotEmpty) + ? jsonDecode(currentRaw) as Map + : {}; + final existing = (snapshot['folders'] as List?) + ?.map((e) { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + }) + .toList() ?? + []; + 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?; + await _persist(accountId, existing, order); + return updated; + } + static Future syncFromServer(Api api, int accountId) async { try { final packet = await api.sendRequest(Opcode.foldersGet, { diff --git a/lib/core/config/app_bubble_behavior.dart b/lib/core/config/app_bubble_behavior.dart new file mode 100644 index 0000000..9ea84cf --- /dev/null +++ b/lib/core/config/app_bubble_behavior.dart @@ -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 current = ValueNotifier( + BubbleBehavior.mutable, + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getString(prefKey); + return _parse(val); + } + + static Future 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 'Неизменяемая'; + } + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 06dab07..3b61d69 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -471,15 +471,26 @@ class AppDatabase { ); } - static Future>> findDialogChatsByParticipant( - int accountId, - int contactId, - ) async { + static Future>> loadDialogChats(int accountId) async { final db = await _instance; return db.query( 'chats_cache', - where: "account_id = ? AND type = 'DIALOG' AND participants LIKE ?", - whereArgs: [accountId, '%"$contactId":%'], + where: "account_id = ? AND type = 'DIALOG'", + whereArgs: [accountId], + ); + } + + static Future>> loadChatsByIds( + int accountId, + List 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], ); } diff --git a/lib/core/utils/bubble_radius.dart b/lib/core/utils/bubble_radius.dart new file mode 100644 index 0000000..7c45ca3 --- /dev/null +++ b/lib/core/utils/bubble_radius.dart @@ -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, + ); +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1597438..60b4d53 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -183,11 +183,10 @@ class _ChatListScreenState extends State return _DeleteKind.blocked; } - _DeleteKind? _selectionDeleteCategory() { + _DeleteKind? _selectionDeleteCategoryFor(List selected) { if (_sessionState != SessionState.online) return null; final myId = _profile?.id; if (myId == null) return null; - final selected = _selectedChatObjects(); if (selected.isEmpty) return null; final cats = selected.map((c) => _categorizeChat(c, myId)).toSet(); if (cats.contains(_DeleteKind.blocked)) return null; @@ -195,6 +194,47 @@ class _ChatListScreenState extends State return cats.single; } + Future _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 _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 = []; + 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 _onDeleteTap() async { final selectedBefore = _selectedChatObjects(); if (selectedBefore.isEmpty) return; @@ -1328,7 +1368,7 @@ class _ChatListScreenState extends State avatar ?? "", isOnline: chat.isOnline, unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, + isMuted: chat.isMuted, isVerified: isVerified, isPinned: isPinned, chatType: "DIALOG", @@ -1358,7 +1398,7 @@ class _ChatListScreenState extends State : '', isOnline: chat.isOnline, unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, + isMuted: chat.isMuted, isVerified: chat.isOfficial, isPinned: isPinned, chatType: chat.type, @@ -1803,37 +1843,53 @@ class _ChatListScreenState extends State ), ], ), - 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, - ), - ), - const Spacer(), - if (_selectionDeleteCategory() != null) + 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.delete, color: cs.onSurface), - onPressed: _onDeleteTap, + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, ), - 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, + ), + ], + ); + }), ), ), ], diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index c74aaaf..09c96fd 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -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 { 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 { ), 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( - 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 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( + valueListenable: AppBubbleBehavior.current, + builder: (context, current, _) { + return SegmentedButton( + 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 onChanged; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index cc8c460..dfe82b3 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -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( - 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, diff --git a/lib/main.dart b/lib/main.dart index 1f4c250..951c017 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ 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'; @@ -75,6 +76,7 @@ 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( From a9c008ea8f58b8596898528c5d79f817ee0b70d6 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 17 May 2026 21:24:59 +0700 Subject: [PATCH 6/7] =?UTF-8?q?=D0=94=D0=90=D0=9D=D0=9E=D0=9D=20=D0=9F?= =?UTF-8?q?=D0=A0=D0=90=D0=91=D0=98=D0=A4=20=D0=94=D0=9E=D0=9A=D0=A1=20?= =?UTF-8?q?=D0=A1=D0=92=D0=AF=D0=A2=20=D0=9F=D0=A0=D0=9E=D0=A4=D0=98=D0=9B?= =?UTF-8?q?=D0=95=D0=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/storage/app_database.dart | 13 + .../screens/chats/chat_info_screen.dart | 131 ++++- lib/frontend/screens/chats/chat_screen.dart | 9 + .../contacts/contact_profile_screen.dart | 456 ++++++++++++++++++ .../screens/contacts/contacts_tab.dart | 209 +++++++- .../screens/profile/debug_menu_screen.dart | 440 +++++++++++++++-- 6 files changed, 1198 insertions(+), 60 deletions(-) create mode 100644 lib/frontend/screens/contacts/contact_profile_screen.dart diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 3b61d69..8a1d41b 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -471,6 +471,19 @@ class AppDatabase { ); } + static Future 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>> loadDialogChats(int accountId) async { final db = await _instance; return db.query( diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 7e93b79..777c32c 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -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 { int _myId = 0; bool _isLoading = true; + bool _extraContactExpanded = false; Map? _chatData; String _selectedTab = ''; bool _descExpanded = false; @@ -921,34 +923,121 @@ class _ChatInfoScreenState extends State { 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().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, ], ), ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index fbaa3a0..003b00a 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -270,6 +270,15 @@ class _ChatScreenState extends State ); }); } + + if (chat == null) { + unawaited( + ChatsModule.refreshChats(api, [widget.chatId]).then((list) { + if (!mounted || list.isEmpty) return; + setState(() => chat = list.first); + }), + ); + } } catch (e) { Haptics.error(); final index = _messages.indexWhere((m) => m.id == tempId); diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart new file mode 100644 index 0000000..034674b --- /dev/null +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -0,0 +1,456 @@ +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 createState() => _ContactProfileScreenState(); +} + +class _ContactProfileScreenState extends State { + bool _loading = true; + Map? _contact; + int? _seenTime; + bool _isOnline = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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.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?; + _isOnline = ((p['status'] as int?) ?? 0) > 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 _options() { + final raw = _contact?['options']; + if (raw is List) return raw.whereType().toSet(); + return const {}; + } + + bool get _isBot => _options().contains('BOT'); + bool get _isVerified => _options().contains('OFFICIAL'); + + String _subtitle() { + if (_isBot) return 'Бот'; + if (_isOnline) 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 _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 = []; + + 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, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 7c15683..3e4abdc 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -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 { _loadContacts(); } + Future _openSearchById() async { + final cs = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => const _SearchContactSheet(), + ); + } + Future _loadContacts() async { final p = await AppDatabase.loadActiveProfile(); if (p == null) { @@ -67,7 +84,16 @@ class _ContactsTabState extends State { 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 { ), IconButton( icon: Icon(Symbols.search, color: cs.onSurface), - onPressed: () {}, + onPressed: _openSearchById, ), ], ), @@ -216,3 +242,182 @@ class _ContactsTabState extends State { ); } } + +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 _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.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('Найти'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index ffac3c3..ed594bc 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -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 { final _idController = TextEditingController(); - String? _searchResult; bool _isSearching = false; + bool _hasSearched = false; + final List<_SearchHit> _hits = []; + final Map _errors = {}; @override void dispose() { @@ -27,26 +34,57 @@ class _DebugMenuScreenState extends State { 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 tryProbe(String label, Future 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); } } @@ -307,13 +345,21 @@ class _DebugMenuScreenState extends State { 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: [ @@ -322,7 +368,7 @@ class _DebugMenuScreenState extends State { controller: _idController, keyboardType: TextInputType.number, decoration: InputDecoration( - hintText: 'Введите user ID', + hintText: 'Введите ID', border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), @@ -349,27 +395,24 @@ class _DebugMenuScreenState extends State { ), ], ), - 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), + ], ], ], ), @@ -382,4 +425,327 @@ class _DebugMenuScreenState extends State { ), ); } +} + +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().toSet() + : {}; + 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, + ), + ), + ], + ), + ); + } } \ No newline at end of file From 99b87a56c76b644a8ea63dc8154048a9ae66e126 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 17 May 2026 23:26:50 +0700 Subject: [PATCH 7/7] =?UTF-8?q?=D0=BD=D0=B5=D0=BC=D0=BD=D0=BE=D0=B6=D0=BA?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B0=D0=BB=20=D1=82=D0=B0=D0=B9=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=B2=20chat=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 36 ++++ lib/core/protocol/packet.dart | 4 +- .../screens/chats/chat_info_screen.dart | 16 +- lib/frontend/screens/chats/chat_screen.dart | 197 +++++++++++++++++- .../contacts/contact_profile_screen.dart | 7 +- .../screens/profile/settings_tab.dart | 143 +++++++++++-- lib/main.dart | 2 + 7 files changed, 366 insertions(+), 39 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index b1f4359..5cc7cca 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -153,6 +153,42 @@ class ChatsModule { static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; + static StreamSubscription? _globalPushSub; + + static void attachGlobalPushHandlers(Api api) { + _globalPushSub?.cancel(); + _globalPushSub = api.pushStream.listen(_handleGlobalPush); + } + + static Future _handleGlobalPush(Packet packet) async { + switch (packet.opcode) { + case Opcode.notifMark: + await _handleNotifMark(packet); + } + } + + static Future _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 _pendingContactUpdates = {}; static Timer? _contactFlushTimer; static Future? _contactFlushFuture; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index d8527a1..592ddd2 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -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; // ответ: не найдено diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 777c32c..ef5a480 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -58,6 +58,7 @@ class _ChatInfoScreenState extends State { Map? _contactData; int? _seenTime; bool _isOnline = false; + int _presenceStatus = 0; bool _isBot = false; // CHAT @@ -144,7 +145,9 @@ class _ChatInfoScreenState extends State { 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; } } } @@ -183,7 +186,7 @@ class _ChatInfoScreenState extends State { _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); @@ -328,7 +331,10 @@ class _ChatInfoScreenState extends State { 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 = @@ -1075,8 +1081,8 @@ class _ChatInfoScreenState extends State { // ─── 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} ч назад'; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 003b00a..c45bf37 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -12,6 +12,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; @@ -75,6 +77,12 @@ class _ChatScreenState extends State final ValueNotifier _showAttachmentPanel = ValueNotifier(false); final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); StreamSubscription? _uploadSub; + StreamSubscription? _pushSub; + final Set _typingUserIds = {}; + final Map _typingTimers = {}; + int _otherStatus = 0; + int? _otherSeenTime; + final ValueNotifier _headerStatusNotifier = ValueNotifier(''); int _tempIdCounter = 0; late final AnimationController _attachAnim; @@ -107,6 +115,12 @@ class _ChatScreenState extends State reverseDuration: const Duration(milliseconds: 240), ); _showAttachmentPanel.addListener(_onAttachPanelToggle); + _pushSub = api.pushStream + .where((p) => + p.opcode == Opcode.notifMessage || + p.opcode == Opcode.notifMark || + p.opcode == Opcode.notifTyping) + .listen(_onIncomingPush); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), @@ -127,8 +141,12 @@ class _ChatScreenState extends State ChatsModule.getChat(_myId, widget.chatId).then((value) { if (mounted && value.isNotEmpty) { setState(() { chat = value.first; }); + _recomputeHeaderStatus(); } }).catchError((_) {}); + if (widget.chatType == 'DIALOG') { + unawaited(_loadOtherPresence()); + } final cachedRows = await AppDatabase.loadMessages( _myId, @@ -184,6 +202,12 @@ class _ChatScreenState extends State _showAttachmentPanel.removeListener(_onAttachPanelToggle); _showAttachmentPanel.dispose(); _uploadSub?.cancel(); + _pushSub?.cancel(); + for (final t in _typingTimers.values) { + t.cancel(); + } + _typingTimers.clear(); + _headerStatusNotifier.dispose(); _uploadStatus.dispose(); _attachAnim.dispose(); _messageController.dispose(); @@ -222,6 +246,161 @@ class _ChatScreenState extends State return 'sent'; } + void _onIncomingPush(Packet packet) { + if (!mounted) return; + switch (packet.opcode) { + case Opcode.notifMessage: + _onIncomingMessage(packet); + case Opcode.notifMark: + _onMessageRead(packet); + case Opcode.notifTyping: + _onTyping(packet); + } + } + + Future _loadOtherPresence() async { + if (_myId == 0) return; + final otherId = widget.chatId ^ _myId; + if (otherId <= 0) return; + try { + final p = await api.sendRequest( + Opcode.contactPresence, + {'contactIds': [otherId]}, + ); + if (!mounted) return; + final presence = (p.payload as Map?)?['presence'] as Map?; + final entry = presence?[otherId.toString()] ?? presence?[otherId]; + if (entry is Map) { + _otherStatus = (entry['status'] as int?) ?? 0; + _otherSeenTime = entry['seen'] as int?; + _recomputeHeaderStatus(); + } + } catch (_) {} + } + + String _formatLastSeen(int secondsSinceEpoch) { + final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); + final diff = DateTime.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} дн назад'; + const months = [ + 'янв', 'фев', 'мар', 'апр', 'мая', 'июн', + 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', + ]; + return 'Был(-а) ${dt.day} ${months[dt.month - 1]} ${dt.year}'; + } + + void _recomputeHeaderStatus() { + _headerStatusNotifier.value = _headerStatus(); + } + + String _headerStatus() { + if (_typingUserIds.isNotEmpty) return 'Печатает...'; + if (widget.chatType == 'CHAT') { + return '${chat?.participants.length ?? 0} участников'; + } + if (widget.chatType == 'CHANNEL') { + return '${chat?.participants.length ?? 0} подписчиков'; + } + if (_otherStatus == 1) return 'В сети'; + if (_otherStatus == 3) return 'Был(-а) недавно'; + final s = _otherSeenTime; + if (s != null && s > 0) return _formatLastSeen(s); + return ''; + } + + void _onTyping(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + if (payload['chatId'] != widget.chatId) return; + final userId = payload['userId']; + if (userId is! int || userId == _myId) return; + + _typingTimers[userId]?.cancel(); + _typingTimers[userId] = Timer(const Duration(seconds: 10), () { + if (!mounted) return; + _typingUserIds.remove(userId); + _typingTimers.remove(userId); + _recomputeHeaderStatus(); + }); + if (_typingUserIds.add(userId)) { + _recomputeHeaderStatus(); + } + } + + void _clearTyping(int userId) { + _typingTimers.remove(userId)?.cancel(); + if (_typingUserIds.remove(userId)) { + _recomputeHeaderStatus(); + } + } + + void _onMessageRead(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + if (payload['chatId'] != widget.chatId) return; + final userId = payload['userId']; + if (userId is! int || userId == _myId) return; + final mark = payload['mark']; + if (mark is! int) return; + if (payload['setAsUnread'] == true) return; + final c = chat; + if (c == null) return; + if (c.participants[userId] == mark) return; + setState(() { + c.participants[userId] = mark; + }); + } + + void _onIncomingMessage(Packet packet) { + if (!mounted) return; + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId != widget.chatId) return; + final msg = payload['message']; + if (msg is! Map) return; + + final senderId = msg['sender']; + if (senderId is! int) return; + if (senderId == _myId) return; + + final msgId = msg['id']?.toString(); + if (msgId == null || msgId.isEmpty) return; + if (_messages.any((m) => m.id == msgId)) return; + + List? attachments; + final attaches = msg['attaches']; + if (attaches is List && attaches.isNotEmpty) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + + final cached = CachedMessage( + id: msgId, + accountId: _myId, + chatId: widget.chatId, + senderId: senderId, + text: msg['text'] as String?, + time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, + status: 'sent', + payload: Map.from(msg), + attachments: attachments, + ); + + setState(() { + _lastSentId = msgId; + _messages.add(cached); + }); + _clearTyping(senderId); + Haptics.tap(); + _scrollToBottom(); + } + Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; @@ -508,7 +687,6 @@ class _ChatScreenState extends State // TODO: Локализация // TODO: Cклонения - final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, appBar: PreferredSize( @@ -583,12 +761,15 @@ class _ChatScreenState extends State ], ], ), - Text( - status, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w400, + ValueListenableBuilder( + valueListenable: _headerStatusNotifier, + builder: (context, status, _) => Text( + status, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w400, + ), ), ), ], @@ -697,7 +878,7 @@ class _ChatScreenState extends State overrideStatus: _effectiveStatus(message), ); - if (isMe && message.id == _lastSentId) { + if (message.id == _lastSentId) { return _SentMessageAnimation( key: ValueKey('anim_${message.id}'), onComplete: () { diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index 034674b..e0051b4 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -29,7 +29,7 @@ class _ContactProfileScreenState extends State { bool _loading = true; Map? _contact; int? _seenTime; - bool _isOnline = false; + int _presenceStatus = 0; @override void initState() { @@ -57,7 +57,7 @@ class _ContactProfileScreenState extends State { final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId]; if (p is Map) { _seenTime = p['seen'] as int?; - _isOnline = ((p['status'] as int?) ?? 0) > 0; + _presenceStatus = (p['status'] as int?) ?? 0; } } } catch (e) { @@ -101,7 +101,8 @@ class _ContactProfileScreenState extends State { String _subtitle() { if (_isBot) return 'Бот'; - if (_isOnline) return 'В сети'; + if (_presenceStatus == 1) return 'В сети'; + if (_presenceStatus == 3) return 'Был(-а) недавно'; if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!); return ''; } diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index f93a07b..53eb24c 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -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 { + static const bool _showLogoutButton = false; + ProfileData? _profile; bool _isPhoneVisible = false; String? _appVersionLabel; @@ -94,6 +98,83 @@ class _SettingsTabState extends State { if (mounted) setState(() => _hapticsEnabled = value); } + Future _confirmLogout() async { + final cs = Theme.of(context).colorScheme; + final confirmed = await showModalBottomSheet( + 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 _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), - ), ], ), ], diff --git a/lib/main.dart b/lib/main.dart index 951c017..a342bbf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ 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'; @@ -53,6 +54,7 @@ void main() async { if (activeAccountId != null) { await ContactsModule.primeCacheFromDb(activeAccountId); } + ChatsModule.attachGlobalPushHandlers(api); await api.connect(); final packageInfo = await PackageInfo.fromPlatform();