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.
354 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|