чет пофиксил + отправка файлов незаконченная

This commit is contained in:
Jganenok
2026-05-14 17:54:05 +07:00
parent 53805e8d74
commit 358b82a12c
6 changed files with 549 additions and 17 deletions
+80
View File
@@ -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<FileHistoryEntry> _history = [];
static List<FileHistoryEntry> 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<FileUploadInfo?> 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<bool> 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<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
+48 -13
View File
@@ -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<ChatScreen>
bool _hasText = false;
bool _isLoading = true;
bool _isSending = false;
bool _showAttachmentPanel = false;
late AnimationController _shimmerController;
List<CachedMessage> _messages = [];
int _myId = 0;
@@ -336,14 +338,28 @@ class _ChatScreenState extends State<ChatScreen>
],
),
)),
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<ChatScreen>
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,
),
],
),
),
),
),
+390
View File
@@ -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<AttachmentPanel> createState() => _AttachmentPanelState();
}
class _AttachmentPanelState extends State<AttachmentPanel> {
final TextEditingController _fileIdController = TextEditingController();
bool _isUploading = false;
Future<void> _pickAndUploadFile() async {
final result = await FilePicker.platform.pickFiles();
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.path == null) return;
setState(() => _isUploading = true);
try {
final uploadInfo = await messagesModule.requestUploadUrl();
if (uploadInfo == null) {
if (mounted) showCustomNotification(context, 'Не удалось получить ссылку');
return;
}
final completer = Completer<void>();
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<int> _rawPut(RawSocket socket, Uri uri, List<int> 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<void>();
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 = <int>[...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))];
socket.write(Uint8List.fromList(allBytes));
final responseBytes = <int>[];
final completer = Completer<int>();
Timer? timer;
socket.listen((event) {
if (event == RawSocketEvent.read) {
final data = socket.read();
if (data != null) responseBytes.addAll(data);
} else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) {
timer?.cancel();
if (responseBytes.isEmpty) {
completer.completeError(const SocketException('Пустой ответ сервера'));
return;
}
final headerEnd = _findHeaderEnd(responseBytes);
if (headerEnd == -1) {
completer.completeError(const SocketException('Не удалось прочитать заголовок ответа'));
return;
}
final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true);
final statusLine = headerStr.split('\r\n').first;
debugPrint('HTTP Response (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<int> bytes) {
for (var i = 0; i < bytes.length - 3; i++) {
if (bytes[i] == 0x0D && bytes[i + 1] == 0x0A &&
bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A) {
return i + 4;
}
}
return -1;
}
Future<void> _uploadByFileId() async {
final fileIdStr = _fileIdController.text.trim();
if (fileIdStr.isEmpty) return;
final fileId = int.tryParse(fileIdStr);
if (fileId == null) {
if (mounted) showCustomNotification(context, 'Неверный fileId');
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,
)),
],
),
),
);
}
}
+2 -2
View File
@@ -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,
);
}
+26 -2
View File
@@ -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"
+3
View File
@@ -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