wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
This commit is contained in:
@@ -0,0 +1,696 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user