поиск стикеров

This commit is contained in:
torvalds
2026-07-05 20:50:26 +07:00
parent 0032c0b146
commit dcc359ff20
5 changed files with 311 additions and 31 deletions
File diff suppressed because one or more lines are too long
+24
View File
@@ -170,6 +170,30 @@ class StickersModule {
return _sets[setId]; return _sets[setId];
} }
Future<void> ensureAllStickersLoaded() {
final ids = <int>{..._recentStickerIds};
for (final set in _sets.values) {
ids.addAll(set.stickerIds);
}
return ensureStickers(ids.toList());
}
List<StickerItem> searchByTags(Set<String> emojiTargets) {
if (emojiTargets.isEmpty) return const [];
final result = <StickerItem>[];
for (final sticker in _stickers.values) {
for (final tag in sticker.tags) {
if (emojiTargets.contains(_stripVariation(tag))) {
result.add(sticker);
break;
}
}
}
return result;
}
static String _stripVariation(String s) => s.replaceAll('', '');
void cacheSet(StickerSet set) => _sets[set.id] = set; void cacheSet(StickerSet set) => _sets[set.id] = set;
Future<StickerSet?> resolveSetByLink(String link) async { Future<StickerSet?> resolveSetByLink(String link) async {
+71
View File
@@ -0,0 +1,71 @@
import 'dart:convert';
import 'package:flutter/services.dart';
class _Entry {
final String emoji;
final List<String> tokens;
const _Entry(this.emoji, this.tokens);
}
class EmojiKeywordIndex {
EmojiKeywordIndex._();
static final EmojiKeywordIndex instance = EmojiKeywordIndex._();
static const _variationSelector = '';
static final _wordPattern = RegExp(r'[0-9a-zа-яё\-]{2,}', unicode: true);
final List<_Entry> _entries = [];
final Set<String> _emojiKeys = {};
Future<void>? _loading;
Future<void> ensureLoaded() => _loading ??= _load();
Future<void> _load() async {
final raw = await rootBundle.loadString('assets/emoji_keywords.json');
final map = json.decode(raw) as Map<String, dynamic>;
map.forEach((emoji, words) {
_entries.add(_Entry(emoji, (words as String).split(' ')));
_emojiKeys.add(emoji);
});
}
static String normalize(String emoji) =>
emoji.replaceAll(_variationSelector, '');
Set<String> resolve(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return const {};
final targets = <String>{};
final normalized = normalize(q);
for (final key in _emojiKeys) {
if (normalized.contains(key)) targets.add(key);
}
final words = _wordPattern
.allMatches(q)
.map((m) => m.group(0)!)
.toList(growable: false);
if (words.isEmpty) return targets;
for (final entry in _entries) {
for (final word in words) {
if (_hasPrefix(entry.tokens, word)) {
targets.add(entry.emoji);
break;
}
}
}
return targets;
}
static bool _hasPrefix(List<String> tokens, String prefix) {
for (final token in tokens) {
if (token.startsWith(prefix)) return true;
}
return false;
}
}
+214 -31
View File
@@ -3,6 +3,8 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../core/utils/debouncer.dart';
import '../../core/utils/emoji_keyword_index.dart';
import '../../main.dart' show stickersModule; import '../../main.dart' show stickersModule;
import '../../models/sticker.dart'; import '../../models/sticker.dart';
import 'small_spinner.dart'; import 'small_spinner.dart';
@@ -55,9 +57,15 @@ class _StickerPanelState extends State<StickerPanel>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
static const double _tabBarHeight = 52; static const double _tabBarHeight = 52;
static const double _headerHeight = 34; static const double _headerHeight = 34;
static const double _searchFieldHeight = 50;
final ScrollController _scroll = ScrollController(); final ScrollController _scroll = ScrollController();
final ValueNotifier<bool> _scrolling = ValueNotifier(false); final ValueNotifier<bool> _scrolling = ValueNotifier(false);
final TextEditingController _searchCtrl = TextEditingController();
final FocusNode _searchFocus = FocusNode();
final Debouncer _searchDebouncer = Debouncer(
const Duration(milliseconds: 220),
);
late final AnimationController _shimmer; late final AnimationController _shimmer;
bool _loading = true; bool _loading = true;
Object? _error; Object? _error;
@@ -65,6 +73,9 @@ class _StickerPanelState extends State<StickerPanel>
List<_Section> _sections = const []; List<_Section> _sections = const [];
List<double> _heights = const []; List<double> _heights = const [];
List<double> _offsets = const []; List<double> _offsets = const [];
String _query = '';
bool _searchLoading = false;
List<StickerItem> _results = const [];
@override @override
void initState() { void initState() {
@@ -74,6 +85,7 @@ class _StickerPanelState extends State<StickerPanel>
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
)..repeat(reverse: true); )..repeat(reverse: true);
_scroll.addListener(_onScroll); _scroll.addListener(_onScroll);
EmojiKeywordIndex.instance.ensureLoaded();
_load(); _load();
} }
@@ -82,10 +94,55 @@ class _StickerPanelState extends State<StickerPanel>
_scroll.removeListener(_onScroll); _scroll.removeListener(_onScroll);
_scroll.dispose(); _scroll.dispose();
_scrolling.dispose(); _scrolling.dispose();
_searchCtrl.dispose();
_searchFocus.dispose();
_searchDebouncer.dispose();
_shimmer.dispose(); _shimmer.dispose();
super.dispose(); super.dispose();
} }
void _onQueryChanged(String value) {
final query = value.trim();
if (query == _query) return;
setState(() {
_query = query;
if (query.isEmpty) {
_results = const [];
_searchLoading = false;
} else {
_searchLoading = true;
}
});
if (query.isEmpty) {
_searchDebouncer.cancel();
return;
}
_searchDebouncer.run(() => _runSearch(query));
}
Future<void> _runSearch(String query) async {
await EmojiKeywordIndex.instance.ensureLoaded();
await stickersModule.ensureAllStickersLoaded();
if (!mounted || _query != query) return;
final targets = EmojiKeywordIndex.instance.resolve(query);
final results = stickersModule.searchByTags(targets);
setState(() {
_results = results;
_searchLoading = false;
});
}
void _clearSearch() {
_searchCtrl.clear();
_searchFocus.unfocus();
_searchDebouncer.cancel();
setState(() {
_query = '';
_results = const [];
_searchLoading = false;
});
}
Future<void> _load() async { Future<void> _load() async {
try { try {
await stickersModule.ensureLoaded(); await stickersModule.ensureLoaded();
@@ -127,7 +184,7 @@ class _StickerPanelState extends State<StickerPanel>
} }
void _onScroll() { void _onScroll() {
if (_offsets.isEmpty) return; if (_offsets.isEmpty || _query.isNotEmpty) return;
final pixels = _scroll.position.pixels; final pixels = _scroll.position.pixels;
var index = 0; var index = 0;
for (var i = 0; i < _offsets.length; i++) { for (var i = 0; i < _offsets.length; i++) {
@@ -146,7 +203,19 @@ class _StickerPanelState extends State<StickerPanel>
} }
void _jumpTo(int index) { void _jumpTo(int index) {
if (index < 0 || index >= _offsets.length) return; if (index < 0 || index >= _sections.length) return;
if (_query.isEmpty) {
_scrollToSection(index);
} else {
_clearSearch();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _scrollToSection(index),
);
}
}
void _scrollToSection(int index) {
if (!mounted || index >= _offsets.length || !_scroll.hasClients) return;
setState(() => _selectedTab = index); setState(() => _selectedTab = index);
final max = _scroll.position.maxScrollExtent; final max = _scroll.position.maxScrollExtent;
_scroll.animateTo( _scroll.animateTo(
@@ -183,7 +252,7 @@ class _StickerPanelState extends State<StickerPanel>
final heights = <double>[]; final heights = <double>[];
final offsets = <double>[]; final offsets = <double>[];
var acc = 0.0; var acc = _searchFieldHeight;
for (final s in _sections) { for (final s in _sections) {
final rows = (s.stickerIds.length / columns).ceil(); final rows = (s.stickerIds.length / columns).ceil();
final h = _headerHeight + rows * cell; final h = _headerHeight + rows * cell;
@@ -202,34 +271,7 @@ class _StickerPanelState extends State<StickerPanel>
thickness: 1, thickness: 1,
color: cs.outlineVariant.withValues(alpha: 0.3), color: cs.outlineVariant.withValues(alpha: 0.3),
), ),
Expanded( Expanded(child: _buildContent(cs, columns, cell)),
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,
),
),
),
),
),
),
], ],
); );
}, },
@@ -283,6 +325,146 @@ class _StickerPanelState extends State<StickerPanel>
), ),
); );
} }
Widget _buildContent(ColorScheme cs, int columns, double cell) {
return StickerScrollScope(
isScrolling: _scrolling,
child: StickerPeekScope(
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: CustomScrollView(
controller: _scroll,
slivers: [
SliverToBoxAdapter(child: _buildSearchField(cs)),
if (_query.isEmpty)
SliverVariedExtentList(
itemExtentBuilder: (i, _) => _heights[i],
delegate: SliverChildBuilderDelegate(
(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,
),
childCount: _sections.length,
),
)
else if (_searchLoading)
SliverFillRemaining(
hasScrollBody: false,
child: Center(child: SmallSpinner()),
)
else if (_results.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Ничего не найдено',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 14,
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 4),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
),
delegate: SliverChildBuilderDelegate(
(context, i) => _resultCell(_results[i]),
childCount: _results.length,
),
),
),
],
),
),
),
);
}
Widget _buildSearchField(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 2, 10, 6),
child: Container(
height: 42,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(21),
),
child: Row(
children: [
const SizedBox(width: 12),
Icon(Symbols.search, size: 22, color: cs.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: _onQueryChanged,
textInputAction: TextInputAction.search,
cursorColor: cs.primary,
style: TextStyle(color: cs.onSurface, fontSize: 15),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: 'Поиск',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 15,
),
),
),
),
if (_query.isEmpty)
const SizedBox(width: 12)
else
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _clearSearch,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Icon(
Symbols.close,
size: 20,
color: cs.onSurfaceVariant,
),
),
),
],
),
),
);
}
Widget _resultCell(StickerItem item) {
return StickerPeekable(
peekId: item.id,
url: item.url,
lottieUrl: item.lottieUrl,
tags: item.tags,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onStickerTap(item),
child: Padding(
padding: const EdgeInsets.all(6),
child: StickerImage(
url: item.url,
lottieUrl: item.lottieUrl,
memCacheWidth: 220,
),
),
),
);
}
} }
class _StickerSection extends StatefulWidget { class _StickerSection extends StatefulWidget {
@@ -384,6 +566,7 @@ class _StickerSectionState extends State<_StickerSection> {
peekId: item.id, peekId: item.id,
url: item.url, url: item.url,
lottieUrl: item.lottieUrl, lottieUrl: item.lottieUrl,
tags: item.tags,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => widget.onTap(item), onTap: () => widget.onTap(item),
+1
View File
@@ -125,6 +125,7 @@ flutter:
- assets/komet.png - assets/komet.png
- assets/komet_icon.png - assets/komet_icon.png
- assets/meteor_icon.png - assets/meteor_icon.png
- assets/emoji_keywords.json
- assets/lottie/ - assets/lottie/
fonts: fonts: