Merge branch 'claude/awesome-golick-007e7d': message unread badges + OS notifications

# Conflicts:
#	apps/app_seeds/lib/di/injector.dart
#	apps/app_seeds/lib/i18n/ast.i18n.json
#	apps/app_seeds/lib/i18n/en.i18n.json
#	apps/app_seeds/lib/i18n/es.i18n.json
#	apps/app_seeds/lib/i18n/pt.i18n.json
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/i18n/strings_ast.g.dart
#	apps/app_seeds/lib/i18n/strings_en.g.dart
#	apps/app_seeds/lib/i18n/strings_es.g.dart
#	apps/app_seeds/lib/i18n/strings_pt.g.dart
#	apps/app_seeds/lib/ui/chat_screen.dart
This commit is contained in:
vjrj 2026-07-10 21:15:59 +02:00
commit 48db8fa7c8
27 changed files with 1238 additions and 114 deletions

View file

@ -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
? () {

View file

@ -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<ChatListScreen> {
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)),

View file

@ -6,12 +6,14 @@ 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/trust_referents.dart';
import '../services/unread_service.dart';
import '../services/wot_settings.dart';
import '../state/messages_cubit.dart';
import '../state/trust_cubit.dart';
@ -59,9 +61,20 @@ class _ChatScreenState extends State<ChatScreen> {
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<UnreadService>() ? getIt<UnreadService>() : 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();
}
@ -165,6 +178,10 @@ class _ChatScreenState extends State<ChatScreen> {
@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();

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

View file

@ -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<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,
);
}
}