поиск стикеров
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -170,6 +170,30 @@ class StickersModule {
|
||||
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;
|
||||
|
||||
Future<StickerSet?> resolveSetByLink(String link) async {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.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 '../../models/sticker.dart';
|
||||
import 'small_spinner.dart';
|
||||
@@ -55,9 +57,15 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const double _tabBarHeight = 52;
|
||||
static const double _headerHeight = 34;
|
||||
static const double _searchFieldHeight = 50;
|
||||
|
||||
final ScrollController _scroll = ScrollController();
|
||||
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;
|
||||
bool _loading = true;
|
||||
Object? _error;
|
||||
@@ -65,6 +73,9 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
List<_Section> _sections = const [];
|
||||
List<double> _heights = const [];
|
||||
List<double> _offsets = const [];
|
||||
String _query = '';
|
||||
bool _searchLoading = false;
|
||||
List<StickerItem> _results = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -74,6 +85,7 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
_scroll.addListener(_onScroll);
|
||||
EmojiKeywordIndex.instance.ensureLoaded();
|
||||
_load();
|
||||
}
|
||||
|
||||
@@ -82,10 +94,55 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
_scroll.removeListener(_onScroll);
|
||||
_scroll.dispose();
|
||||
_scrolling.dispose();
|
||||
_searchCtrl.dispose();
|
||||
_searchFocus.dispose();
|
||||
_searchDebouncer.dispose();
|
||||
_shimmer.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 {
|
||||
try {
|
||||
await stickersModule.ensureLoaded();
|
||||
@@ -127,7 +184,7 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_offsets.isEmpty) return;
|
||||
if (_offsets.isEmpty || _query.isNotEmpty) return;
|
||||
final pixels = _scroll.position.pixels;
|
||||
var index = 0;
|
||||
for (var i = 0; i < _offsets.length; i++) {
|
||||
@@ -146,7 +203,19 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
}
|
||||
|
||||
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);
|
||||
final max = _scroll.position.maxScrollExtent;
|
||||
_scroll.animateTo(
|
||||
@@ -183,7 +252,7 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
|
||||
final heights = <double>[];
|
||||
final offsets = <double>[];
|
||||
var acc = 0.0;
|
||||
var acc = _searchFieldHeight;
|
||||
for (final s in _sections) {
|
||||
final rows = (s.stickerIds.length / columns).ceil();
|
||||
final h = _headerHeight + rows * cell;
|
||||
@@ -202,34 +271,7 @@ class _StickerPanelState extends State<StickerPanel>
|
||||
thickness: 1,
|
||||
color: cs.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
Expanded(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildContent(cs, columns, cell)),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -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 {
|
||||
@@ -384,6 +566,7 @@ class _StickerSectionState extends State<_StickerSection> {
|
||||
peekId: item.id,
|
||||
url: item.url,
|
||||
lottieUrl: item.lottieUrl,
|
||||
tags: item.tags,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => widget.onTap(item),
|
||||
|
||||
@@ -125,6 +125,7 @@ flutter:
|
||||
- assets/komet.png
|
||||
- assets/komet_icon.png
|
||||
- assets/meteor_icon.png
|
||||
- assets/emoji_keywords.json
|
||||
- assets/lottie/
|
||||
|
||||
fonts:
|
||||
|
||||
Reference in New Issue
Block a user