tane/apps/app_seeds/lib/ui/favorites_screen.dart

257 lines
8.1 KiB
Dart

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<FavoritesScreen> createState() => _FavoritesScreenState();
}
class _FavoritesScreenState extends State<FavoritesScreen> {
List<Offer>? _offers;
var _liveKeys = <String>{};
var _checked = false;
StreamSubscription<void>? _changesSub;
@override
void initState() {
super.initState();
_changesSub = widget.savedOffers.changes.listen((_) => _load());
_load();
}
@override
void dispose() {
_changesSub?.cancel();
super.dispose();
}
Future<void> _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<void> _check(List<Offer> offers) async {
final connection = widget.connection;
if (connection == null || offers.isEmpty) return;
final session = await connection.session();
if (session == null || !mounted) return;
final live = <String>{};
final geohashes = {
for (final o in offers)
if (o.approxGeohash.isNotEmpty) o.approxGeohash,
};
final subs = <StreamSubscription<Offer>>[];
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<void>.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: seedFavorite),
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,
),
],
),
),
);
}
}