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 createState() => _GifStickerPickerState(); } class _GifStickerPickerState extends State with SingleTickerProviderStateMixin { late TabController _tabController; final _searchController = TextEditingController(); final _customUrlController = TextEditingController(); final _gifScrollController = ScrollController(); final _packSelectorScroll = ScrollController(); List _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 _packs = []; final Map _packData = {}; String? _selectedPack; bool _stickerLoading = false; bool _packLoading = false; PyramidTheme? _pt; List get _allPackTabs { final tabs = []; 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 _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 _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 _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 _toggleGifFav(String url, String previewUrl, String title) async { await GifFavoriteService.toggle(url, previewUrl, title); setState(() {}); } Future _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.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 _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 _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 _sendSticker(String mxcUrl, String body, Map 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> 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?) ?? []; 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.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( 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 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 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, ), ), ), ), ], ), ), ), ); } }