feat(block2): messaging UI — private 1:1 chat (NIP-17)

- MessagesCubit: 1:1 conversation over MessageTransport. Filters the shared
  inbox to the peer; tags our own sends with our pubkey (NIP-17 wraps don't come
  back to the sender) so the UI can align bubbles; degrades gracefully offline.
- ChatScreen (mockup 10): message bubbles (mine right / peer left) + a composer;
  opens a session lazily, shows 'set up sharing' when there's no relay.
- Entry point: a 'Message' button on each offer card (hidden on your own
  listings) → /chat/:pubkey.
- Routing + i18n en/es/pt.

Tests: 5 cubit (send/receive/order/filter/offline) + chat offline + market, green.
This commit is contained in:
vjrj 2026-07-10 10:59:47 +02:00
parent 0dd7c28039
commit efd64c4da4
13 changed files with 627 additions and 9 deletions

View file

@ -1,6 +1,7 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
@ -166,17 +167,27 @@ class _MarketScreenState extends State<MarketScreen> {
? const Center(child: CircularProgressIndicator())
: BlocProvider.value(
value: cubit,
child: MarketBody(onConfigure: _openConfig),
child: MarketBody(
onConfigure: _openConfig,
selfPubkey: widget.social.publicKeyHex,
),
),
);
}
}
class MarketBody extends StatelessWidget {
const MarketBody({required this.onConfigure, super.key});
const MarketBody({
required this.onConfigure,
this.selfPubkey,
super.key,
});
final VoidCallback onConfigure;
/// This user's own pubkey, so we don't offer to message our own listings.
final String? selfPubkey;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -215,7 +226,15 @@ class MarketBody extends StatelessWidget {
padding: const EdgeInsets.all(16),
itemCount: state.offers.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) => _OfferCard(offer: state.offers[i]),
itemBuilder: (context, i) {
final o = state.offers[i];
return _OfferCard(
offer: o,
onContact: o.authorPubkeyHex == selfPubkey
? null
: () => context.go('/chat/${o.authorPubkeyHex}'),
);
},
);
},
);
@ -225,10 +244,13 @@ class MarketBody extends StatelessWidget {
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
/// instead of any precise location; price only when it's a sale.
class _OfferCard extends StatelessWidget {
const _OfferCard({required this.offer});
const _OfferCard({required this.offer, this.onContact});
final Offer offer;
/// Opens a private chat with the author; null for our own listings.
final VoidCallback? onContact;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -263,8 +285,8 @@ class _OfferCard extends StatelessWidget {
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
const SizedBox(width: 4),
Text(t.market.near, style: const TextStyle(color: seedMuted, fontSize: 13)),
if (offer.type == OfferType.sale && offer.priceAmount != null) ...[
const Spacer(),
const Spacer(),
if (offer.type == OfferType.sale && offer.priceAmount != null)
Text(
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
style: const TextStyle(
@ -272,7 +294,12 @@ class _OfferCard extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
],
if (onContact != null)
TextButton.icon(
onPressed: onContact,
icon: const Icon(Icons.chat_bubble_outline, size: 16),
label: Text(t.market.contact),
),
],
),
],