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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$desiccant$es desiccant = _Translations$desiccant$es._(_root);
|
||||
@override late final _Translations$unit$es unit = _Translations$unit$es._(_root);
|
||||
@override late final _Translations$market$es market = _Translations$market$es._(_root);
|
||||
@override late final _Translations$chat$es chat = _Translations$chat$es._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
|
|
@ -612,6 +613,7 @@ class _Translations$market$es extends Translations$market$en {
|
|||
@override String get searching => '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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$desiccant$pt desiccant = _Translations$desiccant$pt._(_root);
|
||||
@override late final _Translations$unit$pt unit = _Translations$unit$pt._(_root);
|
||||
@override late final _Translations$market$pt market = _Translations$market$pt._(_root);
|
||||
@override late final _Translations$chat$pt chat = _Translations$chat$pt._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
|
|
@ -608,6 +609,7 @@ class _Translations$market$pt extends Translations$market$en {
|
|||
@override String get searching => '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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
133
apps/app_seeds/lib/state/messages_cubit.dart
Normal file
133
apps/app_seeds/lib/state/messages_cubit.dart
Normal file
|
|
@ -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<PrivateMessage> messages;
|
||||
final bool sending;
|
||||
final String? error;
|
||||
|
||||
ChatState copyWith({
|
||||
List<PrivateMessage>? messages,
|
||||
bool? sending,
|
||||
String? Function()? error,
|
||||
}) =>
|
||||
ChatState(
|
||||
messages: messages ?? this.messages,
|
||||
sending: sending ?? this.sending,
|
||||
error: error != null ? error() : this.error,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> 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<ChatState> {
|
||||
MessagesCubit(
|
||||
this._transport, {
|
||||
required this.peerPubkey,
|
||||
required this.selfPubkey,
|
||||
Future<void> Function()? onDispose,
|
||||
}) : _onDispose = onDispose,
|
||||
super(const ChatState());
|
||||
|
||||
final MessageTransport? _transport;
|
||||
final String peerPubkey;
|
||||
final String selfPubkey;
|
||||
final Future<void> Function()? _onDispose;
|
||||
StreamSubscription<PrivateMessage>? _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<void> 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<void> 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<MessagesCubit> 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);
|
||||
}
|
||||
}
|
||||
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(),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
90
apps/app_seeds/test/state/messages_cubit_test.dart
Normal file
90
apps/app_seeds/test/state/messages_cubit_test.dart
Normal file
|
|
@ -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<PrivateMessage> _inbox =
|
||||
StreamController<PrivateMessage>.broadcast();
|
||||
|
||||
void receive(PrivateMessage message) => _inbox.add(message);
|
||||
|
||||
@override
|
||||
Future<void> send({required String toPubkey, required String text}) async =>
|
||||
sent.add((to: toPubkey, text: text));
|
||||
|
||||
@override
|
||||
Stream<PrivateMessage> inbox() => _inbox.stream;
|
||||
|
||||
@override
|
||||
Future<void> 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();
|
||||
});
|
||||
}
|
||||
40
apps/app_seeds/test/ui/chat_screen_test.dart
Normal file
40
apps/app_seeds/test/ui/chat_screen_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue