feat: стикеры
This commit is contained in:
@@ -1453,6 +1453,43 @@ class MessagesModule {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void sendTyping(int chatId, String type) {
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await _api.sendRequest(Opcode.msgTyping, {
|
||||||
|
'chatId': chatId,
|
||||||
|
'type': type,
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> 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<String, dynamic>.from(msg);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||||
try {
|
try {
|
||||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||||
|
|||||||
@@ -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<int, StickerSet> _sets = {};
|
||||||
|
final Map<int, StickerItem> _stickers = {};
|
||||||
|
List<int> _orderedSetIds = [];
|
||||||
|
List<int> _favoriteSetIds = [];
|
||||||
|
List<int> _recentStickerIds = [];
|
||||||
|
|
||||||
|
Future<void>? _loading;
|
||||||
|
|
||||||
|
List<StickerSet> get sets =>
|
||||||
|
_orderedSetIds.map((id) => _sets[id]).whereType<StickerSet>().toList();
|
||||||
|
|
||||||
|
List<int> get favoriteSetIds => _favoriteSetIds;
|
||||||
|
List<int> get recentStickerIds => _recentStickerIds;
|
||||||
|
StickerItem? cachedSticker(int id) => _stickers[id];
|
||||||
|
|
||||||
|
Future<void> ensureLoaded() {
|
||||||
|
return _loading ??= _loadSections().catchError((Object e) {
|
||||||
|
_loading = null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSections() async {
|
||||||
|
final newSetIds = <int>[];
|
||||||
|
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 = <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;
|
||||||
|
|
||||||
|
final ordered = <int>[];
|
||||||
|
final seen = <int>{};
|
||||||
|
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<void> _ensureSetMetas(List<int> 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<List<StickerItem>> ensureStickers(List<int> 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<StickerItem>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseRecents(dynamic list) {
|
||||||
|
if (list is! List) return;
|
||||||
|
final ids = <int>[];
|
||||||
|
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<int> target, dynamic raw) {
|
||||||
|
if (raw is! List) return;
|
||||||
|
for (final e in raw) {
|
||||||
|
if (e is int) target.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Iterable<List<T>> _chunk<T>(List<T> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ import '../../../core/config/app_commands.dart';
|
|||||||
import '../../../core/config/app_visual_style.dart';
|
import '../../../core/config/app_visual_style.dart';
|
||||||
import '../../../core/config/komet_settings.dart';
|
import '../../../core/config/komet_settings.dart';
|
||||||
import '../../../models/attachment.dart';
|
import '../../../models/attachment.dart';
|
||||||
|
import '../../../models/sticker.dart';
|
||||||
import '../../commands/command_registry.dart';
|
import '../../commands/command_registry.dart';
|
||||||
import '../../commands/slash_command.dart';
|
import '../../commands/slash_command.dart';
|
||||||
import '../../widgets/glossy_pill.dart';
|
import '../../widgets/glossy_pill.dart';
|
||||||
@@ -60,6 +61,7 @@ import '../../widgets/theme_reveal.dart';
|
|||||||
import '../../widgets/message_actions_overlay.dart';
|
import '../../widgets/message_actions_overlay.dart';
|
||||||
import '../../widgets/attachment_panel.dart';
|
import '../../widgets/attachment_panel.dart';
|
||||||
import '../../widgets/attachment/attachment_sheet.dart';
|
import '../../widgets/attachment/attachment_sheet.dart';
|
||||||
|
import '../../widgets/sticker_panel.dart';
|
||||||
import '../../widgets/swipe_to_pop.dart';
|
import '../../widgets/swipe_to_pop.dart';
|
||||||
import '../../widgets/schedule_time_picker.dart';
|
import '../../widgets/schedule_time_picker.dart';
|
||||||
import 'scheduled_messages_screen.dart';
|
import 'scheduled_messages_screen.dart';
|
||||||
@@ -206,6 +208,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
||||||
|
final ValueNotifier<bool> _showStickerPanel = ValueNotifier(false);
|
||||||
|
double _stickerPanelHeight = 300;
|
||||||
|
Timer? _stickerTypingTimer;
|
||||||
|
late final AnimationController _stickerAnim;
|
||||||
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(
|
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(
|
||||||
const _UploadStatus(),
|
const _UploadStatus(),
|
||||||
);
|
);
|
||||||
@@ -337,6 +343,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
ChatsModule.chatsChanged.addListener(_onChatsBump);
|
ChatsModule.chatsChanged.addListener(_onChatsBump);
|
||||||
_messageController.addListener(_onTextChanged);
|
_messageController.addListener(_onTextChanged);
|
||||||
|
_messageFocusNode.addListener(_onComposerFocusChanged);
|
||||||
|
_showStickerPanel.addListener(_onStickerPanelToggle);
|
||||||
_scrollController.addListener(_onScrollForDate);
|
_scrollController.addListener(_onScrollForDate);
|
||||||
_scrollController.addListener(_maybeLoadMoreHistory);
|
_scrollController.addListener(_maybeLoadMoreHistory);
|
||||||
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
AppVisualStyle.current.addListener(_onVisualStyleChanged);
|
||||||
@@ -349,6 +357,11 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
duration: const Duration(milliseconds: 320),
|
duration: const Duration(milliseconds: 320),
|
||||||
reverseDuration: const Duration(milliseconds: 240),
|
reverseDuration: const Duration(milliseconds: 240),
|
||||||
);
|
);
|
||||||
|
_stickerAnim = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 240),
|
||||||
|
reverseDuration: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||||||
_commandAnim = AnimationController(
|
_commandAnim = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
@@ -897,7 +910,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_selectedIds.dispose();
|
_selectedIds.dispose();
|
||||||
_commandMatches.dispose();
|
_commandMatches.dispose();
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
|
_messageFocusNode.removeListener(_onComposerFocusChanged);
|
||||||
_messageFocusNode.dispose();
|
_messageFocusNode.dispose();
|
||||||
|
_stickerTypingTimer?.cancel();
|
||||||
|
_stickerAnim.dispose();
|
||||||
|
_showStickerPanel.removeListener(_onStickerPanelToggle);
|
||||||
|
_showStickerPanel.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_shimmerStartTimer?.cancel();
|
_shimmerStartTimer?.cancel();
|
||||||
_shimmerController.dispose();
|
_shimmerController.dispose();
|
||||||
@@ -1383,6 +1401,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildInputArea(context),
|
_buildInputArea(context),
|
||||||
|
_buildStickerPanel(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -4507,11 +4526,15 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
GestureDetector(
|
||||||
Symbols.face,
|
behavior: HitTestBehavior.opaque,
|
||||||
color: mutedIcon,
|
onTap: _toggleStickerPanel,
|
||||||
size: 24,
|
child: Icon(
|
||||||
weight: 400,
|
Symbols.face,
|
||||||
|
color: mutedIcon,
|
||||||
|
size: 24,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -5259,6 +5282,83 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<void> _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<void> _shareLocation() async {
|
Future<void> _shareLocation() async {
|
||||||
final position = await _resolveCurrentPosition();
|
final position = await _resolveCurrentPosition();
|
||||||
if (position == null || !mounted) return;
|
if (position == null || !mounted) return;
|
||||||
|
|||||||
@@ -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<PointerDeviceKind> get dragDevices => const {
|
||||||
|
PointerDeviceKind.touch,
|
||||||
|
PointerDeviceKind.mouse,
|
||||||
|
PointerDeviceKind.trackpad,
|
||||||
|
PointerDeviceKind.stylus,
|
||||||
|
PointerDeviceKind.invertedStylus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Section {
|
||||||
|
final String title;
|
||||||
|
final List<int> 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<StickerPanel> createState() => _StickerPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickerPanelState extends State<StickerPanel>
|
||||||
|
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<double> _heights = const [];
|
||||||
|
List<double> _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<void> _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 = <double>[];
|
||||||
|
final offsets = <double>[];
|
||||||
|
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<int> stickerIds;
|
||||||
|
final int columns;
|
||||||
|
final double cell;
|
||||||
|
final double headerHeight;
|
||||||
|
final Animation<double> 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<void> _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<double> 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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import 'backend/modules/file_uploader.dart';
|
|||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
import 'backend/modules/outbox.dart';
|
import 'backend/modules/outbox.dart';
|
||||||
import 'backend/modules/polls.dart';
|
import 'backend/modules/polls.dart';
|
||||||
|
import 'backend/modules/stickers.dart';
|
||||||
import 'backend/modules/self_check.dart';
|
import 'backend/modules/self_check.dart';
|
||||||
import 'backend/modules/webapp.dart';
|
import 'backend/modules/webapp.dart';
|
||||||
import 'backend/modules/digital_id.dart';
|
import 'backend/modules/digital_id.dart';
|
||||||
@@ -69,6 +70,7 @@ final api = Api();
|
|||||||
final accountModule = AccountModule(api);
|
final accountModule = AccountModule(api);
|
||||||
final messagesModule = MessagesModule(api);
|
final messagesModule = MessagesModule(api);
|
||||||
final pollsModule = PollsModule(api);
|
final pollsModule = PollsModule(api);
|
||||||
|
final stickersModule = StickersModule(api);
|
||||||
final webAppModule = WebAppModule(api);
|
final webAppModule = WebAppModule(api);
|
||||||
final digitalIdModule = DigitalIdModule(webAppModule);
|
final digitalIdModule = DigitalIdModule(webAppModule);
|
||||||
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
class StickerSet {
|
||||||
|
final int id;
|
||||||
|
final String name;
|
||||||
|
final String iconUrl;
|
||||||
|
final List<int> stickerIds;
|
||||||
|
final String? link;
|
||||||
|
|
||||||
|
const StickerSet({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.iconUrl,
|
||||||
|
required this.stickerIds,
|
||||||
|
this.link,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StickerSet.fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
final rawStickers = map['stickers'];
|
||||||
|
final ids = <int>[];
|
||||||
|
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<dynamic, dynamic> 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user