From c3d70d9bfc4952ce9a1b91d723be06eaa6dd6055 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Tue, 14 Jul 2026 15:36:18 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=89=D0=B8=D0=BA=20=D1=84=D0=BE=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 2 + lib/backend/modules/shared_content.dart | 129 ++++ lib/core/utils/media_saver.dart | 85 ++- .../screens/chats/chat_info_screen.dart | 12 +- lib/frontend/screens/chats/chat_screen.dart | 177 +++-- .../attachment/bubbles/bubble_context.dart | 26 +- .../attachment/bubbles/photo_bubble.dart | 72 +- .../chat_info/shared_content_tabs.dart | 35 +- lib/frontend/widgets/message_bubble.dart | 7 + lib/frontend/widgets/photo_viewer.dart | 715 +++++++++++++++++- lib/l10n/app_en.arb | 39 + lib/l10n/app_localizations.dart | 36 + lib/l10n/app_localizations_en.dart | 24 + lib/l10n/app_localizations_ru.dart | 24 + lib/l10n/app_ru.arb | 6 + test/photo_album_bubble_test.dart | 117 +++ test/photo_viewer_test.dart | 196 +++++ 17 files changed, 1539 insertions(+), 163 deletions(-) create mode 100644 test/photo_album_bubble_test.dart create mode 100644 test/photo_viewer_test.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 56136ae..1d32c05 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -8,6 +8,7 @@ import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/cache/info_cache.dart'; import '../../core/cache/message_session_cache.dart'; +import 'shared_content.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; @@ -560,6 +561,7 @@ class ChatsModule { ContactInfoFetch.clear(); PresenceFetch.clear(); ChatInfoFetch.clear(); + SharedContentModule.clearPhotoIndex(); } void _enqueueGlobalPush(Packet packet) { diff --git a/lib/backend/modules/shared_content.dart b/lib/backend/modules/shared_content.dart index d33c7a6..5053422 100644 --- a/lib/backend/modules/shared_content.dart +++ b/lib/backend/modules/shared_content.dart @@ -17,6 +17,7 @@ class SharedMediaItem { final int senderId; final int time; final MessageAttachment attachment; + final String? text; const SharedMediaItem({ required this.messageId, @@ -24,6 +25,7 @@ class SharedMediaItem { required this.senderId, required this.time, required this.attachment, + this.text, }); String get dedupKey { @@ -93,11 +95,136 @@ class CommonChatEntry { } } +class ChatPhotoFeed { + final List items; + final int total; + final bool reachedEnd; + + const ChatPhotoFeed({ + required this.items, + required this.total, + required this.reachedEnd, + }); +} + +class _ChatPhotoIndex { + final List items = []; + final Set seen = {}; + int total = 0; + bool reachedEnd = false; + bool started = false; + Future? inFlight; +} + +String photoDedupKey(String messageId, PhotoAttachment photo) => + '$messageId:p${photo.photoId ?? photo.baseUrl}'; + class SharedContentModule { + static const int _photoIndexPageSize = 60; + static const int _photoIndexMaxPages = 40; + + static final Map _photoIndexes = {}; + final Api _api; SharedContentModule(this._api); + static void clearPhotoIndex() => _photoIndexes.clear(); + + Future photoFeedFor({ + required int chatId, + required String photoKey, + required Future Function() resolveAnchor, + }) async { + final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new); + + for (var page = 0; page < _photoIndexMaxPages; page++) { + if (index.items.any((i) => i.dedupKey == photoKey)) { + return _snapshot(index); + } + if (index.reachedEnd) return null; + await _nextPhotoPage(chatId, index, resolveAnchor); + } + return null; + } + + Future loadMorePhotos({ + required int chatId, + required Future Function() resolveAnchor, + }) async { + final index = _photoIndexes.putIfAbsent(chatId, _ChatPhotoIndex.new); + if (!index.reachedEnd) { + await _nextPhotoPage(chatId, index, resolveAnchor); + } + return _snapshot(index); + } + + ChatPhotoFeed _snapshot(_ChatPhotoIndex index) { + final counted = index.items.length; + final total = index.reachedEnd + ? counted + : (index.total > counted ? index.total : counted); + return ChatPhotoFeed( + items: List.unmodifiable(index.items), + total: total, + reachedEnd: index.reachedEnd, + ); + } + + Future _nextPhotoPage( + int chatId, + _ChatPhotoIndex index, + Future Function() resolveAnchor, + ) async { + final pending = index.inFlight; + if (pending != null) { + await pending; + return; + } + final task = _loadPhotoPage(chatId, index, resolveAnchor); + index.inFlight = task; + try { + await task; + } finally { + index.inFlight = null; + } + } + + Future _loadPhotoPage( + int chatId, + _ChatPhotoIndex index, + Future Function() resolveAnchor, + ) async { + final initial = !index.started; + final anchor = initial + ? await resolveAnchor() + : index.items.last.messageId; + if (anchor == null || anchor.isEmpty) { + index.reachedEnd = true; + return; + } + + final page = await fetchMedia( + chatId: chatId, + anchorMessageId: anchor, + attachTypes: const ['PHOTO'], + forward: initial ? _photoIndexPageSize : 0, + backward: _photoIndexPageSize, + ); + index.started = true; + + var added = 0; + for (final item in page.items) { + if (!index.seen.add(item.dedupKey)) continue; + index.items.add(item); + added++; + } + index.items.sort((a, b) => b.time.compareTo(a.time)); + if (page.total > index.total) index.total = page.total; + + if (added == 0) index.reachedEnd = true; + } + Future fetchMedia({ required int chatId, required String anchorMessageId, @@ -134,6 +261,7 @@ class SharedContentModule { if (id == null) continue; final sender = (map['sender'] as num?)?.toInt() ?? 0; final time = (map['time'] as num?)?.toInt() ?? 0; + final text = map['text'] as String?; final attaches = map['attaches']; if (attaches is! List) continue; for (final a in attaches) { @@ -147,6 +275,7 @@ class SharedContentModule { senderId: sender, time: time, attachment: att, + text: text, ), ); } diff --git a/lib/core/utils/media_saver.dart b/lib/core/utils/media_saver.dart index a29ff0b..9907a30 100644 --- a/lib/core/utils/media_saver.dart +++ b/lib/core/utils/media_saver.dart @@ -30,22 +30,11 @@ Future saveImageFromUrl(String url) async { if (file == null) { return const MediaSaveResult(ok: false, error: 'не удалось загрузить'); } - final saveName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg'; - - if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { - final state = await PhotoManager.requestPermissionExtend(); - if (!state.isAuth && !state.hasAccess) { - return const MediaSaveResult(ok: false, error: 'нет доступа к галерее'); - } - final bytes = await file.readAsBytes(); - await PhotoManager.editor.saveImage(bytes, filename: saveName); - return const MediaSaveResult(ok: true, toGallery: true); - } - - final dir = await _targetDirectory(); - final target = File('${dir.path}${Platform.pathSeparator}$saveName'); - await file.copy(target.path); - return MediaSaveResult(ok: true, location: target.path); + return _persist( + file, + saveName: 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg', + kind: SaveMediaKind.image, + ); } catch (e) { return MediaSaveResult(ok: false, error: e.toString()); } @@ -71,32 +60,54 @@ Future saveMediaFile({ if (file == null) { return const MediaSaveResult(ok: false, error: 'не удалось загрузить'); } - - final toGallery = - kind == SaveMediaKind.image || kind == SaveMediaKind.video; - if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) { - final state = await PhotoManager.requestPermissionExtend(); - if (!state.isAuth && !state.hasAccess) { - return const MediaSaveResult(ok: false, error: 'нет доступа к галерее'); - } - if (kind == SaveMediaKind.video) { - await PhotoManager.editor.saveVideo(file, title: saveName); - } else { - final bytes = await file.readAsBytes(); - await PhotoManager.editor.saveImage(bytes, filename: saveName); - } - return const MediaSaveResult(ok: true, toGallery: true); - } - - final dir = await _targetDirectory(); - final target = File('${dir.path}${Platform.pathSeparator}$saveName'); - await file.copy(target.path); - return MediaSaveResult(ok: true, location: target.path); + return _persist(file, saveName: saveName, kind: kind); } catch (e) { return MediaSaveResult(ok: false, error: e.toString()); } } +Future saveLocalImage(String path, {String? saveName}) async { + try { + final file = File(path); + if (!await file.exists()) { + return const MediaSaveResult(ok: false, error: 'файл не найден'); + } + return _persist( + file, + saveName: saveName ?? 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', + kind: SaveMediaKind.image, + ); + } catch (e) { + return MediaSaveResult(ok: false, error: e.toString()); + } +} + +Future _persist( + File file, { + required String saveName, + required SaveMediaKind kind, +}) async { + final toGallery = kind == SaveMediaKind.image || kind == SaveMediaKind.video; + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && toGallery) { + final state = await PhotoManager.requestPermissionExtend(); + if (!state.isAuth && !state.hasAccess) { + return const MediaSaveResult(ok: false, error: 'нет доступа к галерее'); + } + if (kind == SaveMediaKind.video) { + await PhotoManager.editor.saveVideo(file, title: saveName); + } else { + final bytes = await file.readAsBytes(); + await PhotoManager.editor.saveImage(bytes, filename: saveName); + } + return const MediaSaveResult(ok: true, toGallery: true); + } + + final dir = await _targetDirectory(); + final target = File('${dir.path}${Platform.pathSeparator}$saveName'); + await file.copy(target.path); + return MediaSaveResult(ok: true, location: target.path); +} + Future _targetDirectory() async { try { final downloads = await getDownloadsDirectory(); diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index eb00c5e..c264390 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -37,6 +37,8 @@ class _MemberInfo { }); } +enum ChatInfoTab { media } + class ChatInfoScreen extends StatefulWidget { final int chatId; final String name; @@ -44,6 +46,7 @@ class ChatInfoScreen extends StatefulWidget { final String chatType; final int? dialogPeerId; + final ChatInfoTab? initialTab; final void Function(String messageId, int time)? onJumpToMessage; @@ -54,6 +57,7 @@ class ChatInfoScreen extends StatefulWidget { required this.imageUrl, required this.chatType, this.dialogPeerId, + this.initialTab, this.onJumpToMessage, }); @@ -231,12 +235,18 @@ class _ChatInfoScreenState extends State { setState(() { _isLoading = false; if (_selectedTab.isEmpty && _tabs.isNotEmpty) { - _selectedTab = _tabs.first; + _selectedTab = _initialTabLabel() ?? _tabs.first; } }); } } + String? _initialTabLabel() { + if (widget.initialTab != ChatInfoTab.media) return null; + final media = AppLocalizations.of(context)!.chatInfoTabMedia; + return _tabs.contains(media) ? media : null; + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index d566881..ccf05b5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -81,6 +81,7 @@ import '../../../core/utils/text_format.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/message_bubble.dart'; +import '../../widgets/photo_viewer.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/lottie_image.dart'; import '../../widgets/attachment_panel.dart'; @@ -934,6 +935,47 @@ class _ChatScreenState extends State }); } + void _openChatInfo({ChatInfoTab? initialTab}) { + final navigator = Navigator.of(context); + final chatRoute = ModalRoute.of(context); + navigator.push( + MaterialPageRoute( + builder: (_) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, + initialTab: initialTab, + onJumpToMessage: (chatRoute == null || widget.embedded) + ? null + : (messageId, time) { + navigator.popUntil((r) => r == chatRoute); + _requestGoToMessage(messageId, time); + }, + ), + ), + ); + } + + PhotoViewerActions _photoActions() { + return PhotoViewerActions( + goToMessage: _requestGoToMessage, + forward: _forwardMessageById, + delete: (messageId, senderId) => + _confirmDeleteMessage(messageId, senderId == _myId), + viewAllPhotos: () => _openChatInfo(initialTab: ChatInfoTab.media), + ); + } + + void _forwardMessageById(String messageId) { + final message = _messages.where((m) => m.id == messageId).firstOrNull; + if (message == null) { + showCustomNotification(context, 'Сообщение не загружено'); + return; + } + unawaited(_forwardMessages([message])); + } + void _requestGoToMessage(String id, int time) { if (!mounted) return; setState(_beginTargetNavigation); @@ -2333,12 +2375,12 @@ class _ChatScreenState extends State Haptics.send(); } - Future _confirmDeleteMessage(CachedMessage message, bool isMe) async { - final isLocalOnly = message.id.startsWith('temp_'); + Future _confirmDeleteMessage(String messageId, bool isMe) async { + final isLocalOnly = messageId.startsWith('temp_'); final canForEveryone = isMe && !isLocalOnly; if (isLocalOnly) { - _startDeleteAnimation(message.id); + _startDeleteAnimation(messageId); return; } @@ -2346,7 +2388,7 @@ class _ChatScreenState extends State if (forEveryone == null || !mounted) return; final ok = await messagesModule.deleteMessages(widget.chatId, [ - message.id, + messageId, ], forEveryone: forEveryone); if (!mounted) return; if (!ok) { @@ -2354,7 +2396,7 @@ class _ChatScreenState extends State showCustomNotification(context, 'Не удалось удалить сообщение'); return; } - _startDeleteAnimation(message.id); + _startDeleteAnimation(messageId); } void _startDeleteAnimation(String messageId) { @@ -2619,32 +2661,7 @@ class _ChatScreenState extends State showCall: widget.chatType == 'DIALOG' && !_peerIsBot, onClose: widget.onClose, - onOpenInfo: () { - final navigator = Navigator.of(context); - final chatRoute = ModalRoute.of(context); - navigator.push( - MaterialPageRoute( - builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType, - onJumpToMessage: - (chatRoute == null || widget.embedded) - ? null - : (messageId, time) { - navigator.popUntil( - (r) => r == chatRoute, - ); - _requestGoToMessage( - messageId, - time, - ); - }, - ), - ), - ); - }, + onOpenInfo: _openChatInfo, onOpenScheduled: _openScheduledMessages, onCall: _startCall, onMenu: _openChatMenu, @@ -4684,6 +4701,8 @@ class _ChatScreenState extends State prevMessage: prevMessage, nextMessage: nextMessage, chatType: chat?.type ?? 'CHAT', + chatId: widget.chatId, + photoActions: _photoActions(), overrideStatus: _effectiveStatus(message), otherReadTime: _otherReadTime, reactionsListenable: _reactionNotifierFor( @@ -4722,7 +4741,7 @@ class _ChatScreenState extends State onStartTextSelection: (pos) => _startTextSelection(message, pos), onDelete: () => - _confirmDeleteMessage(message, isMe), + _confirmDeleteMessage(message.id, isMe), onEdit: _canEditMessage(message) ? () => _startEditMessage(message) : null, @@ -5270,12 +5289,7 @@ class _ChatScreenState extends State _scrollToBottom(); try { - final tokens = await Future.wait( - List.generate( - files.length, - (i) => _uploadOnePhoto(files[i], i, progress), - ), - ); + final tokens = await _uploadPhotos(files, progress); if (!mounted) { _disposePhotoProgress(tempId); return; @@ -5463,12 +5477,7 @@ class _ChatScreenState extends State List.filled(files.length, 0), ); try { - final tokens = await Future.wait( - List.generate( - files.length, - (i) => _uploadOnePhoto(files[i], i, progress), - ), - ); + final tokens = await _uploadPhotos(files, progress); if (!mounted) return; if (tokens.any((t) => t == null)) { showCustomNotification(context, 'Не удалось загрузить фото'); @@ -5636,26 +5645,76 @@ class _ChatScreenState extends State ); } + static const int _photoUploadConcurrency = 3; + static const int _photoUploadAttempts = 3; + + Future> _uploadPhotos( + List files, + ValueNotifier> progress, + ) async { + final tokens = List.filled(files.length, null); + var nextIndex = 0; + var failed = false; + + Future worker() async { + while (!failed) { + final i = nextIndex++; + if (i >= files.length) return; + final token = await _uploadOnePhoto(files[i], i, progress); + if (token == null) { + failed = true; + return; + } + tokens[i] = token; + } + } + + final workerCount = math.min(_photoUploadConcurrency, files.length); + await Future.wait(List.generate(workerCount, (_) => worker())); + return tokens; + } + 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; - } - }, - ); + for (var attempt = 0; attempt < _photoUploadAttempts; attempt++) { + if (attempt > 0) { + await Future.delayed(Duration(seconds: attempt)); + if (!mounted) return null; + _setPhotoProgress(progress, index, 0); + } + try { + final url = await messagesModule.requestPhotoUploadUrl(); + if (url == null || url.isEmpty) continue; + final token = await fileUploader.uploadPhoto( + Uri.parse(url), + file, + filename: _photoFilename(file), + onProgress: (sent, total) { + if (total <= 0) return; + _setPhotoProgress(progress, index, (sent / total).clamp(0.0, 1.0)); + }, + ); + if (token != null) return token; + } catch (e) { + logger.w('uploadOnePhoto attempt ${attempt + 1}: $e'); + } + } + return null; + } + + void _setPhotoProgress( + ValueNotifier> progress, + int index, + double value, + ) { + final next = List.from(progress.value); + if (index < next.length) { + next[index] = value; + progress.value = next; + } } String _photoFilename(File file) { diff --git a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart index d4a6e0a..17f37f9 100644 --- a/lib/frontend/widgets/attachment/bubbles/bubble_context.dart +++ b/lib/frontend/widgets/attachment/bubbles/bubble_context.dart @@ -8,6 +8,7 @@ import '../../../../core/config/komet_settings.dart'; import '../../../../core/utils/format.dart'; import '../../../../models/attachment.dart'; import '../../formatted_message_text.dart'; +import '../../photo_viewer.dart'; enum MessageType { text, attachment, voice, control } @@ -62,6 +63,8 @@ class BubbleContext { final bool isMe; final int myId; final String chatType; + final int? chatId; + final PhotoViewerActions? photoActions; final String? overrideStatus; final ValueListenable? otherReadTime; final ValueListenable>? uploadProgress; @@ -79,6 +82,8 @@ class BubbleContext { required this.isMe, required this.myId, required this.chatType, + this.chatId, + this.photoActions, this.overrideStatus, this.otherReadTime, this.uploadProgress, @@ -154,6 +159,10 @@ class BubbleContext { const SizedBox(width: 3), const Icon(Symbols.delete, size: 11, color: Colors.white), ], + if (isMe) ...[ + const SizedBox(width: 3), + statusIcon(color: Colors.white, size: 12), + ], ], ), ); @@ -161,14 +170,17 @@ class BubbleContext { Widget deletedIcon() => Icon(Symbols.delete, size: 13, color: dim); - Widget statusIcon() { + Widget statusIcon({Color? color, double size = 14}) { final base = overrideStatus ?? message.status; final rt = otherReadTime; - if (rt == null) return _statusIconFor(base); + if (rt == null) return _statusIconFor(base, color: color, size: size); return ValueListenableBuilder( valueListenable: rt, - builder: (context, readTime, _) => - _statusIconFor(_readUpgradedStatus(base, readTime)), + builder: (context, readTime, _) => _statusIconFor( + _readUpgradedStatus(base, readTime), + color: color, + size: size, + ), ); } @@ -181,8 +193,8 @@ class BubbleContext { return base; } - Widget _statusIconFor(String? status) { - final v = messageStatusVisual(status, dimColor: dim); - return Icon(v.icon, size: 14, color: v.color); + Widget _statusIconFor(String? status, {Color? color, double size = 14}) { + final v = messageStatusVisual(status, dimColor: color ?? dim); + return Icon(v.icon, size: size, color: v.color); } } diff --git a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart index 0c0826a..1e86714 100644 --- a/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/photo_bubble.dart @@ -156,7 +156,7 @@ class PhotoBubble extends StatelessWidget { Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => _openPhotoViewer(ctx.context, photo), + onTap: () => _openPhotoViewer(ctx.context, 0), ), ), ], @@ -284,28 +284,51 @@ class PhotoBubble extends StatelessWidget { final matchBottom = ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleBottom; + final rows = []; + for (var i = 0; i < displayCount; i += 2) { + if (rows.isNotEmpty) rows.add(const SizedBox(height: 2)); + rows.add( + Row( + children: [ + Expanded(child: _buildGridTile(ctx, photos, i, remaining)), + const SizedBox(width: 2), + Expanded( + child: i + 1 < displayCount + ? _buildGridTile(ctx, photos, i + 1, remaining) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + return ClipRRect( borderRadius: _multiPhotoCornerRadius( matchTop: matchTop, matchBottom: matchBottom, isMe: ctx.isMe, ), - child: GridView.count( - crossAxisCount: 2, - mainAxisSpacing: 2, - crossAxisSpacing: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(displayCount, (i) { - if (i == 3 && remaining > 0) { - return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining', i); - } - return _buildPhotoTile(ctx, photos[i], i); - }), - ), + child: Column(mainAxisSize: MainAxisSize.min, children: rows), ); } + Widget _buildGridTile( + BubbleContext ctx, + List photos, + int index, + int remaining, + ) { + if (index == 3 && remaining > 0) { + return _buildPhotoTileWithOverlay( + ctx, + photos[index], + '+$remaining', + index, + ); + } + return _buildPhotoTile(ctx, photos[index], index); + } + Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) { final cachePx = (BubbleContext.photoMaxSize / @@ -330,7 +353,7 @@ class PhotoBubble extends StatelessWidget { Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => _openPhotoViewer(ctx.context, photo), + onTap: () => _openPhotoViewer(ctx.context, index), ), ), ], @@ -378,6 +401,13 @@ class PhotoBubble extends StatelessWidget { ), if (ctx.uploadProgress != null) _buildUploadOverlay(ctx.uploadProgress!, index), + if (ctx.uploadProgress == null) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _openPhotoViewer(ctx.context, index), + ), + ), ], ), ); @@ -407,13 +437,17 @@ class PhotoBubble extends StatelessWidget { ); } - void _openPhotoViewer(BuildContext context, PhotoAttachment photo) { - final url = photo.baseUrl ?? ''; - if (url.isEmpty) return; + void _openPhotoViewer(BuildContext context, int index) { Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => PhotoViewerScreen(baseUrl: url), + builder: (_) => PhotoViewerScreen( + photos: photos, + initialIndex: index, + chatId: ctx.chatId, + message: ctx.message, + actions: ctx.photoActions, + ), ), ); } diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index 1b8e11d..3d042ed 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -7,7 +7,7 @@ import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:ogg_opus_player/ogg_opus_player.dart'; -import '../../../backend/modules/messages.dart' show ContactCache; +import '../../../backend/modules/messages.dart' show CachedMessage, ContactCache; import '../../../backend/modules/shared_content.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/utils/download_progress.dart'; @@ -646,7 +646,11 @@ class _SharedMediaTabState extends State { ), itemCount: items.length, itemBuilder: (context, index) => - _MediaTile(item: items[index], onGoTo: () => _goTo(items[index])), + _MediaTile( + item: items[index], + onGoTo: () => _goTo(items[index]), + onGoToMessage: widget.onGoToMessage, + ), ); } } @@ -654,8 +658,13 @@ class _SharedMediaTabState extends State { class _MediaTile extends StatelessWidget { final SharedMediaItem item; final VoidCallback onGoTo; + final void Function(String messageId, int time) onGoToMessage; - const _MediaTile({required this.item, required this.onGoTo}); + const _MediaTile({ + required this.item, + required this.onGoTo, + required this.onGoToMessage, + }); void _menu(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -755,7 +764,25 @@ class _MediaTile extends StatelessWidget { } final url = att.baseUrl ?? att.previewData ?? ''; if (url.isEmpty) return; - pushSwipeable(context, (_) => PhotoViewerScreen(baseUrl: url)); + + final photo = att is PhotoAttachment && (att.baseUrl ?? '').isNotEmpty + ? att + : PhotoAttachment(baseUrl: url); + pushSwipeable( + context, + (_) => PhotoViewerScreen( + photos: [photo], + chatId: item.chatId, + message: CachedMessage( + id: item.messageId, + accountId: 0, + chatId: item.chatId, + senderId: item.senderId, + time: item.time, + ), + actions: PhotoViewerActions(goToMessage: onGoToMessage), + ), + ); } } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 81bcf0f..4b1343e 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -18,6 +18,7 @@ import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; import 'formatted_message_text.dart'; +import 'photo_viewer.dart'; import 'selectable_message_text.dart'; import '../../models/attachment.dart'; import '../../models/reaction_info.dart'; @@ -125,6 +126,8 @@ class MessageBubble extends StatelessWidget { final CachedMessage? prevMessage; final CachedMessage? nextMessage; final String chatType; + final int? chatId; + final PhotoViewerActions? photoActions; final String? overrideStatus; final ValueListenable? otherReadTime; final ValueListenable?>? reactionsListenable; @@ -146,6 +149,8 @@ class MessageBubble extends StatelessWidget { this.prevMessage, this.nextMessage, required this.chatType, + this.chatId, + this.photoActions, this.overrideStatus, this.otherReadTime, this.reactionsListenable, @@ -507,6 +512,8 @@ class MessageBubble extends StatelessWidget { isMe: isMe, myId: myId, chatType: chatType, + chatId: chatId, + photoActions: photoActions, overrideStatus: overrideStatus, otherReadTime: otherReadTime, uploadProgress: uploadProgress, diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index a82cc7c..4251737 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -1,57 +1,700 @@ +import 'dart:async'; +import 'dart:io'; +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/services.dart'; import 'package:material_symbols_icons/symbols.dart'; -class PhotoViewerScreen extends StatelessWidget { - final String baseUrl; +import '../../backend/modules/messages.dart'; +import '../../backend/modules/shared_content.dart'; +import '../../core/cache/info_cache.dart'; +import '../../core/config/app_frost.dart'; +import '../../core/utils/format.dart'; +import '../../core/utils/media_cache.dart'; +import '../../core/utils/media_saver.dart'; +import '../../l10n/app_localizations.dart'; +import '../../main.dart'; +import '../../models/attachment.dart'; +import 'chat_menu_overlay.dart'; +import 'custom_notification.dart'; - const PhotoViewerScreen({super.key, required this.baseUrl}); +class PhotoViewerActions { + final void Function(String messageId, int time)? goToMessage; + final void Function(String messageId)? forward; + final void Function(String messageId, int senderId)? delete; + final VoidCallback? viewAllPhotos; - String get _url => baseUrl; + const PhotoViewerActions({ + this.goToMessage, + this.forward, + this.delete, + this.viewAllPhotos, + }); + + bool get isEmpty => + goToMessage == null && + forward == null && + delete == null && + viewAllPhotos == null; +} + +class _ViewerPhoto { + final String id; + final PhotoAttachment photo; + final String messageId; + final int senderId; + final int time; + final String? caption; + + const _ViewerPhoto({ + required this.id, + required this.photo, + required this.messageId, + required this.senderId, + required this.time, + this.caption, + }); + + factory _ViewerPhoto.fromFeed(SharedMediaItem item) { + final photo = item.attachment as PhotoAttachment; + return _ViewerPhoto( + id: item.dedupKey, + photo: photo, + messageId: item.messageId, + senderId: item.senderId, + time: item.time, + caption: item.text, + ); + } +} + +class PhotoViewerScreen extends StatefulWidget { + final List photos; + final int initialIndex; + final int? chatId; + final CachedMessage? message; + final PhotoViewerActions? actions; + + const PhotoViewerScreen({ + super.key, + required this.photos, + this.initialIndex = 0, + this.chatId, + this.message, + this.actions, + }); + + PhotoViewerScreen.single(String baseUrl, {super.key}) + : photos = [PhotoAttachment(baseUrl: baseUrl)], + initialIndex = 0, + chatId = null, + message = null, + actions = null; + + @override + State createState() => _PhotoViewerScreenState(); +} + +class _PhotoViewerScreenState extends State { + static const int _prefetchThreshold = 3; + + late PageController _controller; + late List<_ViewerPhoto> _items; + late int _index; + + final Map _quarterTurns = {}; + bool _feedLoaded = false; + bool _feedFailed = false; + bool _loadingMore = false; + bool _reachedEnd = false; + bool _chromeVisible = true; + int _total = 0; + bool _saving = false; + + @override + void initState() { + super.initState(); + _items = _localItems(); + _index = widget.initialIndex.clamp(0, _items.length - 1); + _controller = PageController(initialPage: _index); + unawaited(_loadFeed()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + List<_ViewerPhoto> _localItems() { + final message = widget.message; + return [ + for (var i = 0; i < widget.photos.length; i++) + _ViewerPhoto( + id: _localId(widget.photos[i], message, i), + photo: widget.photos[i], + messageId: message?.id ?? '', + senderId: message?.senderId ?? 0, + time: message?.time ?? 0, + caption: message?.text, + ), + ]; + } + + String _localId(PhotoAttachment photo, CachedMessage? message, int at) { + final key = _feedKey(photo, message); + return key ?? 'local:${message?.id ?? ''}:$at'; + } + + String? _feedKey(PhotoAttachment photo, CachedMessage? message) { + if (message == null || widget.chatId == null) return null; + if (photo.photoId == null && (photo.baseUrl ?? '').isEmpty) return null; + return photoDedupKey(message.id, photo); + } + + _ViewerPhoto get _current => _items[_index]; + + bool get _feedPending => + !_feedLoaded && + !_feedFailed && + widget.chatId != null && + _feedKey(_current.photo, widget.message) != null; + + Future _loadFeed() async { + final chatId = widget.chatId; + final key = _feedKey(_items[_index].photo, widget.message); + if (chatId == null || key == null) return; + + final feed = await sharedContentModule.photoFeedFor( + chatId: chatId, + photoKey: key, + resolveAnchor: () => _resolveAnchor(chatId), + ); + if (!mounted) return; + if (feed == null) { + setState(() => _feedFailed = true); + return; + } + + final items = feed.items.map(_ViewerPhoto.fromFeed).toList(); + final at = items.indexWhere((i) => i.id == key); + if (at == -1) { + setState(() => _feedFailed = true); + return; + } + + _controller.dispose(); + setState(() { + _items = items; + _index = at; + _total = feed.total; + _reachedEnd = feed.reachedEnd; + _feedLoaded = true; + _controller = PageController(initialPage: at); + }); + } + + Future _loadMore() async { + final chatId = widget.chatId; + if (chatId == null || _loadingMore || _reachedEnd || !_feedLoaded) return; + _loadingMore = true; + try { + final feed = await sharedContentModule.loadMorePhotos( + chatId: chatId, + resolveAnchor: () => _resolveAnchor(chatId), + ); + if (!mounted) return; + setState(() { + _items = feed.items.map(_ViewerPhoto.fromFeed).toList(); + _total = feed.total; + _reachedEnd = feed.reachedEnd; + }); + } finally { + _loadingMore = false; + } + } + + Future _resolveAnchor(int chatId) async { + final info = await ChatInfoFetch.get(chatId); + final lastMessage = info?.raw['lastMessage']; + if (lastMessage is Map) { + final id = lastMessage['id']?.toString(); + if (id != null && id.isNotEmpty) return id; + } + return widget.message?.id; + } + + void _onPageChanged(int index) { + setState(() => _index = index); + if (index >= _items.length - _prefetchThreshold) unawaited(_loadMore()); + } + + void _step(int delta) { + final next = _index + delta; + if (next < 0 || next >= _items.length) return; + _controller.animateToPage( + next, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + } + + void _rotate() { + setState(() { + _quarterTurns[_current.id] = ((_quarterTurns[_current.id] ?? 0) + 1) % 4; + }); + } + + void _toggleChrome() => setState(() => _chromeVisible = !_chromeVisible); + + String _cacheNameFor(PhotoAttachment photo, String url) => + 'photo_${photo.photoId ?? (url.hashCode & 0x7fffffff)}.jpg'; + + Future _fileFor(PhotoAttachment photo) async { + final localPath = photo.localPath; + if (localPath != null) { + final file = File(localPath); + return await file.exists() ? file : null; + } + final url = photo.baseUrl ?? ''; + if (url.isEmpty) return null; + return MediaCache.getOrDownload(_cacheNameFor(photo, url), url); + } + + Future _save() async { + if (_saving) return; + setState(() => _saving = true); + final photo = _current.photo; + final localPath = photo.localPath; + final url = photo.baseUrl ?? ''; + + final MediaSaveResult result; + if (localPath != null) { + result = await saveLocalImage(localPath); + } else if (url.isEmpty) { + result = const MediaSaveResult(ok: false, error: 'нет ссылки'); + } else { + result = await saveMediaFile( + cacheName: _cacheNameFor(photo, url), + resolveUrl: () async => url, + saveName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', + kind: SaveMediaKind.image, + ); + } + + if (!mounted) return; + setState(() => _saving = false); + if (result.ok) { + showCustomNotification( + context, + result.toGallery ? 'Сохранено в галерею' : 'Файл сохранён', + ); + } else { + showCustomNotification( + context, + 'Не удалось сохранить: ${result.error ?? ''}', + ); + } + } + + Future _saveAs() async { + final file = await _fileFor(_current.photo); + if (!mounted) return; + if (file == null) { + showCustomNotification(context, 'Не удалось загрузить фото'); + return; + } + + final bytes = await file.readAsBytes(); + if (!mounted) return; + + final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); + final path = await FilePicker.platform.saveFile( + dialogTitle: AppLocalizations.of(context)!.photoViewerSaveAs, + fileName: 'IMG_${DateTime.now().millisecondsSinceEpoch}.jpg', + type: FileType.any, + bytes: isMobile ? bytes : null, + ); + if (path == null || !mounted) return; + + if (!isMobile) { + await File(path).writeAsBytes(bytes); + if (!mounted) return; + } + showCustomNotification(context, 'Файл сохранён'); + } + + void _openMenu(BuildContext anchorContext) { + final actions = widget.actions; + if (actions == null) return; + final box = anchorContext.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final l10n = AppLocalizations.of(context)!; + final item = _current; + + showChatMenu( + context: context, + anchorRect: box.localToGlobal(Offset.zero) & box.size, + items: [ + if (actions.goToMessage != null) + ChatMenuItem( + icon: Symbols.visibility, + label: l10n.sharedGoToMessage, + onTap: () => + _popThen(() => actions.goToMessage!(item.messageId, item.time)), + ), + if (actions.forward != null) + ChatMenuItem( + icon: Symbols.forward, + label: l10n.msgActionsForward, + onTap: () => _popThen(() => actions.forward!(item.messageId)), + ), + if (actions.delete != null) + ChatMenuItem( + icon: Symbols.delete, + label: l10n.msgActionsDelete, + destructive: true, + dividerAfter: true, + onTap: () => + _popThen(() => actions.delete!(item.messageId, item.senderId)), + ), + ChatMenuItem( + icon: Symbols.download, + label: l10n.photoViewerSaveAs, + onTap: _saveAs, + ), + if (actions.viewAllPhotos != null) + ChatMenuItem( + icon: Symbols.grid_view, + label: l10n.photoViewerViewAll, + onTap: () => _popThen(actions.viewAllPhotos!), + ), + ], + ); + } + + void _popThen(VoidCallback action) { + Navigator.of(context).pop(); + action(); + } @override Widget build(BuildContext context) { + final padding = MediaQuery.of(context).padding; + final hasMenu = !(widget.actions?.isEmpty ?? true); + return Scaffold( backgroundColor: Colors.black, - body: Stack( - children: [ - Positioned.fill( - child: InteractiveViewer( - minScale: 1, - maxScale: 5, - child: Center( - child: _url.isEmpty - ? const Icon( - Symbols.broken_image, - color: Colors.white54, - size: 64, - ) - : CachedNetworkImage( - imageUrl: _url, - fit: BoxFit.contain, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => const Center( - child: CircularProgressIndicator(color: Colors.white), - ), - errorWidget: (_, _, _) => const Icon( - Symbols.broken_image, - color: Colors.white54, - size: 64, + body: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowLeft): () => _step(-1), + const SingleActivator(LogicalKeyboardKey.arrowRight): () => _step(1), + }, + child: Focus( + autofocus: true, + child: Stack( + children: [ + Positioned.fill( + child: PageView.builder( + controller: _controller, + itemCount: _items.length, + onPageChanged: _onPageChanged, + itemBuilder: (_, i) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleChrome, + child: InteractiveViewer( + minScale: 1, + maxScale: 5, + child: Center( + child: RotatedBox( + quarterTurns: _quarterTurns[_items[i].id] ?? 0, + child: _buildImage(_items[i].photo), ), ), + ), + ), + ), ), - ), + Positioned.fill( + child: IgnorePointer( + ignoring: !_chromeVisible, + child: AnimatedOpacity( + opacity: _chromeVisible ? 1 : 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + child: Stack( + children: [ + if (_index > 0) + Align( + alignment: Alignment.centerLeft, + child: _arrow(Symbols.chevron_left, () => _step(-1)), + ), + if (_index < _items.length - 1) + Align( + alignment: Alignment.centerRight, + child: _arrow(Symbols.chevron_right, () => _step(1)), + ), + Positioned( + top: padding.top + 8, + left: 8, + right: 8, + child: Row( + children: [ + IconButton( + icon: const Icon( + Symbols.close, + color: Colors.white, + ), + onPressed: () => Navigator.of(context).pop(), + ), + const Spacer(), + if (hasMenu) + Builder( + builder: (btnContext) => IconButton( + icon: const Icon( + Symbols.more_vert, + color: Colors.white, + ), + onPressed: () => _openMenu(btnContext), + ), + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildBottomBar(padding.bottom), + ), + ], + ), + ), + ), + ), + ], ), - Positioned( - top: MediaQuery.of(context).padding.top + 8, - left: 8, - child: IconButton( - icon: const Icon(Symbols.close, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - ), + ), + ), + ); + } + + Widget _arrow(IconData icon, VoidCallback onTap) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Material( + color: Colors.black.withValues(alpha: 0.35), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, color: Colors.white, size: 28), + ), + ), + ), + ); + } + + Widget _buildBottomBar(double bottomInset) { + final l10n = AppLocalizations.of(context)!; + final caption = _current.caption; + + return Container( + padding: EdgeInsets.fromLTRB(16, 12, 8, bottomInset + 10), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0xB3000000)], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (caption != null && caption.isNotEmpty) ...[ + _buildCaption(caption), + const SizedBox(height: 12), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _buildInfo(l10n)), + IconButton( + icon: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Symbols.download, color: Colors.white), + onPressed: _saving ? null : _save, + tooltip: l10n.sharedDownload, + ), + IconButton( + icon: const Icon( + Symbols.rotate_90_degrees_ccw, + color: Colors.white, + ), + onPressed: _rotate, + tooltip: l10n.photoViewerRotate, + ), + ], ), ], ), ); } + + Widget _buildCaption(String caption) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: AppFrost.panelSigma, + sigmaY: AppFrost.panelSigma, + ), + child: Container( + constraints: const BoxConstraints(maxHeight: 120), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.28), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + width: 0.5, + ), + borderRadius: BorderRadius.circular(12), + ), + child: SingleChildScrollView( + child: Text( + caption, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + height: 1.3, + ), + ), + ), + ), + ), + ); + } + + Widget _buildInfo(AppLocalizations l10n) { + final item = _current; + if (item.messageId.isEmpty) return const SizedBox.shrink(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_feedLoaded) + Text( + l10n.photoViewerCounter(_total - _index, _total), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ) + else if (_feedPending) + const _CounterShimmer(), + const SizedBox(height: 2), + Text( + _sentLine(l10n, item), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ); + } + + String _sentLine(AppLocalizations l10n, _ViewerPhoto item) { + final sender = ContactCache.get(item.senderId) ?? ''; + final sentAt = DateTime.fromMillisecondsSinceEpoch(item.time); + final now = DateTime.now(); + final time = formatClock(sentAt); + final isToday = + sentAt.year == now.year && + sentAt.month == now.month && + sentAt.day == now.day; + return isToday + ? l10n.photoViewerSentToday(sender, time) + : l10n.photoViewerSentOn(sender, formatDateWords(sentAt), time); + } + + Widget _buildImage(PhotoAttachment photo) { + final localPath = photo.localPath; + if (localPath != null) { + return Image.file( + File(localPath), + fit: BoxFit.contain, + errorBuilder: (_, _, _) => _broken(), + ); + } + + final url = photo.baseUrl ?? ''; + if (url.isEmpty) return _broken(); + + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => + const Center(child: CircularProgressIndicator(color: Colors.white)), + errorWidget: (_, _, _) => _broken(), + ); + } + + Widget _broken() => + const Icon(Symbols.broken_image, color: Colors.white54, size: 64); +} + +class _CounterShimmer extends StatefulWidget { + const _CounterShimmer(); + + @override + State<_CounterShimmer> createState() => _CounterShimmerState(); +} + +class _CounterShimmerState extends State<_CounterShimmer> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..repeat(reverse: true); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) => Opacity( + opacity: 0.25 + 0.35 * _controller.value, + child: Container( + width: 120, + height: 16, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ); + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 540d719..fb1f075 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -522,6 +522,45 @@ "sharedLoadMore": "Show more", "sharedGoToMessage": "Go to message", "sharedDownload": "Download", + "photoViewerCounter": "Photo {index} of {total}", + "@photoViewerCounter": { + "placeholders": { + "index": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "photoViewerSentToday": "{sender} • today at {time}", + "@photoViewerSentToday": { + "placeholders": { + "sender": { + "type": "String" + }, + "time": { + "type": "String" + } + } + }, + "photoViewerSentOn": "{sender} • {date} at {time}", + "@photoViewerSentOn": { + "placeholders": { + "sender": { + "type": "String" + }, + "date": { + "type": "String" + }, + "time": { + "type": "String" + } + } + }, + "photoViewerSaveAs": "Save as…", + "photoViewerViewAll": "View all photos", + "photoViewerRotate": "Rotate", "sharedCopyLink": "Copy link", "sharedLinkCopied": "Link copied", "chatInfoActionLeave": "Leave", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 813a48a..3611761 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2570,6 +2570,42 @@ abstract class AppLocalizations { /// **'Download'** String get sharedDownload; + /// No description provided for @photoViewerCounter. + /// + /// In en, this message translates to: + /// **'Photo {index} of {total}'** + String photoViewerCounter(int index, int total); + + /// No description provided for @photoViewerSentToday. + /// + /// In en, this message translates to: + /// **'{sender} • today at {time}'** + String photoViewerSentToday(String sender, String time); + + /// No description provided for @photoViewerSentOn. + /// + /// In en, this message translates to: + /// **'{sender} • {date} at {time}'** + String photoViewerSentOn(String sender, String date, String time); + + /// No description provided for @photoViewerSaveAs. + /// + /// In en, this message translates to: + /// **'Save as…'** + String get photoViewerSaveAs; + + /// No description provided for @photoViewerViewAll. + /// + /// In en, this message translates to: + /// **'View all photos'** + String get photoViewerViewAll; + + /// No description provided for @photoViewerRotate. + /// + /// In en, this message translates to: + /// **'Rotate'** + String get photoViewerRotate; + /// No description provided for @sharedCopyLink. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 68dc295..4f60055 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1308,6 +1308,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sharedDownload => 'Download'; + @override + String photoViewerCounter(int index, int total) { + return 'Photo $index of $total'; + } + + @override + String photoViewerSentToday(String sender, String time) { + return '$sender • today at $time'; + } + + @override + String photoViewerSentOn(String sender, String date, String time) { + return '$sender • $date at $time'; + } + + @override + String get photoViewerSaveAs => 'Save as…'; + + @override + String get photoViewerViewAll => 'View all photos'; + + @override + String get photoViewerRotate => 'Rotate'; + @override String get sharedCopyLink => 'Copy link'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 78cb3af..83ce576 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1314,6 +1314,30 @@ class AppLocalizationsRu extends AppLocalizations { @override String get sharedDownload => 'Скачать'; + @override + String photoViewerCounter(int index, int total) { + return 'Фото $index из $total'; + } + + @override + String photoViewerSentToday(String sender, String time) { + return '$sender • сегодня в $time'; + } + + @override + String photoViewerSentOn(String sender, String date, String time) { + return '$sender • $date в $time'; + } + + @override + String get photoViewerSaveAs => 'Сохранить как…'; + + @override + String get photoViewerViewAll => 'Все фото чата'; + + @override + String get photoViewerRotate => 'Повернуть'; + @override String get sharedCopyLink => 'Копировать ссылку'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a84c69c..c8bf08a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -435,6 +435,12 @@ "sharedLoadMore": "Показать ещё", "sharedGoToMessage": "Перейти к сообщению", "sharedDownload": "Скачать", + "photoViewerCounter": "Фото {index} из {total}", + "photoViewerSentToday": "{sender} • сегодня в {time}", + "photoViewerSentOn": "{sender} • {date} в {time}", + "photoViewerSaveAs": "Сохранить как…", + "photoViewerViewAll": "Все фото чата", + "photoViewerRotate": "Повернуть", "sharedCopyLink": "Копировать ссылку", "sharedLinkCopied": "Ссылка скопирована", "chatInfoActionLeave": "Покинуть", diff --git a/test/photo_album_bubble_test.dart b/test/photo_album_bubble_test.dart new file mode 100644 index 0000000..fe9c7c8 --- /dev/null +++ b/test/photo_album_bubble_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/message_bubble.dart'; +import 'package:komet/frontend/widgets/photo_viewer.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; + +CachedMessage _album(List photos) => CachedMessage( + id: '1', + accountId: 1, + chatId: 2, + senderId: 1, + time: DateTime(2026, 1, 1).millisecondsSinceEpoch, + status: 'sent', + attachments: photos, +); + +List _remote(int count) => List.generate( + count, + (i) => PhotoAttachment( + baseUrl: 'https://example.com/$i.jpg', + width: 1200, + height: 1600, + ), +); + +List _local(int count) => List.generate( + count, + (i) => PhotoAttachment(localPath: '/tmp/photo$i.jpg', width: 1200, height: 1600), +); + +Future _pumpBubble(WidgetTester tester, CachedMessage message) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + tester.view.padding = const FakeViewPadding(top: 210, bottom: 120); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: MessageBubble( + message: message, + isMe: true, + myId: 1, + chatType: 'DIALOG', + ), + ), + ), + ), + ); + await tester.pump(); +} + +int _viewerIndex(WidgetTester tester) => + tester.widget(find.byType(PhotoViewerScreen)).initialIndex; + +Size _bubbleSize(WidgetTester tester) => tester.getSize( + find + .ancestor( + of: find.byType(ClipRRect).first, + matching: find.byType(ConstrainedBox), + ) + .first, +); + +void main() { + testWidgets('album grid ignores safe area insets', (tester) async { + await _pumpBubble(tester, _album(_remote(4))); + + final size = _bubbleSize(tester); + expect(size.height, closeTo(size.width, 1)); + }); + + testWidgets('tapping an album photo opens the viewer at its index', ( + tester, + ) async { + await _pumpBubble(tester, _album(_remote(4))); + + await tester.tap(find.byType(GestureDetector).at(2)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + expect(_viewerIndex(tester), 2); + }); + + testWidgets('the +N tile opens the viewer', (tester) async { + await _pumpBubble(tester, _album(_remote(6))); + + expect(find.text('+2'), findsOneWidget); + + await tester.tap(find.byType(GestureDetector).at(3)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + expect(_viewerIndex(tester), 3); + }); + + testWidgets('photos still uploading open from their local file', ( + tester, + ) async { + await _pumpBubble(tester, _album(_local(4))); + + await tester.tap(find.byType(GestureDetector).first); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(PhotoViewerScreen), findsOneWidget); + }); +} diff --git a/test/photo_viewer_test.dart b/test/photo_viewer_test.dart new file mode 100644 index 0000000..df360be --- /dev/null +++ b/test/photo_viewer_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/backend/modules/messages.dart'; +import 'package:komet/frontend/widgets/photo_viewer.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/models/attachment.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +CachedMessage _message({String? text}) => CachedMessage( + id: '77', + accountId: 1, + chatId: 2, + senderId: 5, + text: text, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sent', + attachments: const [], +); + +Future _pumpViewer( + WidgetTester tester, { + CachedMessage? message, + PhotoViewerActions? actions, +}) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: PhotoViewerScreen( + photos: const [ + PhotoAttachment(baseUrl: 'https://example.com/a.jpg'), + PhotoAttachment(baseUrl: 'https://example.com/b.jpg'), + ], + message: message, + actions: actions, + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('shows who sent the photo and when', (tester) async { + await _pumpViewer(tester, message: _message()); + + expect(find.textContaining('сегодня в'), findsOneWidget); + }); + + testWidgets('rotate button turns the photo by 90 degrees', (tester) async { + await _pumpViewer(tester, message: _message()); + + expect( + tester.widget(find.byType(RotatedBox).first).quarterTurns, + 0, + ); + + await tester.tap(find.byIcon(Symbols.rotate_90_degrees_ccw)); + await tester.pump(); + + expect( + tester.widget(find.byType(RotatedBox).first).quarterTurns, + 1, + ); + }); + + testWidgets('shows the photo caption when there is one', (tester) async { + await _pumpViewer(tester, message: _message(text: 'Делу время')); + + expect(find.text('Делу время'), findsOneWidget); + }); + + testWidgets('arrows step between photos', (tester) async { + await _pumpViewer(tester, message: _message()); + + expect(find.byIcon(Symbols.chevron_left), findsNothing); + expect(find.byIcon(Symbols.chevron_right), findsOneWidget); + + await tester.tap(find.byIcon(Symbols.chevron_right)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(Symbols.chevron_left), findsOneWidget); + expect(find.byIcon(Symbols.chevron_right), findsNothing); + + await tester.tap(find.byIcon(Symbols.chevron_left)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(Symbols.chevron_right), findsOneWidget); + }); + + testWidgets('arrow keys step between photos', (tester) async { + await _pumpViewer(tester, message: _message()); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(Symbols.chevron_left), findsOneWidget); + expect(find.byIcon(Symbols.chevron_right), findsNothing); + }); + + testWidgets('single tap hides the chrome, another tap brings it back', ( + tester, + ) async { + await _pumpViewer(tester, message: _message()); + + double chromeOpacity() => + tester.widget(find.byType(AnimatedOpacity)).opacity; + + expect(chromeOpacity(), 1); + + await tester.tapAt(tester.getCenter(find.byType(PageView))); + await tester.pump(); + expect(chromeOpacity(), 0); + + await tester.tapAt(tester.getCenter(find.byType(PageView))); + await tester.pump(); + expect(chromeOpacity(), 1); + }); + + testWidgets('three-dot menu appears only with actions', (tester) async { + await _pumpViewer(tester, message: _message()); + expect(find.byIcon(Symbols.more_vert), findsNothing); + + await _pumpViewer( + tester, + message: _message(), + actions: PhotoViewerActions( + goToMessage: (_, _) {}, + delete: (_, _) {}, + ), + ); + expect(find.byIcon(Symbols.more_vert), findsOneWidget); + + await tester.tap(find.byIcon(Symbols.more_vert)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Перейти к сообщению'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + expect(find.text('Сохранить как…'), findsOneWidget); + expect(find.text('Переслать'), findsNothing); + }); + + testWidgets('menu action closes the viewer before running', (tester) async { + var ran = false; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PhotoViewerScreen( + photos: const [ + PhotoAttachment(baseUrl: 'https://example.com/a.jpg'), + ], + message: _message(), + actions: PhotoViewerActions( + goToMessage: (_, _) => ran = true, + ), + ), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(PhotoViewerScreen), findsOneWidget); + + await tester.tap(find.byIcon(Symbols.more_vert)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Перейти к сообщению')); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + + expect(ran, isTrue); + expect(find.byType(PhotoViewerScreen), findsNothing); + }); +}