diff --git a/apps/app_seeds/android/app/src/main/AndroidManifest.xml b/apps/app_seeds/android/app/src/main/AndroidManifest.xml
index 4302946..9c6ecee 100644
--- a/apps/app_seeds/android/app/src/main/AndroidManifest.xml
+++ b/apps/app_seeds/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,9 @@
+
+
_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
diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart
index 34b58db..60b8b1d 100644
--- a/apps/app_seeds/lib/bootstrap.dart
+++ b/apps/app_seeds/lib/bootstrap.dart
@@ -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 {
final onboarding = getIt();
final inbox =
getIt.isRegistered() ? getIt() : null;
+ final notifications = getIt.isRegistered()
+ ? getIt()
+ : 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 {
profileStore: getIt(),
profileCache: getIt(),
inbox: inbox,
+ notifications: notifications,
showIntro: !await onboarding.introSeen(),
autoBackup: getIt.isRegistered()
? getIt()
diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart
index 92e6dd6..87b0254 100644
--- a/apps/app_seeds/lib/di/injector.dart
+++ b/apps/app_seeds/lib/di/injector.dart
@@ -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 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());
+
// 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)
+ // Tracks unread private messages so the UI can badge them.
+ ..registerSingleton(
+ UnreadService(getIt(), 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(
social: socialService,
settings: getIt(),
store: getIt(),
profileCache: getIt(),
+ unread: getIt(),
+ notifications: getIt(),
),
);
}
diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json
index 07cff98..81949c7 100644
--- a/apps/app_seeds/lib/i18n/ast.i18n.json
+++ b/apps/app_seeds/lib/i18n/ast.i18n.json
@@ -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}"
}
}
diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json
index ee69b30..a1fd14a 100644
--- a/apps/app_seeds/lib/i18n/en.i18n.json
+++ b/apps/app_seeds/lib/i18n/en.i18n.json
@@ -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}"
}
}
diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json
index 7c0e44a..c44b13e 100644
--- a/apps/app_seeds/lib/i18n/es.i18n.json
+++ b/apps/app_seeds/lib/i18n/es.i18n.json
@@ -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}"
}
}
diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json
index 8df35d2..a4d8d64 100644
--- a/apps/app_seeds/lib/i18n/pt.i18n.json
+++ b/apps/app_seeds/lib/i18n/pt.i18n.json
@@ -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}"
}
}
diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart
index fb59798..32e50c2 100644
--- a/apps/app_seeds/lib/i18n/strings.g.dart
+++ b/apps/app_seeds/lib/i18n/strings.g.dart
@@ -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
diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart
index 2f83eb6..a47658e 100644
--- a/apps/app_seeds/lib/i18n/strings_ast.g.dart
+++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart
@@ -75,6 +75,7 @@ class TranslationsAst extends Translations with BaseTranslations '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,
};
}
diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart
index 7785576..c42e44c 100644
--- a/apps/app_seeds/lib/i18n/strings_en.g.dart
+++ b/apps/app_seeds/lib/i18n/strings_en.g.dart
@@ -76,6 +76,7 @@ class Translations with BaseTranslations {
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,
};
}
diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart
index 98d6f12..abf75ae 100644
--- a/apps/app_seeds/lib/i18n/strings_es.g.dart
+++ b/apps/app_seeds/lib/i18n/strings_es.g.dart
@@ -75,6 +75,7 @@ class TranslationsEs extends Translations with BaseTranslations '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,
};
}
diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart
index 22249a5..04bdf58 100644
--- a/apps/app_seeds/lib/i18n/strings_pt.g.dart
+++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart
@@ -75,6 +75,7 @@ class TranslationsPt extends Translations with BaseTranslations '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,
};
}
diff --git a/apps/app_seeds/lib/services/inbox_service.dart b/apps/app_seeds/lib/services/inbox_service.dart
index 5d61ca0..8282c2e 100644
--- a/apps/app_seeds/lib/services/inbox_service.dart
+++ b/apps/app_seeds/lib/services/inbox_service.dart
@@ -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.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 _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 _cacheName(String peerPubkey) async {
diff --git a/apps/app_seeds/lib/services/notification_service.dart b/apps/app_seeds/lib/services/notification_service.dart
new file mode 100644
index 0000000..18418cc
--- /dev/null
+++ b/apps/app_seeds/lib/services/notification_service.dart
@@ -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 ` 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/')` 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 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 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);
+ }
+}
diff --git a/apps/app_seeds/lib/services/unread_service.dart b/apps/app_seeds/lib/services/unread_service.dart
new file mode 100644
index 0000000..47cfc3b
--- /dev/null
+++ b/apps/app_seeds/lib/services/unread_service.dart
@@ -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.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 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 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 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 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 totalUnreadCount() async {
+ var total = 0;
+ for (final c in await _store.conversations()) {
+ total += await unreadCount(c.peerPubkey);
+ }
+ return total;
+ }
+
+ Future _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 dispose() async {
+ if (!_changes.isClosed) await _changes.close();
+ }
+}
diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart
index 6031ecb..799a972 100644
--- a/apps/app_seeds/lib/ui/app_drawer.dart
+++ b/apps/app_seeds/lib/ui/app_drawer.dart
@@ -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
? () {
diff --git a/apps/app_seeds/lib/ui/chat_list_screen.dart b/apps/app_seeds/lib/ui/chat_list_screen.dart
index a3535c7..46237ab 100644
--- a/apps/app_seeds/lib/ui/chat_list_screen.dart
+++ b/apps/app_seeds/lib/ui/chat_list_screen.dart
@@ -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 {
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)),
diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart
index 97a555c..eed6cfa 100644
--- a/apps/app_seeds/lib/ui/chat_screen.dart
+++ b/apps/app_seeds/lib/ui/chat_screen.dart
@@ -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 {
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() ? getIt() : 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 {
@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();
diff --git a/apps/app_seeds/lib/ui/home_screen.dart b/apps/app_seeds/lib/ui/home_screen.dart
index 47d9a1f..73befef 100644
--- a/apps/app_seeds/lib/ui/home_screen.dart
+++ b/apps/app_seeds/lib/ui/home_screen.dart
@@ -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: [
diff --git a/apps/app_seeds/lib/ui/unread_badge.dart b/apps/app_seeds/lib/ui/unread_badge.dart
new file mode 100644
index 0000000..2957f74
--- /dev/null
+++ b/apps/app_seeds/lib/ui/unread_badge.dart
@@ -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 createState() => _UnreadBadgeState();
+}
+
+class _UnreadBadgeState extends State {
+ UnreadService? _service;
+ StreamSubscription? _sub;
+ int _count = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ _service = widget.service ??
+ (getIt.isRegistered() ? getIt() : 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 _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,
+ );
+ }
+}
diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml
index f6a3f49..9abc745 100644
--- a/apps/app_seeds/pubspec.yaml
+++ b/apps/app_seeds/pubspec.yaml
@@ -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:
diff --git a/apps/app_seeds/test/services/inbox_service_test.dart b/apps/app_seeds/test/services/inbox_service_test.dart
index 2c85de0..5cb5c44 100644
--- a/apps/app_seeds/test/services/inbox_service_test.dart
+++ b/apps/app_seeds/test/services/inbox_service_test.dart
@@ -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 = [];
+ final peers = [];
+
+ @override
+ Future 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);
+ });
+ });
}
diff --git a/apps/app_seeds/test/services/notification_service_test.dart b/apps/app_seeds/test/services/notification_service_test.dart
new file mode 100644
index 0000000..89a08af
--- /dev/null
+++ b/apps/app_seeds/test/services/notification_service_test.dart
@@ -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');
+ });
+}
diff --git a/apps/app_seeds/test/services/unread_service_test.dart b/apps/app_seeds/test/services/unread_service_test.dart
new file mode 100644
index 0000000..35f33de
--- /dev/null
+++ b/apps/app_seeds/test/services/unread_service_test.dart
@@ -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 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 = [];
+ final sub = unread.changes.listen(events.add);
+ await receive('alice', msg('alice', 'hi', 1000));
+ await Future.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 = [];
+ final sub = unread.changes.listen(events.add);
+ await unread.markRead('alice');
+ await Future.delayed(Duration.zero);
+ expect(events, hasLength(1));
+ await sub.cancel();
+ });
+}
diff --git a/apps/app_seeds/test/ui/unread_badge_test.dart b/apps/app_seeds/test/ui/unread_badge_test.dart
new file mode 100644
index 0000000..1c32227
--- /dev/null
+++ b/apps/app_seeds/test/ui/unread_badge_test.dart
@@ -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 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 pumpRefresh(WidgetTester tester) async {
+ for (var i = 0; i < 5; i++) {
+ await tester.runAsync(
+ () => Future.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);
+ });
+}
diff --git a/pubspec.lock b/pubspec.lock
index 9a12b95..85554da 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -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: