From 5fe0f4540e837a868175451343b020a6b0cbf10e Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 21:12:00 +0200 Subject: [PATCH] feat(messages): unread badges + OS notification for private messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private messages (NIP-17) arrived in the foreground with no signal: no unread count, no badge, no OS notification. Hook both into the single InboxService.ingest choke point. - UnreadService: per-peer "last read" in the keystore (timestamps+pubkey only, no message text), live count via a changes stream, active-peer suppression so the open chat never badges or notifies. - UnreadBadge: reusable, RTL-aware (Flutter Badge) — on the home hamburger, the drawer "Chat" item, and per-conversation in the messages list. - Mark-read on chat open (ChatScreen). - NotificationService over flutter_local_notifications: generic "New message from " (no message text, for privacy), payload = pubkey, tap opens /chat/:pubkey. No-op on web/Windows; Android POST_NOTIFICATIONS. - i18n notifications.newMessageFrom (en/es/pt/ast). Background/push stays out of scope (foreground-only by design). Tests: UnreadService, NotificationService (mock plugin), InboxService hooks (new peer notifies/counts; duplicate, own, and open-chat do not), and a UnreadBadge widget test. dart analyze clean; commons_core green. --- .../android/app/src/main/AndroidManifest.xml | 3 + apps/app_seeds/lib/app.dart | 13 +- apps/app_seeds/lib/bootstrap.dart | 8 + apps/app_seeds/lib/di/injector.dart | 15 ++ apps/app_seeds/lib/i18n/ast.i18n.json | 137 ++++++++++++++---- apps/app_seeds/lib/i18n/en.i18n.json | 137 ++++++++++++++---- apps/app_seeds/lib/i18n/es.i18n.json | 137 ++++++++++++++---- apps/app_seeds/lib/i18n/pt.i18n.json | 137 ++++++++++++++---- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 12 ++ apps/app_seeds/lib/i18n/strings_en.g.dart | 14 ++ apps/app_seeds/lib/i18n/strings_es.g.dart | 12 ++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 12 ++ .../app_seeds/lib/services/inbox_service.dart | 35 ++++- .../lib/services/notification_service.dart | 103 +++++++++++++ .../lib/services/unread_service.dart | 96 ++++++++++++ apps/app_seeds/lib/ui/app_drawer.dart | 3 +- apps/app_seeds/lib/ui/chat_list_screen.dart | 10 +- apps/app_seeds/lib/ui/chat_screen.dart | 17 +++ apps/app_seeds/lib/ui/home_screen.dart | 16 +- apps/app_seeds/lib/ui/unread_badge.dart | 86 +++++++++++ apps/app_seeds/pubspec.yaml | 4 + .../test/services/inbox_service_test.dart | 67 +++++++++ .../services/notification_service_test.dart | 78 ++++++++++ .../test/services/unread_service_test.dart | 90 ++++++++++++ apps/app_seeds/test/ui/unread_badge_test.dart | 74 ++++++++++ pubspec.lock | 32 ++++ 27 files changed, 1238 insertions(+), 114 deletions(-) create mode 100644 apps/app_seeds/lib/services/notification_service.dart create mode 100644 apps/app_seeds/lib/services/unread_service.dart create mode 100644 apps/app_seeds/lib/ui/unread_badge.dart create mode 100644 apps/app_seeds/test/services/notification_service_test.dart create mode 100644 apps/app_seeds/test/services/unread_service_test.dart create mode 100644 apps/app_seeds/test/ui/unread_badge_test.dart diff --git a/apps/app_seeds/android/app/src/main/AndroidManifest.xml b/apps/app_seeds/android/app/src/main/AndroidManifest.xml index 4302946..9c6ecee 100644 --- a/apps/app_seeds/android/app/src/main/AndroidManifest.xml +++ b/apps/app_seeds/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + _router.push('/chat/$pubkey'); + } final VarietyRepository repository; final SpeciesRepository species; @@ -94,6 +101,10 @@ class TaneApp extends StatelessWidget { /// App-wide inbox listener; drives the messages list's live refresh. final InboxService? inbox; + + /// OS notifications for foreground messages; its tap handler is wired to the + /// router here. Null in tests / on platforms without notification support. + final NotificationService? notifications; final bool showIntro; /// Drives silent periodic backups off the app lifecycle. Null in widget tests diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 34b58db..60b8b1d 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -13,6 +13,7 @@ import 'services/coarse_location.dart'; import 'services/inbox_service.dart'; import 'services/locale_store.dart'; import 'services/message_store.dart'; +import 'services/notification_service.dart'; import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/profile_cache.dart'; @@ -51,6 +52,12 @@ class _BootstrapState extends State { final onboarding = getIt(); final inbox = getIt.isRegistered() ? getIt() : null; + final notifications = getIt.isRegistered() + ? getIt() + : null; + // Ask for notification permission and set up the OS channel (no-op on + // unsupported platforms). Done before the inbox starts listening. + if (notifications != null) await notifications.initialize(); // Listen for incoming private messages app-wide (foreground) so they arrive // and fill the inbox even before their chat is opened. if (inbox != null) unawaited(inbox.start()); @@ -68,6 +75,7 @@ class _BootstrapState extends State { profileStore: getIt(), profileCache: getIt(), inbox: inbox, + notifications: notifications, showIntro: !await onboarding.introSeen(), autoBackup: getIt.isRegistered() ? getIt() diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 92e6dd6..87b0254 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -23,6 +23,7 @@ import '../services/file_picker_file_service.dart'; import '../services/file_service.dart'; import '../services/inbox_service.dart'; import '../services/locale_store.dart'; +import '../services/notification_service.dart'; import '../services/ocr/label_text_extractor.dart'; import '../services/ocr/ocr_language.dart'; import '../services/ocr/tesseract_label_extractor.dart'; @@ -36,6 +37,7 @@ import '../services/profile_cache.dart'; import '../services/profile_store.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; +import '../services/unread_service.dart'; /// The app's service locator. Kept to the composition root — widgets get their /// repositories from here (or via BlocProvider), never by reaching into it deep @@ -169,19 +171,32 @@ Future configureDependencies() async { ); } + // OS notifications for foreground messages. Registered unconditionally — it + // self-no-ops on unsupported platforms (web/Windows) and when the social layer + // is absent nothing ever calls it. Its tap handler is wired to the router in + // `TaneApp`, after the router exists. + getIt.registerSingleton(NotificationService()); + // Optional: absent only if the derivation above failed — then the app runs // inventory-only, by design (market/chat/profile hidden). if (socialService != null) { getIt ..registerSingleton(socialService) + // Tracks unread private messages so the UI can badge them. + ..registerSingleton( + UnreadService(getIt(), secretStore), + ) // App-wide inbox listener so private messages arrive (and land in the // inbox list) even when the specific chat isn't open. Started in `main`. + // It also updates unread counts and fires notifications. ..registerSingleton( InboxService( social: socialService, settings: getIt(), store: getIt(), profileCache: getIt(), + unread: getIt(), + notifications: getIt(), ), ); } diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 07cff98..81949c7 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -214,8 +214,18 @@ "anyMonth": "Cualquier mes", "noDate": "Indicar data de coyecha", "monthNames": [ - "xineru", "febreru", "marzu", "abril", "mayu", "xunu", - "xunetu", "agostu", "setiembre", "ochobre", "payares", "avientu" + "xineru", + "febreru", + "marzu", + "abril", + "mayu", + "xunu", + "xunetu", + "agostu", + "setiembre", + "ochobre", + "payares", + "avientu" ] }, "lotType": { @@ -330,30 +340,102 @@ "some": "dalgunes", "plenty": "munches", "pinch": "una migaya", - "handful": { "singular": "puñáu", "plural": "puñaos" }, - "teaspoon": { "singular": "cuyaradina", "plural": "cuyaradines" }, - "spoon": { "singular": "cuyar", "plural": "cuyares" }, - "cup": { "singular": "taza", "plural": "tazes" }, - "jar": { "singular": "bote", "plural": "botes" }, - "sack": { "singular": "sacu", "plural": "sacos" }, - "packet": { "singular": "sobre", "plural": "sobres" }, - "cob": { "singular": "panoya", "plural": "panoyes" }, - "pod": { "singular": "vaina", "plural": "vaines" }, - "ear": { "singular": "espiga", "plural": "espigues" }, - "head": { "singular": "cabezuela", "plural": "cabezueles" }, - "fruit": { "singular": "frutu", "plural": "frutos" }, - "bulb": { "singular": "bulbu", "plural": "bulbos" }, - "tuber": { "singular": "tubérculu", "plural": "tubérculos" }, - "seedHead": { "singular": "cabeza de simiente", "plural": "cabeces de simiente" }, - "bunch": { "singular": "manoyu", "plural": "manoyos" }, - "plant": { "singular": "planta", "plural": "plantes" }, - "pot": { "singular": "tiestu", "plural": "tiestos" }, - "tray": { "singular": "bandexa", "plural": "bandexes" }, - "seedling": { "singular": "plántula", "plural": "plántules" }, - "tree": { "singular": "árbol", "plural": "árboles" }, - "cutting": { "singular": "esqueje", "plural": "esquejes" }, - "grams": { "singular": "gramu", "plural": "gramos" }, - "count": { "singular": "grana", "plural": "granes" } + "handful": { + "singular": "puñáu", + "plural": "puñaos" + }, + "teaspoon": { + "singular": "cuyaradina", + "plural": "cuyaradines" + }, + "spoon": { + "singular": "cuyar", + "plural": "cuyares" + }, + "cup": { + "singular": "taza", + "plural": "tazes" + }, + "jar": { + "singular": "bote", + "plural": "botes" + }, + "sack": { + "singular": "sacu", + "plural": "sacos" + }, + "packet": { + "singular": "sobre", + "plural": "sobres" + }, + "cob": { + "singular": "panoya", + "plural": "panoyes" + }, + "pod": { + "singular": "vaina", + "plural": "vaines" + }, + "ear": { + "singular": "espiga", + "plural": "espigues" + }, + "head": { + "singular": "cabezuela", + "plural": "cabezueles" + }, + "fruit": { + "singular": "frutu", + "plural": "frutos" + }, + "bulb": { + "singular": "bulbu", + "plural": "bulbos" + }, + "tuber": { + "singular": "tubérculu", + "plural": "tubérculos" + }, + "seedHead": { + "singular": "cabeza de simiente", + "plural": "cabeces de simiente" + }, + "bunch": { + "singular": "manoyu", + "plural": "manoyos" + }, + "plant": { + "singular": "planta", + "plural": "plantes" + }, + "pot": { + "singular": "tiestu", + "plural": "tiestos" + }, + "tray": { + "singular": "bandexa", + "plural": "bandexes" + }, + "seedling": { + "singular": "plántula", + "plural": "plántules" + }, + "tree": { + "singular": "árbol", + "plural": "árboles" + }, + "cutting": { + "singular": "esqueje", + "plural": "esquejes" + }, + "grams": { + "singular": "gramu", + "plural": "gramos" + }, + "count": { + "singular": "grana", + "plural": "granes" + } }, "market": { "title": "Simiente cerca de ti", @@ -436,5 +518,8 @@ "vouch": "Conozo a esta persona", "vouched": "Avales a esta persona", "circle": "Nel to círculu" + }, + "notifications": { + "newMessageFrom": "Mensaxe nuevu de {name}" } } diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index ee69b30..a1fd14a 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -215,8 +215,18 @@ "anyMonth": "Any month", "noDate": "Set harvest date", "monthNames": [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December" + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" ] }, "lotType": { @@ -331,30 +341,102 @@ "some": "some", "plenty": "plenty", "pinch": "a pinch", - "handful": { "singular": "handful", "plural": "handfuls" }, - "teaspoon": { "singular": "teaspoon", "plural": "teaspoons" }, - "spoon": { "singular": "spoon", "plural": "spoons" }, - "cup": { "singular": "cup", "plural": "cups" }, - "jar": { "singular": "jar", "plural": "jars" }, - "sack": { "singular": "sack", "plural": "sacks" }, - "packet": { "singular": "packet", "plural": "packets" }, - "cob": { "singular": "cob", "plural": "cobs" }, - "pod": { "singular": "pod", "plural": "pods" }, - "ear": { "singular": "ear", "plural": "ears" }, - "head": { "singular": "head", "plural": "heads" }, - "fruit": { "singular": "fruit", "plural": "fruits" }, - "bulb": { "singular": "bulb", "plural": "bulbs" }, - "tuber": { "singular": "tuber", "plural": "tubers" }, - "seedHead": { "singular": "seed head", "plural": "seed heads" }, - "bunch": { "singular": "bunch", "plural": "bunches" }, - "plant": { "singular": "plant", "plural": "plants" }, - "pot": { "singular": "pot", "plural": "pots" }, - "tray": { "singular": "tray", "plural": "trays" }, - "seedling": { "singular": "seedling", "plural": "seedlings" }, - "tree": { "singular": "tree", "plural": "trees" }, - "cutting": { "singular": "cutting", "plural": "cuttings" }, - "grams": { "singular": "gram", "plural": "grams" }, - "count": { "singular": "seed", "plural": "seeds" } + "handful": { + "singular": "handful", + "plural": "handfuls" + }, + "teaspoon": { + "singular": "teaspoon", + "plural": "teaspoons" + }, + "spoon": { + "singular": "spoon", + "plural": "spoons" + }, + "cup": { + "singular": "cup", + "plural": "cups" + }, + "jar": { + "singular": "jar", + "plural": "jars" + }, + "sack": { + "singular": "sack", + "plural": "sacks" + }, + "packet": { + "singular": "packet", + "plural": "packets" + }, + "cob": { + "singular": "cob", + "plural": "cobs" + }, + "pod": { + "singular": "pod", + "plural": "pods" + }, + "ear": { + "singular": "ear", + "plural": "ears" + }, + "head": { + "singular": "head", + "plural": "heads" + }, + "fruit": { + "singular": "fruit", + "plural": "fruits" + }, + "bulb": { + "singular": "bulb", + "plural": "bulbs" + }, + "tuber": { + "singular": "tuber", + "plural": "tubers" + }, + "seedHead": { + "singular": "seed head", + "plural": "seed heads" + }, + "bunch": { + "singular": "bunch", + "plural": "bunches" + }, + "plant": { + "singular": "plant", + "plural": "plants" + }, + "pot": { + "singular": "pot", + "plural": "pots" + }, + "tray": { + "singular": "tray", + "plural": "trays" + }, + "seedling": { + "singular": "seedling", + "plural": "seedlings" + }, + "tree": { + "singular": "tree", + "plural": "trees" + }, + "cutting": { + "singular": "cutting", + "plural": "cuttings" + }, + "grams": { + "singular": "gram", + "plural": "grams" + }, + "count": { + "singular": "seed", + "plural": "seeds" + } }, "market": { "title": "Seeds near you", @@ -439,5 +521,8 @@ "vouch": "I know this person", "vouched": "You vouch for them", "circle": "In your circle" + }, + "notifications": { + "newMessageFrom": "New message from {name}" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 7c0e44a..c44b13e 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -214,8 +214,18 @@ "anyMonth": "Cualquier mes", "noDate": "Indicar fecha de cosecha", "monthNames": [ - "enero", "febrero", "marzo", "abril", "mayo", "junio", - "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre" + "enero", + "febrero", + "marzo", + "abril", + "mayo", + "junio", + "julio", + "agosto", + "septiembre", + "octubre", + "noviembre", + "diciembre" ] }, "lotType": { @@ -330,30 +340,102 @@ "some": "algunas", "plenty": "muchas", "pinch": "una pizca", - "handful": { "singular": "puñado", "plural": "puñados" }, - "teaspoon": { "singular": "cucharadita", "plural": "cucharaditas" }, - "spoon": { "singular": "cuchara", "plural": "cucharas" }, - "cup": { "singular": "taza", "plural": "tazas" }, - "jar": { "singular": "bote", "plural": "botes" }, - "sack": { "singular": "saco", "plural": "sacos" }, - "packet": { "singular": "sobre", "plural": "sobres" }, - "cob": { "singular": "mazorca", "plural": "mazorcas" }, - "pod": { "singular": "vaina", "plural": "vainas" }, - "ear": { "singular": "espiga", "plural": "espigas" }, - "head": { "singular": "cabezuela", "plural": "cabezuelas" }, - "fruit": { "singular": "fruto", "plural": "frutos" }, - "bulb": { "singular": "bulbo", "plural": "bulbos" }, - "tuber": { "singular": "tubérculo", "plural": "tubérculos" }, - "seedHead": { "singular": "cabeza de semillas", "plural": "cabezas de semillas" }, - "bunch": { "singular": "manojo", "plural": "manojos" }, - "plant": { "singular": "planta", "plural": "plantas" }, - "pot": { "singular": "maceta", "plural": "macetas" }, - "tray": { "singular": "bandeja", "plural": "bandejas" }, - "seedling": { "singular": "plántula", "plural": "plántulas" }, - "tree": { "singular": "árbol", "plural": "árboles" }, - "cutting": { "singular": "esqueje", "plural": "esquejes" }, - "grams": { "singular": "gramo", "plural": "gramos" }, - "count": { "singular": "semilla", "plural": "semillas" } + "handful": { + "singular": "puñado", + "plural": "puñados" + }, + "teaspoon": { + "singular": "cucharadita", + "plural": "cucharaditas" + }, + "spoon": { + "singular": "cuchara", + "plural": "cucharas" + }, + "cup": { + "singular": "taza", + "plural": "tazas" + }, + "jar": { + "singular": "bote", + "plural": "botes" + }, + "sack": { + "singular": "saco", + "plural": "sacos" + }, + "packet": { + "singular": "sobre", + "plural": "sobres" + }, + "cob": { + "singular": "mazorca", + "plural": "mazorcas" + }, + "pod": { + "singular": "vaina", + "plural": "vainas" + }, + "ear": { + "singular": "espiga", + "plural": "espigas" + }, + "head": { + "singular": "cabezuela", + "plural": "cabezuelas" + }, + "fruit": { + "singular": "fruto", + "plural": "frutos" + }, + "bulb": { + "singular": "bulbo", + "plural": "bulbos" + }, + "tuber": { + "singular": "tubérculo", + "plural": "tubérculos" + }, + "seedHead": { + "singular": "cabeza de semillas", + "plural": "cabezas de semillas" + }, + "bunch": { + "singular": "manojo", + "plural": "manojos" + }, + "plant": { + "singular": "planta", + "plural": "plantas" + }, + "pot": { + "singular": "maceta", + "plural": "macetas" + }, + "tray": { + "singular": "bandeja", + "plural": "bandejas" + }, + "seedling": { + "singular": "plántula", + "plural": "plántulas" + }, + "tree": { + "singular": "árbol", + "plural": "árboles" + }, + "cutting": { + "singular": "esqueje", + "plural": "esquejes" + }, + "grams": { + "singular": "gramo", + "plural": "gramos" + }, + "count": { + "singular": "semilla", + "plural": "semillas" + } }, "market": { "title": "Semillas cerca de ti", @@ -438,5 +520,8 @@ "vouch": "Conozco a esta persona", "vouched": "Avalas a esta persona", "circle": "En tu círculo" + }, + "notifications": { + "newMessageFrom": "Nuevo mensaje de {name}" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 8df35d2..a4d8d64 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -211,8 +211,18 @@ "anyMonth": "Qualquer mês", "noDate": "Definir data de colheita", "monthNames": [ - "janeiro", "fevereiro", "março", "abril", "maio", "junho", - "julho", "agosto", "setembro", "outubro", "novembro", "dezembro" + "janeiro", + "fevereiro", + "março", + "abril", + "maio", + "junho", + "julho", + "agosto", + "setembro", + "outubro", + "novembro", + "dezembro" ] }, "lotType": { @@ -327,30 +337,102 @@ "some": "bastantes", "plenty": "muitas", "pinch": "uma pitada", - "handful": { "singular": "mão-cheia", "plural": "mãos-cheias" }, - "teaspoon": { "singular": "colher de chá", "plural": "colheres de chá" }, - "spoon": { "singular": "colher", "plural": "colheres" }, - "cup": { "singular": "chávena", "plural": "chávenas" }, - "jar": { "singular": "frasco", "plural": "frascos" }, - "sack": { "singular": "saco", "plural": "sacos" }, - "packet": { "singular": "pacote", "plural": "pacotes" }, - "cob": { "singular": "maçaroca", "plural": "maçarocas" }, - "pod": { "singular": "vagem", "plural": "vagens" }, - "ear": { "singular": "espiga", "plural": "espigas" }, - "head": { "singular": "cabeça", "plural": "cabeças" }, - "fruit": { "singular": "fruto", "plural": "frutos" }, - "bulb": { "singular": "bolbo", "plural": "bolbos" }, - "tuber": { "singular": "tubérculo", "plural": "tubérculos" }, - "seedHead": { "singular": "capítulo", "plural": "capítulos" }, - "bunch": { "singular": "molho", "plural": "molhos" }, - "plant": { "singular": "planta", "plural": "plantas" }, - "pot": { "singular": "vaso", "plural": "vasos" }, - "tray": { "singular": "tabuleiro", "plural": "tabuleiros" }, - "seedling": { "singular": "plântula", "plural": "plântulas" }, - "tree": { "singular": "árvore", "plural": "árvores" }, - "cutting": { "singular": "estaca", "plural": "estacas" }, - "grams": { "singular": "grama", "plural": "gramas" }, - "count": { "singular": "semente", "plural": "sementes" } + "handful": { + "singular": "mão-cheia", + "plural": "mãos-cheias" + }, + "teaspoon": { + "singular": "colher de chá", + "plural": "colheres de chá" + }, + "spoon": { + "singular": "colher", + "plural": "colheres" + }, + "cup": { + "singular": "chávena", + "plural": "chávenas" + }, + "jar": { + "singular": "frasco", + "plural": "frascos" + }, + "sack": { + "singular": "saco", + "plural": "sacos" + }, + "packet": { + "singular": "pacote", + "plural": "pacotes" + }, + "cob": { + "singular": "maçaroca", + "plural": "maçarocas" + }, + "pod": { + "singular": "vagem", + "plural": "vagens" + }, + "ear": { + "singular": "espiga", + "plural": "espigas" + }, + "head": { + "singular": "cabeça", + "plural": "cabeças" + }, + "fruit": { + "singular": "fruto", + "plural": "frutos" + }, + "bulb": { + "singular": "bolbo", + "plural": "bolbos" + }, + "tuber": { + "singular": "tubérculo", + "plural": "tubérculos" + }, + "seedHead": { + "singular": "capítulo", + "plural": "capítulos" + }, + "bunch": { + "singular": "molho", + "plural": "molhos" + }, + "plant": { + "singular": "planta", + "plural": "plantas" + }, + "pot": { + "singular": "vaso", + "plural": "vasos" + }, + "tray": { + "singular": "tabuleiro", + "plural": "tabuleiros" + }, + "seedling": { + "singular": "plântula", + "plural": "plântulas" + }, + "tree": { + "singular": "árvore", + "plural": "árvores" + }, + "cutting": { + "singular": "estaca", + "plural": "estacas" + }, + "grams": { + "singular": "grama", + "plural": "gramas" + }, + "count": { + "singular": "semente", + "plural": "sementes" + } }, "market": { "title": "Sementes perto de ti", @@ -435,5 +517,8 @@ "vouch": "Conheço esta pessoa", "vouched": "Avalizas esta pessoa", "circle": "No teu círculo" + }, + "notifications": { + "newMessageFrom": "Nova mensagem de {name}" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index fb59798..32e50c2 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: 4 -/// Strings: 1548 (387 per locale) +/// Strings: 1552 (388 per locale) /// -/// Built on 2026-07-10 at 17:18 UTC +/// Built on 2026-07-10 at 18:28 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 2f83eb6..a47658e 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -75,6 +75,7 @@ class TranslationsAst extends Translations with BaseTranslations 'Nel to círculu'; } +// Path: notifications +class _Translations$notifications$ast extends Translations$notifications$en { + _Translations$notifications$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Mensaxe nuevu de ${name}'; +} + // Path: intro.slides class _Translations$intro$slides$ast extends Translations$intro$slides$en { _Translations$intro$slides$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -1483,6 +1494,7 @@ extension on TranslationsAst { 'trust.vouch' => 'Conozo a esta persona', 'trust.vouched' => 'Avales a esta persona', 'trust.circle' => 'Nel to círculu', + 'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 7785576..c42e44c 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -76,6 +76,7 @@ class Translations with BaseTranslations { 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); + late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root); } // Path: app @@ -1401,6 +1402,18 @@ class Translations$trust$en { String get circle => 'In your circle'; } +// Path: notifications +class Translations$notifications$en { + Translations$notifications$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'New message from {name}' + String newMessageFrom({required Object name}) => 'New message from ${name}'; +} + // Path: intro.slides class Translations$intro$slides$en { Translations$intro$slides$en.internal(this._root); @@ -2247,6 +2260,7 @@ extension on Translations { 'trust.vouch' => 'I know this person', 'trust.vouched' => 'You vouch for them', 'trust.circle' => 'In your circle', + 'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 98d6f12..abf75ae 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -75,6 +75,7 @@ class TranslationsEs extends Translations with BaseTranslations 'En tu círculo'; } +// Path: notifications +class _Translations$notifications$es extends Translations$notifications$en { + _Translations$notifications$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Nuevo mensaje de ${name}'; +} + // 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); @@ -1487,6 +1498,7 @@ extension on TranslationsEs { 'trust.vouch' => 'Conozco a esta persona', 'trust.vouched' => 'Avalas a esta persona', 'trust.circle' => 'En tu círculo', + 'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 22249a5..04bdf58 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -75,6 +75,7 @@ class TranslationsPt extends Translations with BaseTranslations 'No teu círculo'; } +// Path: notifications +class _Translations$notifications$pt extends Translations$notifications$en { + _Translations$notifications$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String newMessageFrom({required Object name}) => 'Nova mensagem de ${name}'; +} + // 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); @@ -1481,6 +1492,7 @@ extension on TranslationsPt { 'trust.vouch' => 'Conheço esta pessoa', 'trust.vouched' => 'Avalizas esta pessoa', 'trust.circle' => 'No teu círculo', + 'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}', _ => null, }; } diff --git a/apps/app_seeds/lib/services/inbox_service.dart b/apps/app_seeds/lib/services/inbox_service.dart index 5d61ca0..8282c2e 100644 --- a/apps/app_seeds/lib/services/inbox_service.dart +++ b/apps/app_seeds/lib/services/inbox_service.dart @@ -4,10 +4,13 @@ import 'package:commons_core/commons_core.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; +import '../i18n/strings.g.dart'; import 'message_store.dart'; +import 'notification_service.dart'; import 'profile_cache.dart'; import 'social_service.dart'; import 'social_settings.dart'; +import 'unread_service.dart'; /// App-wide inbox listener for private messages (NIP-17). /// @@ -26,15 +29,21 @@ class InboxService { required SocialSettings settings, required MessageStore store, ProfileCache? profileCache, + UnreadService? unread, + NotificationService? notifications, }) : _social = social, _settings = settings, _store = store, - _profileCache = profileCache; + _profileCache = profileCache, + _unread = unread, + _notifications = notifications; final SocialService _social; final SocialSettings _settings; final MessageStore _store; final ProfileCache? _profileCache; + final UnreadService? _unread; + final NotificationService? _notifications; final _changes = StreamController.broadcast(); SocialSession? _session; @@ -101,7 +110,29 @@ class InboxService { final stored = await _store.append(message.fromPubkey, message); if (!stored) return; // a re-delivered duplicate if (!_changes.isClosed) _changes.add(null); - await _cacheName(message.fromPubkey); + + final peer = message.fromPubkey; + // Defensive: never badge or notify for your own message. NIP-17 doesn't + // loop a sent gift wrap back to its author, so this shouldn't happen — the + // guard just keeps the seam honest. + if (peer != _social.publicKeyHex) { + await _unread?.onMessageReceived(peer, message); + await _maybeNotify(peer); + } + await _cacheName(peer); + } + + /// Fires a text-free OS notification for a new message from [peer], unless its + /// chat is the one already on screen (then the message is visible anyway). + Future _maybeNotify(String peer) async { + final notifications = _notifications; + if (notifications == null) return; + if (_unread?.activePeer == peer) return; + final name = await _profileCache?.name(peer) ?? shortPubkey(peer); + await notifications.showMessage( + peerPubkey: peer, + title: t.notifications.newMessageFrom(name: name), + ); } Future _cacheName(String peerPubkey) async { diff --git a/apps/app_seeds/lib/services/notification_service.dart b/apps/app_seeds/lib/services/notification_service.dart new file mode 100644 index 0000000..18418cc --- /dev/null +++ b/apps/app_seeds/lib/services/notification_service.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// Shows an OS notification when a private message arrives while the app is in +/// the foreground. Foreground only by design — when the app is backgrounded the +/// message listener stops, so nothing fires (background/push is a later, larger +/// concern). Web and Windows have no local-notification support, so there this +/// whole service is an inert no-op. +/// +/// For privacy it never carries message text — the caller passes a generic +/// `New message from ` title (see [InboxService]). The peer's pubkey rides +/// along as the payload so a tap can open that exact chat via [onTapChat]. +class NotificationService { + NotificationService({ + FlutterLocalNotificationsPlugin? plugin, + bool? supported, + }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), + _supported = supported ?? _platformSupported; + + final FlutterLocalNotificationsPlugin _plugin; + final bool _supported; + bool _ready = false; + + /// Called with a peer pubkey when the user taps a message notification. The + /// app wires this to `router.push('/chat/')` once the router exists. + void Function(String peerPubkey)? onTapChat; + + static const _channelId = 'messages'; + + static bool get _platformSupported { + if (kIsWeb) return false; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + return true; + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + return false; + } + } + + /// Initialises the plugin and requests permission (Android 13+/iOS/macOS ask + /// at runtime). Idempotent and safe to call on any platform. + Future initialize() async { + if (!_supported || _ready) return; + const android = AndroidInitializationSettings('@mipmap/ic_launcher'); + const darwin = DarwinInitializationSettings(); + const linux = LinuxInitializationSettings(defaultActionName: 'Open'); + await _plugin.initialize( + const InitializationSettings( + android: android, + iOS: darwin, + macOS: darwin, + linux: linux, + ), + onDidReceiveNotificationResponse: (response) { + final payload = response.payload; + if (payload != null && payload.isNotEmpty) onTapChat?.call(payload); + }, + ); + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestNotificationsPermission(); + await _plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions(alert: true, badge: true, sound: true); + await _plugin + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions(alert: true, badge: true, sound: true); + _ready = true; + } + + /// Shows a notification for a new message. [title] is a generic, text-free + /// line like "New message from Alice"; [peerPubkey] becomes the tap payload. + /// No-op on unsupported platforms. + Future showMessage({ + required String peerPubkey, + required String title, + }) async { + if (!_supported) return; + const details = NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + 'Messages', + channelDescription: 'New private messages', + importance: Importance.high, + priority: Priority.high, + ), + iOS: DarwinNotificationDetails(), + macOS: DarwinNotificationDetails(), + linux: LinuxNotificationDetails(), + ); + // One notification per peer (same id replaces the previous), so a chatty + // peer doesn't stack a wall of notifications. + await _plugin.show(peerPubkey.hashCode, title, null, details, + payload: peerPubkey); + } +} diff --git a/apps/app_seeds/lib/services/unread_service.dart b/apps/app_seeds/lib/services/unread_service.dart new file mode 100644 index 0000000..47cfc3b --- /dev/null +++ b/apps/app_seeds/lib/services/unread_service.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; + +import '../security/secret_store.dart'; +import 'message_store.dart'; + +/// Tracks which private messages are still unread, so the UI can show a badge. +/// +/// There is no read/unread flag on a message; instead we keep a per-peer +/// "last read at" timestamp in the keystore (just a timestamp and a pubkey — no +/// message text, so it honours "no plaintext at rest"). A conversation's unread +/// count is simply the peer's messages newer than that mark. Reading history +/// from the [MessageStore] keeps a single source of truth. +/// +/// Only inbound messages count: a message whose author is the peer. Your own +/// sent messages (stored with your pubkey) are never counted. +/// +/// [activePeer] is the chat currently on screen. A message that arrives for it +/// is treated as already read (you're looking at it), and the inbox listener +/// also skips its notification — so the chat you're in never badges or beeps. +class UnreadService { + UnreadService(this._store, this._secrets); + + final MessageStore _store; + final SecretStore _secrets; + + final _changes = StreamController.broadcast(); + + /// The peer whose chat is currently open, or null. Set by [ChatScreen]. + String? activePeer; + + static const _prefix = 'tane.social.unread.'; + + String _key(String peer) => '$_prefix$peer'; + + /// Fires (no payload) whenever an unread count may have changed — on a new + /// inbound message and on [markRead]. Broadcast, so several badges can listen. + Stream get changes => _changes.stream; + + /// Records that a new [message] for [peer] was just persisted. If its chat is + /// open it is marked read immediately; otherwise badges are asked to refresh. + Future onMessageReceived(String peer, PrivateMessage message) async { + if (peer == activePeer) { + await markRead(peer); + } else { + _fire(); + } + } + + /// Marks the conversation with [peer] read up to its latest message, so its + /// unread count drops to zero. Called when the chat opens (and when a message + /// arrives while it's open). + Future markRead(String peer) async { + final history = await _store.history(peer); + final upTo = history.isEmpty + ? DateTime.now() + : history + .map((m) => m.at) + .reduce((a, b) => a.isAfter(b) ? a : b); + await _secrets.write(_key(peer), '${upTo.millisecondsSinceEpoch}'); + _fire(); + } + + /// Unread inbound messages from [peer] (those newer than its last-read mark). + Future unreadCount(String peer) async { + final lastRead = await _lastRead(peer); + final history = await _store.history(peer); + return history + .where((m) => m.fromPubkey == peer && m.at.isAfter(lastRead)) + .length; + } + + /// Unread messages across every conversation (the number for the app badge). + Future totalUnreadCount() async { + var total = 0; + for (final c in await _store.conversations()) { + total += await unreadCount(c.peerPubkey); + } + return total; + } + + Future _lastRead(String peer) async { + final raw = await _secrets.read(_key(peer)); + final ms = raw == null ? null : int.tryParse(raw); + return DateTime.fromMillisecondsSinceEpoch(ms ?? 0); + } + + void _fire() { + if (!_changes.isClosed) _changes.add(null); + } + + Future dispose() async { + if (!_changes.isClosed) await _changes.close(); + } +} diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 6031ecb..799a972 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../i18n/strings.g.dart'; import 'seed_glyph.dart'; import 'theme.dart'; +import 'unread_badge.dart'; /// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is /// the live destination (green seed glyph), the social items (market, profile, @@ -56,7 +57,7 @@ class AppDrawer extends StatelessWidget { : null, ), _DrawerItem( - icon: const Icon(Icons.chat_bubble), + icon: const UnreadBadge(child: Icon(Icons.chat_bubble)), label: t.menu.chat, onTap: marketEnabled ? () { diff --git a/apps/app_seeds/lib/ui/chat_list_screen.dart b/apps/app_seeds/lib/ui/chat_list_screen.dart index a3535c7..46237ab 100644 --- a/apps/app_seeds/lib/ui/chat_list_screen.dart +++ b/apps/app_seeds/lib/ui/chat_list_screen.dart @@ -10,6 +10,7 @@ import '../services/profile_cache.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import 'theme.dart'; +import 'unread_badge.dart'; /// The messages inbox: past conversations, newest first. Shows each peer's /// published name (falling back to a short key), and opens the chat on tap. @@ -120,9 +121,12 @@ class _ChatListScreenState extends State { itemBuilder: (context, i) { final c = items[i]; return ListTile( - leading: const CircleAvatar( - backgroundColor: seedAvatar, - child: Icon(Icons.person, color: seedOnAvatar), + leading: UnreadBadge( + peer: c.peerPubkey, + child: const CircleAvatar( + backgroundColor: seedAvatar, + child: Icon(Icons.person, color: seedOnAvatar), + ), ), title: Text(_names[c.peerPubkey] ?? shortPubkey(c.peerPubkey)), diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 97a555c..eed6cfa 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -5,11 +5,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../di/injector.dart'; import '../i18n/strings.g.dart'; import '../services/message_store.dart'; import '../services/profile_cache.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; +import '../services/unread_service.dart'; import '../state/messages_cubit.dart'; import '../state/trust_cubit.dart'; import 'theme.dart'; @@ -50,9 +52,20 @@ class _ChatScreenState extends State { String? _peerG1; final _input = TextEditingController(); + /// App-wide unread tracker (absent in tests / inventory-only builds). While + /// this chat is open it is the "active peer", so incoming messages for it are + /// read on arrival and never badge or notify. + UnreadService? _unread; + @override void initState() { super.initState(); + _unread = + getIt.isRegistered() ? getIt() : null; + // Set synchronously (before any await) so a message landing during _init is + // already suppressed. + _unread?.activePeer = widget.peerPubkey; + unawaited(_unread?.markRead(widget.peerPubkey)); _init(); } @@ -148,6 +161,10 @@ class _ChatScreenState extends State { @override void dispose() { + if (_unread?.activePeer == widget.peerPubkey) _unread!.activePeer = null; + // Mark read once more on the way out, so anything that arrived while the + // chat was open (and was auto-read) stays cleared. + unawaited(_unread?.markRead(widget.peerPubkey)); _messages?.close(); _trust?.close(); _session?.close(); diff --git a/apps/app_seeds/lib/ui/home_screen.dart b/apps/app_seeds/lib/ui/home_screen.dart index 47d9a1f..73befef 100644 --- a/apps/app_seeds/lib/ui/home_screen.dart +++ b/apps/app_seeds/lib/ui/home_screen.dart @@ -5,6 +5,7 @@ import '../i18n/strings.g.dart'; import 'app_drawer.dart'; import 'seed_glyph.dart'; import 'theme.dart'; +import 'unread_badge.dart'; /// The main menu (redesign screen 00): a sprout logo in a soft green disc over a /// faint seed-glyph watermark, with "Your inventory" as the primary green call @@ -21,7 +22,20 @@ class HomeScreen extends StatelessWidget { Widget build(BuildContext context) { final t = context.t; return Scaffold( - appBar: AppBar(title: Text(t.app.title)), + appBar: AppBar( + title: Text(t.app.title), + // Badge the hamburger so unread messages are visible from home, without + // opening the drawer. Total count across conversations. + leading: Builder( + builder: (context) => UnreadBadge( + child: IconButton( + icon: const Icon(Icons.menu), + tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), + ), + ), drawer: AppDrawer(marketEnabled: marketEnabled), body: Stack( children: [ diff --git a/apps/app_seeds/lib/ui/unread_badge.dart b/apps/app_seeds/lib/ui/unread_badge.dart new file mode 100644 index 0000000..2957f74 --- /dev/null +++ b/apps/app_seeds/lib/ui/unread_badge.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../di/injector.dart'; +import '../services/unread_service.dart'; +import 'theme.dart'; + +/// Wraps [child] with a small live unread-count badge. It listens to the +/// app-wide [UnreadService] and rebuilds whenever a count changes: on a new +/// message, or when a chat is opened (marked read). Shows the total across all +/// conversations, or — when [peer] is set — just that peer's count. +/// +/// When no unread tracker is available (widget tests, inventory-only builds) or +/// the count is zero, it renders [child] unadorned. Positioning is via Flutter's +/// [Badge], which is directional (top-`end`), so it's correct under RTL. +class UnreadBadge extends StatefulWidget { + const UnreadBadge({ + required this.child, + this.peer, + this.service, + super.key, + }); + + final Widget child; + + /// A specific conversation's count; null means the app-wide total. + final String? peer; + + /// Overrides the [UnreadService] lookup (for tests). Falls back to `getIt`. + final UnreadService? service; + + @override + State createState() => _UnreadBadgeState(); +} + +class _UnreadBadgeState extends State { + UnreadService? _service; + StreamSubscription? _sub; + int _count = 0; + + @override + void initState() { + super.initState(); + _service = widget.service ?? + (getIt.isRegistered() ? getIt() : null); + final service = _service; + if (service != null) { + _refresh(); + _sub = service.changes.listen((_) => _refresh()); + } + } + + @override + void didUpdateWidget(UnreadBadge old) { + super.didUpdateWidget(old); + // A recycled row may now point at a different peer. + if (old.peer != widget.peer) _refresh(); + } + + Future _refresh() async { + final service = _service; + if (service == null) return; + final peer = widget.peer; + final count = peer == null + ? await service.totalUnreadCount() + : await service.unreadCount(peer); + if (mounted) setState(() => _count = count); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Badge( + isLabelVisible: _count > 0, + label: Text('$_count'), + backgroundColor: seedGreen, + child: widget.child, + ); + } +} diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index f6a3f49..9abc745 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -74,6 +74,10 @@ dependencies: # Network reachability (BSD-3) to show a global "you're offline" banner — the # social layer needs a connection; the inventory works regardless. connectivity_plus: ^6.1.0 + # Local OS notifications (BSD-3) for a heads-up when a private message arrives + # while the app is foregrounded. Mobile/Linux/macOS only; a no-op on web and + # Windows. Foreground-only by design — background/push is a later concern. + flutter_local_notifications: ^18.0.1 dev_dependencies: flutter_test: diff --git a/apps/app_seeds/test/services/inbox_service_test.dart b/apps/app_seeds/test/services/inbox_service_test.dart index 2c85de0..5cb5c44 100644 --- a/apps/app_seeds/test/services/inbox_service_test.dart +++ b/apps/app_seeds/test/services/inbox_service_test.dart @@ -2,11 +2,30 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/services/inbox_service.dart'; import 'package:tane/services/message_store.dart'; +import 'package:tane/services/notification_service.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; +import 'package:tane/services/unread_service.dart'; import '../support/test_support.dart'; +/// Records notification calls instead of touching the OS plugin. +class _RecordingNotifications extends NotificationService { + _RecordingNotifications() : super(supported: false); + + final titles = []; + final peers = []; + + @override + Future showMessage({ + required String peerPubkey, + required String title, + }) async { + peers.add(peerPubkey); + titles.add(title); + } +} + /// The app-wide inbox listener persists incoming messages and announces changes, /// so the inbox list refreshes even when the specific chat isn't open. Driven /// through the [InboxService.ingest] seam so no relay/network is involved. @@ -52,4 +71,52 @@ void main() { expect(await store.history('alice'), hasLength(1)); await sub.cancel(); }); + + group('unread + notification hooks', () { + late UnreadService unread; + late _RecordingNotifications notifications; + late String myPubkey; + + setUp(() async { + final social = await SocialService.fromRootSeedHex(seedHex); + myPubkey = social.publicKeyHex; + unread = UnreadService(store, InMemorySecretStore()); + notifications = _RecordingNotifications(); + inbox = InboxService( + social: social, + settings: SocialSettings(InMemorySecretStore()), + store: store, + unread: unread, + notifications: notifications, + ); + }); + + tearDown(() => unread.dispose()); + + test('a new peer message updates unread and notifies', () async { + await inbox.ingest(msg('alice', 'hola', 1000)); + expect(await unread.unreadCount('alice'), 1); + expect(notifications.peers, ['alice']); + }); + + test('a duplicate re-delivery neither counts nor notifies', () async { + await inbox.ingest(msg('alice', 'hola', 1000)); + await inbox.ingest(msg('alice', 'hola', 1000)); + expect(await unread.unreadCount('alice'), 1); + expect(notifications.peers, hasLength(1)); + }); + + test('a message authored by me is never notified', () async { + // NIP-17 doesn't loop your own gift wrap back; the guard is defensive. + await inbox.ingest(msg(myPubkey, 'echo', 1000)); + expect(notifications.peers, isEmpty); + }); + + test('the open chat is not notified and stays read', () async { + unread.activePeer = 'alice'; + await inbox.ingest(msg('alice', 'while open', 1000)); + expect(await unread.unreadCount('alice'), 0); + expect(notifications.peers, isEmpty); + }); + }); } diff --git a/apps/app_seeds/test/services/notification_service_test.dart b/apps/app_seeds/test/services/notification_service_test.dart new file mode 100644 index 0000000..89a08af --- /dev/null +++ b/apps/app_seeds/test/services/notification_service_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:tane/services/notification_service.dart'; + +class _MockPlugin extends Mock implements FlutterLocalNotificationsPlugin {} + +void main() { + late _MockPlugin plugin; + + setUpAll(() { + registerFallbackValue( + const NotificationDetails( + android: AndroidNotificationDetails('x', 'x'), + ), + ); + registerFallbackValue(const InitializationSettings()); + }); + + setUp(() { + plugin = _MockPlugin(); + when(() => plugin.show(any(), any(), any(), any(), + payload: any(named: 'payload'))).thenAnswer((_) async {}); + // Generic platform resolution returns null, so the permission requests in + // initialize() are skipped in tests. + when(() => plugin.resolvePlatformSpecificImplementation()) + .thenReturn(null); + }); + + test('showMessage passes the title and pubkey payload, no body', () async { + final service = NotificationService(plugin: plugin, supported: true); + await service.showMessage( + peerPubkey: 'abc123', title: 'New message from Alice'); + final captured = verify(() => plugin.show( + captureAny(), + captureAny(), + captureAny(), + captureAny(), + payload: captureAny(named: 'payload'), + )).captured; + expect(captured[1], 'New message from Alice'); // title + expect(captured[2], isNull); // no body (privacy: no message text) + expect(captured[4], 'abc123'); // payload = peer pubkey + }); + + test('is a no-op on unsupported platforms (web/windows)', () async { + final service = NotificationService(plugin: plugin, supported: false); + await service.initialize(); + await service.showMessage(peerPubkey: 'abc123', title: 'hi'); + verifyNever(() => plugin.show(any(), any(), any(), any(), + payload: any(named: 'payload'))); + }); + + test('a tap with a payload invokes onTapChat with the pubkey', () async { + // Capture the response handler initialize() registers, then simulate a tap. + void Function(NotificationResponse)? handler; + when(() => plugin.initialize( + any(), + onDidReceiveNotificationResponse: + any(named: 'onDidReceiveNotificationResponse'), + )).thenAnswer((inv) async { + handler = inv.namedArguments[#onDidReceiveNotificationResponse] + as void Function(NotificationResponse)?; + return true; + }); + + final service = NotificationService(plugin: plugin, supported: true); + String? tapped; + service.onTapChat = (pk) => tapped = pk; + await service.initialize(); + + handler!(const NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: 'abc123', + )); + expect(tapped, 'abc123'); + }); +} diff --git a/apps/app_seeds/test/services/unread_service_test.dart b/apps/app_seeds/test/services/unread_service_test.dart new file mode 100644 index 0000000..35f33de --- /dev/null +++ b/apps/app_seeds/test/services/unread_service_test.dart @@ -0,0 +1,90 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/message_store.dart'; +import 'package:tane/services/unread_service.dart'; + +import '../support/test_support.dart'; + +/// Unread counting is derived from stored history plus a per-peer "last read" +/// mark; these tests drive it through a real [MessageStore] over an in-memory +/// keystore, so no relay or network is involved. +void main() { + const me = 'me-pubkey'; + + late MessageStore store; + late UnreadService unread; + + setUp(() { + store = MessageStore(InMemorySecretStore()); + unread = UnreadService(store, InMemorySecretStore()); + }); + + tearDown(() => unread.dispose()); + + PrivateMessage msg(String from, String text, int atMs) => PrivateMessage( + fromPubkey: from, + text: text, + at: DateTime.fromMillisecondsSinceEpoch(atMs)); + + // Persist then notify, the way InboxService.ingest does. + Future receive(String peer, PrivateMessage m) async { + await store.append(peer, m); + await unread.onMessageReceived(peer, m); + } + + test('an unknown peer has no unread', () async { + expect(await unread.unreadCount('alice'), 0); + }); + + test('inbound messages increment the unread count', () async { + await receive('alice', msg('alice', 'hi', 1000)); + await receive('alice', msg('alice', 'you there?', 2000)); + expect(await unread.unreadCount('alice'), 2); + }); + + test('markRead clears older messages but keeps newer ones', () async { + await receive('alice', msg('alice', 'a', 1000)); + await unread.markRead('alice'); // reads up to the latest (1000) + expect(await unread.unreadCount('alice'), 0); + + await receive('alice', msg('alice', 'b', 2000)); + expect(await unread.unreadCount('alice'), 1); + }); + + test('own outgoing messages never count as unread', () async { + await receive('alice', msg(me, 'my reply', 1000)); + expect(await unread.unreadCount('alice'), 0); + }); + + test('a message for the open chat is auto-read (no unread)', () async { + unread.activePeer = 'alice'; + await receive('alice', msg('alice', 'while open', 1000)); + expect(await unread.unreadCount('alice'), 0); + }); + + test('totalUnreadCount sums across conversations', () async { + await receive('alice', msg('alice', 'a', 1000)); + await receive('bob', msg('bob', 'b1', 1000)); + await receive('bob', msg('bob', 'b2', 2000)); + expect(await unread.totalUnreadCount(), 3); + }); + + test('changes fires on a new inbound message', () async { + final events = []; + final sub = unread.changes.listen(events.add); + await receive('alice', msg('alice', 'hi', 1000)); + await Future.delayed(Duration.zero); + expect(events, hasLength(1)); + await sub.cancel(); + }); + + test('changes fires on markRead', () async { + await receive('alice', msg('alice', 'hi', 1000)); + final events = []; + final sub = unread.changes.listen(events.add); + await unread.markRead('alice'); + await Future.delayed(Duration.zero); + expect(events, hasLength(1)); + await sub.cancel(); + }); +} diff --git a/apps/app_seeds/test/ui/unread_badge_test.dart b/apps/app_seeds/test/ui/unread_badge_test.dart new file mode 100644 index 0000000..1c32227 --- /dev/null +++ b/apps/app_seeds/test/ui/unread_badge_test.dart @@ -0,0 +1,74 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/message_store.dart'; +import 'package:tane/services/unread_service.dart'; +import 'package:tane/ui/unread_badge.dart'; + +import '../support/test_support.dart'; + +/// The badge is driven by [UnreadService]: it shows a count when there are +/// unread messages and clears when they're read — live, over the service's +/// `changes` stream. Injecting the service avoids the `getIt` lookup. The store +/// reads are real async, so mutations run inside `runAsync`. +void main() { + late MessageStore store; + late UnreadService unread; + + setUp(() { + store = MessageStore(InMemorySecretStore()); + unread = UnreadService(store, InMemorySecretStore()); + }); + + tearDown(() => unread.dispose()); + + Widget host() => MaterialApp( + home: Scaffold( + body: UnreadBadge( + peer: 'alice', + service: unread, + child: const Icon(Icons.chat_bubble), + ), + ), + ); + + Future receive(int atMs) async { + final m = PrivateMessage( + fromPubkey: 'alice', + text: 'hi', + at: DateTime.fromMillisecondsSinceEpoch(atMs)); + await store.append('alice', m); + await unread.onMessageReceived('alice', m); + } + + // Lets pending async (store reads + the widget's stream-driven refresh) + // resolve in the real zone, then renders a frame. + Future pumpRefresh(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + } + + testWidgets('shows an existing unread count on build', (tester) async { + await tester.runAsync(() => receive(1000)); + await tester.pumpWidget(host()); + await pumpRefresh(tester); + expect(find.text('1'), findsOneWidget); + }); + + testWidgets('reacts to a new message and to being read', (tester) async { + await tester.pumpWidget(host()); + await pumpRefresh(tester); + expect(find.text('1'), findsNothing); + + await tester.runAsync(() => receive(1000)); + await pumpRefresh(tester); + expect(find.text('1'), findsOneWidget); + + await tester.runAsync(() => unread.markRead('alice')); + await pumpRefresh(tester); + expect(find.text('1'), findsNothing); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 9a12b95..85554da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -435,6 +435,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: transitive + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_localizations: dependency: transitive description: flutter @@ -1305,6 +1329,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.16" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: