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

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