Merge branch 'claude/awesome-golick-007e7d': message unread badges + OS notifications
# Conflicts: # apps/app_seeds/lib/di/injector.dart # apps/app_seeds/lib/i18n/ast.i18n.json # apps/app_seeds/lib/i18n/en.i18n.json # apps/app_seeds/lib/i18n/es.i18n.json # apps/app_seeds/lib/i18n/pt.i18n.json # apps/app_seeds/lib/i18n/strings.g.dart # apps/app_seeds/lib/i18n/strings_ast.g.dart # apps/app_seeds/lib/i18n/strings_en.g.dart # apps/app_seeds/lib/i18n/strings_es.g.dart # apps/app_seeds/lib/i18n/strings_pt.g.dart # apps/app_seeds/lib/ui/chat_screen.dart
This commit is contained in:
commit
d2b98af36d
27 changed files with 1238 additions and 114 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -58,6 +59,7 @@ class TaneApp extends StatelessWidget {
|
|||
this.trustReferents,
|
||||
this.wotSettings,
|
||||
this.inbox,
|
||||
this.notifications,
|
||||
this.showIntro = false,
|
||||
this.autoBackup,
|
||||
super.key,
|
||||
|
|
@ -76,7 +78,12 @@ class TaneApp extends StatelessWidget {
|
|||
trustReferents,
|
||||
wotSettings,
|
||||
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;
|
||||
|
|
@ -111,6 +118,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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -54,6 +55,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());
|
||||
|
|
@ -74,6 +81,7 @@ class _BootstrapState extends State<Bootstrap> {
|
|||
trustReferents: getIt<TrustReferents>(),
|
||||
wotSettings: getIt<WotSettings>(),
|
||||
inbox: inbox,
|
||||
notifications: notifications,
|
||||
showIntro: !await onboarding.introSeen(),
|
||||
autoBackup: getIt.isRegistered<AutoBackupService>()
|
||||
? getIt<AutoBackupService>()
|
||||
|
|
|
|||
|
|
@ -24,6 +24,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';
|
||||
|
|
@ -39,6 +40,7 @@ import '../services/social_account_store.dart';
|
|||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../services/trust_referents.dart';
|
||||
import '../services/unread_service.dart';
|
||||
import '../services/wot_settings.dart';
|
||||
|
||||
/// The app's service locator. Kept to the composition root — widgets get their
|
||||
|
|
@ -188,19 +190,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>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -466,5 +548,8 @@
|
|||
"validityDays": "Validez del aval (díes)",
|
||||
"reset": "Reafitar a valores Duniter",
|
||||
"saved": "Guardáu"
|
||||
},
|
||||
"notifications": {
|
||||
"newMessageFrom": "Mensaxe nuevu de {name}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -469,5 +551,8 @@
|
|||
"validityDays": "Vouch validity (days)",
|
||||
"reset": "Reset to Duniter defaults",
|
||||
"saved": "Saved"
|
||||
},
|
||||
"notifications": {
|
||||
"newMessageFrom": "New message from {name}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -468,5 +550,8 @@
|
|||
"validityDays": "Validez del aval (días)",
|
||||
"reset": "Restablecer a valores Duniter",
|
||||
"saved": "Guardado"
|
||||
},
|
||||
"notifications": {
|
||||
"newMessageFrom": "Nuevo mensaje de {name}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -465,5 +547,8 @@
|
|||
"validityDays": "Validade do aval (dias)",
|
||||
"reset": "Repor para valores Duniter",
|
||||
"saved": "Guardado"
|
||||
},
|
||||
"notifications": {
|
||||
"newMessageFrom": "Nova mensagem de {name}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 4
|
||||
/// Strings: 1660 (415 per locale)
|
||||
/// Strings: 1664 (416 per locale)
|
||||
///
|
||||
/// Built on 2026-07-10 at 19:01 UTC
|
||||
/// Built on 2026-07-10 at 19:15 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
|||
@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$wot$ast wot = _Translations$wot$ast._(_root);
|
||||
@override late final _Translations$notifications$ast notifications = _Translations$notifications$ast._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
|
|
@ -794,6 +795,16 @@ class _Translations$wot$ast extends Translations$wot$en {
|
|||
@override String get saved => 'Guardáu';
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -1549,6 +1560,7 @@ extension on TranslationsAst {
|
|||
'wot.validityDays' => 'Validez del aval (díes)',
|
||||
'wot.reset' => 'Reafitar a valores Duniter',
|
||||
'wot.saved' => 'Guardáu',
|
||||
'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
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$wot$en wot = Translations$wot$en.internal(_root);
|
||||
late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
|
|
@ -1495,6 +1496,18 @@ class Translations$wot$en {
|
|||
String get saved => 'Saved';
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -2369,6 +2382,7 @@ extension on Translations {
|
|||
'wot.validityDays' => 'Vouch validity (days)',
|
||||
'wot.reset' => 'Reset to Duniter defaults',
|
||||
'wot.saved' => 'Saved',
|
||||
'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@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$wot$es wot = _Translations$wot$es._(_root);
|
||||
@override late final _Translations$notifications$es notifications = _Translations$notifications$es._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
|
|
@ -796,6 +797,16 @@ class _Translations$wot$es extends Translations$wot$en {
|
|||
@override String get saved => 'Guardado';
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -1553,6 +1564,7 @@ extension on TranslationsEs {
|
|||
'wot.validityDays' => 'Validez del aval (días)',
|
||||
'wot.reset' => 'Restablecer a valores Duniter',
|
||||
'wot.saved' => 'Guardado',
|
||||
'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@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$wot$pt wot = _Translations$wot$pt._(_root);
|
||||
@override late final _Translations$notifications$pt notifications = _Translations$notifications$pt._(_root);
|
||||
}
|
||||
|
||||
// Path: app
|
||||
|
|
@ -793,6 +794,16 @@ class _Translations$wot$pt extends Translations$wot$en {
|
|||
@override String get saved => 'Guardado';
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -1547,6 +1558,7 @@ extension on TranslationsPt {
|
|||
'wot.validityDays' => 'Validade do aval (dias)',
|
||||
'wot.reset' => 'Repor para valores Duniter',
|
||||
'wot.saved' => 'Guardado',
|
||||
'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
103
apps/app_seeds/lib/services/notification_service.dart
Normal file
103
apps/app_seeds/lib/services/notification_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
96
apps/app_seeds/lib/services/unread_service.dart
Normal file
96
apps/app_seeds/lib/services/unread_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||
import '../i18n/strings.g.dart';
|
||||
import 'seed_glyph.dart';
|
||||
import 'theme.dart';
|
||||
import 'unread_badge.dart';
|
||||
|
||||
/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is
|
||||
/// the live destination (green seed glyph), the social items (market, profile,
|
||||
|
|
@ -56,7 +57,7 @@ class AppDrawer extends StatelessWidget {
|
|||
: null,
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: const Icon(Icons.chat_bubble),
|
||||
icon: const UnreadBadge(child: Icon(Icons.chat_bubble)),
|
||||
label: t.menu.chat,
|
||||
onTap: marketEnabled
|
||||
? () {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import '../services/profile_cache.dart';
|
|||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import 'theme.dart';
|
||||
import 'unread_badge.dart';
|
||||
|
||||
/// The messages inbox: past conversations, newest first. Shows each peer's
|
||||
/// published name (falling back to a short key), and opens the chat on tap.
|
||||
|
|
@ -120,9 +121,12 @@ class _ChatListScreenState extends State<ChatListScreen> {
|
|||
itemBuilder: (context, i) {
|
||||
final c = items[i];
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: seedAvatar,
|
||||
child: Icon(Icons.person, color: seedOnAvatar),
|
||||
leading: UnreadBadge(
|
||||
peer: c.peerPubkey,
|
||||
child: const CircleAvatar(
|
||||
backgroundColor: seedAvatar,
|
||||
child: Icon(Icons.person, color: seedOnAvatar),
|
||||
),
|
||||
),
|
||||
title: Text(_names[c.peerPubkey] ??
|
||||
shortPubkey(c.peerPubkey)),
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../di/injector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/message_store.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../services/trust_referents.dart';
|
||||
import '../services/unread_service.dart';
|
||||
import '../services/wot_settings.dart';
|
||||
import '../state/messages_cubit.dart';
|
||||
import '../state/trust_cubit.dart';
|
||||
|
|
@ -59,9 +61,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
String? _peerG1;
|
||||
final _input = TextEditingController();
|
||||
|
||||
/// App-wide unread tracker (absent in tests / inventory-only builds). While
|
||||
/// this chat is open it is the "active peer", so incoming messages for it are
|
||||
/// read on arrival and never badge or notify.
|
||||
UnreadService? _unread;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_unread =
|
||||
getIt.isRegistered<UnreadService>() ? getIt<UnreadService>() : null;
|
||||
// Set synchronously (before any await) so a message landing during _init is
|
||||
// already suppressed.
|
||||
_unread?.activePeer = widget.peerPubkey;
|
||||
unawaited(_unread?.markRead(widget.peerPubkey));
|
||||
_init();
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +178,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_unread?.activePeer == widget.peerPubkey) _unread!.activePeer = null;
|
||||
// Mark read once more on the way out, so anything that arrived while the
|
||||
// chat was open (and was auto-read) stays cleared.
|
||||
unawaited(_unread?.markRead(widget.peerPubkey));
|
||||
_messages?.close();
|
||||
_trust?.close();
|
||||
_session?.close();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../i18n/strings.g.dart';
|
|||
import 'app_drawer.dart';
|
||||
import 'seed_glyph.dart';
|
||||
import 'theme.dart';
|
||||
import 'unread_badge.dart';
|
||||
|
||||
/// The main menu (redesign screen 00): a sprout logo in a soft green disc over a
|
||||
/// faint seed-glyph watermark, with "Your inventory" as the primary green call
|
||||
|
|
@ -21,7 +22,20 @@ class HomeScreen extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.app.title)),
|
||||
appBar: AppBar(
|
||||
title: Text(t.app.title),
|
||||
// Badge the hamburger so unread messages are visible from home, without
|
||||
// opening the drawer. Total count across conversations.
|
||||
leading: Builder(
|
||||
builder: (context) => UnreadBadge(
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
drawer: AppDrawer(marketEnabled: marketEnabled),
|
||||
body: Stack(
|
||||
children: [
|
||||
|
|
|
|||
86
apps/app_seeds/lib/ui/unread_badge.dart
Normal file
86
apps/app_seeds/lib/ui/unread_badge.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../di/injector.dart';
|
||||
import '../services/unread_service.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Wraps [child] with a small live unread-count badge. It listens to the
|
||||
/// app-wide [UnreadService] and rebuilds whenever a count changes: on a new
|
||||
/// message, or when a chat is opened (marked read). Shows the total across all
|
||||
/// conversations, or — when [peer] is set — just that peer's count.
|
||||
///
|
||||
/// When no unread tracker is available (widget tests, inventory-only builds) or
|
||||
/// the count is zero, it renders [child] unadorned. Positioning is via Flutter's
|
||||
/// [Badge], which is directional (top-`end`), so it's correct under RTL.
|
||||
class UnreadBadge extends StatefulWidget {
|
||||
const UnreadBadge({
|
||||
required this.child,
|
||||
this.peer,
|
||||
this.service,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// A specific conversation's count; null means the app-wide total.
|
||||
final String? peer;
|
||||
|
||||
/// Overrides the [UnreadService] lookup (for tests). Falls back to `getIt`.
|
||||
final UnreadService? service;
|
||||
|
||||
@override
|
||||
State<UnreadBadge> createState() => _UnreadBadgeState();
|
||||
}
|
||||
|
||||
class _UnreadBadgeState extends State<UnreadBadge> {
|
||||
UnreadService? _service;
|
||||
StreamSubscription<void>? _sub;
|
||||
int _count = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_service = widget.service ??
|
||||
(getIt.isRegistered<UnreadService>() ? getIt<UnreadService>() : null);
|
||||
final service = _service;
|
||||
if (service != null) {
|
||||
_refresh();
|
||||
_sub = service.changes.listen((_) => _refresh());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(UnreadBadge old) {
|
||||
super.didUpdateWidget(old);
|
||||
// A recycled row may now point at a different peer.
|
||||
if (old.peer != widget.peer) _refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final service = _service;
|
||||
if (service == null) return;
|
||||
final peer = widget.peer;
|
||||
final count = peer == null
|
||||
? await service.totalUnreadCount()
|
||||
: await service.unreadCount(peer);
|
||||
if (mounted) setState(() => _count = count);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Badge(
|
||||
isLabelVisible: _count > 0,
|
||||
label: Text('$_count'),
|
||||
backgroundColor: seedGreen,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
78
apps/app_seeds/test/services/notification_service_test.dart
Normal file
78
apps/app_seeds/test/services/notification_service_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
90
apps/app_seeds/test/services/unread_service_test.dart
Normal file
90
apps/app_seeds/test/services/unread_service_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
74
apps/app_seeds/test/ui/unread_badge_test.dart
Normal file
74
apps/app_seeds/test/ui/unread_badge_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
32
pubspec.lock
32
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue