feat: работа с вложениями
This commit is contained in:
@@ -241,6 +241,73 @@ class FileUploader {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> uploadPhoto(
|
||||
Uri uri,
|
||||
File file, {
|
||||
String filename = 'photo.jpg',
|
||||
void Function(int sent, int total)? onProgress,
|
||||
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||
}) async {
|
||||
Socket? socket;
|
||||
try {
|
||||
final fileLength = await file.length();
|
||||
socket = await _openSocket(uri);
|
||||
final boundary =
|
||||
'----KometBoundary${DateTime.now().microsecondsSinceEpoch}';
|
||||
final preamble = utf8.encode(
|
||||
'--$boundary\r\n'
|
||||
'Content-Disposition: form-data; name="file"; filename="$filename"\r\n'
|
||||
'Content-Type: ${_contentTypeForFilename(filename)}\r\n'
|
||||
'\r\n',
|
||||
);
|
||||
final epilogue = utf8.encode('\r\n--$boundary--\r\n');
|
||||
_writeImageHeaders(
|
||||
socket,
|
||||
uri,
|
||||
preamble.length + fileLength + epilogue.length,
|
||||
boundary: boundary,
|
||||
);
|
||||
socket.add(preamble);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
var sent = 0;
|
||||
final body = file.openRead().map((chunk) {
|
||||
sent += chunk.length;
|
||||
if (onProgress != null && stopwatch.elapsed >= progressThrottle) {
|
||||
onProgress(sent, fileLength);
|
||||
stopwatch.reset();
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
await socket.addStream(body);
|
||||
socket.add(epilogue);
|
||||
await socket.flush();
|
||||
onProgress?.call(fileLength, fileLength);
|
||||
|
||||
final response = await _readFullResponse(
|
||||
socket,
|
||||
timeout: const Duration(minutes: 2),
|
||||
);
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch (_) {}
|
||||
|
||||
if (response == null) return null;
|
||||
final (status, responseBody) = response;
|
||||
if (status != 200) {
|
||||
logger.w('uploadPhoto: status=$status');
|
||||
return null;
|
||||
}
|
||||
return _parsePhotoToken(responseBody);
|
||||
} catch (e) {
|
||||
logger.w('uploadPhoto: $e');
|
||||
try {
|
||||
socket?.destroy();
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
|
||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||
final headers = StringBuffer()
|
||||
|
||||
@@ -562,6 +562,51 @@ class MessagesModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<String?> requestPhotoUploadUrl() async {
|
||||
final response = await _api.sendRequest(Opcode.photoUpload, {'count': 1});
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is! Map) return null;
|
||||
return data['url'] as String?;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> sendPhotoMessage(
|
||||
int chatId,
|
||||
List<String> photoTokens, {
|
||||
String? caption,
|
||||
bool notify = true,
|
||||
int maxAttempts = 20,
|
||||
Duration retryDelay = const Duration(seconds: 1),
|
||||
}) async {
|
||||
final message = <String, dynamic>{
|
||||
'cid': DateTime.now().millisecondsSinceEpoch * -1,
|
||||
'attaches': [
|
||||
for (final token in photoTokens)
|
||||
{'_type': 'PHOTO', 'photoToken': token},
|
||||
],
|
||||
};
|
||||
if (caption != null && caption.isNotEmpty) message['text'] = caption;
|
||||
final payload = {'chatId': chatId, 'message': message, 'notify': notify};
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
if (!response.isOk) return null;
|
||||
final data = response.payload;
|
||||
if (data is Map) {
|
||||
final msg = data['message'];
|
||||
if (msg is Map) return Map<String, dynamic>.from(msg);
|
||||
}
|
||||
return null;
|
||||
} on PacketError catch (e) {
|
||||
if (e.errorKey != 'attachment.not.ready') rethrow;
|
||||
if (attempt == maxAttempts - 1) return null;
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
@@ -12,6 +13,14 @@ abstract class GalleryItem {
|
||||
File? get localFile;
|
||||
Future<Uint8List?> thumbnail(int size);
|
||||
Future<File?> originFile();
|
||||
Future<(int, int)?> dimensions();
|
||||
}
|
||||
|
||||
class PickedPhoto {
|
||||
final GalleryItem item;
|
||||
final File? editedFile;
|
||||
|
||||
const PickedPhoto({required this.item, this.editedFile});
|
||||
}
|
||||
|
||||
abstract class GallerySource {
|
||||
@@ -83,6 +92,14 @@ class _AssetGalleryItem implements GalleryItem {
|
||||
|
||||
@override
|
||||
Future<File?> originFile() => asset.file;
|
||||
|
||||
@override
|
||||
Future<(int, int)?> dimensions() async {
|
||||
if (asset.width > 0 && asset.height > 0) {
|
||||
return (asset.width, asset.height);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _DesktopGallerySource implements GallerySource {
|
||||
@@ -159,4 +176,19 @@ class _FileGalleryItem implements GalleryItem {
|
||||
|
||||
@override
|
||||
Future<File?> originFile() async => file;
|
||||
|
||||
@override
|
||||
Future<(int, int)?> dimensions() async {
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final result = (frame.image.width, frame.image.height);
|
||||
frame.image.dispose();
|
||||
codec.dispose();
|
||||
return result;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,22 @@ const int kMaxAvatarBytes = 8 * 1024 * 1024;
|
||||
|
||||
Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input);
|
||||
|
||||
Future<Uint8List?> encodeRgbaToJpeg(Uint8List rgba, int width, int height) =>
|
||||
compute(_encodeRgba, (rgba, width, height));
|
||||
|
||||
Uint8List? _encodeRgba((Uint8List, int, int) args) {
|
||||
final (rgba, width, height) = args;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
final image = img.Image.fromBytes(
|
||||
width: width,
|
||||
height: height,
|
||||
bytes: rgba.buffer,
|
||||
numChannels: 4,
|
||||
order: img.ChannelOrder.rgba,
|
||||
);
|
||||
return img.encodeJpg(image, quality: 90);
|
||||
}
|
||||
|
||||
Uint8List? _encodeAvatar(Uint8List input) {
|
||||
final decoded = img.decodeImage(input);
|
||||
if (decoded == null) return null;
|
||||
|
||||
@@ -4,12 +4,14 @@ import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:komet/backend/modules/chats.dart';
|
||||
import 'package:komet/backend/modules/file_uploader.dart';
|
||||
import 'package:komet/backend/modules/upload_notification_service.dart';
|
||||
import 'package:komet/core/media/gallery_source.dart';
|
||||
import 'package:komet/core/utils/format.dart';
|
||||
import 'package:komet/core/utils/logger.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
||||
@@ -95,6 +97,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
StreamSubscription<MessageEvent>? _messageEventSub;
|
||||
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
|
||||
{};
|
||||
final Map<String, ValueNotifier<List<double>>> _photoUploadProgress = {};
|
||||
|
||||
ValueListenable<List<double>>? _photoProgressFor(CachedMessage m) =>
|
||||
_photoUploadProgress[m.id];
|
||||
|
||||
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
|
||||
final existing = _reactionNotifiers[m.id];
|
||||
@@ -432,6 +438,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
n.dispose();
|
||||
}
|
||||
_reactionNotifiers.clear();
|
||||
for (final n in _photoUploadProgress.values) {
|
||||
n.dispose();
|
||||
}
|
||||
_photoUploadProgress.clear();
|
||||
for (final t in _typingTimers.values) {
|
||||
t.cancel();
|
||||
}
|
||||
@@ -1291,6 +1301,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
chatType: chat?.type ?? 'CHAT',
|
||||
overrideStatus: _effectiveStatus(message),
|
||||
reactionsListenable: _reactionNotifierFor(message),
|
||||
uploadProgress: _photoProgressFor(message),
|
||||
);
|
||||
|
||||
final pressable = _LongPressBubble(
|
||||
@@ -1774,7 +1785,175 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
void _openAttachmentSheet() {
|
||||
showAttachmentSheet(context, title: widget.name);
|
||||
showAttachmentSheet(context, title: widget.name, onSend: _sendPhotos);
|
||||
}
|
||||
|
||||
Future<void> _sendPhotos(List<PickedPhoto> picked, String caption) async {
|
||||
if (_myId == 0) return;
|
||||
final photos = picked.where((ph) => !ph.item.isVideo).toList();
|
||||
if (photos.isEmpty) {
|
||||
if (mounted) showCustomNotification(context, 'Видео пока нельзя отправить');
|
||||
return;
|
||||
}
|
||||
|
||||
final files = <File>[];
|
||||
final attachments = <PhotoAttachment>[];
|
||||
for (final photo in photos) {
|
||||
final edited = photo.editedFile;
|
||||
final file =
|
||||
edited ?? photo.item.localFile ?? await photo.item.originFile();
|
||||
if (file == null) continue;
|
||||
final dim = edited != null
|
||||
? await _decodeImageDimensions(edited)
|
||||
: await photo.item.dimensions();
|
||||
files.add(file);
|
||||
attachments.add(
|
||||
PhotoAttachment(
|
||||
localPath: file.path,
|
||||
width: dim?.$1,
|
||||
height: dim?.$2,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (files.isEmpty || !mounted) return;
|
||||
|
||||
final tempId = _nextTempId();
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final progress = ValueNotifier<List<double>>(
|
||||
List<double>.filled(files.length, 0),
|
||||
);
|
||||
_photoUploadProgress[tempId] = progress;
|
||||
|
||||
_messages.add(
|
||||
CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
chatId: widget.chatId,
|
||||
senderId: _myId,
|
||||
text: caption.isEmpty ? null : caption,
|
||||
time: now,
|
||||
status: 'sending',
|
||||
attachments: attachments,
|
||||
),
|
||||
);
|
||||
_lastSentId = tempId;
|
||||
_bumpMessages();
|
||||
Haptics.send();
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
final tokens = await Future.wait(
|
||||
List.generate(
|
||||
files.length,
|
||||
(i) => _uploadOnePhoto(files[i], i, progress),
|
||||
),
|
||||
);
|
||||
if (!mounted) {
|
||||
_disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
if (tokens.any((t) => t == null)) {
|
||||
_failPhotoMessage(tempId);
|
||||
return;
|
||||
}
|
||||
|
||||
progress.value = List<double>.filled(files.length, 1);
|
||||
|
||||
final serverMsg = await messagesModule.sendPhotoMessage(
|
||||
widget.chatId,
|
||||
tokens.cast<String>(),
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
);
|
||||
if (!mounted) {
|
||||
_disposePhotoProgress(tempId);
|
||||
return;
|
||||
}
|
||||
if (serverMsg == null) {
|
||||
_failPhotoMessage(tempId);
|
||||
return;
|
||||
}
|
||||
|
||||
final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg);
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx != -1) {
|
||||
_messages[idx] = real;
|
||||
_bumpMessages();
|
||||
unawaited(_persistOutgoing(real));
|
||||
}
|
||||
_disposePhotoProgress(tempId);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_failPhotoMessage(tempId);
|
||||
} else {
|
||||
_disposePhotoProgress(tempId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _uploadOnePhoto(
|
||||
File file,
|
||||
int index,
|
||||
ValueNotifier<List<double>> progress,
|
||||
) async {
|
||||
final url = await messagesModule.requestPhotoUploadUrl();
|
||||
if (url == null || url.isEmpty) return null;
|
||||
return fileUploader.uploadPhoto(
|
||||
Uri.parse(url),
|
||||
file,
|
||||
filename: _photoFilename(file),
|
||||
onProgress: (sent, total) {
|
||||
if (total <= 0) return;
|
||||
final next = List<double>.from(progress.value);
|
||||
if (index < next.length) {
|
||||
next[index] = (sent / total).clamp(0.0, 1.0);
|
||||
progress.value = next;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _photoFilename(File file) {
|
||||
final segments = file.uri.pathSegments;
|
||||
final name = segments.isNotEmpty ? segments.last : '';
|
||||
return name.isNotEmpty ? name : 'photo.jpg';
|
||||
}
|
||||
|
||||
void _failPhotoMessage(String tempId) {
|
||||
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||
if (idx != -1) {
|
||||
final old = _messages[idx];
|
||||
_messages[idx] = CachedMessage(
|
||||
id: old.id,
|
||||
accountId: old.accountId,
|
||||
chatId: old.chatId,
|
||||
senderId: old.senderId,
|
||||
text: old.text,
|
||||
time: old.time,
|
||||
status: 'error',
|
||||
attachments: old.attachments,
|
||||
);
|
||||
_bumpMessages();
|
||||
}
|
||||
_disposePhotoProgress(tempId);
|
||||
Haptics.error();
|
||||
}
|
||||
|
||||
void _disposePhotoProgress(String tempId) {
|
||||
_photoUploadProgress.remove(tempId)?.dispose();
|
||||
}
|
||||
|
||||
Future<(int, int)?> _decodeImageDimensions(File file) async {
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final result = (frame.image.width, frame.image.height);
|
||||
frame.image.dispose();
|
||||
codec.dispose();
|
||||
return result;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickAndUploadFile() async {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -16,21 +18,26 @@ const List<PillNavItem> _navItems = [
|
||||
PillNavItem(icon: Symbols.person, label: 'Контакт'),
|
||||
];
|
||||
|
||||
Future<void> showAttachmentSheet(BuildContext context, {String? title}) {
|
||||
Future<void> showAttachmentSheet(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
void Function(List<PickedPhoto> photos, String caption)? onSend,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
requestFocus: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.45),
|
||||
builder: (_) => AttachmentSheet(title: title),
|
||||
builder: (_) => AttachmentSheet(title: title, onSend: onSend),
|
||||
);
|
||||
}
|
||||
|
||||
class AttachmentSheet extends StatefulWidget {
|
||||
final String? title;
|
||||
final void Function(List<PickedPhoto> photos, String caption)? onSend;
|
||||
|
||||
const AttachmentSheet({super.key, this.title});
|
||||
const AttachmentSheet({super.key, this.title, this.onSend});
|
||||
|
||||
@override
|
||||
State<AttachmentSheet> createState() => _AttachmentSheetState();
|
||||
@@ -42,6 +49,8 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
|
||||
final GallerySource _source = GallerySource.create();
|
||||
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
|
||||
final Map<String, File> _edited = {};
|
||||
final TextEditingController _captionCtrl = TextEditingController();
|
||||
final PageController _pageController = PageController();
|
||||
|
||||
bool _navDragging = false;
|
||||
@@ -70,6 +79,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_selected.dispose();
|
||||
_captionCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -111,7 +121,13 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
title: widget.title,
|
||||
selectedIds: _selected,
|
||||
onToggleSelection: () => _toggleSelection(item),
|
||||
onSend: _onSend,
|
||||
onSend: () => _sendSelection(fallback: item),
|
||||
editedFile: _edited[item.id],
|
||||
onEdited: (file) {
|
||||
if (mounted) setState(() => _edited[item.id] = file);
|
||||
},
|
||||
initialCaption: _captionCtrl.text,
|
||||
onCaptionChanged: (text) => _captionCtrl.text = text,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -129,14 +145,18 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
showCustomNotification(context, 'Камера скоро появится');
|
||||
}
|
||||
|
||||
void _onSend() {
|
||||
final count = _selected.value.length;
|
||||
final overlay = Overlay.of(context, rootOverlay: true);
|
||||
void _sendSelection({GalleryItem? fallback}) {
|
||||
final ids = _selected.value;
|
||||
var chosen = _items.where((it) => ids.contains(it.id)).toList();
|
||||
if (chosen.isEmpty && fallback != null) chosen = [fallback];
|
||||
if (chosen.isEmpty) return;
|
||||
final picked = chosen
|
||||
.map((it) => PickedPhoto(item: it, editedFile: _edited[it.id]))
|
||||
.toList();
|
||||
final callback = widget.onSend;
|
||||
final caption = _captionCtrl.text.trim();
|
||||
Navigator.of(context).pop();
|
||||
showCustomNotificationOnOverlay(
|
||||
overlay,
|
||||
'Отправка $count выбранных скоро появится',
|
||||
);
|
||||
callback?.call(picked, caption);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -173,7 +193,8 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: barReserve + 8,
|
||||
bottom:
|
||||
barReserve + 8 + MediaQuery.viewInsetsOf(context).bottom,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_selected,
|
||||
@@ -192,7 +213,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
opacity: galleryT,
|
||||
child: IgnorePointer(
|
||||
ignoring: galleryT < 0.5,
|
||||
child: _buildSendButton(cs, count),
|
||||
child: _buildSendButton(cs),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -212,6 +233,15 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
static const double _barHeight = SlidingPillNav.height + _pillMargin;
|
||||
static const Duration _navAnim = Duration(milliseconds: 300);
|
||||
|
||||
// Matches the chat composer (message input field) surface.
|
||||
Color _composerColor(ColorScheme cs) => Color.alphaBlend(
|
||||
cs.surfaceContainerHighest.withValues(alpha: 0.92),
|
||||
cs.surface,
|
||||
);
|
||||
|
||||
Color _composerBorderColor(ColorScheme cs) =>
|
||||
cs.outlineVariant.withValues(alpha: 0.5);
|
||||
|
||||
Widget _buildPages(
|
||||
ScrollController scrollController,
|
||||
ColorScheme cs,
|
||||
@@ -301,6 +331,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
selectedIds: _selected,
|
||||
onOpen: () => _openPreview(item),
|
||||
onToggle: () => _toggleSelection(item),
|
||||
editedFile: _edited[item.id],
|
||||
cs: cs,
|
||||
);
|
||||
}, childCount: gridPhotos.length),
|
||||
@@ -323,6 +354,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
selectedIds: _selected,
|
||||
onOpen: () => _openPreview(item),
|
||||
onToggle: () => _toggleSelection(item),
|
||||
editedFile: _edited[item.id],
|
||||
cs: cs,
|
||||
);
|
||||
}
|
||||
@@ -480,31 +512,17 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSendButton(ColorScheme cs, int count) {
|
||||
Widget _buildSendButton(ColorScheme cs) {
|
||||
return Material(
|
||||
color: cs.primary,
|
||||
shape: const StadiumBorder(),
|
||||
elevation: 3,
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: _onSend,
|
||||
onTap: () => _sendSelection(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.send, color: cs.onPrimary, size: 22, weight: 500),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Icon(Symbols.send, color: cs.onPrimary, size: 24, weight: 500),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -543,36 +561,98 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final geometry = PillNavGeometry.fromInnerWidth(
|
||||
constraints.maxWidth - 4,
|
||||
_navItems.length,
|
||||
);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) => _onPillDragStart(),
|
||||
onHorizontalDragUpdate: (d) =>
|
||||
_onPillDragUpdate(d.delta.dx, geometry.inactiveWidth),
|
||||
onHorizontalDragEnd: (_) => _onPillDragEnd(),
|
||||
onHorizontalDragCancel: _onPillDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _pageController,
|
||||
builder: (context, _) {
|
||||
return SlidingPillNav(
|
||||
items: _navItems,
|
||||
position: _currentPageT(),
|
||||
geometry: geometry,
|
||||
onTap: _onSectionTap,
|
||||
);
|
||||
},
|
||||
final inset = MediaQuery.viewInsetsOf(context).bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: inset),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin),
|
||||
child: ValueListenableBuilder<Set<String>>(
|
||||
valueListenable: _selected,
|
||||
builder: (context, selected, _) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: selected.isEmpty
|
||||
? _buildPillNav()
|
||||
: _buildCaptionBar(Theme.of(context).colorScheme),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPillNav() {
|
||||
return LayoutBuilder(
|
||||
key: const ValueKey('nav'),
|
||||
builder: (context, constraints) {
|
||||
final geometry = PillNavGeometry.fromInnerWidth(
|
||||
constraints.maxWidth - 4,
|
||||
_navItems.length,
|
||||
);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: (_) => _onPillDragStart(),
|
||||
onHorizontalDragUpdate: (d) =>
|
||||
_onPillDragUpdate(d.delta.dx, geometry.inactiveWidth),
|
||||
onHorizontalDragEnd: (_) => _onPillDragEnd(),
|
||||
onHorizontalDragCancel: _onPillDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _pageController,
|
||||
builder: (context, _) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return SlidingPillNav(
|
||||
items: _navItems,
|
||||
position: _currentPageT(),
|
||||
geometry: geometry,
|
||||
onTap: _onSectionTap,
|
||||
backgroundColor: _composerColor(cs),
|
||||
borderColor: _composerBorderColor(cs),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaptionBar(ColorScheme cs) {
|
||||
return SizedBox(
|
||||
key: const ValueKey('caption'),
|
||||
height: SlidingPillNav.height,
|
||||
child: Center(
|
||||
child: Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: _composerColor(cs),
|
||||
borderRadius: BorderRadius.circular(26),
|
||||
border: Border.all(color: _composerBorderColor(cs), width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _captionCtrl,
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||
cursorColor: cs.primary,
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
hintText: 'Добавить подпись...',
|
||||
hintStyle: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -639,6 +719,7 @@ class _GalleryTile extends StatefulWidget {
|
||||
final ValueListenable<Set<String>> selectedIds;
|
||||
final VoidCallback onOpen;
|
||||
final VoidCallback onToggle;
|
||||
final File? editedFile;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _GalleryTile({
|
||||
@@ -647,6 +728,7 @@ class _GalleryTile extends StatefulWidget {
|
||||
required this.selectedIds,
|
||||
required this.onOpen,
|
||||
required this.onToggle,
|
||||
this.editedFile,
|
||||
required this.cs,
|
||||
});
|
||||
|
||||
@@ -687,7 +769,11 @@ class _GalleryTileState extends State<_GalleryTile> {
|
||||
scale: _selected ? 0.86 : 1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
child: _Thumbnail(item: item, cs: widget.cs),
|
||||
child: _Thumbnail(
|
||||
item: item,
|
||||
editedFile: widget.editedFile,
|
||||
cs: widget.cs,
|
||||
),
|
||||
),
|
||||
if (item.isVideo)
|
||||
Positioned(
|
||||
@@ -722,7 +808,16 @@ class _GalleryTileState extends State<_GalleryTile> {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: _SelectionCheck(selected: _selected, cs: widget.cs),
|
||||
child: ValueListenableBuilder<Set<String>>(
|
||||
valueListenable: widget.selectedIds,
|
||||
builder: (context, ids, _) {
|
||||
final index = ids.toList().indexOf(widget.item.id);
|
||||
return _SelectionCheck(
|
||||
number: index >= 0 ? index + 1 : null,
|
||||
cs: widget.cs,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -733,23 +828,33 @@ class _GalleryTileState extends State<_GalleryTile> {
|
||||
}
|
||||
|
||||
class _SelectionCheck extends StatelessWidget {
|
||||
final bool selected;
|
||||
final int? number;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _SelectionCheck({required this.selected, required this.cs});
|
||||
const _SelectionCheck({required this.number, required this.cs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = number != null;
|
||||
return Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? cs.primary : Colors.black.withValues(alpha: 0.25),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: selected
|
||||
? Icon(Symbols.check, size: 16, color: cs.onPrimary, weight: 700)
|
||||
? Text(
|
||||
'$number',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -757,9 +862,10 @@ class _SelectionCheck extends StatelessWidget {
|
||||
|
||||
class _Thumbnail extends StatefulWidget {
|
||||
final GalleryItem item;
|
||||
final File? editedFile;
|
||||
final ColorScheme cs;
|
||||
|
||||
const _Thumbnail({required this.item, required this.cs});
|
||||
const _Thumbnail({required this.item, this.editedFile, required this.cs});
|
||||
|
||||
@override
|
||||
State<_Thumbnail> createState() => _ThumbnailState();
|
||||
@@ -779,6 +885,16 @@ class _ThumbnailState extends State<_Thumbnail> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final edited = widget.editedFile;
|
||||
if (edited != null) {
|
||||
return Image.file(
|
||||
edited,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: _pixelSize,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
}
|
||||
final file = widget.item.localFile;
|
||||
if (file != null) {
|
||||
return Image.file(
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:komet/core/media/gallery_source.dart';
|
||||
import 'package:komet/core/utils/image_utils.dart';
|
||||
import 'package:komet/frontend/widgets/attachment/photo_draw_editor.dart';
|
||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||
|
||||
const Color _kAccent = Color(0xFF2F8FFF);
|
||||
const Color _kBar = Color(0xFF1E1E1E);
|
||||
@@ -16,6 +22,10 @@ class MediaPreviewScreen extends StatefulWidget {
|
||||
final ValueListenable<Set<String>> selectedIds;
|
||||
final VoidCallback onToggleSelection;
|
||||
final VoidCallback onSend;
|
||||
final File? editedFile;
|
||||
final void Function(File edited)? onEdited;
|
||||
final String initialCaption;
|
||||
final ValueChanged<String>? onCaptionChanged;
|
||||
|
||||
const MediaPreviewScreen({
|
||||
super.key,
|
||||
@@ -24,27 +34,71 @@ class MediaPreviewScreen extends StatefulWidget {
|
||||
required this.onToggleSelection,
|
||||
required this.onSend,
|
||||
this.title,
|
||||
this.editedFile,
|
||||
this.onEdited,
|
||||
this.initialCaption = '',
|
||||
this.onCaptionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaPreviewScreen> createState() => _MediaPreviewScreenState();
|
||||
}
|
||||
|
||||
class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
||||
final TextEditingController _caption = TextEditingController();
|
||||
Future<File?>? _fileFuture;
|
||||
class _MediaPreviewScreenState extends State<MediaPreviewScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TextEditingController _caption = TextEditingController(
|
||||
text: widget.initialCaption,
|
||||
);
|
||||
File? _workingFile;
|
||||
File? _rotationOriginal;
|
||||
int _appliedTurns = 0;
|
||||
int _queuedTurns = 0;
|
||||
bool _rotating = false;
|
||||
late final AnimationController _rotCtrl;
|
||||
Size? _boxSize;
|
||||
double _aspect = 1;
|
||||
double _rotFitScale = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.item.localFile == null) {
|
||||
_fileFuture = widget.item.originFile();
|
||||
_rotCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
);
|
||||
_caption.addListener(
|
||||
() => widget.onCaptionChanged?.call(_caption.text),
|
||||
);
|
||||
_resolveWorkingFile();
|
||||
}
|
||||
|
||||
Future<void> _resolveWorkingFile() async {
|
||||
final initial = widget.editedFile ?? widget.item.localFile;
|
||||
if (initial != null) {
|
||||
_workingFile = initial;
|
||||
_rotationOriginal = initial;
|
||||
_updateAspect();
|
||||
return;
|
||||
}
|
||||
final file = await widget.item.originFile();
|
||||
if (!mounted) return;
|
||||
setState(() => _workingFile = file);
|
||||
_rotationOriginal = file;
|
||||
_updateAspect();
|
||||
}
|
||||
|
||||
Future<void> _updateAspect() async {
|
||||
final file = _workingFile;
|
||||
if (file == null) return;
|
||||
final dims = await decodeImageFileDimensions(file);
|
||||
if (!mounted || dims == null || dims.$2 == 0) return;
|
||||
_aspect = dims.$1 / dims.$2;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_caption.dispose();
|
||||
_rotCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -53,6 +107,135 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
||||
widget.onSend();
|
||||
}
|
||||
|
||||
// Each tap queues one more 90° step; taps are never dropped. Every step is
|
||||
// baked from the pristine original at the net angle, so repeated rotation
|
||||
// never stacks JPEG generations.
|
||||
Future<void> _rotate() async {
|
||||
if (_workingFile == null || _rotationOriginal == null) return;
|
||||
_queuedTurns++;
|
||||
if (_rotating) return;
|
||||
_rotating = true;
|
||||
while (_queuedTurns > 0 && mounted) {
|
||||
_queuedTurns--;
|
||||
await _rotateOneStep();
|
||||
}
|
||||
_rotating = false;
|
||||
}
|
||||
|
||||
Future<void> _rotateOneStep() async {
|
||||
final original = _rotationOriginal;
|
||||
if (original == null) return;
|
||||
final box = _boxSize;
|
||||
_rotFitScale = box != null ? _rotatedFitScale(_aspect, box) : 1.0;
|
||||
final target = (_appliedTurns + 1) % 4;
|
||||
_rotCtrl.value = 0;
|
||||
final bakeFut = _rotateImageFile(original, target);
|
||||
await _rotCtrl.forward();
|
||||
final baked = await bakeFut;
|
||||
if (!mounted) return;
|
||||
if (baked != null) {
|
||||
try {
|
||||
await precacheImage(FileImage(baked), context);
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
_appliedTurns = target;
|
||||
_aspect = _aspect > 0 ? 1 / _aspect : 1;
|
||||
setState(() {
|
||||
_workingFile = baked;
|
||||
_rotCtrl.value = 0;
|
||||
});
|
||||
widget.onEdited?.call(baked);
|
||||
} else {
|
||||
setState(() => _rotCtrl.value = 0);
|
||||
}
|
||||
}
|
||||
|
||||
double _rotatedFitScale(double a, Size box) {
|
||||
final bw = box.width;
|
||||
final bh = box.height;
|
||||
if (bw <= 0 || bh <= 0 || a <= 0) return 1;
|
||||
double dw;
|
||||
double dh;
|
||||
if (bw / bh > a) {
|
||||
dh = bh;
|
||||
dw = bh * a;
|
||||
} else {
|
||||
dw = bw;
|
||||
dh = bw / a;
|
||||
}
|
||||
final s = math.min(bw / dh, bh / dw);
|
||||
return s.isFinite && s > 0 ? s : 1;
|
||||
}
|
||||
|
||||
Future<File?> _rotateImageFile(File src, int quarterTurnsCCW) async {
|
||||
final turns = ((quarterTurnsCCW % 4) + 4) % 4;
|
||||
if (turns == 0) return src;
|
||||
try {
|
||||
final bytes = await src.readAsBytes();
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final image = frame.image;
|
||||
final w = image.width;
|
||||
final h = image.height;
|
||||
final swap = turns.isOdd;
|
||||
final outW = swap ? h : w;
|
||||
final outH = swap ? w : h;
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
canvas.translate(outW / 2, outH / 2);
|
||||
canvas.rotate(-math.pi / 2 * turns);
|
||||
canvas.drawImage(image, Offset(-w / 2, -h / 2), Paint());
|
||||
final picture = recorder.endRecording();
|
||||
final rotated = await picture.toImage(outW, outH);
|
||||
picture.dispose();
|
||||
image.dispose();
|
||||
codec.dispose();
|
||||
final bd = await rotated.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
rotated.dispose();
|
||||
if (bd == null) return null;
|
||||
final jpeg = await encodeRgbaToJpeg(bd.buffer.asUint8List(), outW, outH);
|
||||
if (jpeg == null) return null;
|
||||
final dir = await getTemporaryDirectory();
|
||||
final out = File(
|
||||
p.join(dir.path, 'komet_rot_${DateTime.now().microsecondsSinceEpoch}.jpg'),
|
||||
);
|
||||
await out.writeAsBytes(jpeg);
|
||||
return out;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openDraw() async {
|
||||
final file = _workingFile;
|
||||
if (file == null || _rotating) return;
|
||||
final dims = await decodeImageFileDimensions(file);
|
||||
if (!mounted) return;
|
||||
if (dims == null) {
|
||||
showCustomNotification(context, 'Не удалось открыть редактор');
|
||||
return;
|
||||
}
|
||||
final result = await Navigator.of(context).push<File>(
|
||||
PageRouteBuilder<File>(
|
||||
opaque: true,
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => PhotoDrawEditor(
|
||||
source: file,
|
||||
imageWidth: dims.$1,
|
||||
imageHeight: dims.$2,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
_rotationOriginal = result;
|
||||
_appliedTurns = 0;
|
||||
setState(() => _workingFile = result);
|
||||
_updateAspect();
|
||||
widget.onEdited?.call(result);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -85,11 +268,31 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: InteractiveViewer(
|
||||
minScale: 1,
|
||||
maxScale: 4,
|
||||
child: _buildImage(),
|
||||
child: ClipRect(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_boxSize = constraints.biggest;
|
||||
return Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _rotCtrl,
|
||||
builder: (context, child) {
|
||||
final t = _rotCtrl.value;
|
||||
return Transform.rotate(
|
||||
angle: -math.pi / 2 * t,
|
||||
child: Transform.scale(
|
||||
scale: 1 + (_rotFitScale - 1) * t,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: InteractiveViewer(
|
||||
minScale: 1,
|
||||
maxScale: 4,
|
||||
child: _buildImage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -100,27 +303,15 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
||||
}
|
||||
|
||||
Widget _buildImage() {
|
||||
final local = widget.item.localFile;
|
||||
if (local != null) {
|
||||
return Image.file(local, fit: BoxFit.contain);
|
||||
final file = _workingFile;
|
||||
if (file == null) {
|
||||
return const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white24),
|
||||
);
|
||||
}
|
||||
return FutureBuilder<File?>(
|
||||
future: _fileFuture,
|
||||
builder: (context, snapshot) {
|
||||
final file = snapshot.data;
|
||||
if (file == null) {
|
||||
return const SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white24,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Image.file(file, fit: BoxFit.contain);
|
||||
},
|
||||
);
|
||||
return Image.file(file, fit: BoxFit.contain, gaplessPlayback: true);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
@@ -188,9 +379,9 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_ToolIcon(icon: Symbols.crop_rotate, onTap: () {}),
|
||||
_ToolIcon(icon: Symbols.brush, onTap: () {}),
|
||||
_QualityBadge(onTap: () {}),
|
||||
_ToolIcon(icon: Symbols.crop_rotate, onTap: _rotate),
|
||||
_ToolIcon(icon: Symbols.brush, onTap: _openDraw),
|
||||
const _FileToggle(),
|
||||
_ToolIcon(icon: Symbols.tune, onTap: () {}),
|
||||
],
|
||||
),
|
||||
@@ -219,7 +410,8 @@ class _SelectionToggle extends StatelessWidget {
|
||||
return ValueListenableBuilder<Set<String>>(
|
||||
valueListenable: selectedIds,
|
||||
builder: (context, selected, _) {
|
||||
final isSelected = selected.contains(id);
|
||||
final index = selected.toList().indexOf(id);
|
||||
final isSelected = index >= 0;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -233,11 +425,14 @@ class _SelectionToggle extends StatelessWidget {
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Symbols.check,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
weight: 700,
|
||||
? Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -315,30 +510,32 @@ class _ToolIcon extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityBadge extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
class _FileToggle extends StatefulWidget {
|
||||
const _FileToggle();
|
||||
|
||||
const _QualityBadge({required this.onTap});
|
||||
@override
|
||||
State<_FileToggle> createState() => _FileToggleState();
|
||||
}
|
||||
|
||||
class _FileToggleState extends State<_FileToggle> {
|
||||
bool _active = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'SD',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
return IconButton(
|
||||
onPressed: () => setState(() => _active = !_active),
|
||||
icon: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: _active ? 1 : 0),
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, t, _) {
|
||||
final color = Color.lerp(
|
||||
Colors.white54,
|
||||
Color.lerp(Colors.white, _kAccent, 0.4),
|
||||
t,
|
||||
);
|
||||
return Icon(Symbols.description, color: color, size: 24);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -79,6 +81,7 @@ class MessageBubble extends StatelessWidget {
|
||||
final String chatType;
|
||||
final String? overrideStatus;
|
||||
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
||||
final ValueListenable<List<double>>? uploadProgress;
|
||||
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
@@ -90,6 +93,7 @@ class MessageBubble extends StatelessWidget {
|
||||
required this.chatType,
|
||||
this.overrideStatus,
|
||||
this.reactionsListenable,
|
||||
this.uploadProgress,
|
||||
});
|
||||
|
||||
bool _computeHasPhotoWithCaption() {
|
||||
@@ -847,7 +851,7 @@ class MessageBubble extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(child: _buildCaption(ctx)),
|
||||
Expanded(child: _buildCaption(ctx)),
|
||||
_buildMeta(ctx),
|
||||
],
|
||||
),
|
||||
@@ -1023,7 +1027,6 @@ class MessageBubble extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildSinglePhoto(_BubbleCtx ctx, PhotoAttachment photo) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
final width = photo.width?.toDouble() ?? 200;
|
||||
final height = photo.height?.toDouble() ?? 200;
|
||||
|
||||
@@ -1051,34 +1054,92 @@ class MessageBubble extends StatelessWidget {
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: constrainedWidth,
|
||||
height: constrainedHeight,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (constrainedWidth * dpr).round(),
|
||||
memCacheHeight: (constrainedHeight * dpr).round(),
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, _, _) => _buildPhotoPlaceholder(
|
||||
ctx.cs,
|
||||
constrainedWidth,
|
||||
constrainedHeight,
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildPhotoPlaceholder(ctx.cs, constrainedWidth, constrainedHeight),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openPhotoViewer(ctx.context, photo),
|
||||
),
|
||||
_buildPhotoImage(
|
||||
ctx,
|
||||
photo,
|
||||
constrainedWidth,
|
||||
constrainedHeight,
|
||||
memWidth: (constrainedWidth * dpr).round(),
|
||||
memHeight: (constrainedHeight * dpr).round(),
|
||||
),
|
||||
if (uploadProgress != null) _buildUploadOverlay(uploadProgress!, 0),
|
||||
if (uploadProgress == null)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openPhotoViewer(ctx.context, photo),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoImage(
|
||||
_BubbleCtx ctx,
|
||||
PhotoAttachment photo,
|
||||
double width,
|
||||
double height, {
|
||||
required int memWidth,
|
||||
required int memHeight,
|
||||
}) {
|
||||
final localPath = photo.localPath;
|
||||
if (localPath != null) {
|
||||
return Image.file(
|
||||
File(localPath),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: memWidth,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) =>
|
||||
_buildPhotoPlaceholder(ctx.cs, width, height),
|
||||
);
|
||||
}
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
if (imageUrl.isNotEmpty) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: memWidth,
|
||||
memCacheHeight: memHeight,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height),
|
||||
);
|
||||
}
|
||||
return _buildPhotoPlaceholder(ctx.cs, width, height);
|
||||
}
|
||||
|
||||
Widget _buildUploadOverlay(
|
||||
ValueListenable<List<double>> progress,
|
||||
int index,
|
||||
) {
|
||||
return Positioned.fill(
|
||||
child: ValueListenableBuilder<List<double>>(
|
||||
valueListenable: progress,
|
||||
builder: (context, values, _) {
|
||||
final value = index < values.length ? values[index] : 1.0;
|
||||
final indeterminate = value <= 0 || value >= 1.0;
|
||||
return Container(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
value: indeterminate ? null : value,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTwoPhotos(
|
||||
_BubbleCtx ctx,
|
||||
PhotoAttachment p1,
|
||||
@@ -1105,9 +1166,9 @@ class MessageBubble extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Expanded(child: _buildPhotoTile(ctx, p1)),
|
||||
Expanded(child: _buildPhotoTile(ctx, p1, 0)),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(child: _buildPhotoTile(ctx, p2)),
|
||||
Expanded(child: _buildPhotoTile(ctx, p2, 1)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1143,42 +1204,38 @@ class MessageBubble extends StatelessWidget {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: List.generate(displayCount, (i) {
|
||||
if (i == 3 && remaining > 0) {
|
||||
return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining');
|
||||
return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining', i);
|
||||
}
|
||||
return _buildPhotoTile(ctx, photos[i]);
|
||||
return _buildPhotoTile(ctx, photos[i], i);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo, int index) {
|
||||
final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
|
||||
.round();
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: cachePx,
|
||||
memCacheHeight: cachePx,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, _, _) =>
|
||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||
)
|
||||
else
|
||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openPhotoViewer(ctx.context, photo),
|
||||
),
|
||||
_buildPhotoImage(
|
||||
ctx,
|
||||
photo,
|
||||
double.infinity,
|
||||
double.infinity,
|
||||
memWidth: cachePx,
|
||||
memHeight: cachePx,
|
||||
),
|
||||
if (uploadProgress != null)
|
||||
_buildUploadOverlay(uploadProgress!, index),
|
||||
if (uploadProgress == null)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openPhotoViewer(ctx.context, photo),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1188,28 +1245,22 @@ class MessageBubble extends StatelessWidget {
|
||||
_BubbleCtx ctx,
|
||||
PhotoAttachment photo,
|
||||
String overlay,
|
||||
int index,
|
||||
) {
|
||||
final imageUrl = photo.baseUrl ?? '';
|
||||
final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
|
||||
.round();
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
memCacheWidth: cachePx,
|
||||
memCacheHeight: cachePx,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, _, _) =>
|
||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||
)
|
||||
else
|
||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
||||
_buildPhotoImage(
|
||||
ctx,
|
||||
photo,
|
||||
double.infinity,
|
||||
double.infinity,
|
||||
memWidth: cachePx,
|
||||
memHeight: cachePx,
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black45,
|
||||
@@ -1225,6 +1276,8 @@ class MessageBubble extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (uploadProgress != null)
|
||||
_buildUploadOverlay(uploadProgress!, index),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -37,6 +37,8 @@ class SlidingPillNav extends StatelessWidget {
|
||||
final void Function(int index, Offset globalPosition)? onItemLongPress;
|
||||
final double iconSize;
|
||||
final double labelGap;
|
||||
final Color? backgroundColor;
|
||||
final Color? borderColor;
|
||||
|
||||
const SlidingPillNav({
|
||||
super.key,
|
||||
@@ -48,6 +50,8 @@ class SlidingPillNav extends StatelessWidget {
|
||||
this.onItemLongPress,
|
||||
this.iconSize = 22,
|
||||
this.labelGap = 6,
|
||||
this.backgroundColor,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
static const double height = 68;
|
||||
@@ -71,8 +75,11 @@ class SlidingPillNav extends StatelessWidget {
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHigh,
|
||||
color: backgroundColor ?? cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(34),
|
||||
border: borderColor != null
|
||||
? Border.all(color: borderColor!, width: 0.5)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
|
||||
@@ -62,6 +62,7 @@ class PhotoAttachment extends MessageAttachment {
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? size;
|
||||
final String? localPath;
|
||||
|
||||
const PhotoAttachment({
|
||||
super.previewData,
|
||||
@@ -72,6 +73,7 @@ class PhotoAttachment extends MessageAttachment {
|
||||
this.width,
|
||||
this.height,
|
||||
this.size,
|
||||
this.localPath,
|
||||
}) : super(type: AttachmentType.photo);
|
||||
|
||||
factory PhotoAttachment.fromMap(Map<String, dynamic> map) {
|
||||
|
||||
Reference in New Issue
Block a user