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:
commit
48db8fa7c8
27 changed files with 1238 additions and 114 deletions
|
|
@ -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
|
||||
? () {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
86
apps/app_seeds/lib/ui/unread_badge.dart
Normal file
86
apps/app_seeds/lib/ui/unread_badge.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue