feat: теперь все эмоции внутри бабла, сделал возможность листать авы, и пофиксил падинг
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user