feat: еще стикеры..
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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/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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user