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}) {
|
void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) {
|
||||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||||
final headers = StringBuffer()
|
final headers = StringBuffer()
|
||||||
|
|||||||
@@ -562,6 +562,51 @@ class MessagesModule {
|
|||||||
return false;
|
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 {
|
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||||
try {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
@@ -12,6 +13,14 @@ abstract class GalleryItem {
|
|||||||
File? get localFile;
|
File? get localFile;
|
||||||
Future<Uint8List?> thumbnail(int size);
|
Future<Uint8List?> thumbnail(int size);
|
||||||
Future<File?> originFile();
|
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 {
|
abstract class GallerySource {
|
||||||
@@ -83,6 +92,14 @@ class _AssetGalleryItem implements GalleryItem {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<File?> originFile() => asset.file;
|
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 {
|
class _DesktopGallerySource implements GallerySource {
|
||||||
@@ -159,4 +176,19 @@ class _FileGalleryItem implements GalleryItem {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<File?> originFile() async => file;
|
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?> 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) {
|
Uint8List? _encodeAvatar(Uint8List input) {
|
||||||
final decoded = img.decodeImage(input);
|
final decoded = img.decodeImage(input);
|
||||||
if (decoded == null) return null;
|
if (decoded == null) return null;
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import 'dart:math' as math;
|
|||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:komet/backend/modules/chats.dart';
|
import 'package:komet/backend/modules/chats.dart';
|
||||||
import 'package:komet/backend/modules/file_uploader.dart';
|
import 'package:komet/backend/modules/file_uploader.dart';
|
||||||
import 'package:komet/backend/modules/upload_notification_service.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/format.dart';
|
||||||
import 'package:komet/core/utils/logger.dart';
|
import 'package:komet/core/utils/logger.dart';
|
||||||
import 'package:komet/frontend/screens/chats/chat_info_screen.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;
|
StreamSubscription<MessageEvent>? _messageEventSub;
|
||||||
final Map<String, ValueNotifier<Map<String, dynamic>?>> _reactionNotifiers =
|
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) {
|
ValueNotifier<Map<String, dynamic>?> _reactionNotifierFor(CachedMessage m) {
|
||||||
final existing = _reactionNotifiers[m.id];
|
final existing = _reactionNotifiers[m.id];
|
||||||
@@ -432,6 +438,10 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
|||||||
n.dispose();
|
n.dispose();
|
||||||
}
|
}
|
||||||
_reactionNotifiers.clear();
|
_reactionNotifiers.clear();
|
||||||
|
for (final n in _photoUploadProgress.values) {
|
||||||
|
n.dispose();
|
||||||
|
}
|
||||||
|
_photoUploadProgress.clear();
|
||||||
for (final t in _typingTimers.values) {
|
for (final t in _typingTimers.values) {
|
||||||
t.cancel();
|
t.cancel();
|
||||||
}
|
}
|
||||||
@@ -1291,6 +1301,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
|||||||
chatType: chat?.type ?? 'CHAT',
|
chatType: chat?.type ?? 'CHAT',
|
||||||
overrideStatus: _effectiveStatus(message),
|
overrideStatus: _effectiveStatus(message),
|
||||||
reactionsListenable: _reactionNotifierFor(message),
|
reactionsListenable: _reactionNotifierFor(message),
|
||||||
|
uploadProgress: _photoProgressFor(message),
|
||||||
);
|
);
|
||||||
|
|
||||||
final pressable = _LongPressBubble(
|
final pressable = _LongPressBubble(
|
||||||
@@ -1774,7 +1785,175 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openAttachmentSheet() {
|
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 {
|
Future<void> _pickAndUploadFile() async {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -16,21 +18,26 @@ const List<PillNavItem> _navItems = [
|
|||||||
PillNavItem(icon: Symbols.person, label: 'Контакт'),
|
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>(
|
return showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
requestFocus: false,
|
requestFocus: false,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
barrierColor: Colors.black.withValues(alpha: 0.45),
|
barrierColor: Colors.black.withValues(alpha: 0.45),
|
||||||
builder: (_) => AttachmentSheet(title: title),
|
builder: (_) => AttachmentSheet(title: title, onSend: onSend),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AttachmentSheet extends StatefulWidget {
|
class AttachmentSheet extends StatefulWidget {
|
||||||
final String? title;
|
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
|
@override
|
||||||
State<AttachmentSheet> createState() => _AttachmentSheetState();
|
State<AttachmentSheet> createState() => _AttachmentSheetState();
|
||||||
@@ -42,6 +49,8 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
|
|
||||||
final GallerySource _source = GallerySource.create();
|
final GallerySource _source = GallerySource.create();
|
||||||
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
|
final ValueNotifier<Set<String>> _selected = ValueNotifier(<String>{});
|
||||||
|
final Map<String, File> _edited = {};
|
||||||
|
final TextEditingController _captionCtrl = TextEditingController();
|
||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
|
|
||||||
bool _navDragging = false;
|
bool _navDragging = false;
|
||||||
@@ -70,6 +79,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
_selected.dispose();
|
_selected.dispose();
|
||||||
|
_captionCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +121,13 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
title: widget.title,
|
title: widget.title,
|
||||||
selectedIds: _selected,
|
selectedIds: _selected,
|
||||||
onToggleSelection: () => _toggleSelection(item),
|
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, 'Камера скоро появится');
|
showCustomNotification(context, 'Камера скоро появится');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSend() {
|
void _sendSelection({GalleryItem? fallback}) {
|
||||||
final count = _selected.value.length;
|
final ids = _selected.value;
|
||||||
final overlay = Overlay.of(context, rootOverlay: true);
|
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();
|
Navigator.of(context).pop();
|
||||||
showCustomNotificationOnOverlay(
|
callback?.call(picked, caption);
|
||||||
overlay,
|
|
||||||
'Отправка $count выбранных скоро появится',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -173,7 +193,8 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: barReserve + 8,
|
bottom:
|
||||||
|
barReserve + 8 + MediaQuery.viewInsetsOf(context).bottom,
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: Listenable.merge([
|
animation: Listenable.merge([
|
||||||
_selected,
|
_selected,
|
||||||
@@ -192,7 +213,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
opacity: galleryT,
|
opacity: galleryT,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: galleryT < 0.5,
|
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 double _barHeight = SlidingPillNav.height + _pillMargin;
|
||||||
static const Duration _navAnim = Duration(milliseconds: 300);
|
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(
|
Widget _buildPages(
|
||||||
ScrollController scrollController,
|
ScrollController scrollController,
|
||||||
ColorScheme cs,
|
ColorScheme cs,
|
||||||
@@ -301,6 +331,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
selectedIds: _selected,
|
selectedIds: _selected,
|
||||||
onOpen: () => _openPreview(item),
|
onOpen: () => _openPreview(item),
|
||||||
onToggle: () => _toggleSelection(item),
|
onToggle: () => _toggleSelection(item),
|
||||||
|
editedFile: _edited[item.id],
|
||||||
cs: cs,
|
cs: cs,
|
||||||
);
|
);
|
||||||
}, childCount: gridPhotos.length),
|
}, childCount: gridPhotos.length),
|
||||||
@@ -323,6 +354,7 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
selectedIds: _selected,
|
selectedIds: _selected,
|
||||||
onOpen: () => _openPreview(item),
|
onOpen: () => _openPreview(item),
|
||||||
onToggle: () => _toggleSelection(item),
|
onToggle: () => _toggleSelection(item),
|
||||||
|
editedFile: _edited[item.id],
|
||||||
cs: cs,
|
cs: cs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -480,31 +512,17 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSendButton(ColorScheme cs, int count) {
|
Widget _buildSendButton(ColorScheme cs) {
|
||||||
return Material(
|
return Material(
|
||||||
color: cs.primary,
|
color: cs.primary,
|
||||||
shape: const StadiumBorder(),
|
shape: const StadiumBorder(),
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
customBorder: const StadiumBorder(),
|
customBorder: const StadiumBorder(),
|
||||||
onTap: _onSend,
|
onTap: () => _sendSelection(),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Row(
|
child: Icon(Symbols.send, color: cs.onPrimary, size: 24, weight: 500),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -543,36 +561,98 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBottomBar() {
|
Widget _buildBottomBar() {
|
||||||
return SafeArea(
|
final inset = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
top: false,
|
return Padding(
|
||||||
child: Padding(
|
padding: EdgeInsets.only(bottom: inset),
|
||||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin),
|
child: SafeArea(
|
||||||
child: LayoutBuilder(
|
top: false,
|
||||||
builder: (context, constraints) {
|
child: Padding(
|
||||||
final geometry = PillNavGeometry.fromInnerWidth(
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin),
|
||||||
constraints.maxWidth - 4,
|
child: ValueListenableBuilder<Set<String>>(
|
||||||
_navItems.length,
|
valueListenable: _selected,
|
||||||
);
|
builder: (context, selected, _) {
|
||||||
return GestureDetector(
|
return AnimatedSwitcher(
|
||||||
behavior: HitTestBehavior.opaque,
|
duration: const Duration(milliseconds: 200),
|
||||||
onHorizontalDragStart: (_) => _onPillDragStart(),
|
switchInCurve: Curves.easeOut,
|
||||||
onHorizontalDragUpdate: (d) =>
|
switchOutCurve: Curves.easeIn,
|
||||||
_onPillDragUpdate(d.delta.dx, geometry.inactiveWidth),
|
child: selected.isEmpty
|
||||||
onHorizontalDragEnd: (_) => _onPillDragEnd(),
|
? _buildPillNav()
|
||||||
onHorizontalDragCancel: _onPillDragEnd,
|
: _buildCaptionBar(Theme.of(context).colorScheme),
|
||||||
child: AnimatedBuilder(
|
);
|
||||||
animation: _pageController,
|
},
|
||||||
builder: (context, _) {
|
),
|
||||||
return SlidingPillNav(
|
),
|
||||||
items: _navItems,
|
),
|
||||||
position: _currentPageT(),
|
);
|
||||||
geometry: geometry,
|
}
|
||||||
onTap: _onSectionTap,
|
|
||||||
);
|
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 ValueListenable<Set<String>> selectedIds;
|
||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
final VoidCallback onToggle;
|
final VoidCallback onToggle;
|
||||||
|
final File? editedFile;
|
||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
|
|
||||||
const _GalleryTile({
|
const _GalleryTile({
|
||||||
@@ -647,6 +728,7 @@ class _GalleryTile extends StatefulWidget {
|
|||||||
required this.selectedIds,
|
required this.selectedIds,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onToggle,
|
required this.onToggle,
|
||||||
|
this.editedFile,
|
||||||
required this.cs,
|
required this.cs,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -687,7 +769,11 @@ class _GalleryTileState extends State<_GalleryTile> {
|
|||||||
scale: _selected ? 0.86 : 1.0,
|
scale: _selected ? 0.86 : 1.0,
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: _Thumbnail(item: item, cs: widget.cs),
|
child: _Thumbnail(
|
||||||
|
item: item,
|
||||||
|
editedFile: widget.editedFile,
|
||||||
|
cs: widget.cs,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (item.isVideo)
|
if (item.isVideo)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -722,7 +808,16 @@ class _GalleryTileState extends State<_GalleryTile> {
|
|||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(6),
|
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 {
|
class _SelectionCheck extends StatelessWidget {
|
||||||
final bool selected;
|
final int? number;
|
||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
|
|
||||||
const _SelectionCheck({required this.selected, required this.cs});
|
const _SelectionCheck({required this.number, required this.cs});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selected = number != null;
|
||||||
return Container(
|
return Container(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: selected ? cs.primary : Colors.black.withValues(alpha: 0.25),
|
color: selected ? cs.primary : Colors.black.withValues(alpha: 0.25),
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
),
|
),
|
||||||
child: selected
|
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,
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -757,9 +862,10 @@ class _SelectionCheck extends StatelessWidget {
|
|||||||
|
|
||||||
class _Thumbnail extends StatefulWidget {
|
class _Thumbnail extends StatefulWidget {
|
||||||
final GalleryItem item;
|
final GalleryItem item;
|
||||||
|
final File? editedFile;
|
||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
|
|
||||||
const _Thumbnail({required this.item, required this.cs});
|
const _Thumbnail({required this.item, this.editedFile, required this.cs});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_Thumbnail> createState() => _ThumbnailState();
|
State<_Thumbnail> createState() => _ThumbnailState();
|
||||||
@@ -779,6 +885,16 @@ class _ThumbnailState extends State<_Thumbnail> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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;
|
final file = widget.item.localFile;
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
return Image.file(
|
return Image.file(
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.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/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 _kAccent = Color(0xFF2F8FFF);
|
||||||
const Color _kBar = Color(0xFF1E1E1E);
|
const Color _kBar = Color(0xFF1E1E1E);
|
||||||
@@ -16,6 +22,10 @@ class MediaPreviewScreen extends StatefulWidget {
|
|||||||
final ValueListenable<Set<String>> selectedIds;
|
final ValueListenable<Set<String>> selectedIds;
|
||||||
final VoidCallback onToggleSelection;
|
final VoidCallback onToggleSelection;
|
||||||
final VoidCallback onSend;
|
final VoidCallback onSend;
|
||||||
|
final File? editedFile;
|
||||||
|
final void Function(File edited)? onEdited;
|
||||||
|
final String initialCaption;
|
||||||
|
final ValueChanged<String>? onCaptionChanged;
|
||||||
|
|
||||||
const MediaPreviewScreen({
|
const MediaPreviewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -24,27 +34,71 @@ class MediaPreviewScreen extends StatefulWidget {
|
|||||||
required this.onToggleSelection,
|
required this.onToggleSelection,
|
||||||
required this.onSend,
|
required this.onSend,
|
||||||
this.title,
|
this.title,
|
||||||
|
this.editedFile,
|
||||||
|
this.onEdited,
|
||||||
|
this.initialCaption = '',
|
||||||
|
this.onCaptionChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MediaPreviewScreen> createState() => _MediaPreviewScreenState();
|
State<MediaPreviewScreen> createState() => _MediaPreviewScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
class _MediaPreviewScreenState extends State<MediaPreviewScreen>
|
||||||
final TextEditingController _caption = TextEditingController();
|
with SingleTickerProviderStateMixin {
|
||||||
Future<File?>? _fileFuture;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.item.localFile == null) {
|
_rotCtrl = AnimationController(
|
||||||
_fileFuture = widget.item.originFile();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_caption.dispose();
|
_caption.dispose();
|
||||||
|
_rotCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +107,135 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
widget.onSend();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -85,11 +268,31 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: ClipRect(
|
||||||
child: InteractiveViewer(
|
child: LayoutBuilder(
|
||||||
minScale: 1,
|
builder: (context, constraints) {
|
||||||
maxScale: 4,
|
_boxSize = constraints.biggest;
|
||||||
child: _buildImage(),
|
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() {
|
Widget _buildImage() {
|
||||||
final local = widget.item.localFile;
|
final file = _workingFile;
|
||||||
if (local != null) {
|
if (file == null) {
|
||||||
return Image.file(local, fit: BoxFit.contain);
|
return const SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white24),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return FutureBuilder<File?>(
|
return Image.file(file, fit: BoxFit.contain, gaplessPlayback: true);
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBottomBar() {
|
Widget _buildBottomBar() {
|
||||||
@@ -188,9 +379,9 @@ class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
_ToolIcon(icon: Symbols.crop_rotate, onTap: () {}),
|
_ToolIcon(icon: Symbols.crop_rotate, onTap: _rotate),
|
||||||
_ToolIcon(icon: Symbols.brush, onTap: () {}),
|
_ToolIcon(icon: Symbols.brush, onTap: _openDraw),
|
||||||
_QualityBadge(onTap: () {}),
|
const _FileToggle(),
|
||||||
_ToolIcon(icon: Symbols.tune, onTap: () {}),
|
_ToolIcon(icon: Symbols.tune, onTap: () {}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -219,7 +410,8 @@ class _SelectionToggle extends StatelessWidget {
|
|||||||
return ValueListenableBuilder<Set<String>>(
|
return ValueListenableBuilder<Set<String>>(
|
||||||
valueListenable: selectedIds,
|
valueListenable: selectedIds,
|
||||||
builder: (context, selected, _) {
|
builder: (context, selected, _) {
|
||||||
final isSelected = selected.contains(id);
|
final index = selected.toList().indexOf(id);
|
||||||
|
final isSelected = index >= 0;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
@@ -233,11 +425,14 @@ class _SelectionToggle extends StatelessWidget {
|
|||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
),
|
),
|
||||||
child: isSelected
|
child: isSelected
|
||||||
? const Icon(
|
? Text(
|
||||||
Symbols.check,
|
'${index + 1}',
|
||||||
color: Colors.white,
|
style: const TextStyle(
|
||||||
size: 18,
|
color: Colors.white,
|
||||||
weight: 700,
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -315,30 +510,32 @@ class _ToolIcon extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QualityBadge extends StatelessWidget {
|
class _FileToggle extends StatefulWidget {
|
||||||
final VoidCallback onTap;
|
const _FileToggle();
|
||||||
|
|
||||||
const _QualityBadge({required this.onTap});
|
@override
|
||||||
|
State<_FileToggle> createState() => _FileToggleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileToggleState extends State<_FileToggle> {
|
||||||
|
bool _active = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return IconButton(
|
||||||
onTap: onTap,
|
onPressed: () => setState(() => _active = !_active),
|
||||||
behavior: HitTestBehavior.opaque,
|
icon: TweenAnimationBuilder<double>(
|
||||||
child: Container(
|
tween: Tween(end: _active ? 1 : 0),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
duration: const Duration(milliseconds: 160),
|
||||||
decoration: BoxDecoration(
|
curve: Curves.easeOut,
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
builder: (context, t, _) {
|
||||||
borderRadius: BorderRadius.circular(8),
|
final color = Color.lerp(
|
||||||
),
|
Colors.white54,
|
||||||
child: const Text(
|
Color.lerp(Colors.white, _kAccent, 0.4),
|
||||||
'SD',
|
t,
|
||||||
style: TextStyle(
|
);
|
||||||
color: Colors.white,
|
return Icon(Symbols.description, color: color, size: 24);
|
||||||
fontSize: 13,
|
},
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -79,6 +81,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final String chatType;
|
final String chatType;
|
||||||
final String? overrideStatus;
|
final String? overrideStatus;
|
||||||
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
||||||
|
final ValueListenable<List<double>>? uploadProgress;
|
||||||
|
|
||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -90,6 +93,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
required this.chatType,
|
required this.chatType,
|
||||||
this.overrideStatus,
|
this.overrideStatus,
|
||||||
this.reactionsListenable,
|
this.reactionsListenable,
|
||||||
|
this.uploadProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool _computeHasPhotoWithCaption() {
|
bool _computeHasPhotoWithCaption() {
|
||||||
@@ -847,7 +851,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Flexible(child: _buildCaption(ctx)),
|
Expanded(child: _buildCaption(ctx)),
|
||||||
_buildMeta(ctx),
|
_buildMeta(ctx),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1023,7 +1027,6 @@ class MessageBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSinglePhoto(_BubbleCtx ctx, PhotoAttachment photo) {
|
Widget _buildSinglePhoto(_BubbleCtx ctx, PhotoAttachment photo) {
|
||||||
final imageUrl = photo.baseUrl ?? '';
|
|
||||||
final width = photo.width?.toDouble() ?? 200;
|
final width = photo.width?.toDouble() ?? 200;
|
||||||
final height = photo.height?.toDouble() ?? 200;
|
final height = photo.height?.toDouble() ?? 200;
|
||||||
|
|
||||||
@@ -1051,34 +1054,92 @@ class MessageBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (imageUrl.isNotEmpty)
|
_buildPhotoImage(
|
||||||
CachedNetworkImage(
|
ctx,
|
||||||
imageUrl: imageUrl,
|
photo,
|
||||||
width: constrainedWidth,
|
constrainedWidth,
|
||||||
height: constrainedHeight,
|
constrainedHeight,
|
||||||
fit: BoxFit.cover,
|
memWidth: (constrainedWidth * dpr).round(),
|
||||||
memCacheWidth: (constrainedWidth * dpr).round(),
|
memHeight: (constrainedHeight * 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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
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(
|
Widget _buildTwoPhotos(
|
||||||
_BubbleCtx ctx,
|
_BubbleCtx ctx,
|
||||||
PhotoAttachment p1,
|
PhotoAttachment p1,
|
||||||
@@ -1105,9 +1166,9 @@ class MessageBubble extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _buildPhotoTile(ctx, p1)),
|
Expanded(child: _buildPhotoTile(ctx, p1, 0)),
|
||||||
const SizedBox(width: 2),
|
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(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: List.generate(displayCount, (i) {
|
children: List.generate(displayCount, (i) {
|
||||||
if (i == 3 && remaining > 0) {
|
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) {
|
Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo, int index) {
|
||||||
final imageUrl = photo.baseUrl ?? '';
|
|
||||||
final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
|
final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
|
||||||
.round();
|
.round();
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (imageUrl.isNotEmpty)
|
_buildPhotoImage(
|
||||||
CachedNetworkImage(
|
ctx,
|
||||||
imageUrl: imageUrl,
|
photo,
|
||||||
fit: BoxFit.cover,
|
double.infinity,
|
||||||
width: double.infinity,
|
double.infinity,
|
||||||
height: double.infinity,
|
memWidth: cachePx,
|
||||||
memCacheWidth: cachePx,
|
memHeight: 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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
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,
|
_BubbleCtx ctx,
|
||||||
PhotoAttachment photo,
|
PhotoAttachment photo,
|
||||||
String overlay,
|
String overlay,
|
||||||
|
int index,
|
||||||
) {
|
) {
|
||||||
final imageUrl = photo.baseUrl ?? '';
|
|
||||||
final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
|
final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio)
|
||||||
.round();
|
.round();
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (imageUrl.isNotEmpty)
|
_buildPhotoImage(
|
||||||
CachedNetworkImage(
|
ctx,
|
||||||
imageUrl: imageUrl,
|
photo,
|
||||||
fit: BoxFit.cover,
|
double.infinity,
|
||||||
width: double.infinity,
|
double.infinity,
|
||||||
height: double.infinity,
|
memWidth: cachePx,
|
||||||
memCacheWidth: cachePx,
|
memHeight: cachePx,
|
||||||
memCacheHeight: cachePx,
|
),
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
|
||||||
errorWidget: (_, _, _) =>
|
|
||||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
_buildPhotoPlaceholder(ctx.cs, 100, 100),
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black45,
|
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 void Function(int index, Offset globalPosition)? onItemLongPress;
|
||||||
final double iconSize;
|
final double iconSize;
|
||||||
final double labelGap;
|
final double labelGap;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? borderColor;
|
||||||
|
|
||||||
const SlidingPillNav({
|
const SlidingPillNav({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -48,6 +50,8 @@ class SlidingPillNav extends StatelessWidget {
|
|||||||
this.onItemLongPress,
|
this.onItemLongPress,
|
||||||
this.iconSize = 22,
|
this.iconSize = 22,
|
||||||
this.labelGap = 6,
|
this.labelGap = 6,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.borderColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const double height = 68;
|
static const double height = 68;
|
||||||
@@ -71,8 +75,11 @@ class SlidingPillNav extends StatelessWidget {
|
|||||||
height: height,
|
height: height,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cs.surfaceContainerHigh,
|
color: backgroundColor ?? cs.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(34),
|
borderRadius: BorderRadius.circular(34),
|
||||||
|
border: borderColor != null
|
||||||
|
? Border.all(color: borderColor!, width: 0.5)
|
||||||
|
: null,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class PhotoAttachment extends MessageAttachment {
|
|||||||
final int? width;
|
final int? width;
|
||||||
final int? height;
|
final int? height;
|
||||||
final int? size;
|
final int? size;
|
||||||
|
final String? localPath;
|
||||||
|
|
||||||
const PhotoAttachment({
|
const PhotoAttachment({
|
||||||
super.previewData,
|
super.previewData,
|
||||||
@@ -72,6 +73,7 @@ class PhotoAttachment extends MessageAttachment {
|
|||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
this.size,
|
this.size,
|
||||||
|
this.localPath,
|
||||||
}) : super(type: AttachmentType.photo);
|
}) : super(type: AttachmentType.photo);
|
||||||
|
|
||||||
factory PhotoAttachment.fromMap(Map<String, dynamic> map) {
|
factory PhotoAttachment.fromMap(Map<String, dynamic> map) {
|
||||||
|
|||||||
Reference in New Issue
Block a user