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;