From 03cfa21c75e894e9209df64acc7caac4c1555651 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Jul 2026 12:32:30 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B5=D1=89=D0=B5=20=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B5=D1=80=D1=8B..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 1 + lib/frontend/widgets/message_bubble.dart | 124 ++++-- lib/frontend/widgets/sticker_image.dart | 48 +++ lib/frontend/widgets/sticker_lottie.dart | 382 +++++++++++++++++++ lib/frontend/widgets/sticker_pack_sheet.dart | 62 +-- lib/frontend/widgets/sticker_panel.dart | 78 ++-- lib/frontend/widgets/sticker_peek.dart | 237 ++++++++++++ lib/models/attachment.dart | 6 + lib/models/sticker.dart | 5 + pubspec.lock | 12 +- pubspec.yaml | 1 + 11 files changed, 869 insertions(+), 87 deletions(-) create mode 100644 lib/frontend/widgets/sticker_image.dart create mode 100644 lib/frontend/widgets/sticker_lottie.dart create mode 100644 lib/frontend/widgets/sticker_peek.dart diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 18c55cd..b8c4a81 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5343,6 +5343,7 @@ class _ChatScreenState extends State StickerAttachment( stickerId: sticker.id.toString(), baseUrl: sticker.url, + lottieUrl: sticker.lottieUrl, width: sticker.width, height: sticker.height, ), diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index d732173..4735b97 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -26,6 +26,7 @@ import '../../core/utils/webview_support.dart'; import '../../core/config/app_link_preview.dart'; import 'custom_notification.dart'; import 'link_text.dart'; +import 'sticker_image.dart'; import '../../models/attachment.dart'; import 'poll_view.dart'; import 'photo_viewer.dart'; @@ -181,6 +182,12 @@ class MessageBubble extends StatelessWidget { return first is VideoAttachment && first.isNote; } + bool get _isSticker { + final a = message.attachments; + if (a == null || a.isEmpty) return false; + return a.first is StickerAttachment; + } + MessageType get _contentType { if (_hasShareAttachment) return _computeContentType(); return _contentTypeCache[message] ??= _computeContentType(); @@ -450,7 +457,8 @@ class MessageBubble extends StatelessWidget { final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; final keyboard = _inlineKeyboard; final isVideoNote = _isVideoNote; - final bubbleColor = isVideoNote + final noBubbleBackground = isVideoNote || _isSticker; + final bubbleColor = noBubbleBackground ? Colors.transparent : (isMe ? cs.primaryContainer : cs.surfaceContainerHighest); @@ -538,7 +546,7 @@ class MessageBubble extends StatelessWidget { constraints: BoxConstraints(maxWidth: maxBubbleWidth), decoration: BoxDecoration( color: bubbleColor, - borderRadius: isVideoNote + borderRadius: noBubbleBackground ? null : _borderRadiusFor( AppBubbleShape.current.value, @@ -2335,39 +2343,99 @@ class MessageBubble extends StatelessWidget { Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) { final url = sticker.baseUrl ?? ''; final preview = sticker.previewData ?? ''; - final imageUrl = url.isNotEmpty ? url : preview; + final staticUrl = url.isNotEmpty ? url : preview; + final lottieUrl = sticker is StickerAttachment ? sticker.lottieUrl : null; - final Widget image = ClipRRect( - borderRadius: BorderRadius.circular(photoBorderRadius), - child: Stack( - children: [ - if (imageUrl.isNotEmpty) - CachedNetworkImage( - imageUrl: imageUrl, - width: 150, - height: 150, - fit: BoxFit.contain, - memCacheWidth: 300, - memCacheHeight: 300, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (_, _, _) => - _buildPhotoPlaceholder(ctx.cs, 150, 150), - ) - else - _buildPhotoPlaceholder(ctx.cs, 150, 150), - ], - ), + Widget content = Stack( + children: [ + SizedBox( + width: 150, + height: 150, + child: StickerImage( + url: staticUrl, + lottieUrl: lottieUrl, + size: 150, + memCacheWidth: 300, + ), + ), + Positioned( + bottom: compactTimePadding, + right: compactTimePadding, + child: _buildStickerMeta(ctx), + ), + ], ); final onTap = onStickerTap; - if (onTap == null || sticker is! StickerAttachment) return image; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onTap(sticker), - child: image, + if (onTap != null && sticker is StickerAttachment) { + content = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(sticker), + child: content, + ); + } + return content; + } + + Widget _buildStickerMeta(_BubbleCtx ctx) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _clockText, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + if (isMe) ...[const SizedBox(width: 3), _buildStickerStatusIcon()], + if (message.deleted) ...[ + const SizedBox(width: 3), + const Icon(Symbols.delete, size: 12, color: Colors.white), + ], + ], + ), ); } + Widget _buildStickerStatusIcon() { + final status = overrideStatus ?? message.status; + IconData icon; + Color color; + + switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = Colors.white; + case null: + case 'sent': + icon = Symbols.check; + color = Colors.white; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF4FC3F7); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white; + } + + return Icon(icon, size: 13, color: color); + } + Widget _buildContactAttachment(_BubbleCtx ctx, MessageAttachment contact) { final contactData = contact as ContactAttachment; diff --git a/lib/frontend/widgets/sticker_image.dart b/lib/frontend/widgets/sticker_image.dart new file mode 100644 index 0000000..1e9bd55 --- /dev/null +++ b/lib/frontend/widgets/sticker_image.dart @@ -0,0 +1,48 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import 'sticker_lottie.dart'; + +class StickerImage extends StatelessWidget { + final String? url; + final String? lottieUrl; + final double? size; + final int? memCacheWidth; + + const StickerImage({ + super.key, + this.url, + this.lottieUrl, + this.size, + this.memCacheWidth, + }); + + @override + Widget build(BuildContext context) { + if (lottieUrl != null && lottieUrl!.isNotEmpty) { + return StickerLottie( + lottieUrl: lottieUrl!, + fallbackUrl: url, + size: size, + memCacheWidth: memCacheWidth, + ); + } + return _static(); + } + + Widget _static() { + final src = url ?? ''; + final blank = SizedBox(width: size, height: size); + if (src.isEmpty) return blank; + return CachedNetworkImage( + imageUrl: src, + width: size, + height: size, + fit: BoxFit.contain, + memCacheWidth: memCacheWidth, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => blank, + errorWidget: (_, _, _) => blank, + ); + } +} diff --git a/lib/frontend/widgets/sticker_lottie.dart b/lib/frontend/widgets/sticker_lottie.dart new file mode 100644 index 0000000..5701ff9 --- /dev/null +++ b/lib/frontend/widgets/sticker_lottie.dart @@ -0,0 +1,382 @@ +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:lottie/lottie.dart'; + +class StickerLoadGovernor { + StickerLoadGovernor._() { + _budgetMs = _resolveBudgetMs(); + _avgMs = _budgetMs; + SchedulerBinding.instance.addTimingsCallback(_onTimings); + } + + static final StickerLoadGovernor instance = StickerLoadGovernor._(); + + final ValueNotifier throttled = ValueNotifier(false); + double _budgetMs = 1000 / 60; + double _avgMs = 1000 / 60; + + static double _resolveBudgetMs() { + final displays = ui.PlatformDispatcher.instance.displays; + var hz = displays.isEmpty ? 60.0 : displays.first.refreshRate; + if (!hz.isFinite || hz < 30) hz = 60; + return 1000 / hz; + } + + void _onTimings(List timings) { + for (final t in timings) { + final build = t.buildDuration.inMicroseconds; + final raster = t.rasterDuration.inMicroseconds; + final ms = (build > raster ? build : raster) / 1000.0; + _avgMs = _avgMs * 0.6 + ms * 0.4; + } + final enterMs = _budgetMs * 1.5; + final exitMs = _budgetMs * 0.8; + if (!throttled.value && _avgMs > enterMs) { + throttled.value = true; + } else if (throttled.value && _avgMs < exitMs) { + throttled.value = false; + } + } +} + +class _StickerFrames { + final LottieDrawable drawable; + final int frameCount; + final Duration duration; + final int pxSize; + final List _images; + ui.Image? _lastImage; + int bytes = 0; + int lastUsed = 0; + int active = 0; + + _StickerFrames({ + required this.drawable, + required this.frameCount, + required this.duration, + required this.pxSize, + }) : _images = List.filled(frameCount, null); + + ui.Image frameAt(int index) { + final existing = _images[index]; + if (existing != null) { + _lastImage = existing; + return existing; + } + + final last = _lastImage; + if (last != null && StickerLoadGovernor.instance.throttled.value) { + return last; + } + + final progress = frameCount <= 1 ? 0.0 : index / (frameCount - 1); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + drawable.setProgress(progress); + drawable.draw( + canvas, + Rect.fromLTWH(0, 0, pxSize.toDouble(), pxSize.toDouble()), + fit: BoxFit.contain, + ); + final picture = recorder.endRecording(); + final image = picture.toImageSync(pxSize, pxSize); + picture.dispose(); + + _images[index] = image; + _lastImage = image; + final added = pxSize * pxSize * 4; + bytes += added; + _StickerFrameCache.instance._onBytesAdded(added); + return image; + } + + void dispose() { + for (final image in _images) { + image?.dispose(); + } + _images.fillRange(0, _images.length, null); + _lastImage = null; + bytes = 0; + } +} + +class _StickerFrameCache { + _StickerFrameCache._(); + static final _StickerFrameCache instance = _StickerFrameCache._(); + + static const int _maxBytes = 384 * 1024 * 1024; + static const double _fps = 30; + + final Map _entries = {}; + final Map> _loading = {}; + int _totalBytes = 0; + int _clock = 0; + + int _tick() => ++_clock; + + Future<_StickerFrames?> acquire(String url, int pxSize) async { + final key = '$url@$pxSize'; + final cached = _entries[key]; + if (cached != null) { + cached.lastUsed = _tick(); + cached.active++; + return cached; + } + final pending = _loading[key]; + if (pending != null) { + final entry = await pending; + if (entry != null) { + entry.lastUsed = _tick(); + entry.active++; + } + return entry; + } + final future = _load(url, pxSize, key); + _loading[key] = future; + final entry = await future; + _loading.remove(key); + if (entry != null) { + entry.lastUsed = _tick(); + entry.active++; + } + return entry; + } + + void release(_StickerFrames frames) { + if (frames.active > 0) frames.active--; + frames.lastUsed = _tick(); + _evictIfNeeded(); + } + + Future<_StickerFrames?> _load(String url, int pxSize, String key) async { + try { + final composition = + await NetworkLottie(url, backgroundLoading: true).load(); + final durationMs = composition.duration.inMilliseconds; + var frameCount = (durationMs / 1000 * _fps).round(); + frameCount = frameCount.clamp(1, 120); + final entry = _StickerFrames( + drawable: LottieDrawable(composition), + frameCount: frameCount, + duration: durationMs <= 0 + ? const Duration(seconds: 1) + : composition.duration, + pxSize: pxSize, + ); + _entries[key] = entry; + return entry; + } catch (_) { + return null; + } + } + + void _onBytesAdded(int bytes) { + _totalBytes += bytes; + _evictIfNeeded(); + } + + void _evictIfNeeded() { + if (_totalBytes <= _maxBytes) return; + final candidates = _entries.entries + .where((e) => e.value.active <= 0) + .toList() + ..sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); + for (final candidate in candidates) { + if (_totalBytes <= _maxBytes) break; + _totalBytes -= candidate.value.bytes; + candidate.value.dispose(); + _entries.remove(candidate.key); + } + } +} + +class StickerScrollScope extends InheritedWidget { + final ValueListenable isScrolling; + + const StickerScrollScope({ + super.key, + required this.isScrolling, + required super.child, + }); + + static ValueListenable? of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.isScrolling; + + @override + bool updateShouldNotify(StickerScrollScope oldWidget) => + !identical(oldWidget.isScrolling, isScrolling); +} + +class StickerLottie extends StatefulWidget { + final String lottieUrl; + final String? fallbackUrl; + final double? size; + final int? memCacheWidth; + + const StickerLottie({ + super.key, + required this.lottieUrl, + this.fallbackUrl, + this.size, + this.memCacheWidth, + }); + + @override + State createState() => _StickerLottieState(); +} + +class _StickerLottieState extends State + with SingleTickerProviderStateMixin { + final ValueNotifier _frameIndex = ValueNotifier(0); + late final Ticker _ticker; + _StickerFrames? _frames; + ValueListenable? _scrollState; + int? _px; + bool _started = false; + bool _showedFrames = false; + + bool get _isScrolling => _scrollState?.value ?? false; + bool get _canLoad => + !_isScrolling && !StickerLoadGovernor.instance.throttled.value; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_onTick); + StickerLoadGovernor.instance.throttled.addListener(_onGateChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final state = StickerScrollScope.of(context); + if (!identical(state, _scrollState)) { + _scrollState?.removeListener(_onGateChanged); + _scrollState = state; + _scrollState?.addListener(_onGateChanged); + } + } + + @override + void didUpdateWidget(StickerLottie oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.lottieUrl != widget.lottieUrl) { + _ticker.stop(); + final previous = _frames; + if (previous != null) _StickerFrameCache.instance.release(previous); + _frames = null; + _started = false; + _showedFrames = false; + } + } + + @override + void dispose() { + StickerLoadGovernor.instance.throttled.removeListener(_onGateChanged); + _scrollState?.removeListener(_onGateChanged); + _ticker.dispose(); + final frames = _frames; + if (frames != null) _StickerFrameCache.instance.release(frames); + _frameIndex.dispose(); + super.dispose(); + } + + void _onTick(Duration elapsed) { + final frames = _frames; + if (frames == null || frames.frameCount <= 1) return; + final periodMs = frames.duration.inMilliseconds; + if (periodMs <= 0) return; + final t = (elapsed.inMilliseconds % periodMs) / periodMs; + final index = (t * (frames.frameCount - 1)).round().clamp( + 0, + frames.frameCount - 1, + ); + if (index != _frameIndex.value) _frameIndex.value = index; + } + + void _onGateChanged() { + if (!mounted) return; + if (_isScrolling) { + if (_ticker.isActive) _ticker.stop(); + return; + } + final frames = _frames; + if (frames != null) { + if (!_ticker.isActive && frames.frameCount > 1) _ticker.start(); + } else if (_canLoad && !_started) { + _startLoad(); + } + } + + void _ensure(double box) { + if (_frames != null) return; + final dpr = MediaQuery.devicePixelRatioOf(context); + final raw = (box * dpr.clamp(1.0, 2.0)).clamp(96.0, 512.0); + _px = (raw / 32).ceil() * 32; + if (_started || !_canLoad) return; + _startLoad(); + } + + void _startLoad() { + final px = _px; + if (_started || px == null) return; + _started = true; + _StickerFrameCache.instance.acquire(widget.lottieUrl, px).then((frames) { + if (frames == null) return; + if (!mounted) { + _StickerFrameCache.instance.release(frames); + return; + } + setState(() => _frames = frames); + if (!_isScrolling && frames.frameCount > 1) _ticker.start(); + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final box = widget.size ?? + (constraints.hasBoundedWidth + ? constraints.biggest.shortestSide + : 96.0); + _ensure(box); + final frames = _frames; + if (frames == null || (_isScrolling && !_showedFrames)) { + return _fallback(box); + } + _showedFrames = true; + return ValueListenableBuilder( + valueListenable: _frameIndex, + builder: (_, index, _) => RawImage( + image: frames.frameAt(index), + width: box, + height: box, + fit: BoxFit.contain, + ), + ); + }, + ); + } + + Widget _fallback(double box) { + final url = widget.fallbackUrl ?? ''; + final blank = SizedBox(width: box, height: box); + if (url.isEmpty) return blank; + return CachedNetworkImage( + imageUrl: url, + width: box, + height: box, + fit: BoxFit.contain, + memCacheWidth: widget.memCacheWidth, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => blank, + errorWidget: (_, _, _) => blank, + ); + } +} diff --git a/lib/frontend/widgets/sticker_pack_sheet.dart b/lib/frontend/widgets/sticker_pack_sheet.dart index e03ff28..a1e8067 100644 --- a/lib/frontend/widgets/sticker_pack_sheet.dart +++ b/lib/frontend/widgets/sticker_pack_sheet.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -8,6 +7,8 @@ import '../../main.dart' show stickersModule, messagesModule; import '../../models/sticker.dart'; import '../screens/chats/forward_picker_screen.dart'; import 'custom_notification.dart'; +import 'sticker_image.dart'; +import 'sticker_peek.dart'; enum _PackAction { forward, copyLink } @@ -187,7 +188,7 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { return Column( children: [ _buildHeader(cs, set), - Expanded(child: _buildGrid(cs, set)), + Expanded(child: _buildGrid(set)), _buildActionButton(cs), ], ); @@ -265,37 +266,36 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { ); } - Widget _buildGrid(ColorScheme cs, StickerSet set) { - return GridView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - ), - itemCount: set.stickerIds.length, - itemBuilder: (context, i) { - final item = stickersModule.cachedSticker(set.stickerIds[i]); - if (item == null || item.url.isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.all(6), - child: CachedNetworkImage( - imageUrl: item.url, - fit: BoxFit.contain, - memCacheWidth: 220, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => DecoratedBox( - decoration: BoxDecoration( - color: cs.surfaceContainerHighest.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(12), + Widget _buildGrid(StickerSet set) { + return StickerPeekScope( + child: GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + ), + itemCount: set.stickerIds.length, + itemBuilder: (context, i) { + final item = stickersModule.cachedSticker(set.stickerIds[i]); + if (item == null || item.url.isEmpty) { + return const SizedBox.shrink(); + } + return StickerPeekable( + peekId: item.id, + url: item.url, + lottieUrl: item.lottieUrl, + child: Padding( + padding: const EdgeInsets.all(6), + child: StickerImage( + url: item.url, + lottieUrl: item.lottieUrl, + memCacheWidth: 220, ), ), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), - ); - }, + ); + }, + ), ); } diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart index 51c2490..a30145a 100644 --- a/lib/frontend/widgets/sticker_panel.dart +++ b/lib/frontend/widgets/sticker_panel.dart @@ -5,6 +5,9 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../main.dart' show stickersModule; import '../../models/sticker.dart'; +import 'sticker_image.dart'; +import 'sticker_lottie.dart'; +import 'sticker_peek.dart'; class _DragScrollBehavior extends MaterialScrollBehavior { const _DragScrollBehavior(); @@ -53,6 +56,7 @@ class _StickerPanelState extends State static const double _headerHeight = 34; final ScrollController _scroll = ScrollController(); + final ValueNotifier _scrolling = ValueNotifier(false); late final AnimationController _shimmer; bool _loading = true; Object? _error; @@ -76,6 +80,7 @@ class _StickerPanelState extends State void dispose() { _scroll.removeListener(_onScroll); _scroll.dispose(); + _scrolling.dispose(); _shimmer.dispose(); super.dispose(); } @@ -130,6 +135,15 @@ class _StickerPanelState extends State if (index != _selectedTab) setState(() => _selectedTab = index); } + bool _onScrollNotification(ScrollNotification n) { + if (n is ScrollStartNotification || n is ScrollUpdateNotification) { + if (!_scrolling.value) _scrolling.value = true; + } else if (n is ScrollEndNotification) { + if (_scrolling.value) _scrolling.value = false; + } + return false; + } + void _jumpTo(int index) { if (index < 0 || index >= _offsets.length) return; setState(() => _selectedTab = index); @@ -192,20 +206,30 @@ class _StickerPanelState extends State color: cs.outlineVariant.withValues(alpha: 0.3), ), Expanded( - child: ListView.builder( - controller: _scroll, - padding: EdgeInsets.zero, - itemCount: _sections.length, - itemExtentBuilder: (i, _) => _heights[i], - itemBuilder: (context, i) => _StickerSection( - key: ValueKey(_sections[i].title + i.toString()), - title: _sections[i].title, - stickerIds: _sections[i].stickerIds, - columns: columns, - cell: cell, - headerHeight: _headerHeight, - shimmer: _shimmer, - onTap: widget.onStickerTap, + child: StickerScrollScope( + isScrolling: _scrolling, + child: StickerPeekScope( + child: NotificationListener( + onNotification: _onScrollNotification, + child: ListView.builder( + controller: _scroll, + padding: EdgeInsets.zero, + itemCount: _sections.length, + itemExtentBuilder: (i, _) => _heights[i], + itemBuilder: (context, i) => _StickerSection( + key: ValueKey( + _sections[i].title + i.toString(), + ), + title: _sections[i].title, + stickerIds: _sections[i].stickerIds, + columns: columns, + cell: cell, + headerHeight: _headerHeight, + shimmer: _shimmer, + onTap: widget.onStickerTap, + ), + ), + ), ), ), ), @@ -350,18 +374,20 @@ class _StickerSectionState extends State<_StickerSection> { } final item = stickersModule.cachedSticker(id); if (item == null || item.url.isEmpty) return const SizedBox.shrink(); - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => widget.onTap(item), - child: Padding( - padding: const EdgeInsets.all(6), - child: CachedNetworkImage( - imageUrl: item.url, - fit: BoxFit.contain, - memCacheWidth: 220, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => _ShimmerBox(shimmer: widget.shimmer), - errorWidget: (_, _, _) => const SizedBox.shrink(), + return StickerPeekable( + peekId: item.id, + url: item.url, + lottieUrl: item.lottieUrl, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onTap(item), + child: Padding( + padding: const EdgeInsets.all(6), + child: StickerImage( + url: item.url, + lottieUrl: item.lottieUrl, + memCacheWidth: 220, + ), ), ), ); diff --git a/lib/frontend/widgets/sticker_peek.dart b/lib/frontend/widgets/sticker_peek.dart new file mode 100644 index 0000000..a32147c --- /dev/null +++ b/lib/frontend/widgets/sticker_peek.dart @@ -0,0 +1,237 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../core/utils/haptics.dart'; +import 'sticker_image.dart'; + +class _PeekData { + final String? url; + final String? lottieUrl; + + const _PeekData(this.url, this.lottieUrl); +} + +class StickerPeekScope extends StatefulWidget { + final Widget child; + + const StickerPeekScope({super.key, required this.child}); + + static StickerPeekScopeState? of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_StickerPeekInherited>() + ?.state; + } + + @override + State createState() => StickerPeekScopeState(); +} + +class StickerPeekScopeState extends State + with SingleTickerProviderStateMixin { + final Set _cells = {}; + final ValueNotifier<_PeekData?> _current = ValueNotifier(null); + late final AnimationController _anim; + OverlayEntry? _entry; + Object? _currentId; + bool _disposed = false; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 170), + ); + } + + @override + void dispose() { + _disposed = true; + _entry?.remove(); + _entry = null; + _anim.dispose(); + _current.dispose(); + super.dispose(); + } + + void register(StickerPeekableState cell) => _cells.add(cell); + void unregister(StickerPeekableState cell) => _cells.remove(cell); + + StickerPeekableState? _hitTest(Offset globalPos) { + for (final cell in _cells) { + final rect = cell.globalRect; + if (rect != null && rect.contains(globalPos)) return cell; + } + return null; + } + + void _start(Offset globalPos) { + final cell = _hitTest(globalPos); + if (cell == null) return; + _currentId = cell.widget.peekId; + _current.value = _PeekData(cell.widget.url, cell.widget.lottieUrl); + _showEntry(); + _anim.forward(from: 0); + Haptics.medium(); + } + + void _update(Offset globalPos) { + if (_entry == null) return; + final cell = _hitTest(globalPos); + if (cell == null || cell.widget.peekId == _currentId) return; + _currentId = cell.widget.peekId; + _current.value = _PeekData(cell.widget.url, cell.widget.lottieUrl); + Haptics.selection(); + } + + void _end() { + if (_entry == null) return; + _anim.reverse().whenComplete(_removeEntry); + } + + void _showEntry() { + if (_entry != null) return; + final entry = OverlayEntry( + builder: (_) => _PeekOverlay(anim: _anim, data: _current), + ); + _entry = entry; + Overlay.of(context, rootOverlay: true).insert(entry); + } + + void _removeEntry() { + if (_disposed) return; + _entry?.remove(); + _entry = null; + _currentId = null; + _current.value = null; + } + + @override + Widget build(BuildContext context) { + return _StickerPeekInherited( + state: this, + child: GestureDetector( + onLongPressStart: (d) => _start(d.globalPosition), + onLongPressMoveUpdate: (d) => _update(d.globalPosition), + onLongPressEnd: (_) => _end(), + onLongPressCancel: _end, + child: widget.child, + ), + ); + } +} + +class _StickerPeekInherited extends InheritedWidget { + final StickerPeekScopeState state; + + const _StickerPeekInherited({required this.state, required super.child}); + + @override + bool updateShouldNotify(_StickerPeekInherited oldWidget) => + state != oldWidget.state; +} + +class StickerPeekable extends StatefulWidget { + final Object peekId; + final String? url; + final String? lottieUrl; + final Widget child; + + const StickerPeekable({ + super.key, + required this.peekId, + this.url, + this.lottieUrl, + required this.child, + }); + + @override + State createState() => StickerPeekableState(); +} + +class StickerPeekableState extends State { + StickerPeekScopeState? _scope; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scope = StickerPeekScope.of(context); + if (scope != _scope) { + _scope?.unregister(this); + _scope = scope; + _scope?.register(this); + } + } + + @override + void dispose() { + _scope?.unregister(this); + super.dispose(); + } + + Rect? get globalRect { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return null; + return box.localToGlobal(Offset.zero) & box.size; + } + + @override + Widget build(BuildContext context) => widget.child; +} + +class _PeekOverlay extends StatelessWidget { + final Animation anim; + final ValueListenable<_PeekData?> data; + + const _PeekOverlay({required this.anim, required this.data}); + + @override + Widget build(BuildContext context) { + final previewSize = MediaQuery.sizeOf(context).shortestSide * 0.66; + return IgnorePointer( + child: AnimatedBuilder( + animation: anim, + builder: (context, _) { + final t = Curves.easeOut.transform(anim.value.clamp(0.0, 1.0)); + return Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20 * t, sigmaY: 20 * t), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.3 * t), + ), + ), + ), + Center( + child: Opacity( + opacity: t, + child: Transform.scale( + scale: 0.8 + 0.2 * t, + child: ValueListenableBuilder<_PeekData?>( + valueListenable: data, + builder: (context, d, _) { + if (d == null) return const SizedBox.shrink(); + return SizedBox( + width: previewSize, + height: previewSize, + child: StickerImage( + url: d.url, + lottieUrl: d.lottieUrl, + size: previewSize, + ), + ); + }, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index ef88181..13d202f 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -285,6 +285,7 @@ class FileAttachment extends MessageAttachment { class StickerAttachment extends MessageAttachment { final String? stickerId; final String? stickerPackId; + final String? lottieUrl; final int? width; final int? height; @@ -294,16 +295,20 @@ class StickerAttachment extends MessageAttachment { super.fileUrl, this.stickerId, this.stickerPackId, + this.lottieUrl, this.width, this.height, }) : super(type: AttachmentType.sticker); + bool get isAnimated => lottieUrl != null && lottieUrl!.isNotEmpty; + factory StickerAttachment.fromMap(Map map) { return StickerAttachment( previewData: decodeAttachPreview(map['previewData']), baseUrl: (map['url'] ?? map['baseUrl'])?.toString(), stickerId: map['stickerId']?.toString(), stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(), + lottieUrl: map['lottieUrl']?.toString(), width: map['width'] as int?, height: map['height'] as int?, ); @@ -316,6 +321,7 @@ class StickerAttachment extends MessageAttachment { 'baseUrl': baseUrl, 'stickerId': stickerId, 'stickerPackId': stickerPackId, + 'lottieUrl': lottieUrl, 'width': width, 'height': height, }; diff --git a/lib/models/sticker.dart b/lib/models/sticker.dart index 58ffba5..9ef51a3 100644 --- a/lib/models/sticker.dart +++ b/lib/models/sticker.dart @@ -34,6 +34,7 @@ class StickerSet { class StickerItem { final int id; final String url; + final String? lottieUrl; final int? setId; final int? width; final int? height; @@ -41,14 +42,18 @@ class StickerItem { const StickerItem({ required this.id, required this.url, + this.lottieUrl, this.setId, this.width, this.height, }); + bool get isAnimated => lottieUrl != null && lottieUrl!.isNotEmpty; + factory StickerItem.fromMap(Map map) => StickerItem( id: map['id'] as int, url: map['url']?.toString() ?? '', + lottieUrl: map['lottieUrl']?.toString(), setId: map['setId'] as int?, width: map['width'] as int?, height: map['height'] as int?, diff --git a/pubspec.lock b/pubspec.lock index c2c814e..cd24665 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -789,6 +789,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: "9f050d75e94e783537454b4f14a10f690fc24e83dab6520ef5a4bb185b6d6da3" + url: "https://pub.dev" + source: hosted + version: "3.4.0" m3e_collection: dependency: "direct main" description: @@ -1683,5 +1691,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index d6a818b..6f93305 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 cached_network_image: ^3.4.1 + lottie: ^3.3.1 path_provider: ^2.1.4 share_plus: ^10.1.4 open_filex: ^4.5.0