feat(market): save others' offers as favorites (Wallapop-style)
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.
This commit is contained in:
parent
f6967e8cbb
commit
d6225eed38
19 changed files with 806 additions and 3 deletions
|
|
@ -103,6 +103,12 @@ class AppDrawer extends StatelessWidget {
|
|||
_DrawerItem(
|
||||
icon: const Icon(Icons.favorite),
|
||||
label: t.menu.wishlist,
|
||||
onTap: marketEnabled
|
||||
? () {
|
||||
Navigator.of(context).pop();
|
||||
context.push('/favorites');
|
||||
}
|
||||
: null,
|
||||
),
|
||||
// The ego-centric web of trust ("your people") — live once the
|
||||
// social layer is on.
|
||||
|
|
|
|||
257
apps/app_seeds/lib/ui/favorites_screen.dart
Normal file
257
apps/app_seeds/lib/ui/favorites_screen.dart
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
|||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/saved_offers_store.dart';
|
||||
import '../services/social_connection.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../state/peer_rating_cubit.dart';
|
||||
|
|
@ -27,6 +28,7 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
|||
this.mine = false,
|
||||
this.profileCache,
|
||||
this.selfPubkey,
|
||||
this.savedOffers,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -38,6 +40,10 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
|||
/// Whether this is the current user's own listing (hide the message button).
|
||||
final bool mine;
|
||||
|
||||
/// Store of the user's saved ("favorite") offers, for the save toggle. Null in
|
||||
/// tests / when there's no social layer → the heart action is hidden.
|
||||
final SavedOffersStore? savedOffers;
|
||||
|
||||
/// Optional cache of peers' published display names; null in tests.
|
||||
final ProfileCache? profileCache;
|
||||
|
||||
|
|
@ -58,10 +64,32 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
|||
bool _profileLoading = true;
|
||||
PeerRatingState? _sellerRating;
|
||||
|
||||
/// Whether this offer is in the user's favorites; null while loading (the
|
||||
/// heart action stays hidden until we know, to avoid a flicker).
|
||||
bool? _saved;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSeller();
|
||||
_loadSaved();
|
||||
}
|
||||
|
||||
Future<void> _loadSaved() async {
|
||||
final store = widget.savedOffers;
|
||||
if (store == null) return;
|
||||
final saved = await store.isSaved(SavedOffersStore.keyOf(widget.offer));
|
||||
if (mounted) setState(() => _saved = saved);
|
||||
}
|
||||
|
||||
Future<void> _toggleSaved() async {
|
||||
final store = widget.savedOffers;
|
||||
if (store == null) return;
|
||||
final nowSaved = await store.toggle(
|
||||
widget.offer,
|
||||
savedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
if (mounted) setState(() => _saved = nowSaved);
|
||||
}
|
||||
|
||||
/// Best-effort fetch of the author's published profile (name/about/Ğ1), the
|
||||
|
|
@ -145,8 +173,23 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final o = widget.offer;
|
||||
final saved = _saved;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(o.summary)),
|
||||
appBar: AppBar(
|
||||
title: Text(o.summary),
|
||||
actions: [
|
||||
// Save other people's offers (Wallapop-style). Hidden on your own
|
||||
// listing and until the saved state is known.
|
||||
if (!widget.mine && widget.savedOffers != null && saved != null)
|
||||
IconButton(
|
||||
key: const Key('offerDetail.save'),
|
||||
icon: Icon(saved ? Icons.favorite : Icons.favorite_border),
|
||||
color: saved ? seedGreen : null,
|
||||
tooltip: saved ? t.favorites.remove : t.favorites.save,
|
||||
onPressed: _toggleSaved,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue