поиск стикеров
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];
|
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 {
|
||||||
|
|||||||
@@ -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: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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user