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.
This commit is contained in:
vjrj 2026-07-10 21:12:00 +02:00
parent 63f48db5c2
commit 5fe0f4540e
27 changed files with 1238 additions and 114 deletions

View file

@ -2,6 +2,9 @@
<!-- Coarse location only, for the optional "use my location" sharing area
(reduced to a low-precision geohash; never a precise fix). -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Foreground local notifications for incoming private messages (Android 13+
asks the user at runtime). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="tane"
android:name="${applicationName}"

View file

@ -12,6 +12,7 @@ import 'services/auto_backup_service.dart';
import 'services/coarse_location.dart';
import 'services/inbox_service.dart';
import 'services/message_store.dart';
import 'services/notification_service.dart';
import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart';
import 'services/profile_cache.dart';
@ -51,6 +52,7 @@ class TaneApp extends StatelessWidget {
this.profileStore,
this.profileCache,
this.inbox,
this.notifications,
this.showIntro = false,
this.autoBackup,
super.key,
@ -66,7 +68,12 @@ class TaneApp extends StatelessWidget {
profileStore,
profileCache,
inbox,
);
) {
// A tapped message notification opens that peer's chat. Wired here because
// the router only exists now; taps only happen while the app is foreground,
// so a live router reference is enough.
notifications?.onTapChat = (pubkey) => _router.push('/chat/$pubkey');
}
final VarietyRepository repository;
final SpeciesRepository species;
@ -94,6 +101,10 @@ class TaneApp extends StatelessWidget {
/// App-wide inbox listener; drives the messages list's live refresh.
final InboxService? inbox;
/// OS notifications for foreground messages; its tap handler is wired to the
/// router here. Null in tests / on platforms without notification support.
final NotificationService? notifications;
final bool showIntro;
/// Drives silent periodic backups off the app lifecycle. Null in widget tests

View file

@ -13,6 +13,7 @@ import 'services/coarse_location.dart';
import 'services/inbox_service.dart';
import 'services/locale_store.dart';
import 'services/message_store.dart';
import 'services/notification_service.dart';
import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart';
import 'services/profile_cache.dart';
@ -51,6 +52,12 @@ class _BootstrapState extends State<Bootstrap> {
final onboarding = getIt<OnboardingStore>();
final inbox =
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : null;
final notifications = getIt.isRegistered<NotificationService>()
? getIt<NotificationService>()
: null;
// Ask for notification permission and set up the OS channel (no-op on
// unsupported platforms). Done before the inbox starts listening.
if (notifications != null) await notifications.initialize();
// Listen for incoming private messages app-wide (foreground) so they arrive
// and fill the inbox even before their chat is opened.
if (inbox != null) unawaited(inbox.start());
@ -68,6 +75,7 @@ class _BootstrapState extends State<Bootstrap> {
profileStore: getIt<ProfileStore>(),
profileCache: getIt<ProfileCache>(),
inbox: inbox,
notifications: notifications,
showIntro: !await onboarding.introSeen(),
autoBackup: getIt.isRegistered<AutoBackupService>()
? getIt<AutoBackupService>()

View file

@ -23,6 +23,7 @@ import '../services/file_picker_file_service.dart';
import '../services/file_service.dart';
import '../services/inbox_service.dart';
import '../services/locale_store.dart';
import '../services/notification_service.dart';
import '../services/ocr/label_text_extractor.dart';
import '../services/ocr/ocr_language.dart';
import '../services/ocr/tesseract_label_extractor.dart';
@ -36,6 +37,7 @@ import '../services/profile_cache.dart';
import '../services/profile_store.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
import '../services/unread_service.dart';
/// The app's service locator. Kept to the composition root — widgets get their
/// repositories from here (or via BlocProvider), never by reaching into it deep
@ -169,19 +171,32 @@ Future<void> configureDependencies() async {
);
}
// OS notifications for foreground messages. Registered unconditionally it
// self-no-ops on unsupported platforms (web/Windows) and when the social layer
// is absent nothing ever calls it. Its tap handler is wired to the router in
// `TaneApp`, after the router exists.
getIt.registerSingleton<NotificationService>(NotificationService());
// Optional: absent only if the derivation above failed then the app runs
// inventory-only, by design (market/chat/profile hidden).
if (socialService != null) {
getIt
..registerSingleton<SocialService>(socialService)
// Tracks unread private messages so the UI can badge them.
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore),
)
// App-wide inbox listener so private messages arrive (and land in the
// inbox list) even when the specific chat isn't open. Started in `main`.
// It also updates unread counts and fires notifications.
..registerSingleton<InboxService>(
InboxService(
social: socialService,
settings: getIt<SocialSettings>(),
store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(),
unread: getIt<UnreadService>(),
notifications: getIt<NotificationService>(),
),
);
}

View file

@ -214,8 +214,18 @@
"anyMonth": "Cualquier mes",
"noDate": "Indicar data de coyecha",
"monthNames": [
"xineru", "febreru", "marzu", "abril", "mayu", "xunu",
"xunetu", "agostu", "setiembre", "ochobre", "payares", "avientu"
"xineru",
"febreru",
"marzu",
"abril",
"mayu",
"xunu",
"xunetu",
"agostu",
"setiembre",
"ochobre",
"payares",
"avientu"
]
},
"lotType": {
@ -330,30 +340,102 @@
"some": "dalgunes",
"plenty": "munches",
"pinch": "una migaya",
"handful": { "singular": "puñáu", "plural": "puñaos" },
"teaspoon": { "singular": "cuyaradina", "plural": "cuyaradines" },
"spoon": { "singular": "cuyar", "plural": "cuyares" },
"cup": { "singular": "taza", "plural": "tazes" },
"jar": { "singular": "bote", "plural": "botes" },
"sack": { "singular": "sacu", "plural": "sacos" },
"packet": { "singular": "sobre", "plural": "sobres" },
"cob": { "singular": "panoya", "plural": "panoyes" },
"pod": { "singular": "vaina", "plural": "vaines" },
"ear": { "singular": "espiga", "plural": "espigues" },
"head": { "singular": "cabezuela", "plural": "cabezueles" },
"fruit": { "singular": "frutu", "plural": "frutos" },
"bulb": { "singular": "bulbu", "plural": "bulbos" },
"tuber": { "singular": "tubérculu", "plural": "tubérculos" },
"seedHead": { "singular": "cabeza de simiente", "plural": "cabeces de simiente" },
"bunch": { "singular": "manoyu", "plural": "manoyos" },
"plant": { "singular": "planta", "plural": "plantes" },
"pot": { "singular": "tiestu", "plural": "tiestos" },
"tray": { "singular": "bandexa", "plural": "bandexes" },
"seedling": { "singular": "plántula", "plural": "plántules" },
"tree": { "singular": "árbol", "plural": "árboles" },
"cutting": { "singular": "esqueje", "plural": "esquejes" },
"grams": { "singular": "gramu", "plural": "gramos" },
"count": { "singular": "grana", "plural": "granes" }
"handful": {
"singular": "puñáu",
"plural": "puñaos"
},
"teaspoon": {
"singular": "cuyaradina",
"plural": "cuyaradines"
},
"spoon": {
"singular": "cuyar",
"plural": "cuyares"
},
"cup": {
"singular": "taza",
"plural": "tazes"
},
"jar": {
"singular": "bote",
"plural": "botes"
},
"sack": {
"singular": "sacu",
"plural": "sacos"
},
"packet": {
"singular": "sobre",
"plural": "sobres"
},
"cob": {
"singular": "panoya",
"plural": "panoyes"
},
"pod": {
"singular": "vaina",
"plural": "vaines"
},
"ear": {
"singular": "espiga",
"plural": "espigues"
},
"head": {
"singular": "cabezuela",
"plural": "cabezueles"
},
"fruit": {
"singular": "frutu",
"plural": "frutos"
},
"bulb": {
"singular": "bulbu",
"plural": "bulbos"
},
"tuber": {
"singular": "tubérculu",
"plural": "tubérculos"
},
"seedHead": {
"singular": "cabeza de simiente",
"plural": "cabeces de simiente"
},
"bunch": {
"singular": "manoyu",
"plural": "manoyos"
},
"plant": {
"singular": "planta",
"plural": "plantes"
},
"pot": {
"singular": "tiestu",
"plural": "tiestos"
},
"tray": {
"singular": "bandexa",
"plural": "bandexes"
},
"seedling": {
"singular": "plántula",
"plural": "plántules"
},
"tree": {
"singular": "árbol",
"plural": "árboles"
},
"cutting": {
"singular": "esqueje",
"plural": "esquejes"
},
"grams": {
"singular": "gramu",
"plural": "gramos"
},
"count": {
"singular": "grana",
"plural": "granes"
}
},
"market": {
"title": "Simiente cerca de ti",
@ -436,5 +518,8 @@
"vouch": "Conozo a esta persona",
"vouched": "Avales a esta persona",
"circle": "Nel to círculu"
},
"notifications": {
"newMessageFrom": "Mensaxe nuevu de {name}"
}
}

View file

@ -215,8 +215,18 @@
"anyMonth": "Any month",
"noDate": "Set harvest date",
"monthNames": [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
]
},
"lotType": {
@ -331,30 +341,102 @@
"some": "some",
"plenty": "plenty",
"pinch": "a pinch",
"handful": { "singular": "handful", "plural": "handfuls" },
"teaspoon": { "singular": "teaspoon", "plural": "teaspoons" },
"spoon": { "singular": "spoon", "plural": "spoons" },
"cup": { "singular": "cup", "plural": "cups" },
"jar": { "singular": "jar", "plural": "jars" },
"sack": { "singular": "sack", "plural": "sacks" },
"packet": { "singular": "packet", "plural": "packets" },
"cob": { "singular": "cob", "plural": "cobs" },
"pod": { "singular": "pod", "plural": "pods" },
"ear": { "singular": "ear", "plural": "ears" },
"head": { "singular": "head", "plural": "heads" },
"fruit": { "singular": "fruit", "plural": "fruits" },
"bulb": { "singular": "bulb", "plural": "bulbs" },
"tuber": { "singular": "tuber", "plural": "tubers" },
"seedHead": { "singular": "seed head", "plural": "seed heads" },
"bunch": { "singular": "bunch", "plural": "bunches" },
"plant": { "singular": "plant", "plural": "plants" },
"pot": { "singular": "pot", "plural": "pots" },
"tray": { "singular": "tray", "plural": "trays" },
"seedling": { "singular": "seedling", "plural": "seedlings" },
"tree": { "singular": "tree", "plural": "trees" },
"cutting": { "singular": "cutting", "plural": "cuttings" },
"grams": { "singular": "gram", "plural": "grams" },
"count": { "singular": "seed", "plural": "seeds" }
"handful": {
"singular": "handful",
"plural": "handfuls"
},
"teaspoon": {
"singular": "teaspoon",
"plural": "teaspoons"
},
"spoon": {
"singular": "spoon",
"plural": "spoons"
},
"cup": {
"singular": "cup",
"plural": "cups"
},
"jar": {
"singular": "jar",
"plural": "jars"
},
"sack": {
"singular": "sack",
"plural": "sacks"
},
"packet": {
"singular": "packet",
"plural": "packets"
},
"cob": {
"singular": "cob",
"plural": "cobs"
},
"pod": {
"singular": "pod",
"plural": "pods"
},
"ear": {
"singular": "ear",
"plural": "ears"
},
"head": {
"singular": "head",
"plural": "heads"
},
"fruit": {
"singular": "fruit",
"plural": "fruits"
},
"bulb": {
"singular": "bulb",
"plural": "bulbs"
},
"tuber": {
"singular": "tuber",
"plural": "tubers"
},
"seedHead": {
"singular": "seed head",
"plural": "seed heads"
},
"bunch": {
"singular": "bunch",
"plural": "bunches"
},
"plant": {
"singular": "plant",
"plural": "plants"
},
"pot": {
"singular": "pot",
"plural": "pots"
},
"tray": {
"singular": "tray",
"plural": "trays"
},
"seedling": {
"singular": "seedling",
"plural": "seedlings"
},
"tree": {
"singular": "tree",
"plural": "trees"
},
"cutting": {
"singular": "cutting",
"plural": "cuttings"
},
"grams": {
"singular": "gram",
"plural": "grams"
},
"count": {
"singular": "seed",
"plural": "seeds"
}
},
"market": {
"title": "Seeds near you",
@ -439,5 +521,8 @@
"vouch": "I know this person",
"vouched": "You vouch for them",
"circle": "In your circle"
},
"notifications": {
"newMessageFrom": "New message from {name}"
}
}

View file

@ -214,8 +214,18 @@
"anyMonth": "Cualquier mes",
"noDate": "Indicar fecha de cosecha",
"monthNames": [
"enero", "febrero", "marzo", "abril", "mayo", "junio",
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"
"enero",
"febrero",
"marzo",
"abril",
"mayo",
"junio",
"julio",
"agosto",
"septiembre",
"octubre",
"noviembre",
"diciembre"
]
},
"lotType": {
@ -330,30 +340,102 @@
"some": "algunas",
"plenty": "muchas",
"pinch": "una pizca",
"handful": { "singular": "puñado", "plural": "puñados" },
"teaspoon": { "singular": "cucharadita", "plural": "cucharaditas" },
"spoon": { "singular": "cuchara", "plural": "cucharas" },
"cup": { "singular": "taza", "plural": "tazas" },
"jar": { "singular": "bote", "plural": "botes" },
"sack": { "singular": "saco", "plural": "sacos" },
"packet": { "singular": "sobre", "plural": "sobres" },
"cob": { "singular": "mazorca", "plural": "mazorcas" },
"pod": { "singular": "vaina", "plural": "vainas" },
"ear": { "singular": "espiga", "plural": "espigas" },
"head": { "singular": "cabezuela", "plural": "cabezuelas" },
"fruit": { "singular": "fruto", "plural": "frutos" },
"bulb": { "singular": "bulbo", "plural": "bulbos" },
"tuber": { "singular": "tubérculo", "plural": "tubérculos" },
"seedHead": { "singular": "cabeza de semillas", "plural": "cabezas de semillas" },
"bunch": { "singular": "manojo", "plural": "manojos" },
"plant": { "singular": "planta", "plural": "plantas" },
"pot": { "singular": "maceta", "plural": "macetas" },
"tray": { "singular": "bandeja", "plural": "bandejas" },
"seedling": { "singular": "plántula", "plural": "plántulas" },
"tree": { "singular": "árbol", "plural": "árboles" },
"cutting": { "singular": "esqueje", "plural": "esquejes" },
"grams": { "singular": "gramo", "plural": "gramos" },
"count": { "singular": "semilla", "plural": "semillas" }
"handful": {
"singular": "puñado",
"plural": "puñados"
},
"teaspoon": {
"singular": "cucharadita",
"plural": "cucharaditas"
},
"spoon": {
"singular": "cuchara",
"plural": "cucharas"
},
"cup": {
"singular": "taza",
"plural": "tazas"
},
"jar": {
"singular": "bote",
"plural": "botes"
},
"sack": {
"singular": "saco",
"plural": "sacos"
},
"packet": {
"singular": "sobre",
"plural": "sobres"
},
"cob": {
"singular": "mazorca",
"plural": "mazorcas"
},
"pod": {
"singular": "vaina",
"plural": "vainas"
},
"ear": {
"singular": "espiga",
"plural": "espigas"
},
"head": {
"singular": "cabezuela",
"plural": "cabezuelas"
},
"fruit": {
"singular": "fruto",
"plural": "frutos"
},
"bulb": {
"singular": "bulbo",
"plural": "bulbos"
},
"tuber": {
"singular": "tubérculo",
"plural": "tubérculos"
},
"seedHead": {
"singular": "cabeza de semillas",
"plural": "cabezas de semillas"
},
"bunch": {
"singular": "manojo",
"plural": "manojos"
},
"plant": {
"singular": "planta",
"plural": "plantas"
},
"pot": {
"singular": "maceta",
"plural": "macetas"
},
"tray": {
"singular": "bandeja",
"plural": "bandejas"
},
"seedling": {
"singular": "plántula",
"plural": "plántulas"
},
"tree": {
"singular": "árbol",
"plural": "árboles"
},
"cutting": {
"singular": "esqueje",
"plural": "esquejes"
},
"grams": {
"singular": "gramo",
"plural": "gramos"
},
"count": {
"singular": "semilla",
"plural": "semillas"
}
},
"market": {
"title": "Semillas cerca de ti",
@ -438,5 +520,8 @@
"vouch": "Conozco a esta persona",
"vouched": "Avalas a esta persona",
"circle": "En tu círculo"
},
"notifications": {
"newMessageFrom": "Nuevo mensaje de {name}"
}
}

View file

@ -211,8 +211,18 @@
"anyMonth": "Qualquer mês",
"noDate": "Definir data de colheita",
"monthNames": [
"janeiro", "fevereiro", "março", "abril", "maio", "junho",
"julho", "agosto", "setembro", "outubro", "novembro", "dezembro"
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro"
]
},
"lotType": {
@ -327,30 +337,102 @@
"some": "bastantes",
"plenty": "muitas",
"pinch": "uma pitada",
"handful": { "singular": "mão-cheia", "plural": "mãos-cheias" },
"teaspoon": { "singular": "colher de chá", "plural": "colheres de chá" },
"spoon": { "singular": "colher", "plural": "colheres" },
"cup": { "singular": "chávena", "plural": "chávenas" },
"jar": { "singular": "frasco", "plural": "frascos" },
"sack": { "singular": "saco", "plural": "sacos" },
"packet": { "singular": "pacote", "plural": "pacotes" },
"cob": { "singular": "maçaroca", "plural": "maçarocas" },
"pod": { "singular": "vagem", "plural": "vagens" },
"ear": { "singular": "espiga", "plural": "espigas" },
"head": { "singular": "cabeça", "plural": "cabeças" },
"fruit": { "singular": "fruto", "plural": "frutos" },
"bulb": { "singular": "bolbo", "plural": "bolbos" },
"tuber": { "singular": "tubérculo", "plural": "tubérculos" },
"seedHead": { "singular": "capítulo", "plural": "capítulos" },
"bunch": { "singular": "molho", "plural": "molhos" },
"plant": { "singular": "planta", "plural": "plantas" },
"pot": { "singular": "vaso", "plural": "vasos" },
"tray": { "singular": "tabuleiro", "plural": "tabuleiros" },
"seedling": { "singular": "plântula", "plural": "plântulas" },
"tree": { "singular": "árvore", "plural": "árvores" },
"cutting": { "singular": "estaca", "plural": "estacas" },
"grams": { "singular": "grama", "plural": "gramas" },
"count": { "singular": "semente", "plural": "sementes" }
"handful": {
"singular": "mão-cheia",
"plural": "mãos-cheias"
},
"teaspoon": {
"singular": "colher de chá",
"plural": "colheres de chá"
},
"spoon": {
"singular": "colher",
"plural": "colheres"
},
"cup": {
"singular": "chávena",
"plural": "chávenas"
},
"jar": {
"singular": "frasco",
"plural": "frascos"
},
"sack": {
"singular": "saco",
"plural": "sacos"
},
"packet": {
"singular": "pacote",
"plural": "pacotes"
},
"cob": {
"singular": "maçaroca",
"plural": "maçarocas"
},
"pod": {
"singular": "vagem",
"plural": "vagens"
},
"ear": {
"singular": "espiga",
"plural": "espigas"
},
"head": {
"singular": "cabeça",
"plural": "cabeças"
},
"fruit": {
"singular": "fruto",
"plural": "frutos"
},
"bulb": {
"singular": "bolbo",
"plural": "bolbos"
},
"tuber": {
"singular": "tubérculo",
"plural": "tubérculos"
},
"seedHead": {
"singular": "capítulo",
"plural": "capítulos"
},
"bunch": {
"singular": "molho",
"plural": "molhos"
},
"plant": {
"singular": "planta",
"plural": "plantas"
},
"pot": {
"singular": "vaso",
"plural": "vasos"
},
"tray": {
"singular": "tabuleiro",
"plural": "tabuleiros"
},
"seedling": {
"singular": "plântula",
"plural": "plântulas"
},
"tree": {
"singular": "árvore",
"plural": "árvores"
},
"cutting": {
"singular": "estaca",
"plural": "estacas"
},
"grams": {
"singular": "grama",
"plural": "gramas"
},
"count": {
"singular": "semente",
"plural": "sementes"
}
},
"market": {
"title": "Sementes perto de ti",
@ -435,5 +517,8 @@
"vouch": "Conheço esta pessoa",
"vouched": "Avalizas esta pessoa",
"circle": "No teu círculo"
},
"notifications": {
"newMessageFrom": "Nova mensagem de {name}"
}
}

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 4
/// Strings: 1548 (387 per locale)
/// Strings: 1552 (388 per locale)
///
/// Built on 2026-07-10 at 17:18 UTC
/// Built on 2026-07-10 at 18:28 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -75,6 +75,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
@override late final _Translations$chatList$ast chatList = _Translations$chatList$ast._(_root);
@override late final _Translations$chat$ast chat = _Translations$chat$ast._(_root);
@override late final _Translations$trust$ast trust = _Translations$trust$ast._(_root);
@override late final _Translations$notifications$ast notifications = _Translations$notifications$ast._(_root);
}
// Path: app
@ -756,6 +757,16 @@ class _Translations$trust$ast extends Translations$trust$en {
@override String get circle => 'Nel to círculu';
}
// Path: notifications
class _Translations$notifications$ast extends Translations$notifications$en {
_Translations$notifications$ast._(TranslationsAst root) : this._root = root, super.internal(root);
final TranslationsAst _root; // ignore: unused_field
// Translations
@override String newMessageFrom({required Object name}) => 'Mensaxe nuevu de ${name}';
}
// Path: intro.slides
class _Translations$intro$slides$ast extends Translations$intro$slides$en {
_Translations$intro$slides$ast._(TranslationsAst root) : this._root = root, super.internal(root);
@ -1483,6 +1494,7 @@ extension on TranslationsAst {
'trust.vouch' => 'Conozo a esta persona',
'trust.vouched' => 'Avales a esta persona',
'trust.circle' => 'Nel to círculu',
'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}',
_ => null,
};
}

View file

@ -76,6 +76,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$chatList$en chatList = Translations$chatList$en.internal(_root);
late final Translations$chat$en chat = Translations$chat$en.internal(_root);
late final Translations$trust$en trust = Translations$trust$en.internal(_root);
late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root);
}
// Path: app
@ -1401,6 +1402,18 @@ class Translations$trust$en {
String get circle => 'In your circle';
}
// Path: notifications
class Translations$notifications$en {
Translations$notifications$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'New message from {name}'
String newMessageFrom({required Object name}) => 'New message from ${name}';
}
// Path: intro.slides
class Translations$intro$slides$en {
Translations$intro$slides$en.internal(this._root);
@ -2247,6 +2260,7 @@ extension on Translations {
'trust.vouch' => 'I know this person',
'trust.vouched' => 'You vouch for them',
'trust.circle' => 'In your circle',
'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}',
_ => null,
};
}

View file

@ -75,6 +75,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$chatList$es chatList = _Translations$chatList$es._(_root);
@override late final _Translations$chat$es chat = _Translations$chat$es._(_root);
@override late final _Translations$trust$es trust = _Translations$trust$es._(_root);
@override late final _Translations$notifications$es notifications = _Translations$notifications$es._(_root);
}
// Path: app
@ -758,6 +759,16 @@ class _Translations$trust$es extends Translations$trust$en {
@override String get circle => 'En tu círculo';
}
// Path: notifications
class _Translations$notifications$es extends Translations$notifications$en {
_Translations$notifications$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String newMessageFrom({required Object name}) => 'Nuevo mensaje de ${name}';
}
// Path: intro.slides
class _Translations$intro$slides$es extends Translations$intro$slides$en {
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1487,6 +1498,7 @@ extension on TranslationsEs {
'trust.vouch' => 'Conozco a esta persona',
'trust.vouched' => 'Avalas a esta persona',
'trust.circle' => 'En tu círculo',
'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}',
_ => null,
};
}

View file

@ -75,6 +75,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$chatList$pt chatList = _Translations$chatList$pt._(_root);
@override late final _Translations$chat$pt chat = _Translations$chat$pt._(_root);
@override late final _Translations$trust$pt trust = _Translations$trust$pt._(_root);
@override late final _Translations$notifications$pt notifications = _Translations$notifications$pt._(_root);
}
// Path: app
@ -755,6 +756,16 @@ class _Translations$trust$pt extends Translations$trust$en {
@override String get circle => 'No teu círculo';
}
// Path: notifications
class _Translations$notifications$pt extends Translations$notifications$en {
_Translations$notifications$pt._(TranslationsPt root) : this._root = root, super.internal(root);
final TranslationsPt _root; // ignore: unused_field
// Translations
@override String newMessageFrom({required Object name}) => 'Nova mensagem de ${name}';
}
// Path: intro.slides
class _Translations$intro$slides$pt extends Translations$intro$slides$en {
_Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root);
@ -1481,6 +1492,7 @@ extension on TranslationsPt {
'trust.vouch' => 'Conheço esta pessoa',
'trust.vouched' => 'Avalizas esta pessoa',
'trust.circle' => 'No teu círculo',
'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}',
_ => null,
};
}

View file

@ -4,10 +4,13 @@ import 'package:commons_core/commons_core.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import '../i18n/strings.g.dart';
import 'message_store.dart';
import 'notification_service.dart';
import 'profile_cache.dart';
import 'social_service.dart';
import 'social_settings.dart';
import 'unread_service.dart';
/// App-wide inbox listener for private messages (NIP-17).
///
@ -26,15 +29,21 @@ class InboxService {
required SocialSettings settings,
required MessageStore store,
ProfileCache? profileCache,
UnreadService? unread,
NotificationService? notifications,
}) : _social = social,
_settings = settings,
_store = store,
_profileCache = profileCache;
_profileCache = profileCache,
_unread = unread,
_notifications = notifications;
final SocialService _social;
final SocialSettings _settings;
final MessageStore _store;
final ProfileCache? _profileCache;
final UnreadService? _unread;
final NotificationService? _notifications;
final _changes = StreamController<void>.broadcast();
SocialSession? _session;
@ -101,7 +110,29 @@ class InboxService {
final stored = await _store.append(message.fromPubkey, message);
if (!stored) return; // a re-delivered duplicate
if (!_changes.isClosed) _changes.add(null);
await _cacheName(message.fromPubkey);
final peer = message.fromPubkey;
// Defensive: never badge or notify for your own message. NIP-17 doesn't
// loop a sent gift wrap back to its author, so this shouldn't happen — the
// guard just keeps the seam honest.
if (peer != _social.publicKeyHex) {
await _unread?.onMessageReceived(peer, message);
await _maybeNotify(peer);
}
await _cacheName(peer);
}
/// Fires a text-free OS notification for a new message from [peer], unless its
/// chat is the one already on screen (then the message is visible anyway).
Future<void> _maybeNotify(String peer) async {
final notifications = _notifications;
if (notifications == null) return;
if (_unread?.activePeer == peer) return;
final name = await _profileCache?.name(peer) ?? shortPubkey(peer);
await notifications.showMessage(
peerPubkey: peer,
title: t.notifications.newMessageFrom(name: name),
);
}
Future<void> _cacheName(String peerPubkey) async {

View file

@ -0,0 +1,103 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
/// Shows an OS notification when a private message arrives while the app is in
/// the foreground. Foreground only by design when the app is backgrounded the
/// message listener stops, so nothing fires (background/push is a later, larger
/// concern). Web and Windows have no local-notification support, so there this
/// whole service is an inert no-op.
///
/// For privacy it never carries message text the caller passes a generic
/// `New message from <name>` title (see [InboxService]). The peer's pubkey rides
/// along as the payload so a tap can open that exact chat via [onTapChat].
class NotificationService {
NotificationService({
FlutterLocalNotificationsPlugin? plugin,
bool? supported,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_supported = supported ?? _platformSupported;
final FlutterLocalNotificationsPlugin _plugin;
final bool _supported;
bool _ready = false;
/// Called with a peer pubkey when the user taps a message notification. The
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
void Function(String peerPubkey)? onTapChat;
static const _channelId = 'messages';
static bool get _platformSupported {
if (kIsWeb) return false;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.linux:
return true;
case TargetPlatform.windows:
case TargetPlatform.fuchsia:
return false;
}
}
/// Initialises the plugin and requests permission (Android 13+/iOS/macOS ask
/// at runtime). Idempotent and safe to call on any platform.
Future<void> initialize() async {
if (!_supported || _ready) return;
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const darwin = DarwinInitializationSettings();
const linux = LinuxInitializationSettings(defaultActionName: 'Open');
await _plugin.initialize(
const InitializationSettings(
android: android,
iOS: darwin,
macOS: darwin,
linux: linux,
),
onDidReceiveNotificationResponse: (response) {
final payload = response.payload;
if (payload != null && payload.isNotEmpty) onTapChat?.call(payload);
},
);
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
_ready = true;
}
/// Shows a notification for a new message. [title] is a generic, text-free
/// line like "New message from Alice"; [peerPubkey] becomes the tap payload.
/// No-op on unsupported platforms.
Future<void> showMessage({
required String peerPubkey,
required String title,
}) async {
if (!_supported) return;
const details = NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
'Messages',
channelDescription: 'New private messages',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
);
// One notification per peer (same id replaces the previous), so a chatty
// peer doesn't stack a wall of notifications.
await _plugin.show(peerPubkey.hashCode, title, null, details,
payload: peerPubkey);
}
}

View file

@ -0,0 +1,96 @@
import 'dart:async';
import 'package:commons_core/commons_core.dart';
import '../security/secret_store.dart';
import 'message_store.dart';
/// Tracks which private messages are still unread, so the UI can show a badge.
///
/// There is no read/unread flag on a message; instead we keep a per-peer
/// "last read at" timestamp in the keystore (just a timestamp and a pubkey no
/// message text, so it honours "no plaintext at rest"). A conversation's unread
/// count is simply the peer's messages newer than that mark. Reading history
/// from the [MessageStore] keeps a single source of truth.
///
/// Only inbound messages count: a message whose author is the peer. Your own
/// sent messages (stored with your pubkey) are never counted.
///
/// [activePeer] is the chat currently on screen. A message that arrives for it
/// is treated as already read (you're looking at it), and the inbox listener
/// also skips its notification so the chat you're in never badges or beeps.
class UnreadService {
UnreadService(this._store, this._secrets);
final MessageStore _store;
final SecretStore _secrets;
final _changes = StreamController<void>.broadcast();
/// The peer whose chat is currently open, or null. Set by [ChatScreen].
String? activePeer;
static const _prefix = 'tane.social.unread.';
String _key(String peer) => '$_prefix$peer';
/// Fires (no payload) whenever an unread count may have changed on a new
/// inbound message and on [markRead]. Broadcast, so several badges can listen.
Stream<void> get changes => _changes.stream;
/// Records that a new [message] for [peer] was just persisted. If its chat is
/// open it is marked read immediately; otherwise badges are asked to refresh.
Future<void> onMessageReceived(String peer, PrivateMessage message) async {
if (peer == activePeer) {
await markRead(peer);
} else {
_fire();
}
}
/// Marks the conversation with [peer] read up to its latest message, so its
/// unread count drops to zero. Called when the chat opens (and when a message
/// arrives while it's open).
Future<void> markRead(String peer) async {
final history = await _store.history(peer);
final upTo = history.isEmpty
? DateTime.now()
: history
.map((m) => m.at)
.reduce((a, b) => a.isAfter(b) ? a : b);
await _secrets.write(_key(peer), '${upTo.millisecondsSinceEpoch}');
_fire();
}
/// Unread inbound messages from [peer] (those newer than its last-read mark).
Future<int> unreadCount(String peer) async {
final lastRead = await _lastRead(peer);
final history = await _store.history(peer);
return history
.where((m) => m.fromPubkey == peer && m.at.isAfter(lastRead))
.length;
}
/// Unread messages across every conversation (the number for the app badge).
Future<int> totalUnreadCount() async {
var total = 0;
for (final c in await _store.conversations()) {
total += await unreadCount(c.peerPubkey);
}
return total;
}
Future<DateTime> _lastRead(String peer) async {
final raw = await _secrets.read(_key(peer));
final ms = raw == null ? null : int.tryParse(raw);
return DateTime.fromMillisecondsSinceEpoch(ms ?? 0);
}
void _fire() {
if (!_changes.isClosed) _changes.add(null);
}
Future<void> dispose() async {
if (!_changes.isClosed) await _changes.close();
}
}

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

@ -5,11 +5,13 @@ 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/unread_service.dart';
import '../state/messages_cubit.dart';
import '../state/trust_cubit.dart';
import 'theme.dart';
@ -50,9 +52,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();
}
@ -148,6 +161,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,
);
}
}

View file

@ -74,6 +74,10 @@ dependencies:
# Network reachability (BSD-3) to show a global "you're offline" banner — the
# social layer needs a connection; the inventory works regardless.
connectivity_plus: ^6.1.0
# Local OS notifications (BSD-3) for a heads-up when a private message arrives
# while the app is foregrounded. Mobile/Linux/macOS only; a no-op on web and
# Windows. Foreground-only by design — background/push is a later concern.
flutter_local_notifications: ^18.0.1
dev_dependencies:
flutter_test:

View file

@ -2,11 +2,30 @@ import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/inbox_service.dart';
import 'package:tane/services/message_store.dart';
import 'package:tane/services/notification_service.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/services/unread_service.dart';
import '../support/test_support.dart';
/// Records notification calls instead of touching the OS plugin.
class _RecordingNotifications extends NotificationService {
_RecordingNotifications() : super(supported: false);
final titles = <String>[];
final peers = <String>[];
@override
Future<void> showMessage({
required String peerPubkey,
required String title,
}) async {
peers.add(peerPubkey);
titles.add(title);
}
}
/// The app-wide inbox listener persists incoming messages and announces changes,
/// so the inbox list refreshes even when the specific chat isn't open. Driven
/// through the [InboxService.ingest] seam so no relay/network is involved.
@ -52,4 +71,52 @@ void main() {
expect(await store.history('alice'), hasLength(1));
await sub.cancel();
});
group('unread + notification hooks', () {
late UnreadService unread;
late _RecordingNotifications notifications;
late String myPubkey;
setUp(() async {
final social = await SocialService.fromRootSeedHex(seedHex);
myPubkey = social.publicKeyHex;
unread = UnreadService(store, InMemorySecretStore());
notifications = _RecordingNotifications();
inbox = InboxService(
social: social,
settings: SocialSettings(InMemorySecretStore()),
store: store,
unread: unread,
notifications: notifications,
);
});
tearDown(() => unread.dispose());
test('a new peer message updates unread and notifies', () async {
await inbox.ingest(msg('alice', 'hola', 1000));
expect(await unread.unreadCount('alice'), 1);
expect(notifications.peers, ['alice']);
});
test('a duplicate re-delivery neither counts nor notifies', () async {
await inbox.ingest(msg('alice', 'hola', 1000));
await inbox.ingest(msg('alice', 'hola', 1000));
expect(await unread.unreadCount('alice'), 1);
expect(notifications.peers, hasLength(1));
});
test('a message authored by me is never notified', () async {
// NIP-17 doesn't loop your own gift wrap back; the guard is defensive.
await inbox.ingest(msg(myPubkey, 'echo', 1000));
expect(notifications.peers, isEmpty);
});
test('the open chat is not notified and stays read', () async {
unread.activePeer = 'alice';
await inbox.ingest(msg('alice', 'while open', 1000));
expect(await unread.unreadCount('alice'), 0);
expect(notifications.peers, isEmpty);
});
});
}

View file

@ -0,0 +1,78 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:tane/services/notification_service.dart';
class _MockPlugin extends Mock implements FlutterLocalNotificationsPlugin {}
void main() {
late _MockPlugin plugin;
setUpAll(() {
registerFallbackValue(
const NotificationDetails(
android: AndroidNotificationDetails('x', 'x'),
),
);
registerFallbackValue(const InitializationSettings());
});
setUp(() {
plugin = _MockPlugin();
when(() => plugin.show(any(), any(), any(), any(),
payload: any(named: 'payload'))).thenAnswer((_) async {});
// Generic platform resolution returns null, so the permission requests in
// initialize() are skipped in tests.
when(() => plugin.resolvePlatformSpecificImplementation())
.thenReturn(null);
});
test('showMessage passes the title and pubkey payload, no body', () async {
final service = NotificationService(plugin: plugin, supported: true);
await service.showMessage(
peerPubkey: 'abc123', title: 'New message from Alice');
final captured = verify(() => plugin.show(
captureAny(),
captureAny(),
captureAny(),
captureAny(),
payload: captureAny(named: 'payload'),
)).captured;
expect(captured[1], 'New message from Alice'); // title
expect(captured[2], isNull); // no body (privacy: no message text)
expect(captured[4], 'abc123'); // payload = peer pubkey
});
test('is a no-op on unsupported platforms (web/windows)', () async {
final service = NotificationService(plugin: plugin, supported: false);
await service.initialize();
await service.showMessage(peerPubkey: 'abc123', title: 'hi');
verifyNever(() => plugin.show(any(), any(), any(), any(),
payload: any(named: 'payload')));
});
test('a tap with a payload invokes onTapChat with the pubkey', () async {
// Capture the response handler initialize() registers, then simulate a tap.
void Function(NotificationResponse)? handler;
when(() => plugin.initialize(
any(),
onDidReceiveNotificationResponse:
any(named: 'onDidReceiveNotificationResponse'),
)).thenAnswer((inv) async {
handler = inv.namedArguments[#onDidReceiveNotificationResponse]
as void Function(NotificationResponse)?;
return true;
});
final service = NotificationService(plugin: plugin, supported: true);
String? tapped;
service.onTapChat = (pk) => tapped = pk;
await service.initialize();
handler!(const NotificationResponse(
notificationResponseType: NotificationResponseType.selectedNotification,
payload: 'abc123',
));
expect(tapped, 'abc123');
});
}

View file

@ -0,0 +1,90 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/message_store.dart';
import 'package:tane/services/unread_service.dart';
import '../support/test_support.dart';
/// Unread counting is derived from stored history plus a per-peer "last read"
/// mark; these tests drive it through a real [MessageStore] over an in-memory
/// keystore, so no relay or network is involved.
void main() {
const me = 'me-pubkey';
late MessageStore store;
late UnreadService unread;
setUp(() {
store = MessageStore(InMemorySecretStore());
unread = UnreadService(store, InMemorySecretStore());
});
tearDown(() => unread.dispose());
PrivateMessage msg(String from, String text, int atMs) => PrivateMessage(
fromPubkey: from,
text: text,
at: DateTime.fromMillisecondsSinceEpoch(atMs));
// Persist then notify, the way InboxService.ingest does.
Future<void> receive(String peer, PrivateMessage m) async {
await store.append(peer, m);
await unread.onMessageReceived(peer, m);
}
test('an unknown peer has no unread', () async {
expect(await unread.unreadCount('alice'), 0);
});
test('inbound messages increment the unread count', () async {
await receive('alice', msg('alice', 'hi', 1000));
await receive('alice', msg('alice', 'you there?', 2000));
expect(await unread.unreadCount('alice'), 2);
});
test('markRead clears older messages but keeps newer ones', () async {
await receive('alice', msg('alice', 'a', 1000));
await unread.markRead('alice'); // reads up to the latest (1000)
expect(await unread.unreadCount('alice'), 0);
await receive('alice', msg('alice', 'b', 2000));
expect(await unread.unreadCount('alice'), 1);
});
test('own outgoing messages never count as unread', () async {
await receive('alice', msg(me, 'my reply', 1000));
expect(await unread.unreadCount('alice'), 0);
});
test('a message for the open chat is auto-read (no unread)', () async {
unread.activePeer = 'alice';
await receive('alice', msg('alice', 'while open', 1000));
expect(await unread.unreadCount('alice'), 0);
});
test('totalUnreadCount sums across conversations', () async {
await receive('alice', msg('alice', 'a', 1000));
await receive('bob', msg('bob', 'b1', 1000));
await receive('bob', msg('bob', 'b2', 2000));
expect(await unread.totalUnreadCount(), 3);
});
test('changes fires on a new inbound message', () async {
final events = <void>[];
final sub = unread.changes.listen(events.add);
await receive('alice', msg('alice', 'hi', 1000));
await Future<void>.delayed(Duration.zero);
expect(events, hasLength(1));
await sub.cancel();
});
test('changes fires on markRead', () async {
await receive('alice', msg('alice', 'hi', 1000));
final events = <void>[];
final sub = unread.changes.listen(events.add);
await unread.markRead('alice');
await Future<void>.delayed(Duration.zero);
expect(events, hasLength(1));
await sub.cancel();
});
}

View file

@ -0,0 +1,74 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/message_store.dart';
import 'package:tane/services/unread_service.dart';
import 'package:tane/ui/unread_badge.dart';
import '../support/test_support.dart';
/// The badge is driven by [UnreadService]: it shows a count when there are
/// unread messages and clears when they're read — live, over the service's
/// `changes` stream. Injecting the service avoids the `getIt` lookup. The store
/// reads are real async, so mutations run inside `runAsync`.
void main() {
late MessageStore store;
late UnreadService unread;
setUp(() {
store = MessageStore(InMemorySecretStore());
unread = UnreadService(store, InMemorySecretStore());
});
tearDown(() => unread.dispose());
Widget host() => MaterialApp(
home: Scaffold(
body: UnreadBadge(
peer: 'alice',
service: unread,
child: const Icon(Icons.chat_bubble),
),
),
);
Future<void> receive(int atMs) async {
final m = PrivateMessage(
fromPubkey: 'alice',
text: 'hi',
at: DateTime.fromMillisecondsSinceEpoch(atMs));
await store.append('alice', m);
await unread.onMessageReceived('alice', m);
}
// Lets pending async (store reads + the widget's stream-driven refresh)
// resolve in the real zone, then renders a frame.
Future<void> pumpRefresh(WidgetTester tester) async {
for (var i = 0; i < 5; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
}
}
testWidgets('shows an existing unread count on build', (tester) async {
await tester.runAsync(() => receive(1000));
await tester.pumpWidget(host());
await pumpRefresh(tester);
expect(find.text('1'), findsOneWidget);
});
testWidgets('reacts to a new message and to being read', (tester) async {
await tester.pumpWidget(host());
await pumpRefresh(tester);
expect(find.text('1'), findsNothing);
await tester.runAsync(() => receive(1000));
await pumpRefresh(tester);
expect(find.text('1'), findsOneWidget);
await tester.runAsync(() => unread.markRead('alice'));
await pumpRefresh(tester);
expect(find.text('1'), findsNothing);
});
}

View file

@ -435,6 +435,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: transitive
description:
name: flutter_local_notifications
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
url: "https://pub.dev"
source: hosted
version: "18.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_localizations:
dependency: transitive
description: flutter
@ -1305,6 +1329,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.16"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data:
dependency: transitive
description: