отправка файлов, анимации с реанимации

This commit is contained in:
Jganenok
2026-05-17 16:11:30 +07:00
parent 4e500ef087
commit 33009ea06a
8 changed files with 1114 additions and 582 deletions
+249
View File
@@ -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<UploadEvent> upload({
required int chatId,
required File file,
required String filename,
required int totalSize,
Duration autoForceAfter = const Duration(seconds: 1),
Duration overallTimeout = const Duration(minutes: 5),
Duration progressThrottle = const Duration(milliseconds: 16),
}) {
final ctrl = StreamController<UploadEvent>();
var cancelled = false;
Socket? socket;
ctrl.onCancel = () {
cancelled = true;
try {
socket?.destroy();
} catch (_) {}
};
Future<void> run() async {
try {
final info = await messages.requestUploadUrl();
if (cancelled) return;
if (info == null) {
ctrl.add(const UploadError('no_upload_url'));
return;
}
unawaited(() async {
try {
await api.sendRequest(Opcode.msgTyping, {
'chatId': chatId,
'type': 'FILE',
});
} catch (_) {}
}());
final uri = Uri.parse(info.url);
socket = await _openSocket(uri);
if (cancelled) return;
_writeHeaders(socket!, uri, filename, totalSize);
final stopwatch = Stopwatch()..start();
var sent = 0;
final body = file.openRead().map((chunk) {
sent += chunk.length;
if (stopwatch.elapsed >= progressThrottle) {
ctrl.add(UploadProgress(sent: sent, total: totalSize));
stopwatch.reset();
}
return chunk;
});
await socket!.addStream(body);
await socket!.flush();
if (cancelled) return;
ctrl.add(UploadProgress(sent: totalSize, total: totalSize));
final statusCode = await _readResponse(
socket!,
autoForceAfter: autoForceAfter,
overallTimeout: overallTimeout,
);
try {
socket!.destroy();
} catch (_) {}
if (cancelled) return;
if (statusCode != 200 && statusCode != 0) {
ctrl.add(UploadError('http_$statusCode'));
return;
}
final ok = await messages.sendFileMessage(
chatId,
info.fileId,
token: info.token,
);
if (cancelled) return;
if (!ok) {
ctrl.add(const UploadError('send_failed'));
return;
}
ctrl.add(UploadDone(
fileId: info.fileId,
token: info.token,
url: info.url,
filename: filename,
size: totalSize,
));
} catch (e) {
if (!cancelled) ctrl.add(UploadError(e.toString()));
} finally {
try {
socket?.destroy();
} catch (_) {}
await ctrl.close();
}
}
unawaited(run());
return ctrl.stream;
}
Future<Socket> _openSocket(Uri uri) async {
final proxySettings = await ProxyConfig.load();
final base = proxySettings.isEnabled
? await ProxyConnector(proxySettings).connect(uri.host, uri.port)
: await Socket.connect(uri.host, uri.port);
if (uri.scheme != 'https') return base;
return SecureSocket.secure(
base,
host: uri.host,
onBadCertificate: (_) => true,
);
}
void _writeHeaders(Socket socket, Uri uri, String filename, int total) {
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<int> _readResponse(
Socket socket, {
required Duration autoForceAfter,
required Duration overallTimeout,
}) {
final responseBytes = <int>[];
final completer = Completer<int>();
Timer? force;
Timer? overall;
StreamSubscription<List<int>>? sub;
void finish(int code) {
if (completer.isCompleted) return;
force?.cancel();
overall?.cancel();
sub?.cancel();
completer.complete(code);
}
void fail(Object e) {
if (completer.isCompleted) return;
force?.cancel();
overall?.cancel();
sub?.cancel();
completer.completeError(e);
}
force = Timer(autoForceAfter, () => finish(0));
sub = socket.listen(
responseBytes.addAll,
onError: fail,
onDone: () {
final code = _parseHttpStatus(responseBytes);
if (code == null) {
fail(const SocketException('Не удалось прочитать заголовок ответа'));
} else {
finish(code);
}
},
);
overall = Timer(overallTimeout, () => fail(TimeoutException('Тайм-аут загрузки')));
return completer.future;
}
int? _parseHttpStatus(List<int> bytes) {
final headerEnd = _findHeaderEnd(bytes);
if (headerEnd == -1) return null;
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
final statusLine = headerStr.split('\r\n').first;
final parts = statusLine.split(' ');
if (parts.length < 2) return null;
return int.tryParse(parts[1]);
}
int _findHeaderEnd(List<int> bytes) {
for (var i = 0; i < bytes.length - 3; i++) {
if (bytes[i] == 0x0D &&
bytes[i + 1] == 0x0A &&
bytes[i + 2] == 0x0D &&
bytes[i + 3] == 0x0A) {
return i + 4;
}
}
return -1;
}
}
+85 -8
View File
@@ -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<String, dynamic> toJson() => {
'fileId': fileId,
if (url != null) 'url': url,
if (token != null) 'token': token,
if (filename != null) 'filename': filename,
if (size != null) 'size': size,
'sentAt': sentAt.millisecondsSinceEpoch,
};
static FileHistoryEntry? fromJson(Map<String, dynamic> j) {
final id = j['fileId'];
final ts = j['sentAt'];
if (id is! int || ts is! int) return null;
return FileHistoryEntry(
fileId: id,
url: j['url'] as String?,
token: j['token'] as String?,
filename: j['filename'] as String?,
size: j['size'] as int?,
sentAt: DateTime.fromMillisecondsSinceEpoch(ts),
);
}
}
class FileHistoryCache {
static final List<FileHistoryEntry> _history = [];
static const _prefKey = 'file_history_v1';
static const _maxEntries = 50;
static List<FileHistoryEntry> get history => List.unmodifiable(_history);
static final ValueNotifier<List<FileHistoryEntry>> notifier =
ValueNotifier(const []);
static void add(FileHistoryEntry entry) {
_history.insert(0, entry);
if (_history.length > 50) _history.removeLast();
static List<FileHistoryEntry> get history => notifier.value;
static bool get isEmpty => notifier.value.isEmpty;
static SharedPreferences? _prefs;
static Future<void> load(SharedPreferences prefs) async {
_prefs = prefs;
final raw = prefs.getString(_prefKey);
if (raw == null) return;
try {
final list = jsonDecode(raw);
if (list is! List) return;
final entries = <FileHistoryEntry>[];
for (final e in list) {
if (e is Map) {
final entry = FileHistoryEntry.fromJson(Map<String, dynamic>.from(e));
if (entry != null) entries.add(entry);
}
}
notifier.value = entries;
} catch (_) {}
}
static bool get isEmpty => _history.isEmpty;
static void add(FileHistoryEntry entry) {
final next = [entry, ...notifier.value.where((e) => e.fileId != entry.fileId)];
if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length);
notifier.value = next;
_persist();
}
static void remove(int fileId) {
final next = notifier.value.where((e) => e.fileId != fileId).toList();
if (next.length == notifier.value.length) return;
notifier.value = next;
_persist();
}
static void _persist() {
final prefs = _prefs;
if (prefs == null) return;
final encoded = jsonEncode(notifier.value.map((e) => e.toJson()).toList());
prefs.setString(_prefKey, encoded);
}
}
class FileUploadInfo {
@@ -393,6 +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<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
+14 -25
View File
@@ -13,8 +13,8 @@ enum SocketState { disconnected, connecting, connected }
/// Обёртка над TCP + TLS сокетом.
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
class Connection {
RawSecureSocket? _socket;
StreamSubscription<RawSocketEvent>? _subscription;
SecureSocket? _socket;
StreamSubscription<Uint8List>? _subscription;
SocketState _state = SocketState.disconnected;
final _dataController = StreamController<Uint8List>.broadcast();
@@ -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<RawSecureSocket> _openSecureSocket(
Future<SecureSocket> _openSecureSocket(
String host,
int port,
ProxySettings proxySettings, {
Duration? timeout,
}) async {
RawSocket rawSocket;
Socket socket;
if (proxySettings.isEnabled) {
final connector = ProxyConnector(proxySettings);
rawSocket = await connector.connect(host, port);
socket = await connector.connect(host, port);
logger.i('Подключено через прокси ${proxySettings.type.name}');
} else {
rawSocket = timeout == null
? await RawSocket.connect(host, port)
: await RawSocket.connect(host, port, timeout: timeout);
socket = timeout == null
? await Socket.connect(host, port)
: await Socket.connect(host, port, timeout: timeout);
}
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<void> disconnect() async {
@@ -136,7 +125,7 @@ class Connection {
if (socket != null) {
try {
socket.close();
await socket.close();
} catch (e) {
logger.w('Ошибка при закрытии сокета: $e');
}
+25 -29
View File
@@ -6,28 +6,25 @@ import 'dart:typed_data';
import '../config/proxy_config.dart';
import '../utils/logger.dart';
/// Устанавливает TCP-соединение через SOCKS5 или HTTP CONNECT прокси.
/// Возвращает [RawSocket], который никогда не слушался —
/// его можно передать в [RawSecureSocket.secure].
class ProxyConnector {
final ProxySettings settings;
ProxyConnector(this.settings);
Future<RawSocket> connect(String targetHost, int targetPort) async {
Future<Socket> connect(String targetHost, int targetPort) async {
switch (settings.type) {
case ProxyType.socks5:
return _connectSocks5(targetHost, targetPort);
case ProxyType.httpConnect:
return _connectHttpConnect(targetHost, targetPort);
case ProxyType.none:
return RawSocket.connect(targetHost, targetPort);
return Socket.connect(targetHost, targetPort);
}
}
// ── SOCKS5 (RFC 1928) ──────────────────────────────────────────────────
Future<RawSocket> _connectSocks5(String targetHost, int targetPort) async {
Future<Socket> _connectSocks5(String targetHost, int targetPort) async {
final proxySocket = await RawSocket.connect(settings.host, settings.port);
logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}');
@@ -128,7 +125,7 @@ class ProxyConnector {
// ── HTTP CONNECT ────────────────────────────────────────────────────────
Future<RawSocket> _connectHttpConnect(
Future<Socket> _connectHttpConnect(
String targetHost,
int targetPort,
) async {
@@ -194,19 +191,13 @@ class ProxyConnector {
}
}
// ── Мост: создаём свежий сокет и проксируем через loopback ─────────────
/// После handshake proxy-сокет уже прослушан (single-subscription).
/// Создаём пару локальных сокетов через loopback и проксируем данные
/// между прокси-сокетом и одним концом. Второй конец возвращаем —
/// он «свежий» и его можно передать в [RawSecureSocket.secure].
Future<RawSocket> _bridgeToFreshSocket(
Future<Socket> _bridgeToFreshSocket(
RawSocket proxySocket,
_RawSocketIO io,
) async {
RawServerSocket? server;
ServerSocket? server;
try {
server = await RawServerSocket.bind(
server = await ServerSocket.bind(
InternetAddress.loopbackIPv4,
0,
);
@@ -215,31 +206,36 @@ class ProxyConnector {
proxySocket.close();
rethrow;
}
final clientSide = await RawSocket.connect(
final clientFuture = Socket.connect(
InternetAddress.loopbackIPv4,
server.port,
);
final serverSide = await server.first;
final clientSide = await clientFuture;
await server.close();
// proxy → local (через уже имеющуюся подписку _RawSocketIO)
io.onData = (data) {
serverSide.write(data);
serverSide.add(data);
};
io.onClosed = () {
serverSide.shutdown(SocketDirection.send);
serverSide.close();
};
// local → proxy
serverSide.listen((event) {
if (event == RawSocketEvent.read) {
final data = serverSide.read();
if (data != null) proxySocket.write(data);
} else if (event == RawSocketEvent.readClosed ||
event == RawSocketEvent.closed) {
serverSide.listen(
(data) {
unawaited(io.write(data).catchError((Object _) {
try {
serverSide.destroy();
} catch (_) {}
}));
},
onError: (Object _) {
proxySocket.shutdown(SocketDirection.send);
}
});
},
onDone: () {
proxySocket.shutdown(SocketDirection.send);
},
);
// Сливаем данные, буферизованные во время handshake
io.flushBuffered();
+611 -107
View File
@@ -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<ChatScreen>
final ValueNotifier<bool> _hasText = ValueNotifier(false);
bool _isLoading = true;
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
StreamSubscription<UploadEvent>? _uploadSub;
int _tempIdCounter = 0;
late final AnimationController _attachAnim;
String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
late AnimationController _shimmerController;
List<CachedMessage> _messages = [];
int _myId = 0;
@@ -75,6 +101,12 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
_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<ChatScreen>
}
}
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<ChatScreen>
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<ChatScreen>
],
),
)),
body: Stack(
body: Column(
children: [
Column(
children: [
Expanded(
child: _isLoading && _messages.isEmpty
? _buildShimmerLoading()
: _buildMessagesList(),
),
_buildInputArea(context),
],
Expanded(
child: _isLoading && _messages.isEmpty
? _buildShimmerLoading()
: _buildMessagesList(),
),
ValueListenableBuilder<bool>(
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<ChatScreen>
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<bool>(
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<bool>(
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<ChatScreen>
),
);
}
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<void> _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<bool> _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<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;
_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<bool> hasText;
final ValueNotifier<bool> 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<bool>(
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<bool>(
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<double> anim;
final ColorScheme cs;
final Future<void> Function(FileHistoryEntry entry) onTapEntry;
const _HistoryStrip({
required this.anim,
required this.cs,
required this.onTapEntry,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<FileHistoryEntry>>(
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;
+56 -351
View File
@@ -1,26 +1,17 @@
import 'dart:async';
import 'dart:convert' show utf8;
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:komet/backend/modules/messages.dart' show FileHistoryCache, FileHistoryEntry;
import 'package:komet/core/config/proxy_config.dart';
import 'package:komet/core/protocol/opcode_map.dart';
import 'package:komet/core/protocol/packet.dart';
import 'package:komet/core/transport/proxy_connector.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/main.dart' show api, messagesModule;
import 'package:material_symbols_icons/symbols.dart';
class AttachmentPanel extends StatefulWidget {
final int chatId;
final VoidCallback onClose;
final VoidCallback onPickFile;
final Future<bool> Function(int fileId) onSendById;
const AttachmentPanel({
super.key,
required this.chatId,
required this.onClose,
required this.onPickFile,
required this.onSendById,
});
@override
@@ -29,259 +20,21 @@ class AttachmentPanel extends StatefulWidget {
class _AttachmentPanelState extends State<AttachmentPanel> {
final TextEditingController _fileIdController = TextEditingController();
bool _isUploading = false;
bool _sendingById = false;
Future<void> _pickAndUploadFile() async {
final result = await FilePicker.platform.pickFiles();
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.path == null) return;
setState(() => _isUploading = true);
try {
final uploadInfo = await messagesModule.requestUploadUrl();
if (uploadInfo == null) {
if (mounted) showCustomNotification(context, 'Не удалось получить ссылку');
return;
}
await api.sendRequest(Opcode.msgTyping, {
'chatId': widget.chatId,
'type': 'FILE',
});
final uri = Uri.parse(uploadInfo.url);
final fileBytes = await File(file.path!).readAsBytes();
final proxySettings = await ProxyConfig.load();
int statusCode;
if (proxySettings.isEnabled) {
final connector = ProxyConnector(proxySettings);
final proxySocket = await connector.connect(uri.host, uri.port);
final socket = uri.scheme == 'https'
? await RawSecureSocket.secure(
proxySocket,
host: uri.host,
onBadCertificate: (_) => true,
)
: proxySocket;
statusCode = await _rawPost(socket, uri, fileBytes, file.name);
} else {
final socket = await RawSocket.connect(uri.host, uri.port);
final secureSocket = uri.scheme == 'https'
? await RawSecureSocket.secure(
socket,
host: uri.host,
onBadCertificate: (_) => true,
)
: socket;
statusCode = await _rawPost(secureSocket, uri, fileBytes, file.name);
}
if (statusCode != 200) {
if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode');
return;
}
// Wait for notifAttach push
final pushCompleter = Completer<void>();
void Function(Packet)? pushHandler;
pushHandler = (Packet packet) {
final payload = packet.payload;
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
api.unregisterPushHandler(Opcode.notifAttach);
pushCompleter.complete();
}
};
api.registerPushHandler(Opcode.notifAttach, (Packet p) => pushHandler!(p));
await pushCompleter.future.timeout(
const Duration(seconds: 30),
onTimeout: () {
api.unregisterPushHandler(Opcode.notifAttach);
throw TimeoutException('Тайм-аут подтверждения загрузки');
},
);
// Retry loop: server may say "attachment in progress" (cmd=3)
for (var attempt = 0; attempt < 5; attempt++) {
final sent = await messagesModule.sendFileMessage(
widget.chatId,
uploadInfo.fileId,
token: uploadInfo.token,
);
// Listen for push again (another notifAttach may come)
final msgCompleter = Completer<bool>();
void Function(Packet)? msgHandler;
msgHandler = (Packet packet) {
final payload = packet.payload;
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
api.unregisterPushHandler(Opcode.notifAttach);
msgCompleter.complete(true);
}
};
api.registerPushHandler(Opcode.notifAttach, (Packet p) => msgHandler!(p));
final pushFuture = msgCompleter.future.timeout(
const Duration(seconds: 5),
onTimeout: () {
api.unregisterPushHandler(Opcode.notifAttach);
return false;
},
);
final pushReceived = await pushFuture;
if (pushReceived && sent) {
FileHistoryCache.add(FileHistoryEntry(
fileId: uploadInfo.fileId,
url: uploadInfo.url,
token: uploadInfo.token,
sentAt: DateTime.now(),
));
if (mounted) {
showCustomNotification(context, 'Файл отправлен');
widget.onClose();
}
return;
}
// If push was received, check if message was sent
if (pushReceived) {
FileHistoryCache.add(FileHistoryEntry(
fileId: uploadInfo.fileId,
url: uploadInfo.url,
token: uploadInfo.token,
sentAt: DateTime.now(),
));
if (mounted) {
showCustomNotification(context, 'Файл отправлен');
widget.onClose();
}
return;
}
if (!sent) {
// msgSend failed, maybe server still processing — wait and retry
await Future.delayed(Duration(seconds: 1 + attempt));
continue;
}
// Sent ok, no push received (already processed earlier)
FileHistoryCache.add(FileHistoryEntry(
fileId: uploadInfo.fileId,
url: uploadInfo.url,
token: uploadInfo.token,
sentAt: DateTime.now(),
));
if (mounted) {
showCustomNotification(context, 'Файл отправлен');
widget.onClose();
}
return;
}
if (mounted) showCustomNotification(context, 'Не удалось отправить сообщение');
} catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e');
} finally {
if (mounted) setState(() => _isUploading = false);
}
}
Future<int> _rawPost(RawSocket socket, Uri uri, List<int> body, String filename) async {
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
final host = uri.host;
final total = body.length;
final request = StringBuffer()
..write('POST $path HTTP/1.1\r\n')
..write('Host: $host\r\n')
..write('Content-Type: application/x-binary; charset=x-user-defined\r\n')
..write('Content-Disposition: attachment; filename=$filename\r\n')
..write('Connection: keep-alive\r\n')
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
..write('Content-Range: bytes 0-${total - 1}/$total\r\n')
..write('Content-Length: $total\r\n')
..write('\r\n');
final requestBytes = utf8.encode(request.toString());
final allBytes = <int>[...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))];
socket.write(Uint8List.fromList(allBytes));
final responseBytes = <int>[];
final completer = Completer<int>();
Timer? timer;
socket.listen((event) {
if (event == RawSocketEvent.read) {
final data = socket.read();
if (data != null) responseBytes.addAll(data);
} else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) {
timer?.cancel();
if (responseBytes.isEmpty) {
completer.completeError(const SocketException('Пустой ответ сервера'));
return;
}
final headerEnd = _findHeaderEnd(responseBytes);
if (headerEnd == -1) {
completer.completeError(const SocketException('Не удалось прочитать заголовок ответа'));
return;
}
final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true);
final statusLine = headerStr.split('\r\n').first;
debugPrint('HTTP Response: $statusLine');
final parts = statusLine.split(' ');
completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0);
}
}, onError: (e) {
timer?.cancel();
completer.completeError(e);
});
timer = Timer(const Duration(minutes: 5), () {
socket.close();
completer.completeError(TimeoutException('Тайм-аут загрузки'));
});
return completer.future;
}
int _findHeaderEnd(List<int> bytes) {
for (var i = 0; i < bytes.length - 3; i++) {
if (bytes[i] == 0x0D && bytes[i + 1] == 0x0A &&
bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A) {
return i + 4;
}
}
return -1;
}
Future<void> _uploadByFileId() async {
final fileIdStr = _fileIdController.text.trim();
if (fileIdStr.isEmpty) return;
final fileId = int.tryParse(fileIdStr);
if (fileId == null) {
if (mounted) showCustomNotification(context, 'Неверный fileId');
Future<void> _sendById() async {
final s = _fileIdController.text.trim();
if (s.isEmpty) return;
final id = int.tryParse(s);
if (id == null) {
showCustomNotification(context, 'Неверный fileId');
return;
}
setState(() => _isUploading = true);
try {
final sent = await messagesModule.sendFileMessage(widget.chatId, fileId);
if (sent) {
if (mounted) {
showCustomNotification(context, 'Файл отправлен');
widget.onClose();
}
} else {
if (mounted) showCustomNotification(context, 'Ошибка отправки');
}
} catch (e) {
if (mounted) showCustomNotification(context, 'Ошибка: $e');
} finally {
if (mounted) setState(() => _isUploading = false);
}
setState(() => _sendingById = true);
final ok = await widget.onSendById(id);
if (!mounted) return;
setState(() => _sendingById = false);
if (ok) _fileIdController.clear();
}
@override
@@ -293,34 +46,23 @@ class _AttachmentPanelState extends State<AttachmentPanel> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GestureDetector(
onVerticalDragEnd: (details) {
if (details.velocity.pixelsPerSecond.dy > 300) widget.onClose();
},
child: Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
),
child: Stack(children: [
Column(mainAxisSize: MainAxisSize.min, children: [
const SizedBox(height: 40),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(children: [
Expanded(child: _buildButton(
label: 'Выбрать из файла',
icon: Symbols.folder_open,
filled: true,
onTap: _isUploading ? null : _pickAndUploadFile,
onTap: _sendingById ? null : widget.onPickFile,
cs: cs,
)),
const SizedBox(width: 8),
@@ -328,80 +70,43 @@ class _AttachmentPanelState extends State<AttachmentPanel> {
label: 'Отправить по id',
icon: null,
filled: false,
onTap: _isUploading ? null : _uploadByFileId,
onTap: _sendingById ? null : _sendById,
cs: cs,
)),
]),
),
if (_isUploading)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: LinearProgressIndicator(),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _fileIdController,
style: TextStyle(color: cs.onSurface, fontSize: 14),
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'fileId...',
hintStyle: TextStyle(color: cs.onSurfaceVariant),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const Divider(height: 16),
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('История', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500)),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _fileIdController,
style: TextStyle(color: cs.onSurface, fontSize: 14),
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'fileId...',
hintStyle: TextStyle(color: cs.onSurfaceVariant),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
if (FileHistoryCache.isEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Text('история пуста...', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)),
)
else
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: FileHistoryCache.history.length,
itemBuilder: (ctx, idx) {
final e = FileHistoryCache.history[idx];
return Container(
width: 72,
margin: const EdgeInsets.only(right: 8, bottom: 8),
decoration: BoxDecoration(
color: cs.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
),
child: Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.description, color: cs.onSurfaceVariant, size: 28),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('${e.fileId}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center),
),
],
)),
);
},
),
),
const SizedBox(height: 8),
const SizedBox(height: 12),
]),
),
Positioned(
left: 6,
top: 6,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onClose,
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
child: Icon(Symbols.close, color: cs.onSurfaceVariant, size: 22),
),
),
),
]),
);
}
+71 -62
View File
@@ -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),
],
),
),
);
}
+3
View File
@@ -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<Locale> _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;