diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 150e5ca..51af04e 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -51,6 +51,45 @@ class TranscriptionCache { static bool has(String messageId) => _cache.containsKey(messageId); } +class FileHistoryEntry { + final int fileId; + final String? url; + final String? token; + final DateTime sentAt; + + FileHistoryEntry({ + required this.fileId, + this.url, + this.token, + required this.sentAt, + }); +} + +class FileHistoryCache { + static final List _history = []; + + static List get history => List.unmodifiable(_history); + + static void add(FileHistoryEntry entry) { + _history.insert(0, entry); + if (_history.length > 50) _history.removeLast(); + } + + static bool get isEmpty => _history.isEmpty; +} + +class FileUploadInfo { + final String url; + final int fileId; + final String token; + + FileUploadInfo({ + required this.url, + required this.fileId, + required this.token, + }); +} + class CachedMessage { final String id; final int accountId; @@ -304,6 +343,47 @@ class MessagesModule { return TranscriptionResult(status: transcriptionStatus); } + Future requestUploadUrl({int count = 1}) async { + final payload = {'count': count}; + final response = await _api.sendRequest(Opcode.fileUpload, payload); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return FileUploadInfo( + url: info['url'] as String? ?? '', + fileId: info['fileId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + Future sendFileMessage( + int chatId, + int fileId, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + {'_type': 'FILE', 'fileId': fileId} + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return response.isOk; + } + Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 13d8b3d..9d50c57 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart'; import '../../../models/attachment.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; +import '../../widgets/attachment_panel.dart'; class ChatScreen extends StatefulWidget { final int chatId; @@ -37,6 +38,7 @@ class _ChatScreenState extends State bool _hasText = false; bool _isLoading = true; bool _isSending = false; + bool _showAttachmentPanel = false; late AnimationController _shimmerController; List _messages = []; int _myId = 0; @@ -336,14 +338,28 @@ class _ChatScreenState extends State ], ), )), - body: Column( + body: Stack( children: [ - Expanded( - child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() - : _buildMessagesList(), + Column( + children: [ + Expanded( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), + ), + _buildInputArea(context), + ], ), - _buildInputArea(context), + if (_showAttachmentPanel) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AttachmentPanel( + chatId: widget.chatId, + onClose: () => setState(() => _showAttachmentPanel = false), + ), + ), ], ), ); @@ -582,13 +598,32 @@ class _ChatScreenState extends State opacity: _hasText ? 0 : 1, child: _hasText ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.only(left: 12), - child: Icon( - Symbols.attachment, - color: mutedIcon, - size: 24, - weight: 400, + : GestureDetector( + onTap: _showAttachmentPanel ? null : () => setState(() => _showAttachmentPanel = true), + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Stack( + alignment: Alignment.center, + children: [ + if (_showAttachmentPanel) + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + Icon( + Symbols.attachment, + color: _showAttachmentPanel + ? cs.onSurfaceVariant.withValues(alpha: 0.3) + : mutedIcon, + size: 24, + weight: 400, + ), + ], + ), ), ), ), diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart new file mode 100644 index 0000000..d749691 --- /dev/null +++ b/lib/frontend/widgets/attachment_panel.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:convert'; +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; + + const AttachmentPanel({ + super.key, + required this.chatId, + required this.onClose, + }); + + @override + State createState() => _AttachmentPanelState(); +} + +class _AttachmentPanelState extends State { + final TextEditingController _fileIdController = TextEditingController(); + bool _isUploading = 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; + } + + final completer = Completer(); + void Function(Packet)? handler; + handler = (Packet packet) { + final payload = packet.payload; + if (payload is Map && payload['fileId'] == uploadInfo.fileId) { + api.unregisterPushHandler(Opcode.notifAttach); + completer.complete(); + } + }; + api.registerPushHandler(Opcode.notifAttach, (Packet p) => handler!(p)); + + 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 _rawPut(socket, uri, fileBytes); + } 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 _rawPut(secureSocket, uri, fileBytes); + } + + if (statusCode == 200 || statusCode == 204) { + await completer.future.timeout( + const Duration(seconds: 30), + onTimeout: () { + api.unregisterPushHandler(Opcode.notifAttach); + throw TimeoutException('Тайм-аут подтверждения загрузки'); + }, + ); + + final sent = await messagesModule.sendFileMessage(widget.chatId, uploadInfo.fileId); + if (sent) { + FileHistoryCache.add(FileHistoryEntry( + fileId: uploadInfo.fileId, + url: uploadInfo.url, + token: uploadInfo.token, + sentAt: DateTime.now(), + )); + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + } else { + if (mounted) showCustomNotification(context, 'Ошибка отправки сообщения'); + } + } else { + api.unregisterPushHandler(Opcode.notifAttach); + if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode'); + } + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _isUploading = false); + } + } + + Future _rawPut(RawSocket socket, Uri uri, List body) async { + final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; + final host = uri.host; + + // Try HttpClient-based approach first, fall back to raw socket + final httpClient = HttpClient(); + httpClient.badCertificateCallback = (cert, host, port) => true; + + try { + final request = await httpClient.putUrl(uri); + request.headers.contentType = ContentType('application', 'octet-stream'); + request.add(body is Uint8List ? body : Uint8List.fromList(body)); + final response = await request.close().timeout(const Duration(minutes: 5)); + final statusCode = response.statusCode; + await response.drain(); + debugPrint('HTTP Response via HttpClient: $statusCode'); + socket.close(); + return statusCode; + } catch (e) { + debugPrint('HttpClient failed, falling back to raw socket: $e'); + // Fallback to raw HTTP + final rawRequest = StringBuffer() + ..write('PUT $path HTTP/1.1\r\n') + ..write('Host: $host\r\n') + ..write('Content-Type: application/octet-stream\r\n') + ..write('Content-Length: ${body.length}\r\n') + ..write('Connection: close\r\n') + ..write('\r\n'); + + final requestBytes = utf8.encode(rawRequest.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 (raw): $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'); + 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); + } + } + + @override + void dispose() { + _fileIdController.dispose(); + super.dispose(); + } + + @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), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row(children: [ + Expanded(child: _buildButton( + label: 'Выбрать из файла', + icon: Symbols.folder_open, + filled: true, + onTap: _isUploading ? null : _pickAndUploadFile, + cs: cs, + )), + const SizedBox(width: 8), + Expanded(child: _buildButton( + label: 'Отправить по id', + icon: null, + filled: false, + onTap: _isUploading ? null : _uploadByFileId, + 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)), + ), + ), + 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), + ]), + ), + ); + } + + Widget _buildButton({ + required String label, + required IconData? icon, + required bool filled, + required VoidCallback? onTap, + required ColorScheme cs, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: filled ? cs.primaryContainer : cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: filled ? null : Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: filled ? cs.onPrimaryContainer : cs.onSurface), + const SizedBox(width: 6), + ], + Text(label, style: TextStyle( + color: filled ? cs.onPrimaryContainer : cs.onSurface, + fontWeight: FontWeight.w500, + fontSize: 13, + )), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 69ec7f2..949ae9f 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -168,9 +168,9 @@ class MessageBubble extends StatelessWidget { ); case BubbleShape.groupedMiddle: return BorderRadius.only( - topLeft: cornerTL, + topLeft: isMe ? cornerTL : smallRadius, topRight: smallRadius, - bottomLeft: cornerBL, + bottomLeft: isMe ? cornerBL : smallRadius, bottomRight: smallRadius, ); } diff --git a/pubspec.lock b/pubspec.lock index 1644efb..d510664 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -169,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" fixnum: dependency: transitive description: @@ -203,6 +219,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: @@ -294,7 +318,7 @@ packages: source: hosted version: "1.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -302,7 +326,7 @@ packages: source: hosted version: "1.6.0" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" diff --git a/pubspec.yaml b/pubspec.yaml index 2e32aa1..cf9fd81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,9 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 flutter_secure_storage: ^10.0.0 + http: ^1.4.0 + http_parser: ^4.1.0 + file_picker: ^8.0.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2 path: ^1.9.1