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:
vjrj 2026-07-10 10:59:47 +02:00
parent 0dd7c28039
commit efd64c4da4
13 changed files with 627 additions and 9 deletions

View file

@ -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(

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -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,
};
}

View 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);
}
}

View 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,
),
],
),
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
@ -166,17 +167,27 @@ class _MarketScreenState extends State<MarketScreen> {
? const Center(child: CircularProgressIndicator())
: BlocProvider.value(
value: cubit,
child: MarketBody(onConfigure: _openConfig),
child: MarketBody(
onConfigure: _openConfig,
selfPubkey: widget.social.publicKeyHex,
),
),
);
}
}
class MarketBody extends StatelessWidget {
const MarketBody({required this.onConfigure, super.key});
const MarketBody({
required this.onConfigure,
this.selfPubkey,
super.key,
});
final VoidCallback onConfigure;
/// This user's own pubkey, so we don't offer to message our own listings.
final String? selfPubkey;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -215,7 +226,15 @@ class MarketBody extends StatelessWidget {
padding: const EdgeInsets.all(16),
itemCount: state.offers.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) => _OfferCard(offer: state.offers[i]),
itemBuilder: (context, i) {
final o = state.offers[i];
return _OfferCard(
offer: o,
onContact: o.authorPubkeyHex == selfPubkey
? null
: () => context.go('/chat/${o.authorPubkeyHex}'),
);
},
);
},
);
@ -225,10 +244,13 @@ class MarketBody extends StatelessWidget {
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
/// instead of any precise location; price only when it's a sale.
class _OfferCard extends StatelessWidget {
const _OfferCard({required this.offer});
const _OfferCard({required this.offer, this.onContact});
final Offer offer;
/// Opens a private chat with the author; null for our own listings.
final VoidCallback? onContact;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -263,8 +285,8 @@ class _OfferCard extends StatelessWidget {
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
const SizedBox(width: 4),
Text(t.market.near, style: const TextStyle(color: seedMuted, fontSize: 13)),
if (offer.type == OfferType.sale && offer.priceAmount != null) ...[
const Spacer(),
const Spacer(),
if (offer.type == OfferType.sale && offer.priceAmount != null)
Text(
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
style: const TextStyle(
@ -272,7 +294,12 @@ class _OfferCard extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
],
if (onContact != null)
TextButton.icon(
onPressed: onContact,
icon: const Icon(Icons.chat_bubble_outline, size: 16),
label: Text(t.market.contact),
),
],
),
],