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

This commit is contained in:
torvalds
2026-07-03 10:34:53 +07:00
parent 704f1c4267
commit 811f5c01f0
7 changed files with 534 additions and 20 deletions
+22
View File
@@ -828,6 +828,28 @@ class MessagesModule {
return '';
}
Future<bool> sendLinkMessage(int chatId, String url) async {
final message = <String, dynamic>{
'text': url,
'cid': DateTime.now().millisecondsSinceEpoch * -1,
'elements': [
{
'type': 'LINK',
'from': 0,
'length': url.length,
'attributes': {'url': url},
},
],
'attaches': [],
};
final response = await _api.sendRequest(Opcode.msgSend, {
'chatId': chatId,
'message': message,
'notify': true,
});
return response.isOk;
}
/// Загружает отложенные (запланированные) сообщения чата.
///
/// В отличие от обычной истории, отложенные сообщения не сохраняются
+105 -18
View File
@@ -1,12 +1,17 @@
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/utils/logger.dart';
import '../../models/sticker.dart';
class StickersModule {
final Api _api;
StickersModule(this._api);
StickersModule(this._api) {
_api.pushStream
.where((p) => p.opcode == Opcode.notifAssetsUpdate)
.listen(_handleAssetsPush);
}
final Map<int, StickerSet> _sets = {};
final Map<int, StickerItem> _stickers = {};
@@ -15,6 +20,7 @@ class StickersModule {
List<int> _recentStickerIds = [];
Future<void>? _loading;
Future<void>? _favoritesLoading;
List<StickerSet> get sets =>
_orderedSetIds.map((id) => _sets[id]).whereType<StickerSet>().toList();
@@ -22,6 +28,8 @@ class StickersModule {
List<int> get favoriteSetIds => _favoriteSetIds;
List<int> get recentStickerIds => _recentStickerIds;
StickerItem? cachedSticker(int id) => _stickers[id];
StickerSet? cachedSet(int id) => _sets[id];
bool isFavorite(int setId) => _favoriteSetIds.contains(setId);
Future<void> ensureLoaded() {
return _loading ??= _loadSections().catchError((Object e) {
@@ -30,6 +38,32 @@ class StickersModule {
});
}
Future<void> ensureFavoritesLoaded() {
return _favoritesLoading ??= _loadFavorites().catchError((Object e) {
_favoritesLoading = null;
throw e;
});
}
Future<void> _loadFavorites() async {
final favIds = <int>[];
final favResp = await _api.sendRequest(Opcode.assetsUpdate, {
'type': 'FAVORITE_STICKER',
'sync': 0,
});
if (favResp.isOk && favResp.payload is Map) {
final sections = favResp.payload['sections'];
if (sections is List) {
for (final s in sections) {
if (s is Map && s['id'] == 'FAVORITE_STICKER_SETS') {
_appendIntList(favIds, s['stickerSets']);
}
}
}
}
_favoriteSetIds = favIds;
}
Future<void> _loadSections() async {
final newSetIds = <int>[];
int marker = 0;
@@ -70,26 +104,11 @@ class StickersModule {
marker = m is int ? m : 0;
}
final favIds = <int>[];
final favResp = await _api.sendRequest(Opcode.assetsUpdate, {
'type': 'FAVORITE_STICKER',
'sync': 0,
});
if (favResp.isOk && favResp.payload is Map) {
final sections = favResp.payload['sections'];
if (sections is List) {
for (final s in sections) {
if (s is Map && s['id'] == 'FAVORITE_STICKER_SETS') {
_appendIntList(favIds, s['stickerSets']);
}
}
}
}
_favoriteSetIds = favIds;
await ensureFavoritesLoaded();
final ordered = <int>[];
final seen = <int>{};
for (final id in [...favIds, ...newSetIds]) {
for (final id in [..._favoriteSetIds, ...newSetIds]) {
if (seen.add(id)) ordered.add(id);
}
_orderedSetIds = ordered;
@@ -140,6 +159,74 @@ class StickersModule {
.toList();
}
Future<StickerSet?> ensureSet(int setId) async {
await _ensureSetMetas([setId]);
return _sets[setId];
}
void cacheSet(StickerSet set) => _sets[set.id] = set;
Future<StickerSet?> resolveSetByLink(String link) async {
final resp = await _api.sendRequest(Opcode.linkInfo, {'link': link});
if (!resp.isOk || resp.payload is! Map) return null;
final raw = resp.payload['stickerSet'];
if (raw is! Map || raw['id'] is! int) return null;
final set = StickerSet.fromMap(raw);
_sets[set.id] = set;
return set;
}
Future<int?> resolveSetId(int stickerId) async {
await ensureStickers([stickerId]);
return _stickers[stickerId]?.setId;
}
Future<bool> favoriteSet(int setId) async {
final resp = await _api.sendRequest(Opcode.assetsAdd, {
'type': 'FAVORITE_STICKER_SET',
'id': setId,
});
final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true;
if (ok) _markFavorite(setId, true);
return ok;
}
Future<bool> unfavoriteSet(int setId) async {
final resp = await _api.sendRequest(Opcode.assetsRemove, {
'type': 'FAVORITE_STICKER_SET',
'ids': [setId],
});
final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true;
if (ok) _markFavorite(setId, false);
return ok;
}
void _handleAssetsPush(Packet push) {
final payload = push.payload;
if (payload is! Map) return;
if (payload['type'] != 'FAVORITE_STICKER_SET') return;
final id = payload['id'];
if (id is! int) return;
switch (payload['updateType']) {
case 'ADDED':
_markFavorite(id, true);
case 'REMOVED':
_markFavorite(id, false);
}
}
void _markFavorite(int setId, bool favorite) {
if (favorite) {
if (!_favoriteSetIds.contains(setId)) {
_favoriteSetIds = [setId, ..._favoriteSetIds];
}
} else {
if (_favoriteSetIds.contains(setId)) {
_favoriteSetIds = _favoriteSetIds.where((id) => id != setId).toList();
}
}
}
void _parseRecents(dynamic list) {
if (list is! List) return;
final ids = <int>[];
+5 -1
View File
@@ -1,4 +1,4 @@
enum MaxLinkKind { call, invite, user, content, public, auth }
enum MaxLinkKind { call, invite, user, content, public, auth, stickerSet }
class MaxLink {
final MaxLinkKind kind;
@@ -49,6 +49,10 @@ class MaxLink {
return segments.length >= 2 ? MaxLink(MaxLinkKind.user, url) : null;
case 'c':
return segments.length >= 3 ? MaxLink(MaxLinkKind.content, url) : null;
case 'stickerset':
return segments.length >= 2
? MaxLink(MaxLinkKind.stickerSet, url)
: null;
}
if (_reserved.contains(segments.first.toLowerCase())) return null;
@@ -62,6 +62,7 @@ import '../../widgets/message_actions_overlay.dart';
import '../../widgets/attachment_panel.dart';
import '../../widgets/attachment/attachment_sheet.dart';
import '../../widgets/sticker_panel.dart';
import '../../widgets/sticker_pack_sheet.dart';
import '../../widgets/swipe_to_pop.dart';
import '../../widgets/schedule_time_picker.dart';
import 'scheduled_messages_screen.dart';
@@ -3105,6 +3106,19 @@ class _ChatScreenState extends State<ChatScreen>
);
}
void _openStickerPack(StickerAttachment sticker) {
final stickerId = int.tryParse(sticker.stickerId ?? '');
if (stickerId == null) {
showCustomNotification(context, 'Стикерпак недоступен');
return;
}
showStickerPackSheet(
context,
stickerId: stickerId,
knownSetId: int.tryParse(sticker.stickerPackId ?? ''),
);
}
void _jumpToMessage(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
@@ -3452,6 +3466,7 @@ class _ChatScreenState extends State<ChatScreen>
uploadProgress: _photoProgressFor(message),
onReplyTap: _jumpToMessage,
onAvatarTap: _openSenderProfile,
onStickerTap: _openStickerPack,
);
final canReport = !isMe && !message.isControl;
@@ -10,6 +10,7 @@ import '../screens/contacts/contact_profile_screen.dart';
import 'call_link_handler.dart';
import 'confirm_dialog.dart';
import 'custom_notification.dart';
import 'sticker_pack_sheet.dart';
import 'swipe_route.dart';
import 'web_qr_login.dart';
@@ -26,6 +27,10 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
return true;
}
if (link.kind == MaxLinkKind.stickerSet) {
return _openStickerSet(context, link.url);
}
final resolved = await LinkModule.resolve(api, link.url);
if (!context.mounted) return true;
@@ -44,6 +49,26 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
}
}
Future<bool> _openStickerSet(BuildContext context, String url) async {
final path = url
.replaceFirst(
RegExp(r'^https?://(?:www\.)?max\.ru/', caseSensitive: false),
'',
)
.split('?')
.first
.split('#')
.first;
final set = await stickersModule.resolveSetByLink(path);
if (!context.mounted) return true;
if (set == null) {
showCustomNotification(context, 'Стикерпак недоступен');
return true;
}
await showStickerPackSheet(context, knownSetId: set.id);
return true;
}
void _openContact(BuildContext context, Map<dynamic, dynamic> contact) {
final id = contact['id'];
if (id is! int) {
+11 -1
View File
@@ -111,6 +111,7 @@ class MessageBubble extends StatelessWidget {
final ValueListenable<List<double>>? uploadProgress;
final void Function(String messageId)? onReplyTap;
final void Function(int senderId)? onAvatarTap;
final void Function(StickerAttachment sticker)? onStickerTap;
const MessageBubble({
super.key,
@@ -125,6 +126,7 @@ class MessageBubble extends StatelessWidget {
this.uploadProgress,
this.onReplyTap,
this.onAvatarTap,
this.onStickerTap,
});
bool _computeHasPhotoWithCaption() {
@@ -2335,7 +2337,7 @@ class MessageBubble extends StatelessWidget {
final preview = sticker.previewData ?? '';
final imageUrl = url.isNotEmpty ? url : preview;
return ClipRRect(
final Widget image = ClipRRect(
borderRadius: BorderRadius.circular(photoBorderRadius),
child: Stack(
children: [
@@ -2356,6 +2358,14 @@ class MessageBubble extends StatelessWidget {
],
),
);
final onTap = onStickerTap;
if (onTap == null || sticker is! StickerAttachment) return image;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(sticker),
child: image,
);
}
Widget _buildContactAttachment(_BubbleCtx ctx, MessageAttachment contact) {
@@ -0,0 +1,351 @@
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';
import '../../core/storage/app_database.dart';
import '../../main.dart' show stickersModule, messagesModule;
import '../../models/sticker.dart';
import '../screens/chats/forward_picker_screen.dart';
import 'custom_notification.dart';
enum _PackAction { forward, copyLink }
Future<void> showStickerPackSheet(
BuildContext context, {
int? stickerId,
int? knownSetId,
}) {
assert(stickerId != null || knownSetId != null);
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _StickerPackSheet(
stickerId: stickerId,
knownSetId: knownSetId,
),
);
}
class _StickerPackSheet extends StatefulWidget {
final int? stickerId;
final int? knownSetId;
const _StickerPackSheet({this.stickerId, this.knownSetId});
@override
State<_StickerPackSheet> createState() => _StickerPackSheetState();
}
class _StickerPackSheetState extends State<_StickerPackSheet> {
bool _loading = true;
bool _busy = false;
bool _isFavorite = false;
Object? _error;
StickerSet? _set;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final setId = widget.knownSetId ??
(widget.stickerId != null
? await stickersModule.resolveSetId(widget.stickerId!)
: null);
if (setId == null) throw Exception('no set');
final setFuture = stickersModule.ensureSet(setId);
final favoritesFuture = stickersModule.ensureFavoritesLoaded();
final set = await setFuture;
await favoritesFuture;
if (set == null) throw Exception('no meta');
await stickersModule.ensureStickers(set.stickerIds);
if (!mounted) return;
setState(() {
_set = set;
_isFavorite = stickersModule.isFavorite(set.id);
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
Future<void> _toggle() async {
final set = _set;
if (set == null || _busy) return;
setState(() => _busy = true);
final wasFavorite = _isFavorite;
try {
final ok = wasFavorite
? await stickersModule.unfavoriteSet(set.id)
: await stickersModule.favoriteSet(set.id);
if (!mounted) return;
if (ok) {
setState(() => _isFavorite = !wasFavorite);
showCustomNotification(
context,
wasFavorite ? 'Стикерпак удалён' : 'Стикерпак добавлен',
);
} else {
showCustomNotification(context, 'Не удалось выполнить действие');
}
} catch (e) {
if (!mounted) return;
showCustomNotification(context, 'Ошибка: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _copyLink(StickerSet set) {
final link = set.link;
if (link == null || link.isEmpty) {
showCustomNotification(context, 'Ссылка недоступна');
return;
}
Clipboard.setData(ClipboardData(text: link));
showCustomNotification(context, 'Ссылка скопирована');
}
Future<void> _forward(StickerSet set) async {
final link = set.link;
if (link == null || link.isEmpty) {
showCustomNotification(context, 'Ссылка недоступна');
return;
}
final profile = await AppDatabase.loadActiveProfile();
if (!mounted) return;
final target = await showForwardPicker(
context: context,
accountId: profile?.id ?? 0,
);
if (target == null || !mounted) return;
final ok = await messagesModule.sendLinkMessage(target.chatId, link);
if (!mounted) return;
showCustomNotification(
context,
ok ? 'Переслано в «${target.name}»' : 'Не удалось переслать',
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final height = MediaQuery.sizeOf(context).height * 0.78;
return Container(
height: height,
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 6),
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
Expanded(child: _buildBody(cs)),
],
),
);
}
Widget _buildBody(ColorScheme cs) {
if (_loading) {
return Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary),
),
);
}
final set = _set;
if (_error != null || set == null) {
return Center(
child: Text(
'Стикерпак недоступен',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
);
}
return Column(
children: [
_buildHeader(cs, set),
Expanded(child: _buildGrid(cs, set)),
_buildActionButton(cs),
],
);
}
Widget _buildHeader(ColorScheme cs, StickerSet set) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 8, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
set.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
_pluralStickers(set.stickerIds.length),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
],
),
),
_buildMenu(cs, set),
],
),
);
}
Widget _buildMenu(ColorScheme cs, StickerSet set) {
return PopupMenuButton<_PackAction>(
icon: Icon(Symbols.more_horiz, color: cs.onSurfaceVariant),
color: cs.surfaceContainerHighest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
onSelected: (action) {
switch (action) {
case _PackAction.forward:
_forward(set);
case _PackAction.copyLink:
_copyLink(set);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: _PackAction.forward,
child: Row(
children: [
Icon(Symbols.forward, size: 20, color: cs.onSurface),
const SizedBox(width: 12),
const Text('Переслать'),
],
),
),
PopupMenuItem(
value: _PackAction.copyLink,
child: Row(
children: [
Icon(Symbols.link, size: 20, color: cs.onSurface),
const SizedBox(width: 12),
const Text('Скопировать ссылку'),
],
),
),
],
);
}
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),
),
),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
);
},
);
}
Widget _buildActionButton(ColorScheme cs) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
child: SizedBox(
width: double.infinity,
height: 52,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: _isFavorite
? cs.surfaceContainerHighest
: cs.primary,
foregroundColor: _isFavorite ? cs.onSurface : cs.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: _busy ? null : _toggle,
child: _busy
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: _isFavorite ? cs.onSurface : cs.onPrimary,
),
)
: Text(
_isFavorite ? 'Убрать' : 'Добавить',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
),
);
}
String _pluralStickers(int n) {
final mod100 = n % 100;
final mod10 = n % 10;
if (mod100 >= 11 && mod100 <= 14) return '$n стикеров';
if (mod10 == 1) return '$n стикер';
if (mod10 >= 2 && mod10 <= 4) return '$n стикера';
return '$n стикеров';
}
}