feat: еще стикеры..
This commit is contained in:
@@ -5343,6 +5343,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
StickerAttachment(
|
StickerAttachment(
|
||||||
stickerId: sticker.id.toString(),
|
stickerId: sticker.id.toString(),
|
||||||
baseUrl: sticker.url,
|
baseUrl: sticker.url,
|
||||||
|
lottieUrl: sticker.lottieUrl,
|
||||||
width: sticker.width,
|
width: sticker.width,
|
||||||
height: sticker.height,
|
height: sticker.height,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import '../../core/utils/webview_support.dart';
|
|||||||
import '../../core/config/app_link_preview.dart';
|
import '../../core/config/app_link_preview.dart';
|
||||||
import 'custom_notification.dart';
|
import 'custom_notification.dart';
|
||||||
import 'link_text.dart';
|
import 'link_text.dart';
|
||||||
|
import 'sticker_image.dart';
|
||||||
import '../../models/attachment.dart';
|
import '../../models/attachment.dart';
|
||||||
import 'poll_view.dart';
|
import 'poll_view.dart';
|
||||||
import 'photo_viewer.dart';
|
import 'photo_viewer.dart';
|
||||||
@@ -181,6 +182,12 @@ class MessageBubble extends StatelessWidget {
|
|||||||
return first is VideoAttachment && first.isNote;
|
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 {
|
MessageType get _contentType {
|
||||||
if (_hasShareAttachment) return _computeContentType();
|
if (_hasShareAttachment) return _computeContentType();
|
||||||
return _contentTypeCache[message] ??= _computeContentType();
|
return _contentTypeCache[message] ??= _computeContentType();
|
||||||
@@ -450,7 +457,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
|
final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75;
|
||||||
final keyboard = _inlineKeyboard;
|
final keyboard = _inlineKeyboard;
|
||||||
final isVideoNote = _isVideoNote;
|
final isVideoNote = _isVideoNote;
|
||||||
final bubbleColor = isVideoNote
|
final noBubbleBackground = isVideoNote || _isSticker;
|
||||||
|
final bubbleColor = noBubbleBackground
|
||||||
? Colors.transparent
|
? Colors.transparent
|
||||||
: (isMe ? cs.primaryContainer : cs.surfaceContainerHighest);
|
: (isMe ? cs.primaryContainer : cs.surfaceContainerHighest);
|
||||||
|
|
||||||
@@ -538,7 +546,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bubbleColor,
|
color: bubbleColor,
|
||||||
borderRadius: isVideoNote
|
borderRadius: noBubbleBackground
|
||||||
? null
|
? null
|
||||||
: _borderRadiusFor(
|
: _borderRadiusFor(
|
||||||
AppBubbleShape.current.value,
|
AppBubbleShape.current.value,
|
||||||
@@ -2335,39 +2343,99 @@ class MessageBubble extends StatelessWidget {
|
|||||||
Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) {
|
Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) {
|
||||||
final url = sticker.baseUrl ?? '';
|
final url = sticker.baseUrl ?? '';
|
||||||
final preview = sticker.previewData ?? '';
|
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(
|
Widget content = Stack(
|
||||||
borderRadius: BorderRadius.circular(photoBorderRadius),
|
children: [
|
||||||
child: Stack(
|
SizedBox(
|
||||||
children: [
|
width: 150,
|
||||||
if (imageUrl.isNotEmpty)
|
height: 150,
|
||||||
CachedNetworkImage(
|
child: StickerImage(
|
||||||
imageUrl: imageUrl,
|
url: staticUrl,
|
||||||
width: 150,
|
lottieUrl: lottieUrl,
|
||||||
height: 150,
|
size: 150,
|
||||||
fit: BoxFit.contain,
|
memCacheWidth: 300,
|
||||||
memCacheWidth: 300,
|
),
|
||||||
memCacheHeight: 300,
|
),
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
Positioned(
|
||||||
errorWidget: (_, _, _) =>
|
bottom: compactTimePadding,
|
||||||
_buildPhotoPlaceholder(ctx.cs, 150, 150),
|
right: compactTimePadding,
|
||||||
)
|
child: _buildStickerMeta(ctx),
|
||||||
else
|
),
|
||||||
_buildPhotoPlaceholder(ctx.cs, 150, 150),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final onTap = onStickerTap;
|
final onTap = onStickerTap;
|
||||||
if (onTap == null || sticker is! StickerAttachment) return image;
|
if (onTap != null && sticker is StickerAttachment) {
|
||||||
return GestureDetector(
|
content = GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => onTap(sticker),
|
onTap: () => onTap(sticker),
|
||||||
child: image,
|
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) {
|
Widget _buildContactAttachment(_BubbleCtx ctx, MessageAttachment contact) {
|
||||||
final contactData = contact as ContactAttachment;
|
final contactData = contact as ContactAttachment;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -8,6 +7,8 @@ import '../../main.dart' show stickersModule, messagesModule;
|
|||||||
import '../../models/sticker.dart';
|
import '../../models/sticker.dart';
|
||||||
import '../screens/chats/forward_picker_screen.dart';
|
import '../screens/chats/forward_picker_screen.dart';
|
||||||
import 'custom_notification.dart';
|
import 'custom_notification.dart';
|
||||||
|
import 'sticker_image.dart';
|
||||||
|
import 'sticker_peek.dart';
|
||||||
|
|
||||||
enum _PackAction { forward, copyLink }
|
enum _PackAction { forward, copyLink }
|
||||||
|
|
||||||
@@ -187,7 +188,7 @@ class _StickerPackSheetState extends State<_StickerPackSheet> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(cs, set),
|
_buildHeader(cs, set),
|
||||||
Expanded(child: _buildGrid(cs, set)),
|
Expanded(child: _buildGrid(set)),
|
||||||
_buildActionButton(cs),
|
_buildActionButton(cs),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -265,37 +266,36 @@ class _StickerPackSheetState extends State<_StickerPackSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGrid(ColorScheme cs, StickerSet set) {
|
Widget _buildGrid(StickerSet set) {
|
||||||
return GridView.builder(
|
return StickerPeekScope(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
child: GridView.builder(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
crossAxisCount: 4,
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
mainAxisSpacing: 4,
|
crossAxisCount: 4,
|
||||||
crossAxisSpacing: 4,
|
mainAxisSpacing: 4,
|
||||||
),
|
crossAxisSpacing: 4,
|
||||||
itemCount: set.stickerIds.length,
|
),
|
||||||
itemBuilder: (context, i) {
|
itemCount: set.stickerIds.length,
|
||||||
final item = stickersModule.cachedSticker(set.stickerIds[i]);
|
itemBuilder: (context, i) {
|
||||||
if (item == null || item.url.isEmpty) {
|
final item = stickersModule.cachedSticker(set.stickerIds[i]);
|
||||||
return const SizedBox.shrink();
|
if (item == null || item.url.isEmpty) {
|
||||||
}
|
return const SizedBox.shrink();
|
||||||
return Padding(
|
}
|
||||||
padding: const EdgeInsets.all(6),
|
return StickerPeekable(
|
||||||
child: CachedNetworkImage(
|
peekId: item.id,
|
||||||
imageUrl: item.url,
|
url: item.url,
|
||||||
fit: BoxFit.contain,
|
lottieUrl: item.lottieUrl,
|
||||||
memCacheWidth: 220,
|
child: Padding(
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
padding: const EdgeInsets.all(6),
|
||||||
placeholder: (_, _) => DecoratedBox(
|
child: StickerImage(
|
||||||
decoration: BoxDecoration(
|
url: item.url,
|
||||||
color: cs.surfaceContainerHighest.withValues(alpha: 0.4),
|
lottieUrl: item.lottieUrl,
|
||||||
borderRadius: BorderRadius.circular(12),
|
memCacheWidth: 220,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
|
|
||||||
import '../../main.dart' show stickersModule;
|
import '../../main.dart' show stickersModule;
|
||||||
import '../../models/sticker.dart';
|
import '../../models/sticker.dart';
|
||||||
|
import 'sticker_image.dart';
|
||||||
|
import 'sticker_lottie.dart';
|
||||||
|
import 'sticker_peek.dart';
|
||||||
|
|
||||||
class _DragScrollBehavior extends MaterialScrollBehavior {
|
class _DragScrollBehavior extends MaterialScrollBehavior {
|
||||||
const _DragScrollBehavior();
|
const _DragScrollBehavior();
|
||||||
@@ -53,6 +56,7 @@ class _StickerPanelState extends State<StickerPanel>
|
|||||||
static const double _headerHeight = 34;
|
static const double _headerHeight = 34;
|
||||||
|
|
||||||
final ScrollController _scroll = ScrollController();
|
final ScrollController _scroll = ScrollController();
|
||||||
|
final ValueNotifier<bool> _scrolling = ValueNotifier(false);
|
||||||
late final AnimationController _shimmer;
|
late final AnimationController _shimmer;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
Object? _error;
|
Object? _error;
|
||||||
@@ -76,6 +80,7 @@ class _StickerPanelState extends State<StickerPanel>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_scroll.removeListener(_onScroll);
|
_scroll.removeListener(_onScroll);
|
||||||
_scroll.dispose();
|
_scroll.dispose();
|
||||||
|
_scrolling.dispose();
|
||||||
_shimmer.dispose();
|
_shimmer.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -130,6 +135,15 @@ class _StickerPanelState extends State<StickerPanel>
|
|||||||
if (index != _selectedTab) setState(() => _selectedTab = index);
|
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) {
|
void _jumpTo(int index) {
|
||||||
if (index < 0 || index >= _offsets.length) return;
|
if (index < 0 || index >= _offsets.length) return;
|
||||||
setState(() => _selectedTab = index);
|
setState(() => _selectedTab = index);
|
||||||
@@ -192,20 +206,30 @@ class _StickerPanelState extends State<StickerPanel>
|
|||||||
color: cs.outlineVariant.withValues(alpha: 0.3),
|
color: cs.outlineVariant.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: StickerScrollScope(
|
||||||
controller: _scroll,
|
isScrolling: _scrolling,
|
||||||
padding: EdgeInsets.zero,
|
child: StickerPeekScope(
|
||||||
itemCount: _sections.length,
|
child: NotificationListener<ScrollNotification>(
|
||||||
itemExtentBuilder: (i, _) => _heights[i],
|
onNotification: _onScrollNotification,
|
||||||
itemBuilder: (context, i) => _StickerSection(
|
child: ListView.builder(
|
||||||
key: ValueKey(_sections[i].title + i.toString()),
|
controller: _scroll,
|
||||||
title: _sections[i].title,
|
padding: EdgeInsets.zero,
|
||||||
stickerIds: _sections[i].stickerIds,
|
itemCount: _sections.length,
|
||||||
columns: columns,
|
itemExtentBuilder: (i, _) => _heights[i],
|
||||||
cell: cell,
|
itemBuilder: (context, i) => _StickerSection(
|
||||||
headerHeight: _headerHeight,
|
key: ValueKey(
|
||||||
shimmer: _shimmer,
|
_sections[i].title + i.toString(),
|
||||||
onTap: widget.onStickerTap,
|
),
|
||||||
|
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);
|
final item = stickersModule.cachedSticker(id);
|
||||||
if (item == null || item.url.isEmpty) return const SizedBox.shrink();
|
if (item == null || item.url.isEmpty) return const SizedBox.shrink();
|
||||||
return GestureDetector(
|
return StickerPeekable(
|
||||||
behavior: HitTestBehavior.opaque,
|
peekId: item.id,
|
||||||
onTap: () => widget.onTap(item),
|
url: item.url,
|
||||||
child: Padding(
|
lottieUrl: item.lottieUrl,
|
||||||
padding: const EdgeInsets.all(6),
|
child: GestureDetector(
|
||||||
child: CachedNetworkImage(
|
behavior: HitTestBehavior.opaque,
|
||||||
imageUrl: item.url,
|
onTap: () => widget.onTap(item),
|
||||||
fit: BoxFit.contain,
|
child: Padding(
|
||||||
memCacheWidth: 220,
|
padding: const EdgeInsets.all(6),
|
||||||
fadeInDuration: const Duration(milliseconds: 120),
|
child: StickerImage(
|
||||||
placeholder: (_, _) => _ShimmerBox(shimmer: widget.shimmer),
|
url: item.url,
|
||||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
lottieUrl: item.lottieUrl,
|
||||||
|
memCacheWidth: 220,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,6 +285,7 @@ class FileAttachment extends MessageAttachment {
|
|||||||
class StickerAttachment extends MessageAttachment {
|
class StickerAttachment extends MessageAttachment {
|
||||||
final String? stickerId;
|
final String? stickerId;
|
||||||
final String? stickerPackId;
|
final String? stickerPackId;
|
||||||
|
final String? lottieUrl;
|
||||||
final int? width;
|
final int? width;
|
||||||
final int? height;
|
final int? height;
|
||||||
|
|
||||||
@@ -294,16 +295,20 @@ class StickerAttachment extends MessageAttachment {
|
|||||||
super.fileUrl,
|
super.fileUrl,
|
||||||
this.stickerId,
|
this.stickerId,
|
||||||
this.stickerPackId,
|
this.stickerPackId,
|
||||||
|
this.lottieUrl,
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
}) : super(type: AttachmentType.sticker);
|
}) : super(type: AttachmentType.sticker);
|
||||||
|
|
||||||
|
bool get isAnimated => lottieUrl != null && lottieUrl!.isNotEmpty;
|
||||||
|
|
||||||
factory StickerAttachment.fromMap(Map<String, dynamic> map) {
|
factory StickerAttachment.fromMap(Map<String, dynamic> map) {
|
||||||
return StickerAttachment(
|
return StickerAttachment(
|
||||||
previewData: decodeAttachPreview(map['previewData']),
|
previewData: decodeAttachPreview(map['previewData']),
|
||||||
baseUrl: (map['url'] ?? map['baseUrl'])?.toString(),
|
baseUrl: (map['url'] ?? map['baseUrl'])?.toString(),
|
||||||
stickerId: map['stickerId']?.toString(),
|
stickerId: map['stickerId']?.toString(),
|
||||||
stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(),
|
stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(),
|
||||||
|
lottieUrl: map['lottieUrl']?.toString(),
|
||||||
width: map['width'] as int?,
|
width: map['width'] as int?,
|
||||||
height: map['height'] as int?,
|
height: map['height'] as int?,
|
||||||
);
|
);
|
||||||
@@ -316,6 +321,7 @@ class StickerAttachment extends MessageAttachment {
|
|||||||
'baseUrl': baseUrl,
|
'baseUrl': baseUrl,
|
||||||
'stickerId': stickerId,
|
'stickerId': stickerId,
|
||||||
'stickerPackId': stickerPackId,
|
'stickerPackId': stickerPackId,
|
||||||
|
'lottieUrl': lottieUrl,
|
||||||
'width': width,
|
'width': width,
|
||||||
'height': height,
|
'height': height,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class StickerSet {
|
|||||||
class StickerItem {
|
class StickerItem {
|
||||||
final int id;
|
final int id;
|
||||||
final String url;
|
final String url;
|
||||||
|
final String? lottieUrl;
|
||||||
final int? setId;
|
final int? setId;
|
||||||
final int? width;
|
final int? width;
|
||||||
final int? height;
|
final int? height;
|
||||||
@@ -41,14 +42,18 @@ class StickerItem {
|
|||||||
const StickerItem({
|
const StickerItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.url,
|
required this.url,
|
||||||
|
this.lottieUrl,
|
||||||
this.setId,
|
this.setId,
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get isAnimated => lottieUrl != null && lottieUrl!.isNotEmpty;
|
||||||
|
|
||||||
factory StickerItem.fromMap(Map<dynamic, dynamic> map) => StickerItem(
|
factory StickerItem.fromMap(Map<dynamic, dynamic> map) => StickerItem(
|
||||||
id: map['id'] as int,
|
id: map['id'] as int,
|
||||||
url: map['url']?.toString() ?? '',
|
url: map['url']?.toString() ?? '',
|
||||||
|
lottieUrl: map['lottieUrl']?.toString(),
|
||||||
setId: map['setId'] as int?,
|
setId: map['setId'] as int?,
|
||||||
width: map['width'] as int?,
|
width: map['width'] as int?,
|
||||||
height: map['height'] as int?,
|
height: map['height'] as int?,
|
||||||
|
|||||||
+10
-2
@@ -789,6 +789,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
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:
|
m3e_collection:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1683,5 +1691,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.0 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ dependencies:
|
|||||||
package_info_plus: ^9.0.1
|
package_info_plus: ^9.0.1
|
||||||
mobile_scanner: ^7.2.0
|
mobile_scanner: ^7.2.0
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
|
lottie: ^3.3.1
|
||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
share_plus: ^10.1.4
|
share_plus: ^10.1.4
|
||||||
open_filex: ^4.5.0
|
open_filex: ^4.5.0
|
||||||
|
|||||||
Reference in New Issue
Block a user