99f10c5210
Dart 3 erlaubt mehrere Wildcard-Parameter mit demselben Namen "_" in einem Scope, daher sind __ / ___ für ungenutzte Callback-Parameter (pageBuilder, errorBuilder etc.) unnötig. Rein mechanische Änderung, keine Verhaltensänderung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
697 lines
24 KiB
Dart
697 lines
24 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:pyramid/core/theme.dart';
|
|
import 'package:pyramid/utils/gif_favorite_service.dart';
|
|
|
|
const _giphyApiKey = 'QtEyHWSKVIZJersKNBbJGYIgOhwawjkk';
|
|
const _stickerServerUrl = 'https://stickers.steggi-matrix.work';
|
|
|
|
class GifStickerPicker extends StatefulWidget {
|
|
final Room room;
|
|
final VoidCallback onClose;
|
|
|
|
const GifStickerPicker({
|
|
super.key,
|
|
required this.room,
|
|
required this.onClose,
|
|
});
|
|
|
|
@override
|
|
State<GifStickerPicker> createState() => _GifStickerPickerState();
|
|
}
|
|
|
|
class _GifStickerPickerState extends State<GifStickerPicker>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
final _searchController = TextEditingController();
|
|
final _customUrlController = TextEditingController();
|
|
final _gifScrollController = ScrollController();
|
|
final _packSelectorScroll = ScrollController();
|
|
|
|
List<dynamic> _gifs = [];
|
|
bool _gifLoading = false;
|
|
int _gifOffset = 0;
|
|
int _gifTotal = 0;
|
|
String _gifQuery = '';
|
|
int? _hoveredGifIndex;
|
|
int? _hoveredFavIndex;
|
|
|
|
static const _kFavsPack = '__favs__';
|
|
static const _kMyPack = '__my__';
|
|
|
|
List<String> _packs = [];
|
|
final Map<String, dynamic> _packData = {};
|
|
String? _selectedPack;
|
|
bool _stickerLoading = false;
|
|
bool _packLoading = false;
|
|
PyramidTheme? _pt;
|
|
|
|
List<String> get _allPackTabs {
|
|
final tabs = <String>[];
|
|
if (GifFavoriteService.stickerFavoritesItems.isNotEmpty) tabs.add(_kFavsPack);
|
|
if (GifFavoriteService.userStickerItems.isNotEmpty) tabs.add(_kMyPack);
|
|
tabs.addAll(_packs);
|
|
return tabs;
|
|
}
|
|
|
|
void _selectDefaultTab() {
|
|
final tabs = _allPackTabs;
|
|
if (tabs.isNotEmpty && (_selectedPack == null || !tabs.contains(_selectedPack))) {
|
|
setState(() => _selectedPack = tabs.first);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
_gifScrollController.addListener(_onGifScroll);
|
|
_loadTrendingGifs();
|
|
GifFavoriteService.load().then((_) => setState(() {}));
|
|
GifFavoriteService.loadStickers().then((_) {
|
|
_selectDefaultTab();
|
|
setState(() {});
|
|
});
|
|
_loadStickerPacks();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
_searchController.dispose();
|
|
_customUrlController.dispose();
|
|
_gifScrollController.dispose();
|
|
_packSelectorScroll.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onGifScroll() {
|
|
if (_gifScrollController.position.pixels >=
|
|
_gifScrollController.position.maxScrollExtent - 300) {
|
|
if (!_gifLoading && _gifOffset < _gifTotal) _loadMoreGifs();
|
|
}
|
|
}
|
|
|
|
Future<void> _loadTrendingGifs() async {
|
|
setState(() { _gifLoading = true; _gifs = []; _gifOffset = 0; _gifQuery = ''; });
|
|
try {
|
|
final res = await http.get(Uri.parse(
|
|
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=24&offset=0',
|
|
));
|
|
final data = jsonDecode(res.body);
|
|
setState(() {
|
|
_gifs = data['data'] ?? [];
|
|
_gifTotal = data['pagination']?['total_count'] ?? 0;
|
|
_gifOffset = _gifs.length;
|
|
_gifLoading = false;
|
|
});
|
|
} catch (_) {
|
|
setState(() => _gifLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _searchGifs(String query) async {
|
|
if (query.isEmpty) { _loadTrendingGifs(); return; }
|
|
setState(() { _gifLoading = true; _gifs = []; _gifOffset = 0; _gifQuery = query; });
|
|
try {
|
|
final res = await http.get(Uri.parse(
|
|
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=24&offset=0',
|
|
));
|
|
final data = jsonDecode(res.body);
|
|
setState(() {
|
|
_gifs = data['data'] ?? [];
|
|
_gifTotal = data['pagination']?['total_count'] ?? 0;
|
|
_gifOffset = _gifs.length;
|
|
_gifLoading = false;
|
|
});
|
|
} catch (_) {
|
|
setState(() => _gifLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMoreGifs() async {
|
|
if (_gifLoading) return;
|
|
setState(() => _gifLoading = true);
|
|
try {
|
|
final url = _gifQuery.isEmpty
|
|
? 'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=24&offset=$_gifOffset'
|
|
: 'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(_gifQuery)}&limit=24&offset=$_gifOffset';
|
|
final res = await http.get(Uri.parse(url));
|
|
final data = jsonDecode(res.body);
|
|
setState(() {
|
|
_gifs.addAll(data['data'] ?? []);
|
|
_gifOffset = _gifs.length;
|
|
_gifLoading = false;
|
|
});
|
|
} catch (_) {
|
|
setState(() => _gifLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleGifFav(String url, String previewUrl, String title) async {
|
|
await GifFavoriteService.toggle(url, previewUrl, title);
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _loadStickerPacks() async {
|
|
setState(() => _stickerLoading = true);
|
|
try {
|
|
final userId = widget.room.client.userID ?? '';
|
|
final res = await http.get(Uri.parse(
|
|
'$_stickerServerUrl/packs/index.json?userId=${Uri.encodeComponent(userId)}',
|
|
));
|
|
final data = jsonDecode(res.body);
|
|
final packs = List<String>.from(data['packs'] ?? [])
|
|
.where((p) => !p.startsWith('__'))
|
|
.toList();
|
|
setState(() { _packs = packs; _stickerLoading = false; });
|
|
_selectDefaultTab();
|
|
if (_selectedPack != null && _selectedPack != _kFavsPack && _selectedPack != _kMyPack) {
|
|
await _loadPack(_selectedPack!);
|
|
}
|
|
} catch (_) {
|
|
setState(() => _stickerLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadPack(String packId) async {
|
|
if (_packData.containsKey(packId)) return;
|
|
setState(() => _packLoading = true);
|
|
try {
|
|
final res = await http.get(Uri.parse('$_stickerServerUrl/packs/$packId/pack.json'));
|
|
setState(() { _packData[packId] = jsonDecode(res.body); _packLoading = false; });
|
|
} catch (_) {
|
|
setState(() => _packLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _sendGif(String url, String title) async {
|
|
widget.onClose();
|
|
await widget.room.sendEvent({
|
|
'msgtype': 'm.image',
|
|
'body': title.isNotEmpty ? title : 'GIF',
|
|
'url': url,
|
|
'info': {'mimetype': 'image/gif'},
|
|
});
|
|
}
|
|
|
|
Future<void> _sendSticker(String mxcUrl, String body, Map<String, dynamic> info) async {
|
|
widget.onClose();
|
|
await widget.room.sendEvent(
|
|
{'body': body, 'url': mxcUrl, 'info': info},
|
|
type: EventTypes.Sticker,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = PyramidTheme.of(context);
|
|
_pt = pt;
|
|
return Container(
|
|
width: 380,
|
|
height: 460,
|
|
decoration: BoxDecoration(
|
|
color: pt.bg1,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: pt.border),
|
|
boxShadow: const [
|
|
BoxShadow(color: Color(0x50000000), blurRadius: 24, offset: Offset(0, 8))
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Column(
|
|
children: [
|
|
// Header with tabs
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(bottom: BorderSide(color: pt.border)),
|
|
),
|
|
child: TabBar(
|
|
controller: _tabController,
|
|
indicatorColor: pt.accent,
|
|
labelColor: pt.accent,
|
|
unselectedLabelColor: pt.fgMuted,
|
|
tabs: const [
|
|
Tab(icon: Icon(Icons.gif_box_outlined, size: 20)),
|
|
Tab(icon: Icon(Icons.sticky_note_2_outlined, size: 20)),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [_buildGifTab(), _buildStickerTab()],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── GIF Tab ──────────────────────────────────────────────────────────────
|
|
|
|
Widget _buildGifTab() {
|
|
final favorites = GifFavoriteService.cache;
|
|
final isSearching = _gifQuery.isNotEmpty;
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
style: TextStyle(color: _pt!.fg, fontSize: 13),
|
|
decoration: InputDecoration(
|
|
hintText: 'GIFs suchen…',
|
|
hintStyle: TextStyle(color: _pt!.fgDim, fontSize: 13),
|
|
prefixIcon: Icon(Icons.search, size: 16, color: _pt!.fgDim),
|
|
isDense: true,
|
|
suffixIcon: _searchController.text.isNotEmpty
|
|
? IconButton(
|
|
icon: Icon(Icons.clear, size: 16, color: _pt!.fgDim),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
setState(() {});
|
|
_loadTrendingGifs();
|
|
},
|
|
)
|
|
: null,
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
|
filled: true,
|
|
fillColor: _pt!.bg2,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: _pt!.border)),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: _pt!.border)),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: _pt!.accent)),
|
|
),
|
|
onChanged: (v) => setState(() {}),
|
|
onSubmitted: _searchGifs,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _gifLoading && _gifs.isEmpty
|
|
? Center(child: CircularProgressIndicator(color: _pt!.accent))
|
|
: CustomScrollView(
|
|
controller: _gifScrollController,
|
|
slivers: [
|
|
if (!isSearching && favorites.isNotEmpty) ...[
|
|
SliverToBoxAdapter(child: _SectionLabel('❤ Favoriten')),
|
|
_gifGrid(
|
|
count: favorites.length,
|
|
builder: (i) {
|
|
final fav = favorites[i];
|
|
final url = fav['url'] ?? '';
|
|
final preview = fav['preview_url'] ?? url;
|
|
final title = fav['title'] ?? 'GIF';
|
|
return _GifCell(
|
|
imageUrl: preview,
|
|
isFavorite: true,
|
|
isHovered: _hoveredFavIndex == i,
|
|
onHoverChanged: (h) => setState(() => _hoveredFavIndex = h ? i : null),
|
|
onTap: () => _sendGif(url, title),
|
|
onFavToggle: () => _toggleGifFav(url, preview, title),
|
|
);
|
|
},
|
|
),
|
|
SliverToBoxAdapter(child: _SectionLabel('Trending')),
|
|
],
|
|
_gifGrid(
|
|
count: _gifs.length,
|
|
builder: (i) {
|
|
final gif = _gifs[i];
|
|
final preview = gif['images']?['fixed_height_small']?['url'] ?? gif['images']?['fixed_height']?['url'] ?? '';
|
|
final full = gif['images']?['fixed_height']?['url'] ?? '';
|
|
final title = gif['title'] ?? 'GIF';
|
|
final fav = GifFavoriteService.isFavorite(full);
|
|
return _GifCell(
|
|
imageUrl: preview,
|
|
isFavorite: fav,
|
|
isHovered: _hoveredGifIndex == i,
|
|
onHoverChanged: (h) => setState(() => _hoveredGifIndex = h ? i : null),
|
|
onTap: () => _sendGif(full, title),
|
|
onFavToggle: () => _toggleGifFav(full, preview, title),
|
|
);
|
|
},
|
|
),
|
|
if (_gifLoading)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Center(child: CircularProgressIndicator(
|
|
strokeWidth: 2, color: _pt!.accent)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
SliverGrid _gifGrid({required int count, required Widget Function(int) builder}) =>
|
|
SliverGrid(
|
|
delegate: SliverChildBuilderDelegate((ctx, i) => builder(i), childCount: count),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 3, crossAxisSpacing: 2, mainAxisSpacing: 2, childAspectRatio: 1,
|
|
),
|
|
);
|
|
|
|
// ── Sticker Tab ──────────────────────────────────────────────────────────
|
|
|
|
Widget _buildStickerTab() {
|
|
final allTabs = _allPackTabs;
|
|
if (_stickerLoading) {
|
|
return Center(child: CircularProgressIndicator(color: _pt!.accent));
|
|
}
|
|
if (allTabs.isEmpty) {
|
|
return Center(child: Text('Keine Sticker verfügbar', style: TextStyle(color: _pt!.fgMuted)));
|
|
}
|
|
final selected = (allTabs.contains(_selectedPack) ? _selectedPack : allTabs.first)!;
|
|
|
|
return Column(
|
|
children: [
|
|
SizedBox(
|
|
height: 38,
|
|
child: ListView.builder(
|
|
controller: _packSelectorScroll,
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
|
itemCount: allTabs.length,
|
|
itemBuilder: (context, index) {
|
|
final tab = allTabs[index];
|
|
final isSelected = tab == selected;
|
|
String label = tab == _kFavsPack ? '⭐ Favoriten' : tab == _kMyPack ? '🎨 Meine' : (_packData[tab]?['title'] as String? ?? tab);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: GestureDetector(
|
|
onTap: () async {
|
|
setState(() => _selectedPack = tab);
|
|
if (tab != _kFavsPack && tab != _kMyPack) await _loadPack(tab);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? _pt!.accentSoft : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: isSelected ? _pt!.accent : _pt!.border,
|
|
),
|
|
),
|
|
child: Text(label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isSelected ? _pt!.accent : _pt!.fgMuted,
|
|
)),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Container(height: 1, color: _pt!.border),
|
|
Expanded(
|
|
child: selected == _kFavsPack
|
|
? _buildStickerGrid(GifFavoriteService.stickerFavoritesItems)
|
|
: selected == _kMyPack
|
|
? _buildStickerGrid(GifFavoriteService.userStickerItems, isMyStickers: true)
|
|
: _buildPackGrid(selected),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildStickerGrid(List<Map<String, dynamic>> items, {bool isMyStickers = false}) {
|
|
if (items.isEmpty) {
|
|
return Center(child: Text('Keine Sticker', style: TextStyle(color: _pt!.fgMuted)));
|
|
}
|
|
return GridView.builder(
|
|
padding: const EdgeInsets.all(4),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 4, crossAxisSpacing: 3, mainAxisSpacing: 3,
|
|
),
|
|
itemCount: items.length,
|
|
itemBuilder: (ctx, i) {
|
|
final s = items[i];
|
|
final piUrl = s['url'] as String? ?? '';
|
|
final sendMxc = (s['mxc'] as String?)?.isNotEmpty == true
|
|
? s['mxc'] as String
|
|
: (s['mxc_source'] as String? ?? '');
|
|
final mxcSource = s['mxc_source'] as String? ?? '';
|
|
final title = s['title'] as String? ?? 'Sticker';
|
|
final info = {'mimetype': s['mimetype'] ?? 'image/webp'};
|
|
final isUserSticker = s['user_sticker'] == true;
|
|
|
|
return _StickerCell(
|
|
piUrl: piUrl,
|
|
isFavorited: s['favorited'] == true,
|
|
showHeart: isMyStickers && isUserSticker,
|
|
onTap: () => _sendSticker(sendMxc, title, info),
|
|
onToggleFavorite: (isMyStickers && isUserSticker)
|
|
? () async {
|
|
final newFav = !(s['favorited'] == true);
|
|
await GifFavoriteService.setUserStickerFavorited(mxcSource, newFav);
|
|
if (mounted) setState(() {});
|
|
}
|
|
: null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPackGrid(String packId) {
|
|
if (_packLoading) {
|
|
return Center(child: CircularProgressIndicator(color: _pt!.accent));
|
|
}
|
|
final stickers = (_packData[packId]?['stickers'] as List<dynamic>?) ?? [];
|
|
if (stickers.isEmpty) {
|
|
return Center(child: Text('Leer', style: TextStyle(color: _pt!.fgMuted)));
|
|
}
|
|
return GridView.builder(
|
|
padding: const EdgeInsets.all(4),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 4, crossAxisSpacing: 3, mainAxisSpacing: 3,
|
|
),
|
|
itemCount: stickers.length,
|
|
itemBuilder: (ctx, i) {
|
|
final s = stickers[i];
|
|
final id = s['id']?.toString() ?? '';
|
|
final thumbUrl = '$_stickerServerUrl/packs/$packId/${id}_thumb.png';
|
|
final mxcUrl = (s['url'] as String?) ?? '';
|
|
final body = (s['body'] as String?) ?? '';
|
|
final mimetype = (s['info']?['mimetype'] as String?) ?? 'image/webp';
|
|
final info = Map<String, dynamic>.from(s['info'] as Map? ?? {});
|
|
|
|
final isFav = GifFavoriteService.isStickerFavorite(mxcUrl);
|
|
|
|
return GestureDetector(
|
|
onTap: () => _sendSticker(mxcUrl, body, info),
|
|
onLongPressStart: (details) {
|
|
final dx = details.globalPosition.dx;
|
|
final dy = details.globalPosition.dy;
|
|
final screen = MediaQuery.sizeOf(context);
|
|
showMenu<String>(
|
|
context: context,
|
|
position: RelativeRect.fromLTRB(
|
|
dx, dy, screen.width - dx, screen.height - dy,
|
|
),
|
|
items: [
|
|
PopupMenuItem(
|
|
value: 'fav',
|
|
child: Row(children: [
|
|
Icon(
|
|
isFav ? Icons.favorite_border : Icons.favorite,
|
|
size: 16,
|
|
color: isFav ? _pt!.fgMuted : Colors.red,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
isFav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen',
|
|
style: TextStyle(color: _pt!.fg, fontSize: 14),
|
|
),
|
|
]),
|
|
),
|
|
],
|
|
).then((val) async {
|
|
if (val == 'fav') {
|
|
await GifFavoriteService.togglePackStickerFavorite(
|
|
mxcUrl: mxcUrl,
|
|
thumbUrl: thumbUrl,
|
|
title: body,
|
|
mimetype: mimetype,
|
|
);
|
|
if (mounted) setState(() {});
|
|
}
|
|
});
|
|
},
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: Image.network(
|
|
thumbUrl,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (_, _, _) => Container(color: _pt!.bg2),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StickerCell extends StatefulWidget {
|
|
final String piUrl;
|
|
final bool isFavorited;
|
|
final bool showHeart;
|
|
final VoidCallback onTap;
|
|
final Future<void> Function()? onToggleFavorite;
|
|
|
|
const _StickerCell({
|
|
required this.piUrl,
|
|
required this.isFavorited,
|
|
required this.showHeart,
|
|
required this.onTap,
|
|
this.onToggleFavorite,
|
|
});
|
|
|
|
@override
|
|
State<_StickerCell> createState() => _StickerCellState();
|
|
}
|
|
|
|
class _StickerCellState extends State<_StickerCell> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = PyramidTheme.of(context);
|
|
final showOverlay = widget.showHeart && (_hovered || widget.isFavorited);
|
|
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: GestureDetector(
|
|
onTap: widget.onTap,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Image.network(
|
|
widget.piUrl,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (_, _, _) => Container(color: pt.bg2),
|
|
),
|
|
if (showOverlay)
|
|
Positioned(
|
|
top: 4,
|
|
right: 4,
|
|
child: GestureDetector(
|
|
onTap: widget.onToggleFavorite,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(
|
|
width: 22,
|
|
height: 22,
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
widget.isFavorited ? Icons.favorite : Icons.favorite_border,
|
|
color: widget.isFavorited ? Colors.red : Colors.white,
|
|
size: 13,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionLabel extends StatelessWidget {
|
|
final String title;
|
|
const _SectionLabel(this.title);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
|
child: Text(title,
|
|
style: TextStyle(color: PyramidColors.fgDim,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.6)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GifCell extends StatelessWidget {
|
|
final String imageUrl;
|
|
final bool isFavorite;
|
|
final bool isHovered;
|
|
final ValueChanged<bool> onHoverChanged;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onFavToggle;
|
|
|
|
const _GifCell({
|
|
required this.imageUrl,
|
|
required this.isFavorite,
|
|
required this.isHovered,
|
|
required this.onHoverChanged,
|
|
required this.onTap,
|
|
required this.onFavToggle,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MouseRegion(
|
|
onEnter: (_) => onHoverChanged(true),
|
|
onExit: (_) => onHoverChanged(false),
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Image.network(imageUrl, fit: BoxFit.cover,
|
|
errorBuilder: (_, _, _) => Container(color: PyramidTheme.of(context).bg3)),
|
|
if (isHovered || isFavorite)
|
|
Positioned(
|
|
top: 4, right: 4,
|
|
child: GestureDetector(
|
|
onTap: onFavToggle,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(
|
|
width: 22, height: 22,
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
isFavorite ? Icons.favorite : Icons.favorite_border,
|
|
color: isFavorite ? Colors.red : Colors.white,
|
|
size: 13,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|