Files
pyramid/lib/features/chat/gif_sticker_picker.dart
Bernd Steckmeister 99f10c5210 style: doppelte Unterstriche in Closure-Parametern bereinigen
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>
2026-07-03 06:30:40 +02:00

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,
),
),
),
),
],
),
),
),
);
}
}