tane/apps/app_seeds/lib/ui/chat_screen.dart
vjrj 7a82505a4e feat(plantare): tie the propose form to a real seed
The propose sheet used a free-text seed name, unlinked from the
inventory. Now the seed is picked from your catalogued varieties so the
Plantaré is always tied to a real Variety (tap-through to its detail, and
it shows in that seed's commitments):

- Seed field is an Autocomplete over the inventory; on send the typed name
  resolves to the matching Variety, or creates a quick one if it's new —
  so varietyId is never null. Sheet takes the VarietyRepository; chat
  passes it.
- New repo one-shot `varietyLabels()` (id + label, no joins) feeds the
  picker. Deliberately a Future, not `watchInventory()` — a transient
  sheet must not hold a live Drift subscription (that hung the widget test
  ~36 min). Tests use it too and now run in ~2s with bounded pumps + a
  tall surface, no pumpAndSettle.

Tests: a new name creates & links a Variety; a matching name links the
existing one without duplicating. ui/services/data plantare suites green.
2026-07-15 01:24:50 +02:00

815 lines
27 KiB
Dart

import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/variety_repository.dart';
import '../di/injector.dart';
import '../domain/chat_timeline.dart';
import '../domain/message_rules.dart';
import '../i18n/strings.g.dart';
import '../services/message_store.dart';
import '../services/onboarding_store.dart';
import '../services/plantare_service.dart';
import '../services/profile_cache.dart';
import '../services/profile_store.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
import '../services/unread_service.dart';
import '../state/messages_cubit.dart';
import '../state/peer_rating_cubit.dart';
import '../state/trust_cubit.dart';
import 'market_gate.dart';
import 'peer_avatar.dart';
import 'plantare_propose_sheet.dart';
import 'rating_sheet.dart';
import 'report_sheet.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.connection,
required this.peerPubkey,
this.messageStore,
this.profileCache,
this.onboarding,
this.settings,
super.key,
});
final SocialService social;
/// The shared relay connection (one per identity), carrying messaging + trust.
final SocialConnection connection;
final String peerPubkey;
/// Optional persistence for chat history (encrypted [ChatDatabase]-backed);
/// null in tests.
final MessageStore? messageStore;
/// Optional cache of peer display names; null in tests.
final ProfileCache? profileCache;
/// When set, the first message requires the one-time community-rules
/// acceptance (a chat can be reached from an incoming message without ever
/// entering the market). Null in tests → no gate.
final OnboardingStore? onboarding;
/// Social settings holding the local blocklist; falls back to the app-wide
/// instance so the block action also works when not injected. Null in
/// tests → the block menu is hidden.
final SocialSettings? settings;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
MessagesCubit? _messages;
TrustCubit? _trust;
PeerRatingCubit? _rating;
bool _loading = true;
String? _peerName;
String? _selfName;
String? _peerPicture;
String? _selfPicture;
String? _peerG1;
final _input = TextEditingController();
/// App-wide unread tracker (absent in tests / inventory-only builds). While
/// this chat is open it is the "active peer", so incoming messages for it are
/// read on arrival and never badge or notify.
UnreadService? _unread;
@override
void initState() {
super.initState();
_unread = getIt.isRegistered<UnreadService>()
? getIt<UnreadService>()
: null;
// Set synchronously (before any await) so a message landing during _init is
// already suppressed.
_unread?.activePeer = widget.peerPubkey;
unawaited(_unread?.markRead(widget.peerPubkey));
_init();
}
Future<void> _init() async {
// The shared connection carries both messaging and trust. Offline (not
// connected / unreachable) → null transports and the screen degrades.
final session = await widget.connection.session();
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
final cachedPicture = await widget.profileCache?.picture(widget.peerPubkey);
// My own name + avatar, so my side shows me (not a bare glyph).
final selfStore = getIt.isRegistered<ProfileStore>()
? getIt<ProfileStore>()
: null;
final selfName = selfStore == null ? '' : await selfStore.name();
final selfAvatar = selfStore == null ? '' : await selfStore.avatar();
if (!mounted) return; // shared session is owned by the connection, not us
final self = widget.social.publicKeyHex;
final messages = MessagesCubit(
session?.messages,
peerPubkey: widget.peerPubkey,
selfPubkey: self,
store: widget.messageStore,
)..start();
final trust = TrustCubit(
session?.trust,
peerPubkey: widget.peerPubkey,
selfPubkey: self,
);
unawaited(trust.load());
final rating = PeerRatingCubit(
session?.ratings,
peerPubkey: widget.peerPubkey,
selfPubkey: self,
trust: session?.trust,
);
unawaited(rating.load());
setState(() {
_messages = messages;
_trust = trust;
_rating = rating;
_peerName = cachedName;
_selfName = selfName.isEmpty ? null : selfName;
_peerPicture = cachedPicture;
_selfPicture = selfAvatar.isEmpty ? null : selfAvatar;
_loading = false;
});
// Freshen the peer's name from their published profile.
final live = session;
if (live != null && widget.profileCache != null) {
unawaited(() async {
try {
final profile = await live.profile.fetch(widget.peerPubkey);
if (profile != null && mounted) {
if (profile.name.isNotEmpty) {
await widget.profileCache!.setName(
widget.peerPubkey,
profile.name,
);
}
if (profile.picture.isNotEmpty) {
await widget.profileCache!.setPicture(
widget.peerPubkey,
profile.picture,
);
}
setState(() {
if (profile.name.isNotEmpty) _peerName = profile.name;
if (profile.picture.isNotEmpty) _peerPicture = profile.picture;
if (profile.g1.isNotEmpty) _peerG1 = profile.g1;
});
}
} catch (_) {
// best effort
}
}());
}
}
/// Opens the peer's Ğ1 wallet so you can pay them (Ğ1 is separate from the
/// Nostr key — this uses the address they published in their profile). Falls
/// back to copying the address if no wallet handles the link.
///
/// NOTE: confirm the deep-link scheme for the target wallet (Ğ1nkgo / Ğecko /
/// Cesium) — this uses the Cesium web wallet as a universal fallback.
Future<void> _payG1() async {
final g1 = _peerG1;
if (g1 == null) return;
final messenger = ScaffoldMessenger.of(context);
final t = context.t;
final uri = Uri.parse('https://demo.cesium.app/#/app/wallet/$g1/');
var launched = false;
try {
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
launched = false;
}
if (!launched && mounted) {
await Clipboard.setData(ClipboardData(text: g1));
messenger.showSnackBar(SnackBar(content: Text(t.chat.g1Copied)));
}
}
SocialSettings? get _settings =>
widget.settings ??
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
/// Reports this peer (a standard report event to the community servers);
/// when they were also blocked from the sheet, leaves the chat too.
/// Opens the "propose a signed Plantaré" sheet against this peer (their key is
/// in hand from the conversation), and confirms when a proposal is sent.
Future<void> _proposePlantare() async {
if (!getIt.isRegistered<PlantareService>()) return;
final sent = await showProposePlantareSheet(
context,
service: getIt<PlantareService>(),
repository: getIt<VarietyRepository>(),
peerPubkey: widget.peerPubkey,
peerName: _peerName,
);
if (sent == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t.plantare.sent)),
);
}
}
Future<void> _reportPeer() async {
final outcome = await showReportSheet(
context,
connection: widget.connection,
subjectPubkey: widget.peerPubkey,
settings: _settings,
);
if (outcome != null && outcome.blocked && mounted) {
Navigator.of(context).pop();
}
}
/// Blocks this peer after confirmation and leaves the chat; their offers,
/// conversation and future messages disappear (all locally).
Future<void> _blockPeer() async {
final settings = _settings;
if (settings == null) return;
final t = context.t;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.block.confirmTitle),
content: Text(t.block.confirmBody),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(t.block.confirm),
),
],
),
);
if (confirmed != true || !mounted) return;
await settings.block(widget.peerPubkey);
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(t.block.blockedToast)));
Navigator.of(context).pop();
}
Future<void> _send() async {
final cubit = _messages;
if (cubit == null || _input.text.trim().isEmpty) return;
// Writing to someone is creating content: the community-rules acceptance
// must exist even when this chat was reached without entering the market.
final store = widget.onboarding;
if (store != null) {
final ok = await ensureMarketRulesAccepted(context, store);
if (!ok || !mounted) return;
}
final text = _input.text;
// Links aren't allowed — warn and keep the text so it can be edited.
if (containsUrl(text)) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(context.t.chat.noLinks)));
return;
}
_input.clear();
await cubit.send(text);
}
@override
void dispose() {
if (_unread?.activePeer == widget.peerPubkey) _unread!.activePeer = null;
// Mark read once more on the way out, so anything that arrived while the
// chat was open (and was auto-read) stays cleared.
unawaited(_unread?.markRead(widget.peerPubkey));
_messages?.close();
_trust?.close();
_rating?.close();
// The session belongs to the shared connection — don't close it here.
_input.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final messages = _messages;
final trust = _trust;
final rating = _rating;
final t = context.t;
return Scaffold(
appBar: AppBar(
title: Text(_peerName ?? shortPubkey(widget.peerPubkey)),
actions: [
if (_peerG1 != null)
IconButton(
key: const Key('chat.payG1'),
icon: const Icon(Icons.payments_outlined),
tooltip: t.chat.payG1,
onPressed: _payG1,
),
PopupMenuButton<String>(
key: const Key('chat.menu'),
onSelected: (value) {
if (value == 'plantare') _proposePlantare();
if (value == 'report') _reportPeer();
if (value == 'block') _blockPeer();
},
itemBuilder: (context) => [
if (getIt.isRegistered<PlantareService>())
PopupMenuItem(
value: 'plantare',
child: Text(t.plantare.propose),
),
PopupMenuItem(
value: 'report',
child: Text(t.report.person),
),
if (_settings != null)
PopupMenuItem(
value: 'block',
child: Text(t.block.action),
),
],
),
],
),
body: _loading || messages == null || trust == null || rating == null
? const Center(child: CircularProgressIndicator())
: MultiBlocProvider(
providers: [
BlocProvider.value(value: messages),
BlocProvider.value(value: trust),
BlocProvider.value(value: rating),
],
child: _ChatBody(
controller: _input,
onSend: _send,
peerName: _peerName,
selfName: _selfName,
peerPicture: _peerPicture,
selfPicture: _selfPicture,
),
),
);
}
}
class _ChatBody extends StatelessWidget {
const _ChatBody({
required this.controller,
required this.onSend,
this.peerName,
this.selfName,
this.peerPicture,
this.selfPicture,
});
final TextEditingController controller;
final Future<void> Function() onSend;
final String? peerName;
final String? selfName;
final String? peerPicture;
final String? selfPicture;
@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),
),
),
);
}
// Surface a failed send instead of losing it silently (relay down, etc.).
return BlocListener<MessagesCubit, ChatState>(
listenWhen: (prev, cur) => cur.error != null && prev.error != cur.error,
listener: (context, state) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(t.chat.sendError)));
},
child: Column(
children: [
// Free the vertical space the trust/rating strips take while the
// keyboard is up: on short screens (e.g. landscape) their fixed height
// plus the composer would otherwise overflow the shrunken body. They
// reappear the moment the keyboard is dismissed.
const HideWhenKeyboardOpen(child: _TrustBanner()),
const HideWhenKeyboardOpen(child: _RatingStrip()),
Expanded(
child: ChatMessageList(
peerName: peerName,
selfName: selfName,
peerPicture: peerPicture,
selfPicture: selfPicture,
),
),
_Composer(controller: controller, onSend: onSend),
],
),
);
}
}
/// Hides [child] while the on-screen keyboard is open. Used for the chat's
/// secondary strips (trust, rating) so their fixed height doesn't push the
/// composer off a short screen when the keyboard steals vertical space.
class HideWhenKeyboardOpen extends StatelessWidget {
const HideWhenKeyboardOpen({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) =>
MediaQuery.viewInsetsOf(context).bottom > 0
? const SizedBox.shrink()
: child;
}
/// The scrolling list of chat bubbles. Anchored at the bottom (chat
/// convention): the newest message is always in view, and new arrivals show up
/// in place instead of landing below the fold. Reads the [MessagesCubit] from
/// context.
class ChatMessageList extends StatelessWidget {
const ChatMessageList({
this.peerName,
this.selfName,
this.peerPicture,
this.selfPicture,
super.key,
});
/// The peer's display name, for their avatar's initial (null → a glyph).
final String? peerName;
/// My own display name, for my avatar's initial (null → a glyph).
final String? selfName;
/// The peer's / my published avatar (null → the coloured-initial disc).
final String? peerPicture;
final String? selfPicture;
@override
Widget build(BuildContext context) {
final t = context.t;
final cubit = context.read<MessagesCubit>();
return BlocBuilder<MessagesCubit, ChatState>(
builder: (context, state) {
if (state.messages.isEmpty) {
return Center(
child: Text(t.chat.empty, style: const TextStyle(color: seedMuted)),
);
}
// Group into bubbles + day separators (oldest-first), then render
// bottom-anchored: with `reverse: true` index 0 is the bottom-most
// (newest), so map it to the tail. Scrolling up to read history stays
// put when new messages come in.
final items = chatTimeline(state.messages);
return ListView.builder(
reverse: true,
padding: const EdgeInsets.all(12),
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[items.length - 1 - i];
return switch (item) {
ChatDaySeparator(:final day) => _DaySeparator(day: day),
ChatMessageRow(:final message) => _MessageRow(
message: message,
mine: cubit.isMine(message),
peerName: peerName,
selfName: selfName,
peerPicture: peerPicture,
selfPicture: selfPicture,
selfPubkey: cubit.selfPubkey,
),
};
},
);
},
);
}
}
/// A slim "web of trust" strip: how many vouch for this peer, and a toggle to
/// add/remove your own vouch ("I know this person").
class _TrustBanner extends StatelessWidget {
const _TrustBanner();
@override
Widget build(BuildContext context) {
final t = context.t;
return BlocBuilder<TrustCubit, TrustState>(
builder: (context, state) {
final cubit = context.read<TrustCubit>();
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
// Strongest applicable signal decides the badge (circle > vouched >
// unknown).
final (IconData icon, String label, bool strong) = switch (state.tier) {
TrustTier.inYourCircle => (Icons.verified_user, t.trust.circle, true),
TrustTier.vouched => (
Icons.people_outline,
t.trust.count(n: state.certifierCount),
false,
),
TrustTier.unknown => (Icons.person_outline, t.trust.none, false),
};
return Container(
width: double.infinity,
color: seedPrimaryContainer.withValues(alpha: 0.5),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(icon, size: 18, color: strong ? seedGreen : seedMuted),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: TextStyle(
color: strong ? seedGreen : seedOnSurface,
fontSize: 13,
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
),
),
),
TextButton(
key: const Key('chat.vouch'),
onPressed: state.busy ? null : cubit.toggleVouch,
child: Text(state.iVouch ? t.trust.vouched : t.trust.vouch),
),
],
),
);
},
);
}
}
/// A slim reputation strip under the trust banner: everyone's stars for this
/// peer (highlighting how many come from people you know) and, once you've
/// actually talked with them, a button to leave or edit your own rating.
class _RatingStrip extends StatelessWidget {
const _RatingStrip();
@override
Widget build(BuildContext context) {
final t = context.t;
return BlocBuilder<PeerRatingCubit, PeerRatingState>(
builder: (context, state) {
final cubit = context.read<PeerRatingCubit>();
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
// Soft anchor: you can only rate someone you've talked with.
final canRate = context.select<MessagesCubit, bool>(
(m) => m.state.messages.isNotEmpty,
);
if (!state.hasRatings && !canRate) return const SizedBox.shrink();
return Container(
width: double.infinity,
color: seedPrimaryContainer.withValues(alpha: 0.3),
padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 8, 4),
child: Row(
children: [
Expanded(
child: state.hasRatings
? RatingStars(
average: state.average,
count: state.count,
circleCount: state.circleCount,
)
: const SizedBox.shrink(),
),
if (canRate)
TextButton(
key: const Key('chat.rate'),
onPressed: state.busy
? null
: () => showRatingSheet(context, cubit),
child:
Text(state.iRated ? t.ratings.edit : t.ratings.rate),
),
],
),
);
},
);
}
}
/// A centered pill introducing a new day ("Today", "Yesterday", or a
/// locale-formatted date). Localized via [chatDayLabel].
class _DaySeparator extends StatelessWidget {
const _DaySeparator({required this.day});
final DateTime day;
@override
Widget build(BuildContext context) {
final t = context.t;
// Localizations locale, not the raw app locale: `ast` has no intl date
// symbols and is mapped to Spanish for the framework (see app.dart).
final localeCode = Localizations.localeOf(context).languageCode;
final label = chatDayLabel(
day,
now: DateTime.now(),
today: t.chat.today,
yesterday: t.chat.yesterday,
localeCode: localeCode,
);
return Center(
key: const Key('chat.daySeparator'),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: seedMuted.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: const TextStyle(
color: seedMuted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
);
}
}
/// One message line. Each sender gets their own coloured avatar on their side —
/// mine on the trailing edge, the peer's on the leading edge — so at a glance
/// the two sides are two people, not one column of look-alike bubbles.
class _MessageRow extends StatelessWidget {
const _MessageRow({
required this.message,
required this.mine,
this.peerName,
this.selfName,
this.peerPicture,
this.selfPicture,
this.selfPubkey,
});
final PrivateMessage message;
final bool mine;
final String? peerName;
final String? selfName;
final String? peerPicture;
final String? selfPicture;
/// My own pubkey, so my messages carry my (distinctly coloured) avatar.
final String? selfPubkey;
@override
Widget build(BuildContext context) {
// Each avatar shows its owner's photo/illustration when set, else their
// initial (or a glyph) on a colour derived from their pubkey.
final avatar = mine
? PeerAvatar(
pubkey: selfPubkey ?? message.fromPubkey,
name: selfName,
picture: selfPicture,
)
: PeerAvatar(
pubkey: message.fromPubkey,
name: peerName,
picture: peerPicture,
);
final bubble = Flexible(
child: _Bubble(message: message, mine: mine),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: mine
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: mine
? [bubble, const SizedBox(width: 8), avatar]
: [avatar, const SizedBox(width: 8), bubble],
),
);
}
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.message, required this.mine});
final PrivateMessage message;
final bool mine;
@override
Widget build(BuildContext context) {
final localeCode = Localizations.localeOf(context).languageCode;
final time = chatBubbleTime(message.at, localeCode);
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
// Soft, light bubbles both ways (dark text): mine a tonal green, the
// peer's white with a hairline. Sides + avatar colours carry the
// "who", so the bubbles themselves stay light and easy on the eye.
decoration: BoxDecoration(
color: mine ? seedPrimaryContainer : Colors.white,
borderRadius: BorderRadius.circular(16),
border: mine ? null : Border.all(color: seedOutline),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Long text can be selected/copied — useful for addresses, links.
SelectableText(
message.text,
style: const TextStyle(color: seedOnSurface, fontSize: 15),
),
const SizedBox(height: 2),
// A subtle timestamp, trailing edge (RTL-aware).
Align(
alignment: AlignmentDirectional.centerEnd,
child: Text(
time,
style: const TextStyle(color: seedMuted, fontSize: 11),
),
),
],
),
);
}
}
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,
),
],
),
),
);
}
}