diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 70b2262..d8e8bc9 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1453,6 +1453,43 @@ class MessagesModule { return null; } + void sendTyping(int chatId, String type) { + unawaited(() async { + try { + await _api.sendRequest(Opcode.msgTyping, { + 'chatId': chatId, + 'type': type, + }); + } catch (_) {} + }()); + } + + Future?> sendStickerMessage( + int chatId, + int stickerId, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + {'_type': 'STICKER', 'stickerId': stickerId}, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) return null; + final data = response.payload; + if (data is Map) { + final msg = data['message']; + if (msg is Map) return Map.from(msg); + } + return null; + } + Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/backend/modules/stickers.dart b/lib/backend/modules/stickers.dart new file mode 100644 index 0000000..2e07a2e --- /dev/null +++ b/lib/backend/modules/stickers.dart @@ -0,0 +1,167 @@ +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/utils/logger.dart'; +import '../../models/sticker.dart'; + +class StickersModule { + final Api _api; + + StickersModule(this._api); + + final Map _sets = {}; + final Map _stickers = {}; + List _orderedSetIds = []; + List _favoriteSetIds = []; + List _recentStickerIds = []; + + Future? _loading; + + List get sets => + _orderedSetIds.map((id) => _sets[id]).whereType().toList(); + + List get favoriteSetIds => _favoriteSetIds; + List get recentStickerIds => _recentStickerIds; + StickerItem? cachedSticker(int id) => _stickers[id]; + + Future ensureLoaded() { + return _loading ??= _loadSections().catchError((Object e) { + _loading = null; + throw e; + }); + } + + Future _loadSections() async { + final newSetIds = []; + int marker = 0; + + final stickerResp = await _api.sendRequest(Opcode.assetsUpdate, { + 'type': 'STICKER', + 'sync': 0, + }); + if (stickerResp.isOk && stickerResp.payload is Map) { + final sections = stickerResp.payload['sections']; + if (sections is List) { + for (final s in sections) { + if (s is! Map) continue; + if (s['id'] == 'NEW_STICKER_SETS') { + _appendIntList(newSetIds, s['stickerSets']); + final m = s['marker']; + if (m is int) marker = m; + } else if (s['type'] == 'RECENTS') { + _parseRecents(s['recentsList']); + } + } + } + } + + var guard = 0; + while (marker != 0 && guard < 50) { + guard++; + final page = await _api.sendRequest(Opcode.assetsGet, { + 'sectionId': 'NEW_STICKER_SETS', + 'from': marker, + 'count': 100, + }); + if (!page.isOk || page.payload is! Map) break; + final before = newSetIds.length; + _appendIntList(newSetIds, page.payload['stickerSets']); + if (newSetIds.length == before) break; + final m = page.payload['marker']; + marker = m is int ? m : 0; + } + + final favIds = []; + 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; + + final ordered = []; + final seen = {}; + for (final id in [...favIds, ...newSetIds]) { + if (seen.add(id)) ordered.add(id); + } + _orderedSetIds = ordered; + logger.i('Стикеры: ${ordered.length} паков, ${_recentStickerIds.length} недавних'); + + await _ensureSetMetas(ordered); + } + + Future _ensureSetMetas(List ids) async { + final missing = ids.where((id) => !_sets.containsKey(id)).toList(); + for (final batch in _chunk(missing, 100)) { + final resp = await _api.sendRequest(Opcode.assetsGetByIds, { + 'type': 'STICKER_SET', + 'ids': batch, + }); + if (!resp.isOk || resp.payload is! Map) continue; + final list = resp.payload['stickerSets']; + if (list is! List) continue; + for (final e in list) { + if (e is Map && e['id'] is int) { + final set = StickerSet.fromMap(e); + _sets[set.id] = set; + } + } + } + } + + Future> ensureStickers(List stickerIds) async { + final missing = stickerIds.where((id) => !_stickers.containsKey(id)).toList(); + for (final batch in _chunk(missing, 100)) { + final resp = await _api.sendRequest(Opcode.assetsGetByIds, { + 'type': 'STICKER', + 'ids': batch, + }); + if (!resp.isOk || resp.payload is! Map) continue; + final list = resp.payload['stickers']; + if (list is! List) continue; + for (final e in list) { + if (e is Map && e['id'] is int) { + final item = StickerItem.fromMap(e); + _stickers[item.id] = item; + } + } + } + return stickerIds + .map((id) => _stickers[id]) + .whereType() + .toList(); + } + + void _parseRecents(dynamic list) { + if (list is! List) return; + final ids = []; + for (final e in list) { + if (e is Map && e['type'] == 'STICKER') { + final sid = e['stickerId'] ?? e['id']; + if (sid is int) ids.add(sid); + } + } + _recentStickerIds = ids; + } + + void _appendIntList(List target, dynamic raw) { + if (raw is! List) return; + for (final e in raw) { + if (e is int) target.add(e); + } + } + + Iterable> _chunk(List list, int size) sync* { + for (var i = 0; i < list.length; i += size) { + yield list.sublist(i, i + size > list.length ? list.length : i + size); + } + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 44af4e6..5a6048c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -49,6 +49,7 @@ import '../../../core/config/app_commands.dart'; import '../../../core/config/app_visual_style.dart'; import '../../../core/config/komet_settings.dart'; import '../../../models/attachment.dart'; +import '../../../models/sticker.dart'; import '../../commands/command_registry.dart'; import '../../commands/slash_command.dart'; import '../../widgets/glossy_pill.dart'; @@ -60,6 +61,7 @@ import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment/attachment_sheet.dart'; +import '../../widgets/sticker_panel.dart'; import '../../widgets/swipe_to_pop.dart'; import '../../widgets/schedule_time_picker.dart'; import 'scheduled_messages_screen.dart'; @@ -206,6 +208,10 @@ class _ChatScreenState extends State final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; final ValueNotifier _showAttachmentPanel = ValueNotifier(false); + final ValueNotifier _showStickerPanel = ValueNotifier(false); + double _stickerPanelHeight = 300; + Timer? _stickerTypingTimer; + late final AnimationController _stickerAnim; final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier( const _UploadStatus(), ); @@ -337,6 +343,8 @@ class _ChatScreenState extends State WidgetsBinding.instance.addObserver(this); ChatsModule.chatsChanged.addListener(_onChatsBump); _messageController.addListener(_onTextChanged); + _messageFocusNode.addListener(_onComposerFocusChanged); + _showStickerPanel.addListener(_onStickerPanelToggle); _scrollController.addListener(_onScrollForDate); _scrollController.addListener(_maybeLoadMoreHistory); AppVisualStyle.current.addListener(_onVisualStyleChanged); @@ -349,6 +357,11 @@ class _ChatScreenState extends State duration: const Duration(milliseconds: 320), reverseDuration: const Duration(milliseconds: 240), ); + _stickerAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + reverseDuration: const Duration(milliseconds: 200), + ); _showAttachmentPanel.addListener(_onAttachPanelToggle); _commandAnim = AnimationController( vsync: this, @@ -897,7 +910,12 @@ class _ChatScreenState extends State _selectedIds.dispose(); _commandMatches.dispose(); _messageController.dispose(); + _messageFocusNode.removeListener(_onComposerFocusChanged); _messageFocusNode.dispose(); + _stickerTypingTimer?.cancel(); + _stickerAnim.dispose(); + _showStickerPanel.removeListener(_onStickerPanelToggle); + _showStickerPanel.dispose(); _scrollController.dispose(); _shimmerStartTimer?.cancel(); _shimmerController.dispose(); @@ -1383,6 +1401,7 @@ class _ChatScreenState extends State }, ), _buildInputArea(context), + _buildStickerPanel(context), ], ), ), @@ -4507,11 +4526,15 @@ class _ChatScreenState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon( - Symbols.face, - color: mutedIcon, - size: 24, - weight: 400, + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleStickerPanel, + child: Icon( + Symbols.face, + color: mutedIcon, + size: 24, + weight: 400, + ), ), const SizedBox(width: 12), Expanded( @@ -5259,6 +5282,83 @@ class _ChatScreenState extends State } } + void _toggleStickerPanel() { + if (_showStickerPanel.value) { + _showStickerPanel.value = false; + _messageFocusNode.requestFocus(); + return; + } + final keyboard = MediaQuery.viewInsetsOf(context).bottom; + if (keyboard > 120) _stickerPanelHeight = keyboard; + FocusManager.instance.primaryFocus?.unfocus(); + _showStickerPanel.value = true; + } + + void _onComposerFocusChanged() { + if (_messageFocusNode.hasFocus && _showStickerPanel.value) { + _showStickerPanel.value = false; + } + } + + void _onStickerPanelToggle() { + if (_showStickerPanel.value) { + _stickerAnim.forward(); + _sendStickerTyping(); + _stickerTypingTimer?.cancel(); + _stickerTypingTimer = Timer.periodic( + const Duration(seconds: 4), + (_) => _sendStickerTyping(), + ); + } else { + _stickerAnim.reverse(); + _stickerTypingTimer?.cancel(); + _stickerTypingTimer = null; + } + } + + void _sendStickerTyping() { + if (KometSettings.ghostMode.value) return; + messagesModule.sendTyping(widget.chatId, 'STICKER'); + } + + Future _sendSticker(StickerItem sticker) async { + _showStickerPanel.value = false; + await _sendAttachMessage( + [ + StickerAttachment( + stickerId: sticker.id.toString(), + baseUrl: sticker.url, + width: sticker.width, + height: sticker.height, + ), + ], + () => messagesModule.sendStickerMessage(widget.chatId, sticker.id), + ); + } + + Widget _buildStickerPanel(BuildContext context) { + return AnimatedBuilder( + animation: _stickerAnim, + child: StickerPanel( + height: _stickerPanelHeight, + onStickerTap: _sendSticker, + ), + builder: (context, child) { + final t = Curves.easeOutCubic.transform( + _stickerAnim.value.clamp(0.0, 1.0), + ); + if (t == 0) return const SizedBox.shrink(); + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: t, + child: child, + ), + ); + }, + ); + } + Future _shareLocation() async { final position = await _resolveCurrentPosition(); if (position == null || !mounted) return; diff --git a/lib/frontend/widgets/sticker_panel.dart b/lib/frontend/widgets/sticker_panel.dart new file mode 100644 index 0000000..51c2490 --- /dev/null +++ b/lib/frontend/widgets/sticker_panel.dart @@ -0,0 +1,390 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../main.dart' show stickersModule; +import '../../models/sticker.dart'; + +class _DragScrollBehavior extends MaterialScrollBehavior { + const _DragScrollBehavior(); + + @override + Set get dragDevices => const { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.trackpad, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + }; +} + +class _Section { + final String title; + final List stickerIds; + final IconData? icon; + final String? iconUrl; + + const _Section({ + required this.title, + required this.stickerIds, + this.icon, + this.iconUrl, + }); +} + +class StickerPanel extends StatefulWidget { + final double height; + final void Function(StickerItem sticker) onStickerTap; + + const StickerPanel({ + super.key, + required this.height, + required this.onStickerTap, + }); + + @override + State createState() => _StickerPanelState(); +} + +class _StickerPanelState extends State + with SingleTickerProviderStateMixin { + static const double _tabBarHeight = 52; + static const double _headerHeight = 34; + + final ScrollController _scroll = ScrollController(); + late final AnimationController _shimmer; + bool _loading = true; + Object? _error; + int _selectedTab = 0; + List<_Section> _sections = const []; + List _heights = const []; + List _offsets = const []; + + @override + void initState() { + super.initState(); + _shimmer = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _scroll.addListener(_onScroll); + _load(); + } + + @override + void dispose() { + _scroll.removeListener(_onScroll); + _scroll.dispose(); + _shimmer.dispose(); + super.dispose(); + } + + Future _load() async { + try { + await stickersModule.ensureLoaded(); + if (!mounted) return; + _buildSections(); + setState(() => _loading = false); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e; + }); + } + } + + void _buildSections() { + final sections = <_Section>[]; + final recents = stickersModule.recentStickerIds; + if (recents.isNotEmpty) { + sections.add( + _Section( + title: 'Недавние', + stickerIds: recents, + icon: Symbols.schedule, + ), + ); + } + for (final set in stickersModule.sets) { + if (set.stickerIds.isEmpty) continue; + sections.add( + _Section( + title: set.name, + stickerIds: set.stickerIds, + iconUrl: set.iconUrl, + ), + ); + } + _sections = sections; + } + + void _onScroll() { + if (_offsets.isEmpty) return; + final pixels = _scroll.position.pixels; + var index = 0; + for (var i = 0; i < _offsets.length; i++) { + if (pixels + 1 >= _offsets[i]) index = i; + } + if (index != _selectedTab) setState(() => _selectedTab = index); + } + + void _jumpTo(int index) { + if (index < 0 || index >= _offsets.length) return; + setState(() => _selectedTab = index); + final max = _scroll.position.maxScrollExtent; + _scroll.animateTo( + _offsets[index].clamp(0.0, max), + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + height: widget.height, + color: cs.surface, + child: _loading + ? Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary), + ), + ) + : _error != null || _sections.isEmpty + ? Center( + child: Text( + _error != null ? 'Не удалось загрузить стикеры' : 'Нет стикеров', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ) + : ScrollConfiguration( + behavior: const _DragScrollBehavior(), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = (width / 84).floor().clamp(4, 8); + final cell = width / columns; + + final heights = []; + final offsets = []; + var acc = 0.0; + for (final s in _sections) { + final rows = (s.stickerIds.length / columns).ceil(); + final h = _headerHeight + rows * cell; + offsets.add(acc); + heights.add(h); + acc += h; + } + _heights = heights; + _offsets = offsets; + + return Column( + children: [ + _buildTabBar(cs), + Divider( + height: 1, + thickness: 1, + 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, + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildTabBar(ColorScheme cs) { + return SizedBox( + height: _tabBarHeight, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6), + itemCount: _sections.length, + itemBuilder: (context, i) { + final s = _sections[i]; + final selected = i == _selectedTab; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _jumpTo(i), + child: Container( + width: 44, + height: 44, + margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + decoration: BoxDecoration( + color: selected ? cs.surfaceContainerHighest : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(8), + child: s.icon != null + ? Icon(s.icon, size: 24, color: selected ? cs.primary : cs.onSurfaceVariant) + : CachedNetworkImage( + imageUrl: s.iconUrl ?? '', + fit: BoxFit.contain, + errorWidget: (_, _, _) => + Icon(Symbols.image, size: 20, color: cs.onSurfaceVariant), + ), + ), + ); + }, + ), + ); + } +} + +class _StickerSection extends StatefulWidget { + final String title; + final List stickerIds; + final int columns; + final double cell; + final double headerHeight; + final Animation shimmer; + final void Function(StickerItem sticker) onTap; + + const _StickerSection({ + super.key, + required this.title, + required this.stickerIds, + required this.columns, + required this.cell, + required this.headerHeight, + required this.shimmer, + required this.onTap, + }); + + @override + State<_StickerSection> createState() => _StickerSectionState(); +} + +class _StickerSectionState extends State<_StickerSection> { + bool _loaded = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + await stickersModule.ensureStickers(widget.stickerIds); + if (!mounted) return; + setState(() => _loaded = true); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final ids = widget.stickerIds; + final columns = widget.columns; + final cell = widget.cell; + final rows = (ids.length / columns).ceil(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: widget.headerHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + widget.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + for (var r = 0; r < rows; r++) + Row( + children: [ + for (var c = 0; c < columns; c++) + SizedBox( + width: cell, + height: cell, + child: r * columns + c < ids.length + ? _cell(ids[r * columns + c]) + : null, + ), + ], + ), + ], + ); + } + + Widget _cell(int id) { + if (!_loaded) { + return Padding( + padding: const EdgeInsets.all(6), + child: _ShimmerBox(shimmer: widget.shimmer), + ); + } + 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(), + ), + ), + ); + } +} + +class _ShimmerBox extends StatelessWidget { + final Animation shimmer; + + const _ShimmerBox({required this.shimmer}); + + @override + Widget build(BuildContext context) { + final base = Theme.of(context).colorScheme.surfaceContainerHighest; + return AnimatedBuilder( + animation: shimmer, + builder: (context, _) => DecoratedBox( + decoration: BoxDecoration( + color: base.withValues(alpha: 0.35 + 0.4 * shimmer.value), + borderRadius: BorderRadius.circular(12), + ), + child: const SizedBox.expand(), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index b7bf2b1..cfbe7cf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,6 +43,7 @@ import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/outbox.dart'; import 'backend/modules/polls.dart'; +import 'backend/modules/stickers.dart'; import 'backend/modules/self_check.dart'; import 'backend/modules/webapp.dart'; import 'backend/modules/digital_id.dart'; @@ -69,6 +70,7 @@ final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); final pollsModule = PollsModule(api); +final stickersModule = StickersModule(api); final webAppModule = WebAppModule(api); final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); diff --git a/lib/models/sticker.dart b/lib/models/sticker.dart new file mode 100644 index 0000000..58ffba5 --- /dev/null +++ b/lib/models/sticker.dart @@ -0,0 +1,56 @@ +class StickerSet { + final int id; + final String name; + final String iconUrl; + final List stickerIds; + final String? link; + + const StickerSet({ + required this.id, + required this.name, + required this.iconUrl, + required this.stickerIds, + this.link, + }); + + factory StickerSet.fromMap(Map map) { + final rawStickers = map['stickers']; + final ids = []; + if (rawStickers is List) { + for (final e in rawStickers) { + if (e is int) ids.add(e); + } + } + return StickerSet( + id: map['id'] as int, + name: map['name']?.toString() ?? '', + iconUrl: map['iconUrl']?.toString() ?? '', + stickerIds: ids, + link: map['link']?.toString(), + ); + } +} + +class StickerItem { + final int id; + final String url; + final int? setId; + final int? width; + final int? height; + + const StickerItem({ + required this.id, + required this.url, + this.setId, + this.width, + this.height, + }); + + factory StickerItem.fromMap(Map map) => StickerItem( + id: map['id'] as int, + url: map['url']?.toString() ?? '', + setId: map['setId'] as int?, + width: map['width'] as int?, + height: map['height'] as int?, + ); +}