Files
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

719 lines
20 KiB
Dart

import 'dart:async';
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 'package:shared_preferences/shared_preferences.dart';
import '../../core/utils/debouncer.dart';
import '../../core/utils/emoji_keyword_index.dart';
import '../../main.dart' show stickersModule;
import '../../models/animoji.dart';
import '../../models/sticker.dart';
import 'emoji_panel.dart';
import 'segmented_pill_toggle.dart';
import 'small_spinner.dart';
import 'lottie_image.dart';
import 'sticker_peek.dart';
import '../../core/config/app_frost.dart';
import 'liquid_glass.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;
final void Function(Animoji animoji)? onEmojiTap;
final void Function(double delta)? onResize;
const StickerPanel({
super.key,
required this.height,
required this.onStickerTap,
this.onEmojiTap,
this.onResize,
});
@override
State<StickerPanel> createState() => _StickerPanelState();
}
class _StickerPanelState extends State<StickerPanel>
with SingleTickerProviderStateMixin {
static const double _tabBarHeight = 52;
static const double _headerHeight = 34;
static const double _searchFieldHeight = 50;
static const double _toggleBarHeight = 48;
static const double _resizeHandleHeight = 16;
static const int _modeEmoji = 0;
static const int _modeStickers = 1;
static const String _modePrefKey = 'qlyra_panel_mode';
static int _persistedMode = _modeStickers;
static bool _persistedModeLoaded = false;
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;
late int _mode;
bool _modeUserChosen = false;
int _selectedTab = 0;
List<_Section> _sections = const [];
List<double> _heights = const [];
List<double> _offsets = const [];
String _query = '';
bool _searchLoading = false;
List<StickerItem> _results = const [];
@override
void initState() {
super.initState();
_mode = widget.onEmojiTap == null ? _modeStickers : _persistedMode;
if (!_persistedModeLoaded) unawaited(_loadPersistedMode());
_shimmer = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
_scroll.addListener(_onScroll);
EmojiKeywordIndex.instance.ensureLoaded();
_load();
}
@override
void dispose() {
_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();
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 || _query.isNotEmpty) 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);
}
bool _onScrollNotification(ScrollNotification n) {
if (n is ScrollStartNotification || n is ScrollUpdateNotification) {
if (!_scrolling.value) _scrolling.value = true;
} else if (n is ScrollEndNotification) {
if (_scrolling.value) _scrolling.value = false;
}
return false;
}
void _jumpTo(int index) {
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(
_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 SizedBox(
height: widget.height,
child: GlassSurface(
liquid: false,
frostTint: AppFrost.glassTint(cs),
border: Border(top: AppFrost.hairline(cs)),
child: SafeArea(
top: false,
child: Column(
children: [
if (widget.onResize != null) _buildResizeHandle(cs),
Expanded(
child: _mode == _modeEmoji && widget.onEmojiTap != null
? EmojiPanel(onEmojiTap: widget.onEmojiTap!)
: _buildStickerBody(cs),
),
if (widget.onEmojiTap != null) _buildToggleBar(cs),
],
),
),
),
);
}
Widget _buildResizeHandle(ColorScheme cs) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: (details) => widget.onResize!(-details.delta.dy),
child: SizedBox(
height: _resizeHandleHeight,
child: Center(
child: Container(
width: 38,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(2),
),
),
),
),
);
}
Widget _buildStickerBody(ColorScheme cs) {
if (_loading) return Center(child: SmallSpinner());
if (_error != null || _sections.isEmpty) {
return Center(
child: Text(
_error != null ? 'Не удалось загрузить стикеры' : 'Нет стикеров',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
);
}
return 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 = _searchFieldHeight;
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: _buildContent(cs, columns, cell)),
],
);
},
),
);
}
Future<void> _loadPersistedMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_modePrefKey);
_persistedModeLoaded = true;
if (value != _modeEmoji && value != _modeStickers) return;
_persistedMode = value!;
if (!mounted || _modeUserChosen || widget.onEmojiTap == null) return;
if (_mode != value) setState(() => _mode = value);
} catch (_) {
_persistedModeLoaded = true;
}
}
void _setMode(int mode) {
if (mode == _mode) return;
_modeUserChosen = true;
_persistedMode = mode;
setState(() => _mode = mode);
unawaited(_persistMode(mode));
}
Future<void> _persistMode(int mode) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_modePrefKey, mode);
} catch (_) {}
}
Widget _buildToggleBar(ColorScheme cs) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Divider(
height: 1,
thickness: 1,
color: cs.outlineVariant.withValues(alpha: 0.3),
),
SizedBox(
height: _toggleBarHeight,
child: Center(
child: SegmentedPillToggle(
labels: const ['Эмодзи', 'Стикеры'],
selected: _mode,
onChanged: _setMode,
),
),
),
],
);
}
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,
),
),
),
);
},
),
);
}
Widget _buildContent(ColorScheme cs, int columns, double cell) {
return LottieScrollScope(
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: LottieImage(
url: item.url,
lottieUrl: item.lottieUrl,
memCacheWidth: 220,
),
),
),
);
}
}
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 StickerPeekable(
peekId: item.id,
url: item.url,
lottieUrl: item.lottieUrl,
tags: item.tags,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onTap(item),
child: Padding(
padding: const EdgeInsets.all(6),
child: LottieImage(
url: item.url,
lottieUrl: item.lottieUrl,
memCacheWidth: 220,
),
),
),
);
}
}
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(),
),
);
}
}