diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 7b6a37e..6de0836 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -18,6 +18,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_list_screen.dart'; import 'ui/chat_screen.dart'; import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; @@ -106,6 +107,11 @@ class TaneApp extends StatelessWidget { outbox: outbox, ), ), + if (social != null && socialSettings != null && messageStore != null) + GoRoute( + path: '/messages', + builder: (context, state) => ChatListScreen(store: messageStore), + ), if (social != null && socialSettings != null) GoRoute( path: '/chat/:pubkey', diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index f8f41ad..5d5e23b 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -361,6 +361,10 @@ "locationFailed": "Couldn't get your location — check that location is on and the permission is granted", "queued": "Saved — we'll share these when you're connected" }, + "chatList": { + "title": "Messages", + "empty": "No conversations yet. Message someone from the market." + }, "chat": { "title": "Chat", "hint": "Write a message…", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index a33d644..78f1142 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -361,6 +361,10 @@ "locationFailed": "No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido", "queued": "Guardado — las compartiremos cuando tengas conexión" }, + "chatList": { + "title": "Mensajes", + "empty": "Aún no hay conversaciones. Escribe a alguien desde el mercado." + }, "chat": { "title": "Chat", "hint": "Escribe un mensaje…", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index a7db478..bfa2144 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -357,6 +357,10 @@ "locationFailed": "Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida", "queued": "Guardado — vamos partilhá-las quando tiveres ligação" }, + "chatList": { + "title": "Mensagens", + "empty": "Ainda não há conversas. Escreve a alguém a partir do mercado." + }, "chat": { "title": "Conversa", "hint": "Escreve uma mensagem…", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 8684e22..685034b 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 992 (330 per locale) +/// Strings: 998 (332 per locale) /// -/// Built on 2026-07-10 at 10:09 UTC +/// Built on 2026-07-10 at 10:26 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index a80492d..881e3ae 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -70,6 +70,7 @@ class Translations with BaseTranslations { late final Translations$desiccant$en desiccant = Translations$desiccant$en.internal(_root); late final Translations$unit$en unit = Translations$unit$en.internal(_root); late final Translations$market$en market = Translations$market$en.internal(_root); + late final Translations$chatList$en chatList = Translations$chatList$en.internal(_root); late final Translations$chat$en chat = Translations$chat$en.internal(_root); late final Translations$trust$en trust = Translations$trust$en.internal(_root); } @@ -1142,6 +1143,21 @@ class Translations$market$en { String get queued => 'Saved — we\'ll share these when you\'re connected'; } +// Path: chatList +class Translations$chatList$en { + Translations$chatList$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Messages' + String get title => 'Messages'; + + /// en: 'No conversations yet. Message someone from the market.' + String get empty => 'No conversations yet. Message someone from the market.'; +} + // Path: chat class Translations$chat$en { Translations$chat$en.internal(this._root); @@ -1969,6 +1985,8 @@ extension on Translations { 'market.useLocation' => 'Use my approximate location', 'market.locationFailed' => 'Couldn\'t get your location — check that location is on and the permission is granted', 'market.queued' => 'Saved — we\'ll share these when you\'re connected', + 'chatList.title' => 'Messages', + 'chatList.empty' => 'No conversations yet. Message someone from the market.', 'chat.title' => 'Chat', 'chat.hint' => 'Write a message…', 'chat.send' => 'Send', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 95e01a6..c6dd643 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -69,6 +69,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Guardado — las compartiremos cuando tengas conexión'; } +// Path: chatList +class _Translations$chatList$es extends Translations$chatList$en { + _Translations$chatList$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Mensajes'; + @override String get empty => 'Aún no hay conversaciones. Escribe a alguien desde el mercado.'; +} + // Path: chat class _Translations$chat$es extends Translations$chat$en { _Translations$chat$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1325,6 +1337,8 @@ extension on TranslationsEs { 'market.useLocation' => 'Usar mi ubicación aproximada', 'market.locationFailed' => 'No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido', 'market.queued' => 'Guardado — las compartiremos cuando tengas conexión', + 'chatList.title' => 'Mensajes', + 'chatList.empty' => 'Aún no hay conversaciones. Escribe a alguien desde el mercado.', 'chat.title' => 'Chat', 'chat.hint' => 'Escribe un mensaje…', 'chat.send' => 'Enviar', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 4e7e7f8..2b41df7 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -69,6 +69,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Guardado — vamos partilhá-las quando tiveres ligação'; } +// Path: chatList +class _Translations$chatList$pt extends Translations$chatList$en { + _Translations$chatList$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Mensagens'; + @override String get empty => 'Ainda não há conversas. Escreve a alguém a partir do mercado.'; +} + // Path: chat class _Translations$chat$pt extends Translations$chat$en { _Translations$chat$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1317,6 +1329,8 @@ extension on TranslationsPt { 'market.useLocation' => 'Usar a minha localização aproximada', 'market.locationFailed' => 'Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida', 'market.queued' => 'Guardado — vamos partilhá-las quando tiveres ligação', + 'chatList.title' => 'Mensagens', + 'chatList.empty' => 'Ainda não há conversas. Escreve a alguém a partir do mercado.', 'chat.title' => 'Conversa', 'chat.hint' => 'Escreve uma mensagem…', 'chat.send' => 'Enviar', diff --git a/apps/app_seeds/lib/services/message_store.dart b/apps/app_seeds/lib/services/message_store.dart index 18d0fd2..3ffd67f 100644 --- a/apps/app_seeds/lib/services/message_store.dart +++ b/apps/app_seeds/lib/services/message_store.dart @@ -8,12 +8,29 @@ import '../security/secret_store.dart'; /// capped, per-peer JSON list — recent history survives leaving the chat. Small /// and simple on purpose; the encrypted Drift DB is the eventual home if chats /// grow large. +/// A conversation preview for the messages inbox. +class ChatSummary { + const ChatSummary({ + required this.peerPubkey, + required this.lastText, + required this.lastAt, + }); + + final String peerPubkey; + final String lastText; + final DateTime lastAt; +} + class MessageStore { MessageStore(this._store); final SecretStore _store; static const _prefix = 'tane.social.chat.'; + /// Index of peers we have a conversation with (the keystore is key/value with + /// no key enumeration, so we track the list ourselves). + static const _indexKey = 'tane.social.chats'; + /// Keep only the most recent [_cap] messages per conversation, to bound the /// keystore entry size. static const _cap = 200; @@ -36,7 +53,7 @@ class MessageStore { } /// Appends [message] to the conversation with [peerPubkey] (trimming to the - /// cap). + /// cap) and records the peer in the conversation index. Future append(String peerPubkey, PrivateMessage message) async { final next = [...await history(peerPubkey), message]; final capped = @@ -52,5 +69,35 @@ class MessageStore { }, ]), ); + await _rememberPeer(peerPubkey); + } + + /// Conversations, most-recently-active first (for the messages inbox). + Future> conversations() async { + final summaries = []; + for (final peer in await _peers()) { + final history = await this.history(peer); + if (history.isEmpty) continue; + final last = history.last; + summaries.add(ChatSummary( + peerPubkey: peer, + lastText: last.text, + lastAt: last.at, + )); + } + summaries.sort((a, b) => b.lastAt.compareTo(a.lastAt)); + return summaries; + } + + Future> _peers() async { + final raw = await _store.read(_indexKey); + if (raw == null || raw.isEmpty) return const []; + return raw.split('\n').where((s) => s.isNotEmpty).toList(); + } + + Future _rememberPeer(String peer) async { + final peers = await _peers(); + if (peers.contains(peer)) return; + await _store.write(_indexKey, [...peers, peer].join('\n')); } } diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index f7f5285..4bbd463 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -49,6 +49,12 @@ class AppDrawer extends StatelessWidget { _DrawerItem( icon: const Icon(Icons.chat_bubble), label: t.menu.chat, + onTap: marketEnabled + ? () { + Navigator.of(context).pop(); + context.go('/messages'); + } + : null, ), _DrawerItem( icon: const Icon(Icons.favorite), diff --git a/apps/app_seeds/lib/ui/chat_list_screen.dart b/apps/app_seeds/lib/ui/chat_list_screen.dart new file mode 100644 index 0000000..9c87bdb --- /dev/null +++ b/apps/app_seeds/lib/ui/chat_list_screen.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../i18n/strings.g.dart'; +import '../services/message_store.dart'; +import 'theme.dart'; + +/// The messages inbox: past conversations, newest first. Tapping one opens the +/// chat. Reached from the drawer's "Chat" entry. +class ChatListScreen extends StatefulWidget { + const ChatListScreen({required this.store, super.key}); + + final MessageStore store; + + @override + State createState() => _ChatListScreenState(); +} + +class _ChatListScreenState extends State { + List? _items; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final items = await widget.store.conversations(); + if (mounted) setState(() => _items = items); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final items = _items; + return Scaffold( + appBar: AppBar(title: Text(t.chatList.title)), + body: items == null + ? const Center(child: CircularProgressIndicator()) + : items.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + t.chatList.empty, + textAlign: TextAlign.center, + style: const TextStyle(color: seedMuted, fontSize: 15), + ), + ), + ) + : ListView.separated( + itemCount: items.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) { + final c = items[i]; + return ListTile( + leading: const CircleAvatar( + backgroundColor: seedAvatar, + child: Icon(Icons.person, color: seedOnAvatar), + ), + title: Text(_shortId(c.peerPubkey)), + subtitle: Text( + c.lastText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => context.go('/chat/${c.peerPubkey}'), + ); + }, + ), + ); + } +} + +/// A compact, human-ish rendering of a public key until we resolve names. +String _shortId(String pubkey) => pubkey.length <= 12 + ? pubkey + : '${pubkey.substring(0, 6)}…${pubkey.substring(pubkey.length - 4)}'; diff --git a/apps/app_seeds/test/services/message_store_test.dart b/apps/app_seeds/test/services/message_store_test.dart index 0ae890c..7bd742b 100644 --- a/apps/app_seeds/test/services/message_store_test.dart +++ b/apps/app_seeds/test/services/message_store_test.dart @@ -34,6 +34,18 @@ void main() { expect((await store.history('b')).single.text, 'toB'); }); + test('conversations lists peers newest-first with their last message', + () async { + await store.append('peerA', msg('me', 'hi A', 1000)); + await store.append('peerB', msg('peerB', 'yo B', 3000)); + await store.append('peerA', msg('peerA', 'back A', 2000)); + + final convos = await store.conversations(); + expect(convos.map((c) => c.peerPubkey), ['peerB', 'peerA']); // 3000 > 2000 + expect(convos.first.lastText, 'yo B'); + expect(convos.last.lastText, 'back A'); + }); + test('history is capped to the most recent 200', () async { for (var i = 0; i < 210; i++) { await store.append('peer', msg('me', 'm$i', i));