Adds a Favorites feature: a heart on the offer detail saves another person's listing to an encrypted, per-identity SavedOffersStore (keystore JSON snapshot, no plaintext at rest). A new /favorites screen (wired from the drawer) lists saved offers offline-first and, when a relay is reachable, flags ones that are gone as "no longer available". i18n en/es/pt/ast; store + screen + detail-heart tests.
257 lines
8.1 KiB
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: 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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|