diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index f309523..40495e3 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -18,6 +18,7 @@ import '../../../core/config/app_show_extra_info.dart'; import '../../../core/config/app_stories.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; +import '../../../core/utils/logger.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../models/chat_info.dart'; @@ -157,6 +158,9 @@ class _ChatInfoScreenState extends State List _avatarPages = const []; int _avatarIndex = 0; int _avatarTotal = 0; + bool _avatarHover = false; + bool _avatarHistoryBusy = false; + bool _avatarHistoryLoaded = false; double _headerDelta = 0; bool _expandArmed = false; @@ -627,7 +631,13 @@ class _ChatInfoScreenState extends State Widget _buildMorphHeader(BuildContext context, ColorScheme cs, double t) { final topPad = MediaQuery.paddingOf(context).top; - if (t > 0) _headerEverExpanded = true; + if (t > 0) { + _headerEverExpanded = true; + final peerId = _otherId; + if (!_avatarHistoryLoaded && peerId != null) { + unawaited(_loadAvatarHistory(peerId)); + } + } final iconColor = Color.lerp(cs.onSurface, Colors.white, t)!; final nameColor = Color.lerp(cs.onSurface, Colors.white, t)!; final subColor = Color.lerp( @@ -841,29 +851,152 @@ class _ChatInfoScreenState extends State return KeyedSubtree( key: _avatarKey, - child: ProfileHeroAvatar( - tag: widget.heroTag, - size: _headerAvatarSize, - child: GestureDetector( - onTap: expanded ? openHistory : (openStories ?? openHistory), - onLongPress: expanded ? null : (openStories == null ? null : openHistory), - child: ClipRRect( - borderRadius: BorderRadius.circular(radius), - child: _headerAvatarContent(cs, t), + child: GestureDetector( + onTap: expanded ? openHistory : (openStories ?? openHistory), + onLongPress: expanded + ? null + : (openStories == null ? null : openHistory), + child: Stack( + fit: StackFit.expand, + children: [ + ProfileHeroAvatar( + tag: widget.heroTag, + size: _headerAvatarSize, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: _headerAvatarContent(cs), + ), + ), + Offstage( + offstage: t < 0.5 || _avatarPages.length < 2, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: _avatarPager(cs, t), + ), + ), + ], + ), + ), + ); + } + + Widget _avatarPager(ColorScheme cs, double t) { + final pages = _avatarPages; + if (pages.length < 2) return const SizedBox.shrink(); + final interactive = t > 0.5; + return MouseRegion( + onEnter: (_) { + if (!_avatarHover) setState(() => _avatarHover = true); + }, + onExit: (_) { + if (_avatarHover) setState(() => _avatarHover = false); + }, + child: Stack( + fit: StackFit.expand, + children: [ + ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: PointerDeviceKind.values.toSet(), + scrollbars: false, + overscroll: false, + ), + child: PageView.builder( + controller: _avatarPageController, + itemCount: pages.length, + physics: interactive + ? const PageScrollPhysics() + : const NeverScrollableScrollPhysics(), + onPageChanged: (i) => setState(() => _avatarIndex = i), + itemBuilder: (_, i) => _avatarPhoto(cs, pages[i]), + ), + ), + if (interactive && _avatarHover) ...[ + _avatarArrow( + alignment: Alignment.centerLeft, + icon: Icons.chevron_left, + enabled: _avatarIndex > 0, + onTap: () => _stepAvatar(-1), + ), + _avatarArrow( + alignment: Alignment.centerRight, + icon: Icons.chevron_right, + enabled: _avatarIndex < pages.length - 1, + onTap: () => _stepAvatar(1), + ), + ], + ], + ), + ); + } + + Widget _avatarArrow({ + required Alignment alignment, + required IconData icon, + required bool enabled, + required VoidCallback onTap, + }) { + return Align( + alignment: alignment, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: enabled ? 1 : 0, + child: IgnorePointer( + ignoring: !enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Colors.black38, + shape: BoxShape.circle, + ), + child: Icon(icon, color: Colors.white, size: 24), + ), + ), ), ), ), ); } - Widget _headerAvatarContent(ColorScheme cs, double t) { + void _stepAvatar(int delta) { + final target = (_avatarIndex + delta).clamp(0, _avatarPages.length - 1); + if (target == _avatarIndex) return; + _avatarPageController.animateToPage( + target, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + Widget _avatarPhoto(ColorScheme cs, String url) { + return CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + memCacheWidth: _headerEverExpanded ? 720 : 288, + fadeInDuration: const Duration(milliseconds: 150), + errorWidget: (_, _, _) => ColoredBox( + color: cs.surfaceContainerHigh, + child: Center( + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 32), + ), + ), + ), + ); + } + + Widget _headerAvatarContent(ColorScheme cs) { if (_peerDeleted) { return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52); } - final pages = _avatarPages.isNotEmpty - ? _avatarPages - : (widget.imageUrl.isEmpty ? const [] : [widget.imageUrl]); - if (pages.isEmpty) { + final url = _avatarPages.isNotEmpty ? _avatarPages.first : widget.imageUrl; + if (url.isEmpty) { return KometAvatar( name: widget.name, size: _headerAvatarSize, @@ -871,29 +1004,7 @@ class _ChatInfoScreenState extends State fadeIn: false, ); } - return PageView.builder( - controller: _avatarPageController, - itemCount: pages.length, - physics: t > 0.99 - ? const PageScrollPhysics() - : const NeverScrollableScrollPhysics(), - onPageChanged: (i) => setState(() => _avatarIndex = i), - itemBuilder: (_, i) => CachedNetworkImage( - imageUrl: pages[i], - fit: BoxFit.cover, - memCacheWidth: _headerEverExpanded ? 720 : 288, - fadeInDuration: const Duration(milliseconds: 150), - errorWidget: (_, _, _) => ColoredBox( - color: cs.surfaceContainerHigh, - child: Center( - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 32), - ), - ), - ), - ), - ); + return _avatarPhoto(cs, url); } void _refreshUnreadStories() { @@ -1254,30 +1365,27 @@ class _ChatInfoScreenState extends State btns = [chatBtn, muteBtn, leaveBtn]; } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - for (int i = 0; i < btns.length; i++) ...[ - _actionBtn(cs, btns[i].icon, btns[i].label, btns[i].onTap), - if (i < btns.length - 1) const SizedBox(width: 8), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + for (int i = 0; i < btns.length; i++) ...[ + _actionBtn(cs, btns[i].icon, btns[i].label, btns[i].onTap), + if (i < btns.length - 1) const SizedBox(width: 8), ], - ), - if (_canAddContact) ...[ - const SizedBox(height: 8), - _wideActionBtn( - cs, - Symbols.person_add, - l10n.contactProfileActionAddContact, - _addContactBusy ? null : _addToContacts, - ), ], + ), + if (_canAddContact) ...[ + const SizedBox(height: 8), + _wideActionBtn( + cs, + Symbols.person_add, + l10n.contactProfileActionAddContact, + _addContactBusy ? null : _addToContacts, + ), ], - ), + ], ); } @@ -2489,12 +2597,20 @@ class _ChatInfoScreenState extends State } Future _loadAvatarHistory(int peerId) async { - if (!_headerHasPhoto) return; + if (!_headerHasPhoto || _avatarHistoryBusy) return; + _avatarHistoryBusy = true; final cached = ContactsModule.cachedPhotos(peerId); if (cached != null) _applyAvatarPhotos(cached); - final photos = await ContactsModule.fetchPhotos(api, peerId, count: 30); - if (!mounted) return; - _applyAvatarPhotos(photos); + try { + final photos = await ContactsModule.fetchPhotos(api, peerId, count: 30); + if (!mounted) return; + _avatarHistoryLoaded = true; + _applyAvatarPhotos(photos); + } catch (e) { + logger.w('Не удалось получить историю аватарок $peerId: $e'); + } finally { + _avatarHistoryBusy = false; + } } void _applyAvatarPhotos(ContactPhotos photos) { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 65ded70..9b6c0b4 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1535,16 +1535,29 @@ class _ChatListScreenState extends State child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _openStories(0), - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only( - right: 8, - ), - child: FoldedStoryStack( - previews: - storiesModule.previews, - opacity: 1.0 - _pullRatio, + child: SizedBox( + width: + (FoldedStoryStack.widthFor( + storiesModule + .previews + .length, + ) + + 8) * + (1.0 - _pullRatio), + height: FoldedStoryStack.outerSize, + child: OverflowBox( + alignment: Alignment.centerLeft, + maxWidth: + FoldedStoryStack.widthFor( + storiesModule + .previews + .length, + ), + child: FoldedStoryStack( + previews: + storiesModule.previews, + opacity: 1.0 - _pullRatio, + ), ), ), ), diff --git a/lib/frontend/screens/stories/story_ring.dart b/lib/frontend/screens/stories/story_ring.dart index 9793e1e..33f7083 100644 --- a/lib/frontend/screens/stories/story_ring.dart +++ b/lib/frontend/screens/stories/story_ring.dart @@ -388,6 +388,19 @@ class _StorySelfTileState extends State { /// Свёрнутая мини-стопка колец, показывается в заголовке при закрытом доке. class FoldedStoryStack extends StatelessWidget { + static const int maxShown = 3; + static const double avatarSize = 28; + static const double _rim = 1.5; + static const double _gap = 1.5; + static const double step = 14; + + static const double outerSize = avatarSize + (_rim + _gap) * 2; + + static double widthFor(int count) { + final shown = count > maxShown ? maxShown : (count < 1 ? 1 : count); + return outerSize + step * (shown - 1); + } + final List previews; final double opacity; @@ -400,35 +413,43 @@ class FoldedStoryStack extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final shown = previews.take(3).toList(); + final shown = previews.take(maxShown).toList(); return Opacity( opacity: opacity.clamp(0.0, 1.0), - child: Stack( - children: [ - for (var i = 0; i < shown.length; i++) - Positioned( - left: i * 14.0, - child: StoryOwnerBuilder( - owner: shown[i].owner, - builder: (context, info) => Container( - padding: const EdgeInsets.all(1.5), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.surface, - border: Border.all( - color: shown[i].hasUnread ? cs.primary : cs.outlineVariant, - width: 1.5, + child: SizedBox( + height: outerSize, + width: widthFor(shown.length), + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < shown.length; i++) + Positioned( + left: i * step, + top: 0, + child: StoryOwnerBuilder( + owner: shown[i].owner, + builder: (context, info) => Container( + padding: const EdgeInsets.all(_gap), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.surface, + border: Border.all( + color: shown[i].hasUnread + ? cs.primary + : cs.outlineVariant, + width: _rim, + ), + ), + child: KometAvatar( + name: info?.name.isNotEmpty == true ? info!.name : '?', + size: avatarSize, + imageUrl: info?.avatarUrl, + ), ), ), - child: KometAvatar( - name: info?.name.isNotEmpty == true ? info!.name : '?', - size: 28, - imageUrl: info?.avatarUrl, - ), ), - ), - ), - ], + ], + ), ), ); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e97ed37..82dee10 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -844,7 +844,14 @@ class MessageBubble extends StatelessWidget { } final shape = _computeShape(); - final hasPhotoCap = _computeHasPhotoWithCaption(); + final hasReactions = _hasReactions(); + final hasPhotoCap = + _computeHasPhotoWithCaption() || + (contentType == MessageType.attachment && + hasReactions && + !_isSticker && + !_isVideoNote && + _jumboAnimojiUrls == null); final hasMultiPhotos = _computeHasMultiplePhotosNoCaption(); final textColor = bubbleTextColor(context); @@ -901,8 +908,7 @@ class MessageBubble extends StatelessWidget { ) : _buildContent(makeCtx()); - final reactionsUnder = _reactionsUnderBubble(contentType); - final reactionsInside = contentType != MessageType.text && !reactionsUnder; + final reactionsInside = contentType != MessageType.text; final reply = message.replyInfo; @@ -915,7 +921,15 @@ class MessageBubble extends StatelessWidget { ? Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [bubbleContent, _reactionsBar(cs)], + children: [ + bubbleContent, + _reactionsBar( + cs, + inset: padding == EdgeInsets.zero + ? const EdgeInsets.fromLTRB(8, 4, 8, 6) + : const EdgeInsets.only(top: 4), + ), + ], ) : bubbleContent; @@ -1031,7 +1045,6 @@ class MessageBubble extends StatelessWidget { ) else bubbleBox, - if (reactionsUnder) _reactionsBar(cs), ], ), ], @@ -1267,25 +1280,22 @@ class MessageBubble extends StatelessWidget { return null; } - bool _reactionsUnderBubble(MessageType contentType) { - if (contentType != MessageType.attachment) return false; - final attachments = message.attachments; - if (attachments == null || attachments.isEmpty) return false; - if (attachments.first is ForwardedMessageAttachment) return false; - if (attachments.any((a) => a is ContactAttachment)) return false; - if (attachments.whereType().length >= 2) return false; - return true; + bool _hasReactions() { + final info = ReactionInfo.fromMap(_resolveReactionInfo()); + return info != null && info.counters.isNotEmpty; } - Widget _reactionsBar(ColorScheme cs) { + Widget _reactionsBar(ColorScheme cs, {required EdgeInsets inset}) { final listenable = reactionsListenable; if (listenable != null) { return ValueListenableBuilder?>( valueListenable: listenable, - builder: (context, info, _) => _buildReactionsBarFor(cs, info), + builder: (context, info, _) => + _buildReactionsBarFor(cs, info, inset: inset), ); } - return _buildReactionsBar(cs); + final info = message.payload?['reactionInfo']; + return _buildReactionsBarFor(cs, info is Map ? info : null, inset: inset); } Widget _buildContent(BubbleContext ctx) { @@ -1386,16 +1396,15 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildReactionsBar(ColorScheme cs) { - final info = message.payload?['reactionInfo']; - return _buildReactionsBarFor(cs, info is Map ? info : null); - } - - Widget _buildReactionsBarFor(ColorScheme cs, Map? info) { + Widget _buildReactionsBarFor( + ColorScheme cs, + Map? info, { + EdgeInsets inset = const EdgeInsets.only(top: 4), + }) { final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info)); if (chips.isEmpty) return const SizedBox.shrink(); return Padding( - padding: const EdgeInsets.only(top: 4), + padding: inset, child: Wrap(spacing: 4, runSpacing: 4, children: chips), ); } diff --git a/test/reactions_inside_bubble_test.dart b/test/reactions_inside_bubble_test.dart new file mode 100644 index 0000000..7f5c053 --- /dev/null +++ b/test/reactions_inside_bubble_test.dart @@ -0,0 +1,134 @@ +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/l10n/app_localizations.dart'; +import 'package:komet/models/animoji.dart'; +import 'package:komet/models/attachment.dart'; + +const int _me = 1; +const int _peer = 7; + +Map get _reactions => { + 'totalCount': 2, + 'counters': [ + {'reaction': '🔥', 'count': 2}, + ], + 'yourReaction': '🔥', +}; + +CachedMessage _photo({String? caption}) => CachedMessage( + id: '1', + accountId: _me, + chatId: 2, + senderId: _peer, + text: caption, + time: DateTime(2026, 1, 1, 12, 0).millisecondsSinceEpoch, + status: 'sent', + attachments: [ + PhotoAttachment( + baseUrl: 'https://example.com/synthetic.jpg', + width: 180, + height: 240, + ), + ], + payload: {'reactionInfo': _reactions}, +); + +CachedMessage _text() => CachedMessage( + id: '2', + accountId: _me, + chatId: 2, + senderId: _peer, + text: 'привет', + time: DateTime(2026, 1, 1, 12, 0).millisecondsSinceEpoch, + status: 'sent', + payload: {'reactionInfo': _reactions}, +); + +Future _pump(WidgetTester tester, CachedMessage message) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.5; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('ru'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MessageBubble( + key: const ValueKey('bubble'), + message: message, + isMe: false, + myId: _me, + chatType: 'DIALOG', + reactionAnimojiResolver: (emoji) => + Animoji(id: 1, emoji: emoji, iconUrl: 'https://example.com/a.png'), + ), + ), + ), + ); + await tester.pump(); +} + +/// Прямоугольник контейнера-бабла (самый крупный Container внутри пузыря). +Rect _bubbleRect(WidgetTester tester) { + final containers = find.descendant( + of: find.byKey(const ValueKey('bubble')), + matching: find.byType(Container), + ); + Rect? best; + for (final element in tester.elementList(containers)) { + final box = element.renderObject as RenderBox?; + if (box == null || !box.hasSize) continue; + final rect = box.localToGlobal(Offset.zero) & box.size; + final current = best; + if (current == null || + rect.height * rect.width > current.height * current.width) { + best = rect; + } + } + return best!; +} + +/// Чип реакции ищем по счётчику: сам глиф может быть анимодзи, а не текстом. +Rect _reactionRect(WidgetTester tester) { + final counter = find.descendant( + of: find.byKey(const ValueKey('bubble')), + matching: find.text('2'), + ); + expect(counter, findsOneWidget); + final chip = find + .ancestor(of: counter, matching: find.byType(Container)) + .first; + return tester.getTopLeft(chip) & tester.getSize(chip); +} + +void main() { + testWidgets('реакция под фото лежит внутри бабла', (tester) async { + await _pump(tester, _photo()); + final bubble = _bubbleRect(tester); + final chip = _reactionRect(tester); + expect( + bubble.contains(chip.topLeft) && bubble.contains(chip.bottomRight), + isTrue, + reason: 'чип реакции должен быть внутри бабла: $chip vs $bubble', + ); + }); + + testWidgets('реакция у фото с подписью тоже внутри бабла', (tester) async { + await _pump(tester, _photo(caption: 'подпись')); + final bubble = _bubbleRect(tester); + final chip = _reactionRect(tester); + expect(bubble.contains(chip.topLeft), isTrue); + expect(bubble.contains(chip.bottomRight), isTrue); + }); + + testWidgets('реакция в текстовом сообщении внутри бабла', (tester) async { + await _pump(tester, _text()); + final bubble = _bubbleRect(tester); + final chip = _reactionRect(tester); + expect(bubble.contains(chip.topLeft), isTrue); + expect(bubble.contains(chip.bottomRight), isTrue); + }); +}