feat(messages): unread badges + OS notification for private messages

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 <name>" (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.
This commit is contained in:
vjrj 2026-07-10 21:12:00 +02:00
parent 63f48db5c2
commit 5fe0f4540e
27 changed files with 1238 additions and 114 deletions

View file

@ -12,6 +12,7 @@ import 'services/auto_backup_service.dart';
import 'services/coarse_location.dart';
import 'services/inbox_service.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,7 @@ class TaneApp extends StatelessWidget {
this.profileStore,
this.profileCache,
this.inbox,
this.notifications,
this.showIntro = false,
this.autoBackup,
super.key,
@ -66,7 +68,12 @@ class TaneApp extends StatelessWidget {
profileStore,
profileCache,
inbox,
);
) {
// A tapped message notification opens that peer's chat. Wired here because
// the router only exists now; taps only happen while the app is foreground,
// so a live router reference is enough.
notifications?.onTapChat = (pubkey) => _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