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
+179 -63
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,29 +851,152 @@ 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),
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 <String>[] : [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<ChatInfoScreen>
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<ChatInfoScreen>
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<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);
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) {
@@ -1535,16 +1535,29 @@ 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: 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,
),
),
),
),
+45 -24
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,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,
),
),
),
),
],
],
),
),
);
}
+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),
);
}