feat: работа с вложениями

This commit is contained in:
Jganenok
2026-06-10 00:58:19 +07:00
parent 5d9a70f1b4
commit ea08c96726
11 changed files with 2134 additions and 191 deletions
+67
View File
@@ -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()
+45
View File
@@ -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, {
+32
View File
@@ -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;
}
}
}
+16
View File
@@ -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;
+180 -1
View File
@@ -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
+117 -64
View File
@@ -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),
],
),
);
+8 -1
View File
@@ -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),
+2
View File
@@ -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) {