чет пофиксил + отправка файлов незаконченная
This commit is contained in:
@@ -51,6 +51,45 @@ class TranscriptionCache {
|
|||||||
static bool has(String messageId) => _cache.containsKey(messageId);
|
static bool has(String messageId) => _cache.containsKey(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FileHistoryEntry {
|
||||||
|
final int fileId;
|
||||||
|
final String? url;
|
||||||
|
final String? token;
|
||||||
|
final DateTime sentAt;
|
||||||
|
|
||||||
|
FileHistoryEntry({
|
||||||
|
required this.fileId,
|
||||||
|
this.url,
|
||||||
|
this.token,
|
||||||
|
required this.sentAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileHistoryCache {
|
||||||
|
static final List<FileHistoryEntry> _history = [];
|
||||||
|
|
||||||
|
static List<FileHistoryEntry> get history => List.unmodifiable(_history);
|
||||||
|
|
||||||
|
static void add(FileHistoryEntry entry) {
|
||||||
|
_history.insert(0, entry);
|
||||||
|
if (_history.length > 50) _history.removeLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get isEmpty => _history.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileUploadInfo {
|
||||||
|
final String url;
|
||||||
|
final int fileId;
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
FileUploadInfo({
|
||||||
|
required this.url,
|
||||||
|
required this.fileId,
|
||||||
|
required this.token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class CachedMessage {
|
class CachedMessage {
|
||||||
final String id;
|
final String id;
|
||||||
final int accountId;
|
final int accountId;
|
||||||
@@ -304,6 +343,47 @@ class MessagesModule {
|
|||||||
return TranscriptionResult(status: transcriptionStatus);
|
return TranscriptionResult(status: transcriptionStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<FileUploadInfo?> requestUploadUrl({int count = 1}) async {
|
||||||
|
final payload = {'count': count};
|
||||||
|
final response = await _api.sendRequest(Opcode.fileUpload, payload);
|
||||||
|
if (!response.isOk) return null;
|
||||||
|
|
||||||
|
final data = response.payload;
|
||||||
|
if (data is! Map) return null;
|
||||||
|
|
||||||
|
final infoList = data['info'] as List?;
|
||||||
|
if (infoList == null || infoList.isEmpty) return null;
|
||||||
|
|
||||||
|
final info = infoList.first;
|
||||||
|
if (info is! Map) return null;
|
||||||
|
|
||||||
|
return FileUploadInfo(
|
||||||
|
url: info['url'] as String? ?? '',
|
||||||
|
fileId: info['fileId'] as int? ?? 0,
|
||||||
|
token: info['token'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> sendFileMessage(
|
||||||
|
int chatId,
|
||||||
|
int fileId, {
|
||||||
|
bool notify = true,
|
||||||
|
}) async {
|
||||||
|
final payload = {
|
||||||
|
'chatId': chatId,
|
||||||
|
'message': {
|
||||||
|
'cid': DateTime.now().millisecondsSinceEpoch * -1,
|
||||||
|
'attaches': [
|
||||||
|
{'_type': 'FILE', 'fileId': fileId}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'notify': notify,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||||
|
return response.isOk;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||||
try {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart';
|
|||||||
import '../../../models/attachment.dart';
|
import '../../../models/attachment.dart';
|
||||||
import '../../../backend/modules/messages.dart' show ContactCache;
|
import '../../../backend/modules/messages.dart' show ContactCache;
|
||||||
import '../../widgets/message_bubble.dart';
|
import '../../widgets/message_bubble.dart';
|
||||||
|
import '../../widgets/attachment_panel.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final int chatId;
|
final int chatId;
|
||||||
@@ -37,6 +38,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
bool _hasText = false;
|
bool _hasText = false;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isSending = false;
|
bool _isSending = false;
|
||||||
|
bool _showAttachmentPanel = false;
|
||||||
late AnimationController _shimmerController;
|
late AnimationController _shimmerController;
|
||||||
List<CachedMessage> _messages = [];
|
List<CachedMessage> _messages = [];
|
||||||
int _myId = 0;
|
int _myId = 0;
|
||||||
@@ -336,14 +338,28 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
body: Column(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Column(
|
||||||
child: _isLoading && _messages.isEmpty
|
children: [
|
||||||
? _buildShimmerLoading()
|
Expanded(
|
||||||
: _buildMessagesList(),
|
child: _isLoading && _messages.isEmpty
|
||||||
|
? _buildShimmerLoading()
|
||||||
|
: _buildMessagesList(),
|
||||||
|
),
|
||||||
|
_buildInputArea(context),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_buildInputArea(context),
|
if (_showAttachmentPanel)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: AttachmentPanel(
|
||||||
|
chatId: widget.chatId,
|
||||||
|
onClose: () => setState(() => _showAttachmentPanel = false),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -582,13 +598,32 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
opacity: _hasText ? 0 : 1,
|
opacity: _hasText ? 0 : 1,
|
||||||
child: _hasText
|
child: _hasText
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Padding(
|
: GestureDetector(
|
||||||
padding: const EdgeInsets.only(left: 12),
|
onTap: _showAttachmentPanel ? null : () => setState(() => _showAttachmentPanel = true),
|
||||||
child: Icon(
|
child: Padding(
|
||||||
Symbols.attachment,
|
padding: const EdgeInsets.only(left: 12),
|
||||||
color: mutedIcon,
|
child: Stack(
|
||||||
size: 24,
|
alignment: Alignment.center,
|
||||||
weight: 400,
|
children: [
|
||||||
|
if (_showAttachmentPanel)
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: cs.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Symbols.attachment,
|
||||||
|
color: _showAttachmentPanel
|
||||||
|
? cs.onSurfaceVariant.withValues(alpha: 0.3)
|
||||||
|
: mutedIcon,
|
||||||
|
size: 24,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:komet/backend/modules/messages.dart' show FileHistoryCache, FileHistoryEntry;
|
||||||
|
import 'package:komet/core/config/proxy_config.dart';
|
||||||
|
import 'package:komet/core/protocol/opcode_map.dart';
|
||||||
|
import 'package:komet/core/protocol/packet.dart';
|
||||||
|
import 'package:komet/core/transport/proxy_connector.dart';
|
||||||
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||||
|
import 'package:komet/main.dart' show api, messagesModule;
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
class AttachmentPanel extends StatefulWidget {
|
||||||
|
final int chatId;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
const AttachmentPanel({
|
||||||
|
super.key,
|
||||||
|
required this.chatId,
|
||||||
|
required this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AttachmentPanel> createState() => _AttachmentPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AttachmentPanelState extends State<AttachmentPanel> {
|
||||||
|
final TextEditingController _fileIdController = TextEditingController();
|
||||||
|
bool _isUploading = false;
|
||||||
|
|
||||||
|
Future<void> _pickAndUploadFile() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles();
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.path == null) return;
|
||||||
|
|
||||||
|
setState(() => _isUploading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uploadInfo = await messagesModule.requestUploadUrl();
|
||||||
|
if (uploadInfo == null) {
|
||||||
|
if (mounted) showCustomNotification(context, 'Не удалось получить ссылку');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final completer = Completer<void>();
|
||||||
|
void Function(Packet)? handler;
|
||||||
|
handler = (Packet packet) {
|
||||||
|
final payload = packet.payload;
|
||||||
|
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
|
||||||
|
api.unregisterPushHandler(Opcode.notifAttach);
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
api.registerPushHandler(Opcode.notifAttach, (Packet p) => handler!(p));
|
||||||
|
|
||||||
|
await api.sendRequest(Opcode.msgTyping, {
|
||||||
|
'chatId': widget.chatId,
|
||||||
|
'type': 'FILE',
|
||||||
|
});
|
||||||
|
|
||||||
|
final uri = Uri.parse(uploadInfo.url);
|
||||||
|
final fileBytes = await File(file.path!).readAsBytes();
|
||||||
|
final proxySettings = await ProxyConfig.load();
|
||||||
|
|
||||||
|
int statusCode;
|
||||||
|
if (proxySettings.isEnabled) {
|
||||||
|
final connector = ProxyConnector(proxySettings);
|
||||||
|
final proxySocket = await connector.connect(uri.host, uri.port);
|
||||||
|
final socket = uri.scheme == 'https'
|
||||||
|
? await RawSecureSocket.secure(
|
||||||
|
proxySocket,
|
||||||
|
host: uri.host,
|
||||||
|
onBadCertificate: (_) => true,
|
||||||
|
)
|
||||||
|
: proxySocket;
|
||||||
|
statusCode = await _rawPut(socket, uri, fileBytes);
|
||||||
|
} else {
|
||||||
|
final socket = await RawSocket.connect(uri.host, uri.port);
|
||||||
|
final secureSocket = uri.scheme == 'https'
|
||||||
|
? await RawSecureSocket.secure(
|
||||||
|
socket,
|
||||||
|
host: uri.host,
|
||||||
|
onBadCertificate: (_) => true,
|
||||||
|
)
|
||||||
|
: socket;
|
||||||
|
statusCode = await _rawPut(secureSocket, uri, fileBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode == 200 || statusCode == 204) {
|
||||||
|
await completer.future.timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
onTimeout: () {
|
||||||
|
api.unregisterPushHandler(Opcode.notifAttach);
|
||||||
|
throw TimeoutException('Тайм-аут подтверждения загрузки');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final sent = await messagesModule.sendFileMessage(widget.chatId, uploadInfo.fileId);
|
||||||
|
if (sent) {
|
||||||
|
FileHistoryCache.add(FileHistoryEntry(
|
||||||
|
fileId: uploadInfo.fileId,
|
||||||
|
url: uploadInfo.url,
|
||||||
|
token: uploadInfo.token,
|
||||||
|
sentAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
if (mounted) {
|
||||||
|
showCustomNotification(context, 'Файл отправлен');
|
||||||
|
widget.onClose();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mounted) showCustomNotification(context, 'Ошибка отправки сообщения');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
api.unregisterPushHandler(Opcode.notifAttach);
|
||||||
|
if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isUploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _rawPut(RawSocket socket, Uri uri, List<int> body) async {
|
||||||
|
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||||
|
final host = uri.host;
|
||||||
|
|
||||||
|
// Try HttpClient-based approach first, fall back to raw socket
|
||||||
|
final httpClient = HttpClient();
|
||||||
|
httpClient.badCertificateCallback = (cert, host, port) => true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final request = await httpClient.putUrl(uri);
|
||||||
|
request.headers.contentType = ContentType('application', 'octet-stream');
|
||||||
|
request.add(body is Uint8List ? body : Uint8List.fromList(body));
|
||||||
|
final response = await request.close().timeout(const Duration(minutes: 5));
|
||||||
|
final statusCode = response.statusCode;
|
||||||
|
await response.drain<void>();
|
||||||
|
debugPrint('HTTP Response via HttpClient: $statusCode');
|
||||||
|
socket.close();
|
||||||
|
return statusCode;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('HttpClient failed, falling back to raw socket: $e');
|
||||||
|
// Fallback to raw HTTP
|
||||||
|
final rawRequest = StringBuffer()
|
||||||
|
..write('PUT $path HTTP/1.1\r\n')
|
||||||
|
..write('Host: $host\r\n')
|
||||||
|
..write('Content-Type: application/octet-stream\r\n')
|
||||||
|
..write('Content-Length: ${body.length}\r\n')
|
||||||
|
..write('Connection: close\r\n')
|
||||||
|
..write('\r\n');
|
||||||
|
|
||||||
|
final requestBytes = utf8.encode(rawRequest.toString());
|
||||||
|
final allBytes = <int>[...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))];
|
||||||
|
socket.write(Uint8List.fromList(allBytes));
|
||||||
|
|
||||||
|
final responseBytes = <int>[];
|
||||||
|
final completer = Completer<int>();
|
||||||
|
Timer? timer;
|
||||||
|
|
||||||
|
socket.listen((event) {
|
||||||
|
if (event == RawSocketEvent.read) {
|
||||||
|
final data = socket.read();
|
||||||
|
if (data != null) responseBytes.addAll(data);
|
||||||
|
} else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) {
|
||||||
|
timer?.cancel();
|
||||||
|
if (responseBytes.isEmpty) {
|
||||||
|
completer.completeError(const SocketException('Пустой ответ сервера'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final headerEnd = _findHeaderEnd(responseBytes);
|
||||||
|
if (headerEnd == -1) {
|
||||||
|
completer.completeError(const SocketException('Не удалось прочитать заголовок ответа'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true);
|
||||||
|
final statusLine = headerStr.split('\r\n').first;
|
||||||
|
debugPrint('HTTP Response (raw): $statusLine');
|
||||||
|
final parts = statusLine.split(' ');
|
||||||
|
completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0);
|
||||||
|
}
|
||||||
|
}, onError: (e) {
|
||||||
|
timer?.cancel();
|
||||||
|
completer.completeError(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
timer = Timer(const Duration(minutes: 5), () {
|
||||||
|
socket.close();
|
||||||
|
completer.completeError(TimeoutException('Тайм-аут загрузки'));
|
||||||
|
});
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _findHeaderEnd(List<int> bytes) {
|
||||||
|
for (var i = 0; i < bytes.length - 3; i++) {
|
||||||
|
if (bytes[i] == 0x0D && bytes[i + 1] == 0x0A &&
|
||||||
|
bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A) {
|
||||||
|
return i + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadByFileId() async {
|
||||||
|
final fileIdStr = _fileIdController.text.trim();
|
||||||
|
if (fileIdStr.isEmpty) return;
|
||||||
|
final fileId = int.tryParse(fileIdStr);
|
||||||
|
if (fileId == null) {
|
||||||
|
if (mounted) showCustomNotification(context, 'Неверный fileId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _isUploading = true);
|
||||||
|
try {
|
||||||
|
final sent = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
||||||
|
if (sent) {
|
||||||
|
if (mounted) {
|
||||||
|
showCustomNotification(context, 'Файл отправлен');
|
||||||
|
widget.onClose();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mounted) showCustomNotification(context, 'Ошибка отправки');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isUploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_fileIdController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return GestureDetector(
|
||||||
|
onVerticalDragEnd: (details) {
|
||||||
|
if (details.velocity.pixelsPerSecond.dy > 300) widget.onClose();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(top: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(child: _buildButton(
|
||||||
|
label: 'Выбрать из файла',
|
||||||
|
icon: Symbols.folder_open,
|
||||||
|
filled: true,
|
||||||
|
onTap: _isUploading ? null : _pickAndUploadFile,
|
||||||
|
cs: cs,
|
||||||
|
)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: _buildButton(
|
||||||
|
label: 'Отправить по id',
|
||||||
|
icon: null,
|
||||||
|
filled: false,
|
||||||
|
onTap: _isUploading ? null : _uploadByFileId,
|
||||||
|
cs: cs,
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
if (_isUploading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: LinearProgressIndicator(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: _fileIdController,
|
||||||
|
style: TextStyle(color: cs.onSurface, fontSize: 14),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'fileId...',
|
||||||
|
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, bottom: 4),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text('История', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (FileHistoryCache.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text('история пуста...', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
height: 100,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: FileHistoryCache.history.length,
|
||||||
|
itemBuilder: (ctx, idx) {
|
||||||
|
final e = FileHistoryCache.history[idx];
|
||||||
|
return Container(
|
||||||
|
width: 72,
|
||||||
|
margin: const EdgeInsets.only(right: 8, bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Center(child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.description, color: cs.onSurfaceVariant, size: 28),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Text('${e.fileId}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildButton({
|
||||||
|
required String label,
|
||||||
|
required IconData? icon,
|
||||||
|
required bool filled,
|
||||||
|
required VoidCallback? onTap,
|
||||||
|
required ColorScheme cs,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: filled ? cs.primaryContainer : cs.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: filled ? null : Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: 18, color: filled ? cs.onPrimaryContainer : cs.onSurface),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
|
Text(label, style: TextStyle(
|
||||||
|
color: filled ? cs.onPrimaryContainer : cs.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 13,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,9 +168,9 @@ class MessageBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
case BubbleShape.groupedMiddle:
|
case BubbleShape.groupedMiddle:
|
||||||
return BorderRadius.only(
|
return BorderRadius.only(
|
||||||
topLeft: cornerTL,
|
topLeft: isMe ? cornerTL : smallRadius,
|
||||||
topRight: smallRadius,
|
topRight: smallRadius,
|
||||||
bottomLeft: cornerBL,
|
bottomLeft: isMe ? cornerBL : smallRadius,
|
||||||
bottomRight: smallRadius,
|
bottomRight: smallRadius,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-2
@@ -81,6 +81,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -169,6 +177,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.7"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -203,6 +219,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.34"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -294,7 +318,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -302,7 +326,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
version: "1.6.0"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http_parser
|
name: http_parser
|
||||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ dependencies:
|
|||||||
flutter_timezone: ^5.0.1
|
flutter_timezone: ^5.0.1
|
||||||
timezone: ^0.11.0
|
timezone: ^0.11.0
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
http: ^1.4.0
|
||||||
|
http_parser: ^4.1.0
|
||||||
|
file_picker: ^8.0.0
|
||||||
sqflite: ^2.4.2
|
sqflite: ^2.4.2
|
||||||
sqflite_common_ffi: ^2.4.0+2
|
sqflite_common_ffi: ^2.4.0+2
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
|
|||||||
Reference in New Issue
Block a user