import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/saved_offers_store.dart'; import '../services/social_connection.dart'; import 'market_widgets.dart'; import 'theme.dart'; /// The Wallapop-style "Favorites": offers from other people the user saved. /// Shows the saved snapshots offline-first; when a relay is reachable it checks /// which ones are still published and flags the rest as no longer available /// (they may have been withdrawn or expired). Tapping opens the offer detail. class FavoritesScreen extends StatefulWidget { const FavoritesScreen({ required this.savedOffers, this.connection, super.key, }); final SavedOffersStore savedOffers; /// The shared relay connection, used to check whether saved offers still /// exist. Null (tests / no social layer) → no check, snapshots shown as-is. final SocialConnection? connection; @override State createState() => _FavoritesScreenState(); } class _FavoritesScreenState extends State { List? _offers; var _liveKeys = {}; var _checked = false; StreamSubscription? _changesSub; @override void initState() { super.initState(); _changesSub = widget.savedOffers.changes.listen((_) => _load()); _load(); } @override void dispose() { _changesSub?.cancel(); super.dispose(); } Future _load() async { final offers = await widget.savedOffers.list(); if (!mounted) return; setState(() => _offers = offers); unawaited(_check(offers)); } /// Asks the relay which saved offers are still out there. Offline (no session) /// leaves [_checked] false, so nothing is wrongly flagged as gone. Future _check(List offers) async { final connection = widget.connection; if (connection == null || offers.isEmpty) return; final session = await connection.session(); if (session == null || !mounted) return; final live = {}; final geohashes = { for (final o in offers) if (o.approxGeohash.isNotEmpty) o.approxGeohash, }; final subs = >[]; for (final geohash in geohashes) { subs.add( session.offers .discover(DiscoveryQuery(geohashPrefix: geohash)) .listen((o) => live.add(SavedOffersStore.keyOf(o)), onError: (_) {}), ); } // The discover streams stay open for live offers and never signal "done"; // give them a beat to deliver what's stored, then take stock. await Future.delayed(const Duration(seconds: 5)); for (final s in subs) { unawaited(s.cancel()); } if (!mounted) return; setState(() { _liveKeys = live; _checked = true; }); } @override Widget build(BuildContext context) { final t = context.t; final offers = _offers; return Scaffold( appBar: AppBar(title: Text(t.favorites.title)), body: offers == null ? const Center(child: CircularProgressIndicator()) : offers.isEmpty ? _Empty(text: t.favorites.empty) : ListView.separated( padding: const EdgeInsets.all(12), itemCount: offers.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, i) { final offer = offers[i]; final key = SavedOffersStore.keyOf(offer); // Only call something gone once a check actually ran and didn't // see it — never while offline (checked == false). final gone = _checked && !_liveKeys.contains(key); return _FavoriteCard( offer: offer, unavailable: gone, onOpen: () => context.push('/market/offer', extra: offer), onRemove: () => widget.savedOffers.remove(key), ); }, ), ); } } class _FavoriteCard extends StatelessWidget { const _FavoriteCard({ required this.offer, required this.unavailable, required this.onOpen, required this.onRemove, }); final Offer offer; final bool unavailable; final VoidCallback onOpen; final VoidCallback onRemove; @override Widget build(BuildContext context) { final t = context.t; final radius = BorderRadius.circular(14); return Material( color: Colors.white, borderRadius: radius, child: InkWell( key: Key('favorites.offer.${offer.authorPubkeyHex}.${offer.id}'), onTap: onOpen, borderRadius: radius, child: Container( decoration: BoxDecoration( borderRadius: radius, border: Border.all(color: seedOutline), ), padding: const EdgeInsets.all(16), child: Opacity( opacity: unavailable ? 0.55 : 1, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (offer.imageUrl != null) ...[ OfferThumbnail( url: offer.imageUrl!, semanticLabel: t.market.photo, ), const SizedBox(width: 12), ], Expanded( child: Text( offer.summary, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w500, color: seedOnSurface, ), ), ), IconButton( key: Key('favorites.remove.${offer.authorPubkeyHex}' '.${offer.id}'), icon: const Icon(Icons.favorite, color: seedGreen), tooltip: t.favorites.remove, onPressed: onRemove, ), ], ), const SizedBox(height: 4), Row( children: [ if (unavailable) Row( children: [ const Icon(Icons.cloud_off, size: 15, color: seedMuted), const SizedBox(width: 4), Text( t.favorites.unavailable, style: const TextStyle(color: seedMuted, fontSize: 13), ), ], ) else OfferTypeChip(label: offerTypeLabel(t, offer.type)), const Spacer(), if (offer.type == OfferType.sale && offer.priceAmount != null) Text( '${offer.priceAmount} ${offer.priceCurrency ?? ''}' .trim(), style: const TextStyle( color: seedOnSurface, fontWeight: FontWeight.w600, ), ), ], ), ], ), ), ), ), ); } } class _Empty extends StatelessWidget { const _Empty({required this.text}); final String text; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.favorite_border, size: 64, color: seedGreen.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( text, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), ], ), ), ); } }