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
719 lines
20 KiB
Dart
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(),
|
|
),
|
|
);
|
|
}
|
|
}
|