feat: еще стикеры..

This commit is contained in:
Jganenokk
2026-07-03 12:32:30 +07:00
parent a0f4acf4b5
commit 03cfa21c75
11 changed files with 869 additions and 87 deletions
@@ -5343,6 +5343,7 @@ class _ChatScreenState extends State<ChatScreen>
StickerAttachment(
stickerId: sticker.id.toString(),
baseUrl: sticker.url,
lottieUrl: sticker.lottieUrl,
width: sticker.width,
height: sticker.height,
),
+96 -28
View File
@@ -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;
+48
View File
@@ -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,
);
}
}
+382
View File
@@ -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<bool> 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<FrameTiming> 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<ui.Image?> _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<ui.Image?>.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<String, _StickerFrames> _entries = {};
final Map<String, Future<_StickerFrames?>> _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<bool> isScrolling;
const StickerScrollScope({
super.key,
required this.isScrolling,
required super.child,
});
static ValueListenable<bool>? of(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<StickerScrollScope>()
?.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<StickerLottie> createState() => _StickerLottieState();
}
class _StickerLottieState extends State<StickerLottie>
with SingleTickerProviderStateMixin {
final ValueNotifier<int> _frameIndex = ValueNotifier(0);
late final Ticker _ticker;
_StickerFrames? _frames;
ValueListenable<bool>? _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<int>(
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,
);
}
}
+31 -31
View File
@@ -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(),
),
);
},
);
},
),
);
}
+52 -26
View File
@@ -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<StickerPanel>
static const double _headerHeight = 34;
final ScrollController _scroll = ScrollController();
final ValueNotifier<bool> _scrolling = ValueNotifier(false);
late final AnimationController _shimmer;
bool _loading = true;
Object? _error;
@@ -76,6 +80,7 @@ class _StickerPanelState extends State<StickerPanel>
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
_scrolling.dispose();
_shimmer.dispose();
super.dispose();
}
@@ -130,6 +135,15 @@ class _StickerPanelState extends State<StickerPanel>
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<StickerPanel>
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<ScrollNotification>(
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,
),
),
),
);
+237
View File
@@ -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<StickerPeekScope> createState() => StickerPeekScopeState();
}
class StickerPeekScopeState extends State<StickerPeekScope>
with SingleTickerProviderStateMixin {
final Set<StickerPeekableState> _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<StickerPeekable> createState() => StickerPeekableState();
}
class StickerPeekableState extends State<StickerPeekable> {
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<double> 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,
),
);
},
),
),
),
),
],
);
},
),
);
}
}
+6
View File
@@ -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<String, dynamic> 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,
};
+5
View File
@@ -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<dynamic, dynamic> 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?,
+10 -2
View File
@@ -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"
+1
View File
@@ -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