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:
parent
cf99b7ff87
commit
4326e79419
13 changed files with 627 additions and 9 deletions
216
apps/app_seeds/lib/ui/chat_screen.dart
Normal file
216
apps/app_seeds/lib/ui/chat_screen.dart
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../state/messages_cubit.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
|
||||
/// metadata-hiding (NIP-17) under the hood; local-first, so it degrades to a
|
||||
/// "set up sharing" note when there's no relay.
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({
|
||||
required this.social,
|
||||
required this.settings,
|
||||
required this.peerPubkey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final SocialService social;
|
||||
final SocialSettings settings;
|
||||
final String peerPubkey;
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
MessagesCubit? _cubit;
|
||||
bool _loading = true;
|
||||
final _input = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final cubit = await createMessagesCubit(
|
||||
widget.social,
|
||||
widget.settings,
|
||||
peerPubkey: widget.peerPubkey,
|
||||
);
|
||||
if (!mounted) {
|
||||
await cubit.close();
|
||||
return;
|
||||
}
|
||||
cubit.start();
|
||||
setState(() {
|
||||
_cubit = cubit;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final cubit = _cubit;
|
||||
if (cubit == null || _input.text.trim().isEmpty) return;
|
||||
final text = _input.text;
|
||||
_input.clear();
|
||||
await cubit.send(text);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cubit?.close();
|
||||
_input.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final cubit = _cubit;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.chat.title)),
|
||||
body: _loading || cubit == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: BlocProvider.value(
|
||||
value: cubit,
|
||||
child: _ChatBody(controller: _input, onSend: _send),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatBody extends StatelessWidget {
|
||||
const _ChatBody({required this.controller, required this.onSend});
|
||||
|
||||
final TextEditingController controller;
|
||||
final Future<void> Function() onSend;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final cubit = context.read<MessagesCubit>();
|
||||
if (!cubit.isOnline) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
t.chat.offline,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 15),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: BlocBuilder<MessagesCubit, ChatState>(
|
||||
builder: (context, state) {
|
||||
if (state.messages.isEmpty) {
|
||||
return Center(
|
||||
child: Text(t.chat.empty,
|
||||
style: const TextStyle(color: seedMuted)),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: state.messages.length,
|
||||
itemBuilder: (context, i) {
|
||||
final m = state.messages[i];
|
||||
return _Bubble(text: m.text, mine: cubit.isMine(m));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_Composer(controller: controller, onSend: onSend),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Bubble extends StatelessWidget {
|
||||
const _Bubble({required this.text, required this.mine});
|
||||
|
||||
final String text;
|
||||
final bool mine;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: mine ? seedGreen : seedPrimaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: mine ? Colors.white : seedOnSurface,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Composer extends StatelessWidget {
|
||||
const _Composer({required this.controller, required this.onSend});
|
||||
|
||||
final TextEditingController controller;
|
||||
final Future<void> Function() onSend;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const Key('chat.input'),
|
||||
controller: controller,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => onSend(),
|
||||
decoration: InputDecoration(
|
||||
hintText: t.chat.hint,
|
||||
filled: true,
|
||||
fillColor: seedField,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
key: const Key('chat.send'),
|
||||
icon: const Icon(Icons.send),
|
||||
tooltip: t.chat.send,
|
||||
onPressed: onSend,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue