feat: теперь все эмоции внутри бабла, сделал возможность листать авы, и пофиксил падинг

This commit is contained in:
Jganenokk
2026-08-05 20:58:24 +07:00
parent 3915cb957e
commit 47fe58b852
5 changed files with 413 additions and 120 deletions
+150 -34
View File
@@ -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<ChatInfoScreen>
List<String> _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<ChatInfoScreen>
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,45 +851,131 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
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),
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, t),
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) {
if (_peerDeleted) {
return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52);
}
final pages = _avatarPages.isNotEmpty
? _avatarPages
: (widget.imageUrl.isEmpty ? const <String>[] : [widget.imageUrl]);
if (pages.isEmpty) {
return KometAvatar(
name: widget.name,
size: _headerAvatarSize,
fontSize: 36,
fadeIn: false,
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,
);
}
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],
Widget _avatarPhoto(ColorScheme cs, String url) {
return CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
memCacheWidth: _headerEverExpanded ? 720 : 288,
fadeInDuration: const Duration(milliseconds: 150),
@@ -892,10 +988,25 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
),
),
),
),
);
}
Widget _headerAvatarContent(ColorScheme cs) {
if (_peerDeleted) {
return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52);
}
final url = _avatarPages.isNotEmpty ? _avatarPages.first : widget.imageUrl;
if (url.isEmpty) {
return KometAvatar(
name: widget.name,
size: _headerAvatarSize,
fontSize: 36,
fadeIn: false,
);
}
return _avatarPhoto(cs, url);
}
void _refreshUnreadStories() {
final preview = _storyPreview;
if (preview == null || preview.unreadCount <= 0) {
@@ -1254,9 +1365,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
btns = [chatBtn, muteBtn, leaveBtn];
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
@@ -1277,7 +1386,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
),
],
],
),
);
}
@@ -2489,12 +2597,20 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
}
Future<void> _loadAvatarHistory(int peerId) async {
if (!_headerHasPhoto) return;
if (!_headerHasPhoto || _avatarHistoryBusy) return;
_avatarHistoryBusy = true;
final cached = ContactsModule.cachedPhotos(peerId);
if (cached != null) _applyAvatarPhotos(cached);
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) {
@@ -1535,11 +1535,23 @@ class _ChatListScreenState extends State<ChatListScreen>
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openStories(0),
child: Container(
width: 50 * (1.0 - _pullRatio),
height: 32,
margin: const EdgeInsets.only(
right: 8,
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:
@@ -1549,6 +1561,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
),
),
),
Flexible(
child: Text(
connectionStatusLabel(
+27 -6
View File
@@ -388,6 +388,19 @@ class _StorySelfTileState extends State<StorySelfTile> {
/// Свёрнутая мини-стопка колец, показывается в заголовке при закрытом доке.
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<StoryPreview> previews;
final double opacity;
@@ -400,29 +413,36 @@ 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: SizedBox(
height: outerSize,
width: widthFor(shown.length),
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < shown.length; i++)
Positioned(
left: i * 14.0,
left: i * step,
top: 0,
child: StoryOwnerBuilder(
owner: shown[i].owner,
builder: (context, info) => Container(
padding: const EdgeInsets.all(1.5),
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: 1.5,
color: shown[i].hasUnread
? cs.primary
: cs.outlineVariant,
width: _rim,
),
),
child: KometAvatar(
name: info?.name.isNotEmpty == true ? info!.name : '?',
size: 28,
size: avatarSize,
imageUrl: info?.avatarUrl,
),
),
@@ -430,6 +450,7 @@ class FoldedStoryStack extends StatelessWidget {
),
],
),
),
);
}
}
+32 -23
View File
@@ -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<PhotoAttachment>().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<Map<String, dynamic>?>(
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),
);
}
+134
View File
@@ -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<String, dynamic> 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<void> _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);
});
}