отправка файлов, анимации с реанимации
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user