tane/apps/app_seeds/lib/ui/unread_badge.dart
vjrj 5fe0f4540e 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.
2026-07-10 21:12:00 +02:00

86 lines
2.3 KiB
Dart

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<UnreadBadge> createState() => _UnreadBadgeState();
}
class _UnreadBadgeState extends State<UnreadBadge> {
UnreadService? _service;
StreamSubscription<void>? _sub;
int _count = 0;
@override
void initState() {
super.initState();
_service = widget.service ??
(getIt.isRegistered<UnreadService>() ? getIt<UnreadService>() : 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<void> _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,
);
}
}