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/config/app_stories.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/utils/format.dart';
|
import '../../../core/utils/format.dart';
|
||||||
|
import '../../../core/utils/logger.dart';
|
||||||
import '../../../core/utils/haptics.dart';
|
import '../../../core/utils/haptics.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../../models/chat_info.dart';
|
import '../../../models/chat_info.dart';
|
||||||
@@ -157,6 +158,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
List<String> _avatarPages = const [];
|
List<String> _avatarPages = const [];
|
||||||
int _avatarIndex = 0;
|
int _avatarIndex = 0;
|
||||||
int _avatarTotal = 0;
|
int _avatarTotal = 0;
|
||||||
|
bool _avatarHover = false;
|
||||||
|
bool _avatarHistoryBusy = false;
|
||||||
|
bool _avatarHistoryLoaded = false;
|
||||||
|
|
||||||
double _headerDelta = 0;
|
double _headerDelta = 0;
|
||||||
bool _expandArmed = false;
|
bool _expandArmed = false;
|
||||||
@@ -627,7 +631,13 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
|
|
||||||
Widget _buildMorphHeader(BuildContext context, ColorScheme cs, double t) {
|
Widget _buildMorphHeader(BuildContext context, ColorScheme cs, double t) {
|
||||||
final topPad = MediaQuery.paddingOf(context).top;
|
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 iconColor = Color.lerp(cs.onSurface, Colors.white, t)!;
|
||||||
final nameColor = Color.lerp(cs.onSurface, Colors.white, t)!;
|
final nameColor = Color.lerp(cs.onSurface, Colors.white, t)!;
|
||||||
final subColor = Color.lerp(
|
final subColor = Color.lerp(
|
||||||
@@ -841,29 +851,152 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
|
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: _avatarKey,
|
key: _avatarKey,
|
||||||
child: ProfileHeroAvatar(
|
child: GestureDetector(
|
||||||
tag: widget.heroTag,
|
onTap: expanded ? openHistory : (openStories ?? openHistory),
|
||||||
size: _headerAvatarSize,
|
onLongPress: expanded
|
||||||
child: GestureDetector(
|
? null
|
||||||
onTap: expanded ? openHistory : (openStories ?? openHistory),
|
: (openStories == null ? null : openHistory),
|
||||||
onLongPress: expanded ? null : (openStories == null ? null : openHistory),
|
child: Stack(
|
||||||
child: ClipRRect(
|
fit: StackFit.expand,
|
||||||
borderRadius: BorderRadius.circular(radius),
|
children: [
|
||||||
child: _headerAvatarContent(cs, t),
|
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) {
|
if (_peerDeleted) {
|
||||||
return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52);
|
return _ghostAvatar(radius: _headerAvatarSize / 2, fontSize: 52);
|
||||||
}
|
}
|
||||||
final pages = _avatarPages.isNotEmpty
|
final url = _avatarPages.isNotEmpty ? _avatarPages.first : widget.imageUrl;
|
||||||
? _avatarPages
|
if (url.isEmpty) {
|
||||||
: (widget.imageUrl.isEmpty ? const <String>[] : [widget.imageUrl]);
|
|
||||||
if (pages.isEmpty) {
|
|
||||||
return KometAvatar(
|
return KometAvatar(
|
||||||
name: widget.name,
|
name: widget.name,
|
||||||
size: _headerAvatarSize,
|
size: _headerAvatarSize,
|
||||||
@@ -871,29 +1004,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
fadeIn: false,
|
fadeIn: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return PageView.builder(
|
return _avatarPhoto(cs, url);
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refreshUnreadStories() {
|
void _refreshUnreadStories() {
|
||||||
@@ -1254,30 +1365,27 @@ class _ChatInfoScreenState extends State<ChatInfoScreen>
|
|||||||
btns = [chatBtn, muteBtn, leaveBtn];
|
btns = [chatBtn, muteBtn, leaveBtn];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
for (int i = 0; i < btns.length; i++) ...[
|
||||||
children: [
|
_actionBtn(cs, btns[i].icon, btns[i].label, btns[i].onTap),
|
||||||
for (int i = 0; i < btns.length; i++) ...[
|
if (i < btns.length - 1) const SizedBox(width: 8),
|
||||||
_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<ChatInfoScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAvatarHistory(int peerId) async {
|
Future<void> _loadAvatarHistory(int peerId) async {
|
||||||
if (!_headerHasPhoto) return;
|
if (!_headerHasPhoto || _avatarHistoryBusy) return;
|
||||||
|
_avatarHistoryBusy = true;
|
||||||
final cached = ContactsModule.cachedPhotos(peerId);
|
final cached = ContactsModule.cachedPhotos(peerId);
|
||||||
if (cached != null) _applyAvatarPhotos(cached);
|
if (cached != null) _applyAvatarPhotos(cached);
|
||||||
final photos = await ContactsModule.fetchPhotos(api, peerId, count: 30);
|
try {
|
||||||
if (!mounted) return;
|
final photos = await ContactsModule.fetchPhotos(api, peerId, count: 30);
|
||||||
_applyAvatarPhotos(photos);
|
if (!mounted) return;
|
||||||
|
_avatarHistoryLoaded = true;
|
||||||
|
_applyAvatarPhotos(photos);
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('Не удалось получить историю аватарок $peerId: $e');
|
||||||
|
} finally {
|
||||||
|
_avatarHistoryBusy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyAvatarPhotos(ContactPhotos photos) {
|
void _applyAvatarPhotos(ContactPhotos photos) {
|
||||||
|
|||||||
@@ -1535,16 +1535,29 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => _openStories(0),
|
onTap: () => _openStories(0),
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
width: 50 * (1.0 - _pullRatio),
|
width:
|
||||||
height: 32,
|
(FoldedStoryStack.widthFor(
|
||||||
margin: const EdgeInsets.only(
|
storiesModule
|
||||||
right: 8,
|
.previews
|
||||||
),
|
.length,
|
||||||
child: FoldedStoryStack(
|
) +
|
||||||
previews:
|
8) *
|
||||||
storiesModule.previews,
|
(1.0 - _pullRatio),
|
||||||
opacity: 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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -388,6 +388,19 @@ class _StorySelfTileState extends State<StorySelfTile> {
|
|||||||
|
|
||||||
/// Свёрнутая мини-стопка колец, показывается в заголовке при закрытом доке.
|
/// Свёрнутая мини-стопка колец, показывается в заголовке при закрытом доке.
|
||||||
class FoldedStoryStack extends StatelessWidget {
|
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 List<StoryPreview> previews;
|
||||||
final double opacity;
|
final double opacity;
|
||||||
|
|
||||||
@@ -400,35 +413,43 @@ class FoldedStoryStack extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final shown = previews.take(3).toList();
|
final shown = previews.take(maxShown).toList();
|
||||||
return Opacity(
|
return Opacity(
|
||||||
opacity: opacity.clamp(0.0, 1.0),
|
opacity: opacity.clamp(0.0, 1.0),
|
||||||
child: Stack(
|
child: SizedBox(
|
||||||
children: [
|
height: outerSize,
|
||||||
for (var i = 0; i < shown.length; i++)
|
width: widthFor(shown.length),
|
||||||
Positioned(
|
child: Stack(
|
||||||
left: i * 14.0,
|
clipBehavior: Clip.none,
|
||||||
child: StoryOwnerBuilder(
|
children: [
|
||||||
owner: shown[i].owner,
|
for (var i = 0; i < shown.length; i++)
|
||||||
builder: (context, info) => Container(
|
Positioned(
|
||||||
padding: const EdgeInsets.all(1.5),
|
left: i * step,
|
||||||
decoration: BoxDecoration(
|
top: 0,
|
||||||
shape: BoxShape.circle,
|
child: StoryOwnerBuilder(
|
||||||
color: cs.surface,
|
owner: shown[i].owner,
|
||||||
border: Border.all(
|
builder: (context, info) => Container(
|
||||||
color: shown[i].hasUnread ? cs.primary : cs.outlineVariant,
|
padding: const EdgeInsets.all(_gap),
|
||||||
width: 1.5,
|
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -844,7 +844,14 @@ class MessageBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final shape = _computeShape();
|
final shape = _computeShape();
|
||||||
final hasPhotoCap = _computeHasPhotoWithCaption();
|
final hasReactions = _hasReactions();
|
||||||
|
final hasPhotoCap =
|
||||||
|
_computeHasPhotoWithCaption() ||
|
||||||
|
(contentType == MessageType.attachment &&
|
||||||
|
hasReactions &&
|
||||||
|
!_isSticker &&
|
||||||
|
!_isVideoNote &&
|
||||||
|
_jumboAnimojiUrls == null);
|
||||||
final hasMultiPhotos = _computeHasMultiplePhotosNoCaption();
|
final hasMultiPhotos = _computeHasMultiplePhotosNoCaption();
|
||||||
final textColor = bubbleTextColor(context);
|
final textColor = bubbleTextColor(context);
|
||||||
|
|
||||||
@@ -901,8 +908,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: _buildContent(makeCtx());
|
: _buildContent(makeCtx());
|
||||||
|
|
||||||
final reactionsUnder = _reactionsUnderBubble(contentType);
|
final reactionsInside = contentType != MessageType.text;
|
||||||
final reactionsInside = contentType != MessageType.text && !reactionsUnder;
|
|
||||||
|
|
||||||
final reply = message.replyInfo;
|
final reply = message.replyInfo;
|
||||||
|
|
||||||
@@ -915,7 +921,15 @@ class MessageBubble extends StatelessWidget {
|
|||||||
? Column(
|
? Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
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;
|
: bubbleContent;
|
||||||
|
|
||||||
@@ -1031,7 +1045,6 @@ class MessageBubble extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
bubbleBox,
|
bubbleBox,
|
||||||
if (reactionsUnder) _reactionsBar(cs),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1267,25 +1280,22 @@ class MessageBubble extends StatelessWidget {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _reactionsUnderBubble(MessageType contentType) {
|
bool _hasReactions() {
|
||||||
if (contentType != MessageType.attachment) return false;
|
final info = ReactionInfo.fromMap(_resolveReactionInfo());
|
||||||
final attachments = message.attachments;
|
return info != null && info.counters.isNotEmpty;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _reactionsBar(ColorScheme cs) {
|
Widget _reactionsBar(ColorScheme cs, {required EdgeInsets inset}) {
|
||||||
final listenable = reactionsListenable;
|
final listenable = reactionsListenable;
|
||||||
if (listenable != null) {
|
if (listenable != null) {
|
||||||
return ValueListenableBuilder<Map<String, dynamic>?>(
|
return ValueListenableBuilder<Map<String, dynamic>?>(
|
||||||
valueListenable: listenable,
|
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) {
|
Widget _buildContent(BubbleContext ctx) {
|
||||||
@@ -1386,16 +1396,15 @@ class MessageBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildReactionsBar(ColorScheme cs) {
|
Widget _buildReactionsBarFor(
|
||||||
final info = message.payload?['reactionInfo'];
|
ColorScheme cs,
|
||||||
return _buildReactionsBarFor(cs, info is Map ? info : null);
|
Map? info, {
|
||||||
}
|
EdgeInsets inset = const EdgeInsets.only(top: 4),
|
||||||
|
}) {
|
||||||
Widget _buildReactionsBarFor(ColorScheme cs, Map? info) {
|
|
||||||
final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info));
|
final chips = _buildReactionChipsFor(cs, ReactionInfo.fromMap(info));
|
||||||
if (chips.isEmpty) return const SizedBox.shrink();
|
if (chips.isEmpty) return const SizedBox.shrink();
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: inset,
|
||||||
child: Wrap(spacing: 4, runSpacing: 4, children: chips),
|
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