From ea08c96726fe7893caedfee35b2fb8c92e0fcd24 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 10 Jun 2026 00:57:48 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/file_uploader.dart | 67 + lib/backend/modules/messages.dart | 45 + lib/core/media/gallery_source.dart | 32 + lib/core/utils/image_utils.dart | 16 + lib/frontend/screens/chats/chat_screen.dart | 181 ++- .../widgets/attachment/attachment_sheet.dart | 248 +++- .../attachment/media_preview_screen.dart | 315 ++++- .../widgets/attachment/photo_draw_editor.dart | 1229 +++++++++++++++++ lib/frontend/widgets/message_bubble.dart | 181 ++- lib/frontend/widgets/sliding_pill_nav.dart | 9 +- lib/models/attachment.dart | 2 + 11 files changed, 2134 insertions(+), 191 deletions(-) create mode 100644 lib/frontend/widgets/attachment/photo_draw_editor.dart diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 3dc5342..26007cc 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -241,6 +241,73 @@ class FileUploader { } } + Future 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() diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index bbca3cb..856c015 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -562,6 +562,51 @@ class MessagesModule { return false; } + Future 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?> sendPhotoMessage( + int chatId, + List photoTokens, { + String? caption, + bool notify = true, + int maxAttempts = 20, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + '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.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 downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/core/media/gallery_source.dart b/lib/core/media/gallery_source.dart index 6bd3929..1fc7ea8 100644 --- a/lib/core/media/gallery_source.dart +++ b/lib/core/media/gallery_source.dart @@ -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 thumbnail(int size); Future 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 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 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; + } + } } diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart index 413f6f1..f010030 100644 --- a/lib/core/utils/image_utils.dart +++ b/lib/core/utils/image_utils.dart @@ -9,6 +9,22 @@ const int kMaxAvatarBytes = 8 * 1024 * 1024; Future compressAvatar(Uint8List input) => compute(_encodeAvatar, input); +Future 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; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index d5404a5..70843b0 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 with TickerProviderStateMixin { StreamSubscription? _messageEventSub; final Map?>> _reactionNotifiers = {}; + final Map>> _photoUploadProgress = {}; + + ValueListenable>? _photoProgressFor(CachedMessage m) => + _photoUploadProgress[m.id]; ValueNotifier?> _reactionNotifierFor(CachedMessage m) { final existing = _reactionNotifiers[m.id]; @@ -432,6 +438,10 @@ class _ChatScreenState extends State 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 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 with TickerProviderStateMixin { } void _openAttachmentSheet() { - showAttachmentSheet(context, title: widget.name); + showAttachmentSheet(context, title: widget.name, onSend: _sendPhotos); + } + + Future _sendPhotos(List 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 = []; + final attachments = []; + 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.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.filled(files.length, 1); + + final serverMsg = await messagesModule.sendPhotoMessage( + widget.chatId, + tokens.cast(), + 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 _uploadOnePhoto( + File file, + int index, + ValueNotifier> 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.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 _pickAndUploadFile() async { diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index fc12169..5d78374 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -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 _navItems = [ PillNavItem(icon: Symbols.person, label: 'Контакт'), ]; -Future showAttachmentSheet(BuildContext context, {String? title}) { +Future showAttachmentSheet( + BuildContext context, { + String? title, + void Function(List photos, String caption)? onSend, +}) { return showModalBottomSheet( 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 photos, String caption)? onSend; - const AttachmentSheet({super.key, this.title}); + const AttachmentSheet({super.key, this.title, this.onSend}); @override State createState() => _AttachmentSheetState(); @@ -42,6 +49,8 @@ class _AttachmentSheetState extends State { final GallerySource _source = GallerySource.create(); final ValueNotifier> _selected = ValueNotifier({}); + final Map _edited = {}; + final TextEditingController _captionCtrl = TextEditingController(); final PageController _pageController = PageController(); bool _navDragging = false; @@ -70,6 +79,7 @@ class _AttachmentSheetState extends State { void dispose() { _pageController.dispose(); _selected.dispose(); + _captionCtrl.dispose(); super.dispose(); } @@ -111,7 +121,13 @@ class _AttachmentSheetState extends State { 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 { 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 { ), 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 { opacity: galleryT, child: IgnorePointer( ignoring: galleryT < 0.5, - child: _buildSendButton(cs, count), + child: _buildSendButton(cs), ), ); }, @@ -212,6 +233,15 @@ class _AttachmentSheetState extends State { 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 { 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 { selectedIds: _selected, onOpen: () => _openPreview(item), onToggle: () => _toggleSelection(item), + editedFile: _edited[item.id], cs: cs, ); } @@ -480,31 +512,17 @@ class _AttachmentSheetState extends State { ); } - 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 { } 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>( + 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> 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>( + 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( diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index 130bb91..eaefd53 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -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> selectedIds; final VoidCallback onToggleSelection; final VoidCallback onSend; + final File? editedFile; + final void Function(File edited)? onEdited; + final String initialCaption; + final ValueChanged? 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 createState() => _MediaPreviewScreenState(); } -class _MediaPreviewScreenState extends State { - final TextEditingController _caption = TextEditingController(); - Future? _fileFuture; +class _MediaPreviewScreenState extends State + 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 _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 _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 { 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 _rotate() async { + if (_workingFile == null || _rotationOriginal == null) return; + _queuedTurns++; + if (_rotating) return; + _rotating = true; + while (_queuedTurns > 0 && mounted) { + _queuedTurns--; + await _rotateOneStep(); + } + _rotating = false; + } + + Future _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 _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 _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( + PageRouteBuilder( + 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 { 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 { } 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( - 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 { 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>( 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( + 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); + }, ), ); } diff --git a/lib/frontend/widgets/attachment/photo_draw_editor.dart b/lib/frontend/widgets/attachment/photo_draw_editor.dart new file mode 100644 index 0000000..4df357e --- /dev/null +++ b/lib/frontend/widgets/attachment/photo_draw_editor.dart @@ -0,0 +1,1229 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +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/utils/image_utils.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; + +const Color _kPanel = Color(0xFF101010); + +enum DrawTool { pen, marker, neon, eraser } + +enum ShapeKind { circle, rectangle, star, cloud, arrow } + +enum _EditTab { draw, stickers, text } + +sealed class EditMark {} + +class StrokeMark extends EditMark { + final List points; + final Color color; + final double width; + final DrawTool tool; + + StrokeMark({ + required this.points, + required this.color, + required this.width, + required this.tool, + }); +} + +class ShapeMark extends EditMark { + final ShapeKind kind; + final Offset start; + final Offset end; + final Color color; + final double width; + + ShapeMark({ + required this.kind, + required this.start, + required this.end, + required this.color, + required this.width, + }); +} + +class TextMark extends EditMark { + String text; + Offset position; + Color color; + double fontSize; + double rotation; + + TextMark({ + required this.text, + required this.position, + required this.color, + required this.fontSize, + this.rotation = 0, + }); +} + +Future<(int, int)?> decodeImageFileDimensions(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; + } +} + +class PhotoDrawEditor extends StatefulWidget { + final File source; + final int imageWidth; + final int imageHeight; + + const PhotoDrawEditor({ + super.key, + required this.source, + required this.imageWidth, + required this.imageHeight, + }); + + @override + State createState() => _PhotoDrawEditorState(); +} + +class _PhotoDrawEditorState extends State { + final GlobalKey _boundaryKey = GlobalKey(); + final ValueNotifier _canvasRev = ValueNotifier(0); + final List _marks = []; + StrokeMark? _liveStroke; + ShapeMark? _liveShape; + TextMark? _draggingText; + + DrawTool _tool = DrawTool.pen; + Color _color = Colors.white; + double _width = 8; + TextMark? _selectedText; + bool _resizingText = false; + double _resizeBaseSize = 0; + double _resizeBaseDist = 1; + double _resizeBaseRotation = 0; + double _resizeBaseAngle = 0; + ShapeKind? _shapeMode; + _EditTab _tab = _EditTab.draw; + bool _paletteOpen = false; + bool _shapesOpen = false; + bool _baking = false; + + @override + void dispose() { + _canvasRev.dispose(); + super.dispose(); + } + + void _bumpCanvas() => _canvasRev.value++; + + void _undo() { + if (_marks.isEmpty) return; + if (identical(_marks.last, _selectedText)) _selectedText = null; + setState(() => _marks.removeLast()); + } + + void _clearAll() { + if (_marks.isEmpty) return; + _selectedText = null; + setState(_marks.clear); + } + + void _onPanStart(Offset pos) { + if (_tab == _EditTab.text) { + final sel = _selectedText; + if (sel != null && _nearHandle(sel, pos)) { + final v = pos - sel.position; + _resizingText = true; + _resizeBaseSize = sel.fontSize; + _resizeBaseDist = math.max(8, v.distance); + _resizeBaseRotation = sel.rotation; + _resizeBaseAngle = math.atan2(v.dy, v.dx); + return; + } + final hit = _hitText(pos); + _draggingText = hit; + if (hit != null && !identical(hit, _selectedText)) { + _selectedText = hit; + _bumpCanvas(); + } + return; + } + final shape = _shapeMode; + if (shape != null) { + _liveShape = ShapeMark( + kind: shape, + start: pos, + end: pos, + color: _color, + width: _width, + ); + } else { + _liveStroke = StrokeMark( + points: [pos], + color: _color, + width: _width, + tool: _tool, + ); + } + _bumpCanvas(); + } + + void _onPanUpdate(Offset pos) { + if (_tab == _EditTab.text) { + if (_resizingText) { + final sel = _selectedText; + if (sel != null) { + final v = pos - sel.position; + final angle = math.atan2(v.dy, v.dx); + sel.fontSize = (_resizeBaseSize * v.distance / _resizeBaseDist).clamp( + 10.0, + 200.0, + ); + sel.rotation = _resizeBaseRotation + (angle - _resizeBaseAngle); + _bumpCanvas(); + } + return; + } + final t = _draggingText; + if (t != null) { + t.position = pos; + _bumpCanvas(); + } + return; + } + final shape = _liveShape; + if (shape != null) { + _liveShape = ShapeMark( + kind: shape.kind, + start: shape.start, + end: pos, + color: shape.color, + width: shape.width, + ); + _bumpCanvas(); + } else if (_liveStroke != null) { + final pts = _liveStroke!.points; + if (pts.isEmpty || (pos - pts.last).distance >= 2.0) { + pts.add(pos); + _bumpCanvas(); + } + } + } + + void _onPanEnd() { + if (_tab == _EditTab.text) { + _resizingText = false; + _draggingText = null; + return; + } + final shape = _liveShape; + if (shape != null) { + if ((shape.end - shape.start).distance > 4) _marks.add(shape); + setState(() { + _liveShape = null; + _shapeMode = null; + }); + } else if (_liveStroke != null) { + if (_liveStroke!.points.isNotEmpty) _marks.add(_liveStroke!); + setState(() => _liveStroke = null); + } + } + + TextMark? _hitText(Offset pos) { + for (final m in _marks.reversed) { + if (m is! TextMark) continue; + final local = _toLocal(pos, m); + final box = textMarkSize(m); + if (local.dx.abs() <= box.width / 2 && local.dy.abs() <= box.height / 2) { + return m; + } + } + return null; + } + + Offset _toLocal(Offset pos, TextMark t) { + final v = pos - t.position; + final c = math.cos(-t.rotation); + final s = math.sin(-t.rotation); + return Offset(v.dx * c - v.dy * s, v.dx * s + v.dy * c); + } + + bool _nearHandle(TextMark t, Offset pos) { + final (left, right) = handlePositions(t); + return (pos - left).distance < 26 || (pos - right).distance < 26; + } + + Future _addText() async { + final controller = TextEditingController(); + final String? text; + try { + text = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: const Text('Текст', style: TextStyle(color: Colors.white)), + content: TextField( + controller: controller, + autofocus: true, + style: const TextStyle(color: Colors.white), + cursorColor: Colors.white, + decoration: const InputDecoration( + hintText: 'Введите текст', + hintStyle: TextStyle(color: Colors.white38), + ), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: const Text('ОК'), + ), + ], + ), + ); + } finally { + controller.dispose(); + } + if (text == null || text.trim().isEmpty || !mounted) return; + final ro = _boundaryKey.currentContext?.findRenderObject(); + final size = ro is RenderBox ? ro.size : const Size(300, 300); + final mark = TextMark( + text: text.trim(), + position: Offset(size.width / 2, size.height / 2), + color: _color, + fontSize: 34, + ); + setState(() { + _marks.add(mark); + _selectedText = mark; + }); + } + + Future _apply() async { + if (_baking) return; + if (_marks.isEmpty) { + Navigator.of(context).pop(); + return; + } + setState(() => _baking = true); + final file = await _bake(); + if (!mounted) return; + if (file == null) { + setState(() => _baking = false); + showCustomNotification(context, 'Не удалось применить изменения'); + return; + } + Navigator.of(context).pop(file); + } + + Future _bake() async { + final ro = _boundaryKey.currentContext?.findRenderObject(); + if (ro is! RenderBox || ro.size.isEmpty) return null; + final box = ro.size; + try { + // Composite at the image's native resolution rather than capturing the + // on-screen widget, so the photo keeps its full quality. + final bytes = await widget.source.readAsBytes(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final image = frame.image; + + const maxDim = 4096; + final srcMax = math.max(image.width, image.height); + final cap = srcMax > maxDim ? maxDim / srcMax : 1.0; + final outW = (image.width * cap).round(); + final outH = (image.height * cap).round(); + final scale = outW / box.width; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(scale); + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + Rect.fromLTWH(0, 0, box.width, box.height), + Paint(), + ); + _DrawingPainter(marks: _marks).paintMarks(canvas, box); + final picture = recorder.endRecording(); + image.dispose(); + codec.dispose(); + + final rendered = await picture.toImage(outW, outH); + picture.dispose(); + final byteData = await rendered.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + rendered.dispose(); + if (byteData == null) return null; + + final jpeg = await encodeRgbaToJpeg( + byteData.buffer.asUint8List(), + outW, + outH, + ); + if (jpeg == null) return null; + final dir = await getTemporaryDirectory(); + final out = File( + p.join(dir.path, 'komet_edit_${DateTime.now().microsecondsSinceEpoch}.jpg'), + ); + await out.writeAsBytes(jpeg); + return out; + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Column( + children: [ + _buildTopBar(), + Expanded(child: _buildCanvas()), + _buildBottomPanel(), + ], + ), + if (_tab == _EditTab.draw) _buildSideSlider(), + if (_baking) + const Positioned.fill( + child: ColoredBox( + color: Colors.black54, + child: Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTopBar() { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + children: [ + IconButton( + onPressed: _marks.isEmpty ? null : _undo, + icon: const Icon(Symbols.undo), + color: Colors.white, + disabledColor: Colors.white24, + ), + const Spacer(), + TextButton( + onPressed: _marks.isEmpty ? null : _clearAll, + child: Text( + 'Очистить всё', + style: TextStyle( + color: _marks.isEmpty ? Colors.white24 : Colors.white, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCanvas() { + final aspect = widget.imageHeight > 0 + ? widget.imageWidth / widget.imageHeight + : 1.0; + return Center( + child: AspectRatio( + aspectRatio: aspect <= 0 ? 1.0 : aspect, + // Only this subtree repaints while drawing (driven by _canvasRev), + // so the toolbar/tabs/slider don't rebuild on every pointer move. + child: ValueListenableBuilder( + valueListenable: _canvasRev, + child: Image.file( + widget.source, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + builder: (context, _, image) { + final selected = _tab == _EditTab.text ? _selectedText : null; + return Stack( + fit: StackFit.expand, + children: [ + RepaintBoundary( + key: _boundaryKey, + child: Stack( + fit: StackFit.expand, + children: [ + image!, + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (d) => _onPanStart(d.localPosition), + onPanUpdate: (d) => _onPanUpdate(d.localPosition), + onPanEnd: (_) => _onPanEnd(), + child: CustomPaint( + painter: _DrawingPainter( + marks: _marks, + live: _liveStroke ?? _liveShape, + ), + ), + ), + ), + ], + ), + ), + if (selected != null) + Positioned.fill( + child: IgnorePointer( + child: CustomPaint(painter: _SelectionPainter(selected)), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildSideSlider() { + return Positioned( + left: 2, + top: 0, + bottom: 0, + child: Center( + child: SizedBox( + height: 220, + child: RotatedBox( + quarterTurns: 3, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + thumbColor: Colors.white, + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9), + ), + child: Slider( + min: 2, + max: 40, + value: _width, + onChanged: (v) => setState(() => _width = v), + ), + ), + ), + ), + ), + ); + } + + Widget _buildBottomPanel() { + return Container( + color: _kPanel, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_paletteOpen) _buildColorPicker(), + if (_shapesOpen && _tab == _EditTab.draw) _buildShapesRow(), + _buildToolbar(), + const SizedBox(height: 2), + _buildTabs(), + ], + ), + ), + ); + } + + Widget _buildToolbar() { + switch (_tab) { + case _EditTab.draw: + return _buildDrawToolbar(); + case _EditTab.text: + return _buildTextToolbar(); + case _EditTab.stickers: + return const SizedBox(height: 56); + } + } + + Widget _buildDrawToolbar() { + return SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: 10), + _buildColorButton(), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildToolButton(DrawTool.pen, Symbols.edit), + _buildToolButton(DrawTool.marker, Symbols.ink_highlighter), + _buildToolButton(DrawTool.neon, Symbols.auto_awesome), + _buildToolButton(DrawTool.eraser, Symbols.ink_eraser), + ], + ), + ), + IconButton( + onPressed: () => setState(() { + _shapesOpen = !_shapesOpen; + _paletteOpen = false; + }), + icon: Icon( + Symbols.add, + color: _shapeMode != null ? _color : Colors.white, + ), + ), + const SizedBox(width: 8), + ], + ), + ); + } + + Widget _buildTextToolbar() { + return SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: 10), + _buildColorButton(), + const SizedBox(width: 14), + TextButton.icon( + onPressed: _addText, + icon: const Icon(Symbols.add, color: Colors.white), + label: const Text( + 'Добавить текст', + style: TextStyle(color: Colors.white, fontSize: 15), + ), + ), + const Spacer(), + ], + ), + ); + } + + Widget _buildColorButton() { + return GestureDetector( + onTap: () => setState(() { + _paletteOpen = !_paletteOpen; + _shapesOpen = false; + }), + child: Container( + width: 32, + height: 32, + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: SweepGradient( + colors: [ + Color(0xFFFF3B30), + Color(0xFFFFCC00), + Color(0xFF34C759), + Color(0xFF00C7BE), + Color(0xFF2F8FFF), + Color(0xFFAF52DE), + Color(0xFFFF3B30), + ], + ), + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _color, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ); + } + + Widget _buildToolButton(DrawTool tool, IconData icon) { + final selected = _shapeMode == null && _tool == tool; + return GestureDetector( + onTap: () => setState(() { + _tool = tool; + _shapeMode = null; + _shapesOpen = false; + }), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected + ? Colors.white.withValues(alpha: 0.18) + : Colors.transparent, + ), + child: Icon( + icon, + color: selected ? Colors.white : Colors.white60, + size: 24, + ), + ), + ); + } + + Widget _buildColorPicker() { + return _ColorPicker( + color: _color, + onChanged: (c) => setState(() { + _color = c; + if (_tab == _EditTab.text) _selectedText?.color = c; + }), + ); + } + + Widget _buildShapesRow() { + const shapes = <(ShapeKind, IconData)>[ + (ShapeKind.circle, Symbols.circle), + (ShapeKind.rectangle, Symbols.rectangle), + (ShapeKind.star, Symbols.star), + (ShapeKind.cloud, Symbols.cloud), + (ShapeKind.arrow, Symbols.north_east), + ]; + return SizedBox( + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final (kind, icon) in shapes) + IconButton( + onPressed: () => setState(() { + _shapeMode = kind; + _shapesOpen = false; + }), + icon: Icon( + icon, + color: _shapeMode == kind ? _color : Colors.white, + ), + ), + ], + ), + ); + } + + Widget _buildTabs() { + return SizedBox( + height: 48, + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Symbols.close, color: Colors.white), + ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildTab('РИСУНОК', _EditTab.draw), + _buildTab('СТИКЕРЫ', _EditTab.stickers, disabled: true), + _buildTab('ТЕКСТ', _EditTab.text), + ], + ), + ), + IconButton( + onPressed: _baking ? null : _apply, + icon: const Icon(Symbols.check, color: Colors.white), + ), + ], + ), + ); + } + + Widget _buildTab(String label, _EditTab tab, {bool disabled = false}) { + final selected = _tab == tab; + return GestureDetector( + onTap: disabled + ? null + : () => setState(() { + _tab = tab; + _paletteOpen = false; + _shapesOpen = false; + if (tab != _EditTab.draw) _shapeMode = null; + }), + child: Text( + label, + style: TextStyle( + color: disabled + ? Colors.white24 + : (selected ? Colors.white : Colors.white60), + fontSize: 14, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class _DrawingPainter extends CustomPainter { + final List marks; + final EditMark? live; + + _DrawingPainter({required this.marks, this.live}); + + @override + void paint(Canvas canvas, Size size) => paintMarks(canvas, size); + + void paintMarks(Canvas canvas, Size size) { + // An isolated layer is only needed so the eraser can punch through the + // strokes to reveal the photo beneath — skip it otherwise (it's costly). + final needsLayer = _hasEraser(); + if (needsLayer) canvas.saveLayer(Offset.zero & size, Paint()); + for (final m in marks) { + _paintMark(canvas, m); + } + final l = live; + if (l != null) _paintMark(canvas, l); + if (needsLayer) canvas.restore(); + } + + bool _hasEraser() { + for (final m in marks) { + if (m is StrokeMark && m.tool == DrawTool.eraser) return true; + } + final l = live; + return l is StrokeMark && l.tool == DrawTool.eraser; + } + + void _paintMark(Canvas canvas, EditMark m) { + switch (m) { + case StrokeMark s: + _paintStroke(canvas, s); + case ShapeMark sh: + _paintShape(canvas, sh); + case TextMark t: + _paintText(canvas, t); + } + } + + void _paintStroke(Canvas canvas, StrokeMark s) { + final paint = Paint() + ..color = s.color + ..strokeWidth = s.width + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + + switch (s.tool) { + case DrawTool.pen: + break; + case DrawTool.marker: + paint.color = s.color.withValues(alpha: 0.4); + paint.strokeWidth = s.width * 1.6; + paint.strokeCap = StrokeCap.square; + case DrawTool.neon: + final glow = Paint() + ..color = s.color.withValues(alpha: 0.7) + ..strokeWidth = s.width * 2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + _drawStrokeGeometry(canvas, s, glow); + paint.color = Colors.white; + case DrawTool.eraser: + paint.blendMode = BlendMode.clear; + } + + _drawStrokeGeometry(canvas, s, paint); + } + + void _drawStrokeGeometry(Canvas canvas, StrokeMark s, Paint paint) { + if (s.points.length < 2) { + final dot = Paint() + ..color = paint.color + ..blendMode = paint.blendMode + ..maskFilter = paint.maskFilter + ..style = PaintingStyle.fill; + canvas.drawCircle(s.points.first, paint.strokeWidth / 2, dot); + return; + } + final path = Path()..moveTo(s.points.first.dx, s.points.first.dy); + for (var i = 1; i < s.points.length; i++) { + path.lineTo(s.points[i].dx, s.points[i].dy); + } + canvas.drawPath(path, paint); + } + + void _paintShape(Canvas canvas, ShapeMark sh) { + final paint = Paint() + ..color = sh.color + ..strokeWidth = sh.width + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final rect = Rect.fromPoints(sh.start, sh.end); + switch (sh.kind) { + case ShapeKind.circle: + canvas.drawOval(rect, paint); + case ShapeKind.rectangle: + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(10)), + paint, + ); + case ShapeKind.star: + canvas.drawPath(_starPath(rect), paint); + case ShapeKind.cloud: + canvas.drawPath(_cloudPath(rect), paint); + case ShapeKind.arrow: + _paintArrow(canvas, sh.start, sh.end, paint); + } + } + + Path _starPath(Rect rect) { + final cx = rect.center.dx; + final cy = rect.center.dy; + final outer = math.min(rect.width.abs(), rect.height.abs()) / 2; + final inner = outer * 0.45; + final path = Path(); + for (var i = 0; i < 10; i++) { + final r = i.isEven ? outer : inner; + final angle = -math.pi / 2 + i * math.pi / 5; + final x = cx + r * math.cos(angle); + final y = cy + r * math.sin(angle); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + path.close(); + return path; + } + + Path _cloudPath(Rect rect) { + final w = rect.width; + final h = rect.height; + Offset pt(double nx, double ny) => + Offset(rect.left + nx * w, rect.top + ny * h); + final path = Path()..moveTo(pt(0.25, 0.78).dx, pt(0.25, 0.78).dy); + path + ..cubicTo(pt(0.0, 0.78).dx, pt(0.0, 0.78).dy, pt(0.0, 0.45).dx, + pt(0.0, 0.45).dy, pt(0.22, 0.42).dx, pt(0.22, 0.42).dy) + ..cubicTo(pt(0.2, 0.12).dx, pt(0.2, 0.12).dy, pt(0.56, 0.08).dx, + pt(0.56, 0.08).dy, pt(0.62, 0.36).dx, pt(0.62, 0.36).dy) + ..cubicTo(pt(0.86, 0.24).dx, pt(0.86, 0.24).dy, pt(1.02, 0.5).dx, + pt(1.02, 0.5).dy, pt(0.8, 0.6).dx, pt(0.8, 0.6).dy) + ..cubicTo(pt(1.02, 0.66).dx, pt(1.02, 0.66).dy, pt(0.96, 0.9).dx, + pt(0.96, 0.9).dy, pt(0.74, 0.8).dx, pt(0.74, 0.8).dy) + ..cubicTo(pt(0.7, 0.98).dx, pt(0.7, 0.98).dy, pt(0.34, 0.98).dx, + pt(0.34, 0.98).dy, pt(0.25, 0.78).dx, pt(0.25, 0.78).dy) + ..close(); + return path; + } + + void _paintArrow(Canvas canvas, Offset start, Offset end, Paint paint) { + canvas.drawLine(start, end, paint); + final angle = math.atan2(end.dy - start.dy, end.dx - start.dx); + final headLen = math.max(paint.strokeWidth * 4, 18.0); + const headAngle = math.pi / 7; + final p1 = end - + Offset(math.cos(angle - headAngle), math.sin(angle - headAngle)) * + headLen; + final p2 = end - + Offset(math.cos(angle + headAngle), math.sin(angle + headAngle)) * + headLen; + canvas.drawLine(end, p1, paint); + canvas.drawLine(end, p2, paint); + } + + void _paintText(Canvas canvas, TextMark t) { + final tp = layoutText(t); + canvas.save(); + canvas.translate(t.position.dx, t.position.dy); + canvas.rotate(t.rotation); + tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2)); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _DrawingPainter oldDelegate) => true; +} + +final Expando<_TextLayout> _textLayoutCache = Expando<_TextLayout>(); + +class _TextLayout { + final String text; + final double fontSize; + final Color color; + final TextPainter painter; + + _TextLayout(this.text, this.fontSize, this.color, this.painter); +} + +/// Laid-out [TextPainter] for a [TextMark], memoized per mark so a static text +/// isn't re-laid-out every frame, and the size/paint passes share one layout. +TextPainter layoutText(TextMark t) { + final cached = _textLayoutCache[t]; + if (cached != null && + cached.text == t.text && + cached.fontSize == t.fontSize && + cached.color == t.color) { + return cached.painter; + } + final tp = TextPainter( + text: TextSpan( + text: t.text, + style: TextStyle( + color: t.color, + fontSize: t.fontSize, + fontWeight: FontWeight.w600, + shadows: const [Shadow(blurRadius: 4, color: Colors.black54)], + ), + ), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + )..layout(maxWidth: 2000); + _textLayoutCache[t] = _TextLayout(t.text, t.fontSize, t.color, tp); + return tp; +} + +Size textMarkSize(TextMark t) { + final tp = layoutText(t); + return Size(tp.width + 32, tp.height + 24); +} + +(Offset, Offset) handlePositions(TextMark t) { + final hw = textMarkSize(t).width / 2; + final c = math.cos(t.rotation); + final s = math.sin(t.rotation); + return ( + t.position + Offset(-hw * c, -hw * s), + t.position + Offset(hw * c, hw * s), + ); +} + +class _SelectionPainter extends CustomPainter { + final TextMark text; + + _SelectionPainter(this.text); + + @override + void paint(Canvas canvas, Size size) { + final box = textMarkSize(text); + final hw = box.width / 2; + final hh = box.height / 2; + canvas.save(); + canvas.translate(text.position.dx, text.position.dy); + canvas.rotate(text.rotation); + + final border = Paint() + ..color = Colors.white + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + final tl = Offset(-hw, -hh); + final tr = Offset(hw, -hh); + final br = Offset(hw, hh); + final bl = Offset(-hw, hh); + _dashedLine(canvas, tl, tr, border); + _dashedLine(canvas, tr, br, border); + _dashedLine(canvas, br, bl, border); + _dashedLine(canvas, bl, tl, border); + + final fill = Paint() + ..color = const Color(0xFF2F8FFF) + ..style = PaintingStyle.fill; + final ring = Paint() + ..color = Colors.white + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + for (final c in [Offset(-hw, 0), Offset(hw, 0)]) { + canvas.drawCircle(c, 7, fill); + canvas.drawCircle(c, 7, ring); + } + canvas.restore(); + } + + void _dashedLine(Canvas canvas, Offset a, Offset b, Paint paint) { + const dash = 7.0; + const gap = 5.0; + final total = (b - a).distance; + if (total <= 0) return; + final dir = (b - a) / total; + var d = 0.0; + while (d < total) { + final start = a + dir * d; + final end = a + dir * math.min(d + dash, total); + canvas.drawLine(start, end, paint); + d += dash + gap; + } + } + + @override + bool shouldRepaint(covariant _SelectionPainter oldDelegate) => true; +} + +class _ColorPicker extends StatefulWidget { + final Color color; + final ValueChanged onChanged; + + const _ColorPicker({required this.color, required this.onChanged}); + + @override + State<_ColorPicker> createState() => _ColorPickerState(); +} + +class _ColorPickerState extends State<_ColorPicker> { + late HSVColor _hsv; + + @override + void initState() { + super.initState(); + final hsv = HSVColor.fromColor(widget.color); + _hsv = hsv.saturation == 0 ? hsv.withHue(0) : hsv; + } + + void _setSV(Offset pos, Size size) { + if (size.width <= 0 || size.height <= 0) return; + final s = (pos.dx / size.width).clamp(0.0, 1.0); + final v = (1 - pos.dy / size.height).clamp(0.0, 1.0); + setState(() => _hsv = _hsv.withSaturation(s).withValue(v)); + widget.onChanged(_hsv.toColor()); + } + + void _setHue(double dx, double width) { + if (width <= 0) return; + setState(() => _hsv = _hsv.withHue((dx / width).clamp(0.0, 1.0) * 360)); + widget.onChanged(_hsv.toColor()); + } + + @override + Widget build(BuildContext context) { + final hueColor = HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor(); + return Container( + color: _kPanel, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 132, + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (d) => _setSV(d.localPosition, size), + onPanUpdate: (d) => _setSV(d.localPosition, size), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Colors.white, hueColor], + ), + ), + ), + ), + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black], + ), + ), + ), + ), + Positioned( + left: _hsv.saturation * size.width - 9, + top: (1 - _hsv.value) * size.height - 9, + child: _thumb(_hsv.toColor()), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 14), + SizedBox( + height: 22, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (d) => _setHue(d.localPosition.dx, width), + onPanUpdate: (d) => _setHue(d.localPosition.dx, width), + child: ClipRRect( + borderRadius: BorderRadius.circular(11), + child: Stack( + children: [ + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFFF0000), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF00FFFF), + Color(0xFF0000FF), + Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + ), + ), + ), + ), + Positioned( + left: (_hsv.hue / 360) * width - 9, + top: 1, + bottom: 1, + child: _thumb(hueColor), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _thumb(Color color) { + return Container( + width: 18, + height: 18, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 3)], + ), + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 681746b..859ab66 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -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?>? reactionsListenable; + final ValueListenable>? 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> progress, + int index, + ) { + return Positioned.fill( + child: ValueListenableBuilder>( + 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), ], ), ); diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart index def6650..45ddf99 100644 --- a/lib/frontend/widgets/sliding_pill_nav.dart +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -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), diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 4ee7718..d1fb2f0 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -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 map) {