tane/apps/app_seeds/lib/ui/home_screen.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

354 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
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
/// to action and "Open market" (Block 2) as a disabled outlined card. The
/// hamburger opens [AppDrawer].
class HomeScreen extends StatelessWidget {
const HomeScreen({this.marketEnabled = false, super.key});
/// When the Block 2 social layer is wired, the market becomes a live
/// destination; otherwise it stays a disabled "coming soon" card.
final bool marketEnabled;
@override
Widget build(BuildContext context) {
final t = context.t;
return Scaffold(
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: [
const Positioned.fill(child: _SeedWatermark()),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 128,
height: 128,
decoration: const BoxDecoration(
color: Color(0xFFDDF3CE),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Image.asset(
'assets/logo.png',
height: 84,
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 10),
Text(
t.app.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF1A2A13),
fontSize: 26,
fontWeight: FontWeight.w500,
letterSpacing: -0.3,
),
),
const SizedBox(height: 2),
Text(
t.home.tagline,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF55624A),
fontSize: 14,
),
),
const SizedBox(height: 34),
_PrimaryMenuCard(
key: const Key('home.inventory'),
icon: Icons.inventory_2_outlined,
label: t.home.yourInventory,
subtitle: t.home.yourInventorySubtitle,
onTap: () => context.push('/inventory'),
),
const SizedBox(height: 16),
_OutlinedMenuCard(
key: const Key('home.market'),
icon: Icons.storefront_outlined,
label: t.home.openMarket,
subtitle: t.home.openMarketSubtitle,
tag: marketEnabled ? null : t.common.comingSoon,
onTap: marketEnabled
? () => context.push('/market')
: null,
),
],
),
),
),
),
],
),
);
}
}
/// The primary green call-to-action: white icon in a translucent disc, a title
/// and a subtitle, with a soft green shadow.
class _PrimaryMenuCard extends StatelessWidget {
const _PrimaryMenuCard({
required this.icon,
required this.label,
required this.subtitle,
required this.onTap,
super.key,
});
final IconData icon;
final String label;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: seedGreen,
borderRadius: BorderRadius.circular(18),
elevation: 3,
shadowColor: seedGreen.withValues(alpha: 0.4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 22),
child: Row(
children: [
_IconDisc(
background: Colors.white.withValues(alpha: 0.18),
child: Icon(icon, color: Colors.white, size: 26),
),
const SizedBox(width: 16),
Expanded(
child: _CardText(
label: label,
subtitle: subtitle,
color: Colors.white,
subtitleColor: Colors.white.withValues(alpha: 0.85),
),
),
],
),
),
),
);
}
}
/// A disabled Block-2 destination: an outlined white card with a green-disc
/// icon and a "soon" tag.
class _OutlinedMenuCard extends StatelessWidget {
const _OutlinedMenuCard({
required this.icon,
required this.label,
required this.subtitle,
this.tag,
this.onTap,
super.key,
});
final IconData icon;
final String label;
final String subtitle;
/// A small trailing tag (e.g. "coming soon"); omitted for live cards.
final String? tag;
/// When set, the card is tappable; otherwise it reads as disabled.
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final card = Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFB8C4AC)),
),
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 22),
child: Row(
children: [
_IconDisc(
background: const Color(0xFFDDF3CE),
child: Icon(icon, color: seedGreen, size: 26),
),
const SizedBox(width: 16),
Expanded(
child: _CardText(
label: label,
subtitle: subtitle,
color: seedOnSurface,
subtitleColor: seedMuted,
),
),
if (tag != null)
Text(
tag!.toUpperCase(),
style: const TextStyle(
color: Color(0xFFB3BDA8),
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
)
else if (onTap != null)
const Icon(Icons.chevron_right, color: seedMuted),
],
),
);
if (onTap == null) return card;
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(18),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: card,
),
);
}
}
class _IconDisc extends StatelessWidget {
const _IconDisc({required this.background, required this.child});
final Color background;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(shape: BoxShape.circle, color: background),
alignment: Alignment.center,
child: child,
);
}
}
class _CardText extends StatelessWidget {
const _CardText({
required this.label,
required this.subtitle,
required this.color,
required this.subtitleColor,
});
final String label;
final String subtitle;
final Color color;
final Color subtitleColor;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: color,
fontSize: 19,
fontWeight: FontWeight.w500,
),
),
Text(subtitle, style: TextStyle(color: subtitleColor, fontSize: 13)),
],
);
}
}
/// A single seed pictogram scattered onto the watermark: [ax]/[ay] are
/// fractional positions (0..1) within the layer, [angle] a slight tilt so the
/// scatter feels hand-strewn rather than gridded (like screen 00).
class _ScatteredGlyph {
const _ScatteredGlyph(this.char, this.ax, this.ay, this.size, this.angle);
final String char;
final double ax;
final double ay;
final double size;
final double angle;
}
/// A faint, disordered scatter of seed pictograms behind the menu (like screen
/// 00): glyphs strewn around the edges at staggered positions and tilts,
/// positioned by fraction so it holds up at any screen size.
class _SeedWatermark extends StatelessWidget {
const _SeedWatermark();
static const _glyphs = [
_ScatteredGlyph(SeedGlyphs.sack, 0.06, 0.14, 62, -0.18),
_ScatteredGlyph(SeedGlyphs.envelope, 0.82, 0.09, 48, 0.22),
_ScatteredGlyph(SeedGlyphs.scattered, 0.70, 0.30, 44, -0.12),
_ScatteredGlyph(SeedGlyphs.jar, 0.16, 0.44, 40, 0.15),
_ScatteredGlyph(SeedGlyphs.jars, 0.85, 0.66, 44, 0.18),
_ScatteredGlyph(SeedGlyphs.smallSpoon, 0.10, 0.74, 48, -0.22),
_ScatteredGlyph(SeedGlyphs.pouring, 0.40, 0.90, 52, 0.10),
_ScatteredGlyph(SeedGlyphs.mug, 0.72, 0.88, 60, -0.14),
];
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
return Stack(
children: [
for (final g in _glyphs)
Positioned(
left: w * g.ax,
top: h * g.ay,
child: Opacity(
opacity: 0.08,
child: Transform.rotate(
angle: g.angle,
child: SeedGlyph(
g.char,
size: g.size,
color: seedGreenDark,
),
),
),
),
],
);
},
),
);
}
}