diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 4cacc52..82ae76d 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -17,6 +17,7 @@ import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; import 'ui/about_screen.dart'; import 'ui/auto_backup_gate.dart'; +import 'ui/chat_screen.dart'; import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; import 'ui/inventory_list_screen.dart'; @@ -98,6 +99,15 @@ class TaneApp extends StatelessWidget { outbox: outbox, ), ), + if (social != null && socialSettings != null) + GoRoute( + path: '/chat/:pubkey', + builder: (context, state) => ChatScreen( + social: social, + settings: socialSettings, + peerPubkey: state.pathParameters['pubkey']!, + ), + ), GoRoute( path: '/intro', builder: (context, state) => IntroScreen( diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index b8d17b0..0bd284b 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -342,6 +342,7 @@ "searching": "Looking around your area…", "empty": "No seeds shared near you yet", "near": "Near you", + "contact": "Message", "configTitle": "Sharing setup", "setupIntro": "Sharing with people nearby is optional. To turn it on, set your rough area and join a community you trust — a seed network or collective that runs a shared meeting point for passing messages. No company in the middle.", "areaLabel": "Your area", @@ -358,5 +359,12 @@ "useLocation": "Use my approximate location", "locationFailed": "Couldn't get your location", "queued": "Saved — we'll share these when you're connected" + }, + "chat": { + "title": "Chat", + "hint": "Write a message…", + "send": "Send", + "empty": "No messages yet — say hello", + "offline": "Set up sharing to send messages" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 18db293..3dfc41a 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -342,6 +342,7 @@ "searching": "Buscando por tu zona…", "empty": "Aún no hay semillas compartidas cerca de ti", "near": "Cerca de ti", + "contact": "Mensaje", "configTitle": "Configuración de compartir", "setupIntro": "Compartir con gente cercana es opcional. Para activarlo, indica tu zona aproximada y únete a una comunidad de confianza — una red de semillas o un colectivo que mantiene un punto de encuentro compartido para pasar mensajes. Sin ninguna empresa en medio.", "areaLabel": "Tu zona", @@ -358,5 +359,12 @@ "useLocation": "Usar mi ubicación aproximada", "locationFailed": "No se pudo obtener tu ubicación", "queued": "Guardado — las compartiremos cuando tengas conexión" + }, + "chat": { + "title": "Chat", + "hint": "Escribe un mensaje…", + "send": "Enviar", + "empty": "Aún no hay mensajes — saluda", + "offline": "Configura el compartir para enviar mensajes" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 86c9217..047786a 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -338,6 +338,7 @@ "searching": "A procurar pela tua zona…", "empty": "Ainda não há sementes partilhadas perto de ti", "near": "Perto de ti", + "contact": "Mensagem", "configTitle": "Configuração de partilha", "setupIntro": "Partilhar com pessoas por perto é opcional. Para ativar, indica a tua zona aproximada e junta-te a uma comunidade de confiança — uma rede de sementes ou um coletivo que mantém um ponto de encontro partilhado para passar mensagens. Sem nenhuma empresa no meio.", "areaLabel": "A tua zona", @@ -354,5 +355,12 @@ "useLocation": "Usar a minha localização aproximada", "locationFailed": "Não foi possível obter a tua localização", "queued": "Guardado — vamos partilhá-las quando tiveres ligação" + }, + "chat": { + "title": "Conversa", + "hint": "Escreve uma mensagem…", + "send": "Enviar", + "empty": "Ainda não há mensagens — diz olá", + "offline": "Configura a partilha para enviar mensagens" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index b0e2ea2..4236067 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 956 (318 per locale) +/// Strings: 974 (324 per locale) /// -/// Built on 2026-07-10 at 08:49 UTC +/// Built on 2026-07-10 at 08:58 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 5717e7b..9b6cdc5 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -70,6 +70,7 @@ class Translations with BaseTranslations { late final Translations$desiccant$en desiccant = Translations$desiccant$en.internal(_root); late final Translations$unit$en unit = Translations$unit$en.internal(_root); late final Translations$market$en market = Translations$market$en.internal(_root); + late final Translations$chat$en chat = Translations$chat$en.internal(_root); } // Path: app @@ -1085,6 +1086,9 @@ class Translations$market$en { /// en: 'Near you' String get near => 'Near you'; + /// en: 'Message' + String get contact => 'Message'; + /// en: 'Sharing setup' String get configTitle => 'Sharing setup'; @@ -1134,6 +1138,30 @@ class Translations$market$en { String get queued => 'Saved — we\'ll share these when you\'re connected'; } +// Path: chat +class Translations$chat$en { + Translations$chat$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Chat' + String get title => 'Chat'; + + /// en: 'Write a message…' + String get hint => 'Write a message…'; + + /// en: 'Send' + String get send => 'Send'; + + /// en: 'No messages yet — say hello' + String get empty => 'No messages yet — say hello'; + + /// en: 'Set up sharing to send messages' + String get offline => 'Set up sharing to send messages'; +} + // Path: intro.slides class Translations$intro$slides$en { Translations$intro$slides$en.internal(this._root); @@ -1895,6 +1923,7 @@ extension on Translations { 'market.searching' => 'Looking around your area…', 'market.empty' => 'No seeds shared near you yet', 'market.near' => 'Near you', + 'market.contact' => 'Message', 'market.configTitle' => 'Sharing setup', 'market.setupIntro' => 'Sharing with people nearby is optional. To turn it on, set your rough area and join a community you trust — a seed network or collective that runs a shared meeting point for passing messages. No company in the middle.', 'market.areaLabel' => 'Your area', @@ -1911,6 +1940,11 @@ extension on Translations { 'market.useLocation' => 'Use my approximate location', 'market.locationFailed' => 'Couldn\'t get your location', 'market.queued' => 'Saved — we\'ll share these when you\'re connected', + 'chat.title' => 'Chat', + 'chat.hint' => 'Write a message…', + 'chat.send' => 'Send', + 'chat.empty' => 'No messages yet — say hello', + 'chat.offline' => 'Set up sharing to send messages', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 1742fb2..bda48c2 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -69,6 +69,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Buscando por tu zona…'; @override String get empty => 'Aún no hay semillas compartidas cerca de ti'; @override String get near => 'Cerca de ti'; + @override String get contact => 'Mensaje'; @override String get configTitle => 'Configuración de compartir'; @override String get setupIntro => 'Compartir con gente cercana es opcional. Para activarlo, indica tu zona aproximada y únete a una comunidad de confianza — una red de semillas o un colectivo que mantiene un punto de encuentro compartido para pasar mensajes. Sin ninguna empresa en medio.'; @override String get areaLabel => 'Tu zona'; @@ -630,6 +632,20 @@ class _Translations$market$es extends Translations$market$en { @override String get queued => 'Guardado — las compartiremos cuando tengas conexión'; } +// Path: chat +class _Translations$chat$es extends Translations$chat$en { + _Translations$chat$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Chat'; + @override String get hint => 'Escribe un mensaje…'; + @override String get send => 'Enviar'; + @override String get empty => 'Aún no hay mensajes — saluda'; + @override String get offline => 'Configura el compartir para enviar mensajes'; +} + // Path: intro.slides class _Translations$intro$slides$es extends Translations$intro$slides$en { _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1275,6 +1291,7 @@ extension on TranslationsEs { 'market.searching' => 'Buscando por tu zona…', 'market.empty' => 'Aún no hay semillas compartidas cerca de ti', 'market.near' => 'Cerca de ti', + 'market.contact' => 'Mensaje', 'market.configTitle' => 'Configuración de compartir', 'market.setupIntro' => 'Compartir con gente cercana es opcional. Para activarlo, indica tu zona aproximada y únete a una comunidad de confianza — una red de semillas o un colectivo que mantiene un punto de encuentro compartido para pasar mensajes. Sin ninguna empresa en medio.', 'market.areaLabel' => 'Tu zona', @@ -1291,6 +1308,11 @@ extension on TranslationsEs { 'market.useLocation' => 'Usar mi ubicación aproximada', 'market.locationFailed' => 'No se pudo obtener tu ubicación', 'market.queued' => 'Guardado — las compartiremos cuando tengas conexión', + 'chat.title' => 'Chat', + 'chat.hint' => 'Escribe un mensaje…', + 'chat.send' => 'Enviar', + 'chat.empty' => 'Aún no hay mensajes — saluda', + 'chat.offline' => 'Configura el compartir para enviar mensajes', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 1becfb1..9e7ae77 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -69,6 +69,7 @@ class TranslationsPt extends Translations with BaseTranslations 'A procurar pela tua zona…'; @override String get empty => 'Ainda não há sementes partilhadas perto de ti'; @override String get near => 'Perto de ti'; + @override String get contact => 'Mensagem'; @override String get configTitle => 'Configuração de partilha'; @override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Para ativar, indica a tua zona aproximada e junta-te a uma comunidade de confiança — uma rede de sementes ou um coletivo que mantém um ponto de encontro partilhado para passar mensagens. Sem nenhuma empresa no meio.'; @override String get areaLabel => 'A tua zona'; @@ -626,6 +628,20 @@ class _Translations$market$pt extends Translations$market$en { @override String get queued => 'Guardado — vamos partilhá-las quando tiveres ligação'; } +// Path: chat +class _Translations$chat$pt extends Translations$chat$en { + _Translations$chat$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Conversa'; + @override String get hint => 'Escreve uma mensagem…'; + @override String get send => 'Enviar'; + @override String get empty => 'Ainda não há mensagens — diz olá'; + @override String get offline => 'Configura a partilha para enviar mensagens'; +} + // Path: intro.slides class _Translations$intro$slides$pt extends Translations$intro$slides$en { _Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1267,6 +1283,7 @@ extension on TranslationsPt { 'market.searching' => 'A procurar pela tua zona…', 'market.empty' => 'Ainda não há sementes partilhadas perto de ti', 'market.near' => 'Perto de ti', + 'market.contact' => 'Mensagem', 'market.configTitle' => 'Configuração de partilha', 'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Para ativar, indica a tua zona aproximada e junta-te a uma comunidade de confiança — uma rede de sementes ou um coletivo que mantém um ponto de encontro partilhado para passar mensagens. Sem nenhuma empresa no meio.', 'market.areaLabel' => 'A tua zona', @@ -1283,6 +1300,11 @@ extension on TranslationsPt { 'market.useLocation' => 'Usar a minha localização aproximada', 'market.locationFailed' => 'Não foi possível obter a tua localização', 'market.queued' => 'Guardado — vamos partilhá-las quando tiveres ligação', + 'chat.title' => 'Conversa', + 'chat.hint' => 'Escreve uma mensagem…', + 'chat.send' => 'Enviar', + 'chat.empty' => 'Ainda não há mensagens — diz olá', + 'chat.offline' => 'Configura a partilha para enviar mensagens', _ => null, }; } diff --git a/apps/app_seeds/lib/state/messages_cubit.dart b/apps/app_seeds/lib/state/messages_cubit.dart new file mode 100644 index 0000000..9c7bdb7 --- /dev/null +++ b/apps/app_seeds/lib/state/messages_cubit.dart @@ -0,0 +1,133 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../services/social_service.dart'; +import '../services/social_settings.dart'; + +/// A 1:1 conversation with one peer. Holds the running message list plus send +/// status. Transport-agnostic — depends on [MessageTransport], not the relay. +class ChatState extends Equatable { + const ChatState({ + this.messages = const [], + this.sending = false, + this.error, + }); + + /// Messages in this conversation, oldest first. Incoming ones arrive from the + /// inbox; our own are appended optimistically on send (NIP-17 wraps don't come + /// back to the sender). + final List messages; + final bool sending; + final String? error; + + ChatState copyWith({ + List? messages, + bool? sending, + String? Function()? error, + }) => + ChatState( + messages: messages ?? this.messages, + sending: sending ?? this.sending, + error: error != null ? error() : this.error, + ); + + @override + List get props => [messages, sending, error]; +} + +/// Drives a 1:1 chat over a [MessageTransport]. Filters the shared inbox to +/// [peerPubkey], and tags our own sent messages with [selfPubkey] so the UI can +/// tell the two sides apart. Degrades gracefully when offline (null transport). +class MessagesCubit extends Cubit { + MessagesCubit( + this._transport, { + required this.peerPubkey, + required this.selfPubkey, + Future Function()? onDispose, + }) : _onDispose = onDispose, + super(const ChatState()); + + final MessageTransport? _transport; + final String peerPubkey; + final String selfPubkey; + final Future Function()? _onDispose; + StreamSubscription? _sub; + + bool get isOnline => _transport != null; + + /// Subscribes to incoming messages from [peerPubkey]. + void start() { + final transport = _transport; + if (transport == null) return; + _sub = transport.inbox().listen( + (message) { + if (message.fromPubkey != peerPubkey) return; // another conversation + emit(state.copyWith(messages: [...state.messages, message])); + }, + onError: (Object e) => emit(state.copyWith(error: () => '$e')), + ); + } + + /// Sends [text] to the peer and appends it optimistically. + Future send(String text) async { + final transport = _transport; + final trimmed = text.trim(); + if (transport == null || trimmed.isEmpty) return; + emit(state.copyWith(sending: true, error: () => null)); + try { + await transport.send(toPubkey: peerPubkey, text: trimmed); + emit(state.copyWith( + sending: false, + messages: [ + ...state.messages, + PrivateMessage( + fromPubkey: selfPubkey, + text: trimmed, + at: DateTime.now(), + ), + ], + )); + } catch (e) { + emit(state.copyWith(sending: false, error: () => '$e')); + } + } + + /// Whether [message] was sent by us (for right-aligned bubbles). + bool isMine(PrivateMessage message) => message.fromPubkey == selfPubkey; + + @override + Future close() async { + await _sub?.cancel(); + await _onDispose?.call(); + return super.close(); + } +} + +/// Opens a [MessagesCubit] wired to the social layer for a chat with +/// [peerPubkey], or an offline one that degrades gracefully. +Future createMessagesCubit( + SocialService social, + SocialSettings settings, { + required String peerPubkey, +}) async { + final relays = await settings.relayUrls(); + if (relays.isEmpty) { + return MessagesCubit(null, + peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex); + } + try { + final session = await social.openSession(relays); + return MessagesCubit( + session.messages, + peerPubkey: peerPubkey, + selfPubkey: social.publicKeyHex, + onDispose: session.close, + ); + } catch (_) { + return MessagesCubit(null, + peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex); + } +} diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart new file mode 100644 index 0000000..ce42515 --- /dev/null +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -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 createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + MessagesCubit? _cubit; + bool _loading = true; + final _input = TextEditingController(); + + @override + void initState() { + super.initState(); + _init(); + } + + Future _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 _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 Function() onSend; + + @override + Widget build(BuildContext context) { + final t = context.t; + final cubit = context.read(); + 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( + 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 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, + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index fce2ab7..5daeff4 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -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 { ? 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), + ), ], ), ], diff --git a/apps/app_seeds/test/state/messages_cubit_test.dart b/apps/app_seeds/test/state/messages_cubit_test.dart new file mode 100644 index 0000000..d0d8e00 --- /dev/null +++ b/apps/app_seeds/test/state/messages_cubit_test.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/state/messages_cubit.dart'; + +/// In-memory [MessageTransport]: records sends, lets a test push inbox messages. +class FakeMessageTransport implements MessageTransport { + final List<({String to, String text})> sent = []; + final StreamController _inbox = + StreamController.broadcast(); + + void receive(PrivateMessage message) => _inbox.add(message); + + @override + Future send({required String toPubkey, required String text}) async => + sent.add((to: toPubkey, text: text)); + + @override + Stream inbox() => _inbox.stream; + + @override + Future close() async => _inbox.close(); +} + +void main() { + const peer = 'aa'; + const me = 'bb'; + + PrivateMessage msg(String from, String text) => + PrivateMessage(fromPubkey: from, text: text, at: DateTime(2026)); + + test('receives messages from the peer only', () async { + final transport = FakeMessageTransport(); + final cubit = + MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); + + transport.receive(msg(peer, 'hola')); + transport.receive(msg('cc', 'not for this chat')); // other conversation + await pumpEventQueue(); + + expect(cubit.state.messages, hasLength(1)); + expect(cubit.state.messages.single.text, 'hola'); + expect(cubit.isMine(cubit.state.messages.single), isFalse); + await cubit.close(); + }); + + test('send delivers to the peer and appends our own message', () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); + + await cubit.send(' ¿cambiamos? '); + expect(transport.sent, [(to: peer, text: '¿cambiamos?')]); + expect(cubit.state.messages, hasLength(1)); + expect(cubit.isMine(cubit.state.messages.single), isTrue); + expect(cubit.state.sending, isFalse); + await cubit.close(); + }); + + test('a full exchange keeps order and sides', () async { + final transport = FakeMessageTransport(); + final cubit = + MessagesCubit(transport, peerPubkey: peer, selfPubkey: me)..start(); + + await cubit.send('hi'); + transport.receive(msg(peer, 'hello')); + await pumpEventQueue(); + + expect(cubit.state.messages.map((m) => m.text), ['hi', 'hello']); + expect(cubit.state.messages.map(cubit.isMine), [true, false]); + await cubit.close(); + }); + + test('empty/whitespace text is not sent', () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.send(' '); + expect(transport.sent, isEmpty); + expect(cubit.state.messages, isEmpty); + await cubit.close(); + }); + + test('offline (no transport) never throws', () async { + final cubit = MessagesCubit(null, peerPubkey: peer, selfPubkey: me)..start(); + expect(cubit.isOnline, isFalse); + await cubit.send('hi'); + expect(cubit.state.messages, isEmpty); + await cubit.close(); + }); +} diff --git a/apps/app_seeds/test/ui/chat_screen_test.dart b/apps/app_seeds/test/ui/chat_screen_test.dart new file mode 100644 index 0000000..87326e9 --- /dev/null +++ b/apps/app_seeds/test/ui/chat_screen_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/ui/chat_screen.dart'; + +import '../support/test_support.dart'; + +void main() { + testWidgets('with no relay configured, chat shows the offline note', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + LocaleSettings.setLocaleSync(AppLocale.en); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: ChatScreen( + social: social, + settings: settings, + peerPubkey: 'ab' * 32, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Set up sharing to send messages'), findsOneWidget); + }); +}