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 'state/variety_detail_cubit.dart';
|
||||||
import 'ui/about_screen.dart';
|
import 'ui/about_screen.dart';
|
||||||
import 'ui/auto_backup_gate.dart';
|
import 'ui/auto_backup_gate.dart';
|
||||||
|
import 'ui/chat_screen.dart';
|
||||||
import 'ui/home_screen.dart';
|
import 'ui/home_screen.dart';
|
||||||
import 'ui/intro_screen.dart';
|
import 'ui/intro_screen.dart';
|
||||||
import 'ui/inventory_list_screen.dart';
|
import 'ui/inventory_list_screen.dart';
|
||||||
|
|
@ -98,6 +99,15 @@ class TaneApp extends StatelessWidget {
|
||||||
outbox: outbox,
|
outbox: outbox,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (social != null && socialSettings != null)
|
||||||
|
GoRoute(
|
||||||
|
path: '/chat/:pubkey',
|
||||||
|
builder: (context, state) => ChatScreen(
|
||||||
|
social: social,
|
||||||
|
settings: socialSettings,
|
||||||
|
peerPubkey: state.pathParameters['pubkey']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/intro',
|
path: '/intro',
|
||||||
builder: (context, state) => IntroScreen(
|
builder: (context, state) => IntroScreen(
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,7 @@
|
||||||
"searching": "Looking around your area…",
|
"searching": "Looking around your area…",
|
||||||
"empty": "No seeds shared near you yet",
|
"empty": "No seeds shared near you yet",
|
||||||
"near": "Near you",
|
"near": "Near you",
|
||||||
|
"contact": "Message",
|
||||||
"configTitle": "Sharing setup",
|
"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.",
|
"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",
|
"areaLabel": "Your area",
|
||||||
|
|
@ -358,5 +359,12 @@
|
||||||
"useLocation": "Use my approximate location",
|
"useLocation": "Use my approximate location",
|
||||||
"locationFailed": "Couldn't get your location",
|
"locationFailed": "Couldn't get your location",
|
||||||
"queued": "Saved — we'll share these when you're connected"
|
"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…",
|
"searching": "Buscando por tu zona…",
|
||||||
"empty": "Aún no hay semillas compartidas cerca de ti",
|
"empty": "Aún no hay semillas compartidas cerca de ti",
|
||||||
"near": "Cerca de ti",
|
"near": "Cerca de ti",
|
||||||
|
"contact": "Mensaje",
|
||||||
"configTitle": "Configuración de compartir",
|
"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.",
|
"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",
|
"areaLabel": "Tu zona",
|
||||||
|
|
@ -358,5 +359,12 @@
|
||||||
"useLocation": "Usar mi ubicación aproximada",
|
"useLocation": "Usar mi ubicación aproximada",
|
||||||
"locationFailed": "No se pudo obtener tu ubicación",
|
"locationFailed": "No se pudo obtener tu ubicación",
|
||||||
"queued": "Guardado — las compartiremos cuando tengas conexió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…",
|
"searching": "A procurar pela tua zona…",
|
||||||
"empty": "Ainda não há sementes partilhadas perto de ti",
|
"empty": "Ainda não há sementes partilhadas perto de ti",
|
||||||
"near": "Perto de ti",
|
"near": "Perto de ti",
|
||||||
|
"contact": "Mensagem",
|
||||||
"configTitle": "Configuração de partilha",
|
"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.",
|
"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",
|
"areaLabel": "A tua zona",
|
||||||
|
|
@ -354,5 +355,12 @@
|
||||||
"useLocation": "Usar a minha localização aproximada",
|
"useLocation": "Usar a minha localização aproximada",
|
||||||
"locationFailed": "Não foi possível obter a tua localização",
|
"locationFailed": "Não foi possível obter a tua localização",
|
||||||
"queued": "Guardado — vamos partilhá-las quando tiveres ligaçã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`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 3
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// 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$desiccant$en desiccant = Translations$desiccant$en.internal(_root);
|
||||||
late final Translations$unit$en unit = Translations$unit$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$market$en market = Translations$market$en.internal(_root);
|
||||||
|
late final Translations$chat$en chat = Translations$chat$en.internal(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// Path: app
|
||||||
|
|
@ -1085,6 +1086,9 @@ class Translations$market$en {
|
||||||
/// en: 'Near you'
|
/// en: 'Near you'
|
||||||
String get near => 'Near you';
|
String get near => 'Near you';
|
||||||
|
|
||||||
|
/// en: 'Message'
|
||||||
|
String get contact => 'Message';
|
||||||
|
|
||||||
/// en: 'Sharing setup'
|
/// en: 'Sharing setup'
|
||||||
String get configTitle => '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';
|
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
|
// Path: intro.slides
|
||||||
class Translations$intro$slides$en {
|
class Translations$intro$slides$en {
|
||||||
Translations$intro$slides$en.internal(this._root);
|
Translations$intro$slides$en.internal(this._root);
|
||||||
|
|
@ -1895,6 +1923,7 @@ extension on Translations {
|
||||||
'market.searching' => 'Looking around your area…',
|
'market.searching' => 'Looking around your area…',
|
||||||
'market.empty' => 'No seeds shared near you yet',
|
'market.empty' => 'No seeds shared near you yet',
|
||||||
'market.near' => 'Near you',
|
'market.near' => 'Near you',
|
||||||
|
'market.contact' => 'Message',
|
||||||
'market.configTitle' => 'Sharing setup',
|
'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.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',
|
'market.areaLabel' => 'Your area',
|
||||||
|
|
@ -1911,6 +1940,11 @@ extension on Translations {
|
||||||
'market.useLocation' => 'Use my approximate location',
|
'market.useLocation' => 'Use my approximate location',
|
||||||
'market.locationFailed' => 'Couldn\'t get your location',
|
'market.locationFailed' => 'Couldn\'t get your location',
|
||||||
'market.queued' => 'Saved — we\'ll share these when you\'re connected',
|
'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,
|
_ => 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$desiccant$es desiccant = _Translations$desiccant$es._(_root);
|
||||||
@override late final _Translations$unit$es unit = _Translations$unit$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$market$es market = _Translations$market$es._(_root);
|
||||||
|
@override late final _Translations$chat$es chat = _Translations$chat$es._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// 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 searching => 'Buscando por tu zona…';
|
||||||
@override String get empty => 'Aún no hay semillas compartidas cerca de ti';
|
@override String get empty => 'Aún no hay semillas compartidas cerca de ti';
|
||||||
@override String get near => '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 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 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';
|
@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';
|
@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
|
// Path: intro.slides
|
||||||
class _Translations$intro$slides$es extends Translations$intro$slides$en {
|
class _Translations$intro$slides$es extends Translations$intro$slides$en {
|
||||||
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_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.searching' => 'Buscando por tu zona…',
|
||||||
'market.empty' => 'Aún no hay semillas compartidas cerca de ti',
|
'market.empty' => 'Aún no hay semillas compartidas cerca de ti',
|
||||||
'market.near' => 'Cerca de ti',
|
'market.near' => 'Cerca de ti',
|
||||||
|
'market.contact' => 'Mensaje',
|
||||||
'market.configTitle' => 'Configuración de compartir',
|
'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.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',
|
'market.areaLabel' => 'Tu zona',
|
||||||
|
|
@ -1291,6 +1308,11 @@ extension on TranslationsEs {
|
||||||
'market.useLocation' => 'Usar mi ubicación aproximada',
|
'market.useLocation' => 'Usar mi ubicación aproximada',
|
||||||
'market.locationFailed' => 'No se pudo obtener tu ubicación',
|
'market.locationFailed' => 'No se pudo obtener tu ubicación',
|
||||||
'market.queued' => 'Guardado — las compartiremos cuando tengas conexió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,
|
_ => 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$desiccant$pt desiccant = _Translations$desiccant$pt._(_root);
|
||||||
@override late final _Translations$unit$pt unit = _Translations$unit$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$market$pt market = _Translations$market$pt._(_root);
|
||||||
|
@override late final _Translations$chat$pt chat = _Translations$chat$pt._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// 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 searching => 'A procurar pela tua zona…';
|
||||||
@override String get empty => 'Ainda não há sementes partilhadas perto de ti';
|
@override String get empty => 'Ainda não há sementes partilhadas perto de ti';
|
||||||
@override String get near => '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 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 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';
|
@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';
|
@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
|
// Path: intro.slides
|
||||||
class _Translations$intro$slides$pt extends Translations$intro$slides$en {
|
class _Translations$intro$slides$pt extends Translations$intro$slides$en {
|
||||||
_Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_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.searching' => 'A procurar pela tua zona…',
|
||||||
'market.empty' => 'Ainda não há sementes partilhadas perto de ti',
|
'market.empty' => 'Ainda não há sementes partilhadas perto de ti',
|
||||||
'market.near' => 'Perto de ti',
|
'market.near' => 'Perto de ti',
|
||||||
|
'market.contact' => 'Mensagem',
|
||||||
'market.configTitle' => 'Configuração de partilha',
|
'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.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',
|
'market.areaLabel' => 'A tua zona',
|
||||||
|
|
@ -1283,6 +1300,11 @@ extension on TranslationsPt {
|
||||||
'market.useLocation' => 'Usar a minha localização aproximada',
|
'market.useLocation' => 'Usar a minha localização aproximada',
|
||||||
'market.locationFailed' => 'Não foi possível obter a tua localização',
|
'market.locationFailed' => 'Não foi possível obter a tua localização',
|
||||||
'market.queued' => 'Guardado — vamos partilhá-las quando tiveres ligaçã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,
|
_ => 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:commons_core/commons_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
|
@ -166,17 +167,27 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: BlocProvider.value(
|
: BlocProvider.value(
|
||||||
value: cubit,
|
value: cubit,
|
||||||
child: MarketBody(onConfigure: _openConfig),
|
child: MarketBody(
|
||||||
|
onConfigure: _openConfig,
|
||||||
|
selfPubkey: widget.social.publicKeyHex,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MarketBody extends StatelessWidget {
|
class MarketBody extends StatelessWidget {
|
||||||
const MarketBody({required this.onConfigure, super.key});
|
const MarketBody({
|
||||||
|
required this.onConfigure,
|
||||||
|
this.selfPubkey,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final VoidCallback onConfigure;
|
final VoidCallback onConfigure;
|
||||||
|
|
||||||
|
/// This user's own pubkey, so we don't offer to message our own listings.
|
||||||
|
final String? selfPubkey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -215,7 +226,15 @@ class MarketBody extends StatelessWidget {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: state.offers.length,
|
itemCount: state.offers.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
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"
|
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
|
||||||
/// instead of any precise location; price only when it's a sale.
|
/// instead of any precise location; price only when it's a sale.
|
||||||
class _OfferCard extends StatelessWidget {
|
class _OfferCard extends StatelessWidget {
|
||||||
const _OfferCard({required this.offer});
|
const _OfferCard({required this.offer, this.onContact});
|
||||||
|
|
||||||
final Offer offer;
|
final Offer offer;
|
||||||
|
|
||||||
|
/// Opens a private chat with the author; null for our own listings.
|
||||||
|
final VoidCallback? onContact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -263,8 +285,8 @@ class _OfferCard extends StatelessWidget {
|
||||||
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(t.market.near, style: const TextStyle(color: seedMuted, fontSize: 13)),
|
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(
|
Text(
|
||||||
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
|
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|
@ -272,7 +294,12 @@ class _OfferCard extends StatelessWidget {
|
||||||
fontWeight: FontWeight.w600,
|
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