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
|
<!-- Coarse location only, for the optional "use my location" sharing area
|
||||||
(reduced to a low-precision geohash; never a precise fix). -->
|
(reduced to a low-precision geohash; never a precise fix). -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<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
|
<application
|
||||||
android:label="tane"
|
android:label="tane"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'services/auto_backup_service.dart';
|
||||||
import 'services/coarse_location.dart';
|
import 'services/coarse_location.dart';
|
||||||
import 'services/inbox_service.dart';
|
import 'services/inbox_service.dart';
|
||||||
import 'services/message_store.dart';
|
import 'services/message_store.dart';
|
||||||
|
import 'services/notification_service.dart';
|
||||||
import 'services/offer_outbox.dart';
|
import 'services/offer_outbox.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
import 'services/profile_cache.dart';
|
import 'services/profile_cache.dart';
|
||||||
|
|
@ -58,6 +59,7 @@ class TaneApp extends StatelessWidget {
|
||||||
this.trustReferents,
|
this.trustReferents,
|
||||||
this.wotSettings,
|
this.wotSettings,
|
||||||
this.inbox,
|
this.inbox,
|
||||||
|
this.notifications,
|
||||||
this.showIntro = false,
|
this.showIntro = false,
|
||||||
this.autoBackup,
|
this.autoBackup,
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -76,7 +78,12 @@ class TaneApp extends StatelessWidget {
|
||||||
trustReferents,
|
trustReferents,
|
||||||
wotSettings,
|
wotSettings,
|
||||||
inbox,
|
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 VarietyRepository repository;
|
||||||
final SpeciesRepository species;
|
final SpeciesRepository species;
|
||||||
|
|
@ -111,6 +118,10 @@ class TaneApp extends StatelessWidget {
|
||||||
|
|
||||||
/// App-wide inbox listener; drives the messages list's live refresh.
|
/// App-wide inbox listener; drives the messages list's live refresh.
|
||||||
final InboxService? inbox;
|
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;
|
final bool showIntro;
|
||||||
|
|
||||||
/// Drives silent periodic backups off the app lifecycle. Null in widget tests
|
/// 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/inbox_service.dart';
|
||||||
import 'services/locale_store.dart';
|
import 'services/locale_store.dart';
|
||||||
import 'services/message_store.dart';
|
import 'services/message_store.dart';
|
||||||
|
import 'services/notification_service.dart';
|
||||||
import 'services/offer_outbox.dart';
|
import 'services/offer_outbox.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
import 'services/profile_cache.dart';
|
import 'services/profile_cache.dart';
|
||||||
|
|
@ -54,6 +55,12 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
final onboarding = getIt<OnboardingStore>();
|
final onboarding = getIt<OnboardingStore>();
|
||||||
final inbox =
|
final inbox =
|
||||||
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : null;
|
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
|
// Listen for incoming private messages app-wide (foreground) so they arrive
|
||||||
// and fill the inbox even before their chat is opened.
|
// and fill the inbox even before their chat is opened.
|
||||||
if (inbox != null) unawaited(inbox.start());
|
if (inbox != null) unawaited(inbox.start());
|
||||||
|
|
@ -74,6 +81,7 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
trustReferents: getIt<TrustReferents>(),
|
trustReferents: getIt<TrustReferents>(),
|
||||||
wotSettings: getIt<WotSettings>(),
|
wotSettings: getIt<WotSettings>(),
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
|
notifications: notifications,
|
||||||
showIntro: !await onboarding.introSeen(),
|
showIntro: !await onboarding.introSeen(),
|
||||||
autoBackup: getIt.isRegistered<AutoBackupService>()
|
autoBackup: getIt.isRegistered<AutoBackupService>()
|
||||||
? getIt<AutoBackupService>()
|
? getIt<AutoBackupService>()
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import '../services/file_picker_file_service.dart';
|
||||||
import '../services/file_service.dart';
|
import '../services/file_service.dart';
|
||||||
import '../services/inbox_service.dart';
|
import '../services/inbox_service.dart';
|
||||||
import '../services/locale_store.dart';
|
import '../services/locale_store.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
import '../services/ocr/label_text_extractor.dart';
|
import '../services/ocr/label_text_extractor.dart';
|
||||||
import '../services/ocr/ocr_language.dart';
|
import '../services/ocr/ocr_language.dart';
|
||||||
import '../services/ocr/tesseract_label_extractor.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_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../services/trust_referents.dart';
|
import '../services/trust_referents.dart';
|
||||||
|
import '../services/unread_service.dart';
|
||||||
import '../services/wot_settings.dart';
|
import '../services/wot_settings.dart';
|
||||||
|
|
||||||
/// The app's service locator. Kept to the composition root — widgets get their
|
/// 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
|
// Optional: absent only if the derivation above failed — then the app runs
|
||||||
// inventory-only, by design (market/chat/profile hidden).
|
// inventory-only, by design (market/chat/profile hidden).
|
||||||
if (socialService != null) {
|
if (socialService != null) {
|
||||||
getIt
|
getIt
|
||||||
..registerSingleton<SocialService>(socialService)
|
..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
|
// 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`.
|
// inbox list) even when the specific chat isn't open. Started in `main`.
|
||||||
|
// It also updates unread counts and fires notifications.
|
||||||
..registerSingleton<InboxService>(
|
..registerSingleton<InboxService>(
|
||||||
InboxService(
|
InboxService(
|
||||||
social: socialService,
|
social: socialService,
|
||||||
settings: getIt<SocialSettings>(),
|
settings: getIt<SocialSettings>(),
|
||||||
store: getIt<MessageStore>(),
|
store: getIt<MessageStore>(),
|
||||||
profileCache: getIt<ProfileCache>(),
|
profileCache: getIt<ProfileCache>(),
|
||||||
|
unread: getIt<UnreadService>(),
|
||||||
|
notifications: getIt<NotificationService>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,8 +214,18 @@
|
||||||
"anyMonth": "Cualquier mes",
|
"anyMonth": "Cualquier mes",
|
||||||
"noDate": "Indicar data de coyecha",
|
"noDate": "Indicar data de coyecha",
|
||||||
"monthNames": [
|
"monthNames": [
|
||||||
"xineru", "febreru", "marzu", "abril", "mayu", "xunu",
|
"xineru",
|
||||||
"xunetu", "agostu", "setiembre", "ochobre", "payares", "avientu"
|
"febreru",
|
||||||
|
"marzu",
|
||||||
|
"abril",
|
||||||
|
"mayu",
|
||||||
|
"xunu",
|
||||||
|
"xunetu",
|
||||||
|
"agostu",
|
||||||
|
"setiembre",
|
||||||
|
"ochobre",
|
||||||
|
"payares",
|
||||||
|
"avientu"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lotType": {
|
"lotType": {
|
||||||
|
|
@ -330,30 +340,102 @@
|
||||||
"some": "dalgunes",
|
"some": "dalgunes",
|
||||||
"plenty": "munches",
|
"plenty": "munches",
|
||||||
"pinch": "una migaya",
|
"pinch": "una migaya",
|
||||||
"handful": { "singular": "puñáu", "plural": "puñaos" },
|
"handful": {
|
||||||
"teaspoon": { "singular": "cuyaradina", "plural": "cuyaradines" },
|
"singular": "puñáu",
|
||||||
"spoon": { "singular": "cuyar", "plural": "cuyares" },
|
"plural": "puñaos"
|
||||||
"cup": { "singular": "taza", "plural": "tazes" },
|
},
|
||||||
"jar": { "singular": "bote", "plural": "botes" },
|
"teaspoon": {
|
||||||
"sack": { "singular": "sacu", "plural": "sacos" },
|
"singular": "cuyaradina",
|
||||||
"packet": { "singular": "sobre", "plural": "sobres" },
|
"plural": "cuyaradines"
|
||||||
"cob": { "singular": "panoya", "plural": "panoyes" },
|
},
|
||||||
"pod": { "singular": "vaina", "plural": "vaines" },
|
"spoon": {
|
||||||
"ear": { "singular": "espiga", "plural": "espigues" },
|
"singular": "cuyar",
|
||||||
"head": { "singular": "cabezuela", "plural": "cabezueles" },
|
"plural": "cuyares"
|
||||||
"fruit": { "singular": "frutu", "plural": "frutos" },
|
},
|
||||||
"bulb": { "singular": "bulbu", "plural": "bulbos" },
|
"cup": {
|
||||||
"tuber": { "singular": "tubérculu", "plural": "tubérculos" },
|
"singular": "taza",
|
||||||
"seedHead": { "singular": "cabeza de simiente", "plural": "cabeces de simiente" },
|
"plural": "tazes"
|
||||||
"bunch": { "singular": "manoyu", "plural": "manoyos" },
|
},
|
||||||
"plant": { "singular": "planta", "plural": "plantes" },
|
"jar": {
|
||||||
"pot": { "singular": "tiestu", "plural": "tiestos" },
|
"singular": "bote",
|
||||||
"tray": { "singular": "bandexa", "plural": "bandexes" },
|
"plural": "botes"
|
||||||
"seedling": { "singular": "plántula", "plural": "plántules" },
|
},
|
||||||
"tree": { "singular": "árbol", "plural": "árboles" },
|
"sack": {
|
||||||
"cutting": { "singular": "esqueje", "plural": "esquejes" },
|
"singular": "sacu",
|
||||||
"grams": { "singular": "gramu", "plural": "gramos" },
|
"plural": "sacos"
|
||||||
"count": { "singular": "grana", "plural": "granes" }
|
},
|
||||||
|
"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": {
|
"market": {
|
||||||
"title": "Simiente cerca de ti",
|
"title": "Simiente cerca de ti",
|
||||||
|
|
@ -466,5 +548,8 @@
|
||||||
"validityDays": "Validez del aval (díes)",
|
"validityDays": "Validez del aval (díes)",
|
||||||
"reset": "Reafitar a valores Duniter",
|
"reset": "Reafitar a valores Duniter",
|
||||||
"saved": "Guardáu"
|
"saved": "Guardáu"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"newMessageFrom": "Mensaxe nuevu de {name}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -215,8 +215,18 @@
|
||||||
"anyMonth": "Any month",
|
"anyMonth": "Any month",
|
||||||
"noDate": "Set harvest date",
|
"noDate": "Set harvest date",
|
||||||
"monthNames": [
|
"monthNames": [
|
||||||
"January", "February", "March", "April", "May", "June",
|
"January",
|
||||||
"July", "August", "September", "October", "November", "December"
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lotType": {
|
"lotType": {
|
||||||
|
|
@ -331,30 +341,102 @@
|
||||||
"some": "some",
|
"some": "some",
|
||||||
"plenty": "plenty",
|
"plenty": "plenty",
|
||||||
"pinch": "a pinch",
|
"pinch": "a pinch",
|
||||||
"handful": { "singular": "handful", "plural": "handfuls" },
|
"handful": {
|
||||||
"teaspoon": { "singular": "teaspoon", "plural": "teaspoons" },
|
"singular": "handful",
|
||||||
"spoon": { "singular": "spoon", "plural": "spoons" },
|
"plural": "handfuls"
|
||||||
"cup": { "singular": "cup", "plural": "cups" },
|
},
|
||||||
"jar": { "singular": "jar", "plural": "jars" },
|
"teaspoon": {
|
||||||
"sack": { "singular": "sack", "plural": "sacks" },
|
"singular": "teaspoon",
|
||||||
"packet": { "singular": "packet", "plural": "packets" },
|
"plural": "teaspoons"
|
||||||
"cob": { "singular": "cob", "plural": "cobs" },
|
},
|
||||||
"pod": { "singular": "pod", "plural": "pods" },
|
"spoon": {
|
||||||
"ear": { "singular": "ear", "plural": "ears" },
|
"singular": "spoon",
|
||||||
"head": { "singular": "head", "plural": "heads" },
|
"plural": "spoons"
|
||||||
"fruit": { "singular": "fruit", "plural": "fruits" },
|
},
|
||||||
"bulb": { "singular": "bulb", "plural": "bulbs" },
|
"cup": {
|
||||||
"tuber": { "singular": "tuber", "plural": "tubers" },
|
"singular": "cup",
|
||||||
"seedHead": { "singular": "seed head", "plural": "seed heads" },
|
"plural": "cups"
|
||||||
"bunch": { "singular": "bunch", "plural": "bunches" },
|
},
|
||||||
"plant": { "singular": "plant", "plural": "plants" },
|
"jar": {
|
||||||
"pot": { "singular": "pot", "plural": "pots" },
|
"singular": "jar",
|
||||||
"tray": { "singular": "tray", "plural": "trays" },
|
"plural": "jars"
|
||||||
"seedling": { "singular": "seedling", "plural": "seedlings" },
|
},
|
||||||
"tree": { "singular": "tree", "plural": "trees" },
|
"sack": {
|
||||||
"cutting": { "singular": "cutting", "plural": "cuttings" },
|
"singular": "sack",
|
||||||
"grams": { "singular": "gram", "plural": "grams" },
|
"plural": "sacks"
|
||||||
"count": { "singular": "seed", "plural": "seeds" }
|
},
|
||||||
|
"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": {
|
"market": {
|
||||||
"title": "Seeds near you",
|
"title": "Seeds near you",
|
||||||
|
|
@ -469,5 +551,8 @@
|
||||||
"validityDays": "Vouch validity (days)",
|
"validityDays": "Vouch validity (days)",
|
||||||
"reset": "Reset to Duniter defaults",
|
"reset": "Reset to Duniter defaults",
|
||||||
"saved": "Saved"
|
"saved": "Saved"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"newMessageFrom": "New message from {name}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,8 +214,18 @@
|
||||||
"anyMonth": "Cualquier mes",
|
"anyMonth": "Cualquier mes",
|
||||||
"noDate": "Indicar fecha de cosecha",
|
"noDate": "Indicar fecha de cosecha",
|
||||||
"monthNames": [
|
"monthNames": [
|
||||||
"enero", "febrero", "marzo", "abril", "mayo", "junio",
|
"enero",
|
||||||
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"
|
"febrero",
|
||||||
|
"marzo",
|
||||||
|
"abril",
|
||||||
|
"mayo",
|
||||||
|
"junio",
|
||||||
|
"julio",
|
||||||
|
"agosto",
|
||||||
|
"septiembre",
|
||||||
|
"octubre",
|
||||||
|
"noviembre",
|
||||||
|
"diciembre"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lotType": {
|
"lotType": {
|
||||||
|
|
@ -330,30 +340,102 @@
|
||||||
"some": "algunas",
|
"some": "algunas",
|
||||||
"plenty": "muchas",
|
"plenty": "muchas",
|
||||||
"pinch": "una pizca",
|
"pinch": "una pizca",
|
||||||
"handful": { "singular": "puñado", "plural": "puñados" },
|
"handful": {
|
||||||
"teaspoon": { "singular": "cucharadita", "plural": "cucharaditas" },
|
"singular": "puñado",
|
||||||
"spoon": { "singular": "cuchara", "plural": "cucharas" },
|
"plural": "puñados"
|
||||||
"cup": { "singular": "taza", "plural": "tazas" },
|
},
|
||||||
"jar": { "singular": "bote", "plural": "botes" },
|
"teaspoon": {
|
||||||
"sack": { "singular": "saco", "plural": "sacos" },
|
"singular": "cucharadita",
|
||||||
"packet": { "singular": "sobre", "plural": "sobres" },
|
"plural": "cucharaditas"
|
||||||
"cob": { "singular": "mazorca", "plural": "mazorcas" },
|
},
|
||||||
"pod": { "singular": "vaina", "plural": "vainas" },
|
"spoon": {
|
||||||
"ear": { "singular": "espiga", "plural": "espigas" },
|
"singular": "cuchara",
|
||||||
"head": { "singular": "cabezuela", "plural": "cabezuelas" },
|
"plural": "cucharas"
|
||||||
"fruit": { "singular": "fruto", "plural": "frutos" },
|
},
|
||||||
"bulb": { "singular": "bulbo", "plural": "bulbos" },
|
"cup": {
|
||||||
"tuber": { "singular": "tubérculo", "plural": "tubérculos" },
|
"singular": "taza",
|
||||||
"seedHead": { "singular": "cabeza de semillas", "plural": "cabezas de semillas" },
|
"plural": "tazas"
|
||||||
"bunch": { "singular": "manojo", "plural": "manojos" },
|
},
|
||||||
"plant": { "singular": "planta", "plural": "plantas" },
|
"jar": {
|
||||||
"pot": { "singular": "maceta", "plural": "macetas" },
|
"singular": "bote",
|
||||||
"tray": { "singular": "bandeja", "plural": "bandejas" },
|
"plural": "botes"
|
||||||
"seedling": { "singular": "plántula", "plural": "plántulas" },
|
},
|
||||||
"tree": { "singular": "árbol", "plural": "árboles" },
|
"sack": {
|
||||||
"cutting": { "singular": "esqueje", "plural": "esquejes" },
|
"singular": "saco",
|
||||||
"grams": { "singular": "gramo", "plural": "gramos" },
|
"plural": "sacos"
|
||||||
"count": { "singular": "semilla", "plural": "semillas" }
|
},
|
||||||
|
"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": {
|
"market": {
|
||||||
"title": "Semillas cerca de ti",
|
"title": "Semillas cerca de ti",
|
||||||
|
|
@ -468,5 +550,8 @@
|
||||||
"validityDays": "Validez del aval (días)",
|
"validityDays": "Validez del aval (días)",
|
||||||
"reset": "Restablecer a valores Duniter",
|
"reset": "Restablecer a valores Duniter",
|
||||||
"saved": "Guardado"
|
"saved": "Guardado"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"newMessageFrom": "Nuevo mensaje de {name}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,8 +211,18 @@
|
||||||
"anyMonth": "Qualquer mês",
|
"anyMonth": "Qualquer mês",
|
||||||
"noDate": "Definir data de colheita",
|
"noDate": "Definir data de colheita",
|
||||||
"monthNames": [
|
"monthNames": [
|
||||||
"janeiro", "fevereiro", "março", "abril", "maio", "junho",
|
"janeiro",
|
||||||
"julho", "agosto", "setembro", "outubro", "novembro", "dezembro"
|
"fevereiro",
|
||||||
|
"março",
|
||||||
|
"abril",
|
||||||
|
"maio",
|
||||||
|
"junho",
|
||||||
|
"julho",
|
||||||
|
"agosto",
|
||||||
|
"setembro",
|
||||||
|
"outubro",
|
||||||
|
"novembro",
|
||||||
|
"dezembro"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lotType": {
|
"lotType": {
|
||||||
|
|
@ -327,30 +337,102 @@
|
||||||
"some": "bastantes",
|
"some": "bastantes",
|
||||||
"plenty": "muitas",
|
"plenty": "muitas",
|
||||||
"pinch": "uma pitada",
|
"pinch": "uma pitada",
|
||||||
"handful": { "singular": "mão-cheia", "plural": "mãos-cheias" },
|
"handful": {
|
||||||
"teaspoon": { "singular": "colher de chá", "plural": "colheres de chá" },
|
"singular": "mão-cheia",
|
||||||
"spoon": { "singular": "colher", "plural": "colheres" },
|
"plural": "mãos-cheias"
|
||||||
"cup": { "singular": "chávena", "plural": "chávenas" },
|
},
|
||||||
"jar": { "singular": "frasco", "plural": "frascos" },
|
"teaspoon": {
|
||||||
"sack": { "singular": "saco", "plural": "sacos" },
|
"singular": "colher de chá",
|
||||||
"packet": { "singular": "pacote", "plural": "pacotes" },
|
"plural": "colheres de chá"
|
||||||
"cob": { "singular": "maçaroca", "plural": "maçarocas" },
|
},
|
||||||
"pod": { "singular": "vagem", "plural": "vagens" },
|
"spoon": {
|
||||||
"ear": { "singular": "espiga", "plural": "espigas" },
|
"singular": "colher",
|
||||||
"head": { "singular": "cabeça", "plural": "cabeças" },
|
"plural": "colheres"
|
||||||
"fruit": { "singular": "fruto", "plural": "frutos" },
|
},
|
||||||
"bulb": { "singular": "bolbo", "plural": "bolbos" },
|
"cup": {
|
||||||
"tuber": { "singular": "tubérculo", "plural": "tubérculos" },
|
"singular": "chávena",
|
||||||
"seedHead": { "singular": "capítulo", "plural": "capítulos" },
|
"plural": "chávenas"
|
||||||
"bunch": { "singular": "molho", "plural": "molhos" },
|
},
|
||||||
"plant": { "singular": "planta", "plural": "plantas" },
|
"jar": {
|
||||||
"pot": { "singular": "vaso", "plural": "vasos" },
|
"singular": "frasco",
|
||||||
"tray": { "singular": "tabuleiro", "plural": "tabuleiros" },
|
"plural": "frascos"
|
||||||
"seedling": { "singular": "plântula", "plural": "plântulas" },
|
},
|
||||||
"tree": { "singular": "árvore", "plural": "árvores" },
|
"sack": {
|
||||||
"cutting": { "singular": "estaca", "plural": "estacas" },
|
"singular": "saco",
|
||||||
"grams": { "singular": "grama", "plural": "gramas" },
|
"plural": "sacos"
|
||||||
"count": { "singular": "semente", "plural": "sementes" }
|
},
|
||||||
|
"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": {
|
"market": {
|
||||||
"title": "Sementes perto de ti",
|
"title": "Sementes perto de ti",
|
||||||
|
|
@ -465,5 +547,8 @@
|
||||||
"validityDays": "Validade do aval (dias)",
|
"validityDays": "Validade do aval (dias)",
|
||||||
"reset": "Repor para valores Duniter",
|
"reset": "Repor para valores Duniter",
|
||||||
"saved": "Guardado"
|
"saved": "Guardado"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"newMessageFrom": "Nova mensagem de {name}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 4
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// 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$chat$ast chat = _Translations$chat$ast._(_root);
|
||||||
@override late final _Translations$trust$ast trust = _Translations$trust$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$wot$ast wot = _Translations$wot$ast._(_root);
|
||||||
|
@override late final _Translations$notifications$ast notifications = _Translations$notifications$ast._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// Path: app
|
||||||
|
|
@ -794,6 +795,16 @@ class _Translations$wot$ast extends Translations$wot$en {
|
||||||
@override String get saved => 'Guardáu';
|
@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
|
// Path: intro.slides
|
||||||
class _Translations$intro$slides$ast extends Translations$intro$slides$en {
|
class _Translations$intro$slides$ast extends Translations$intro$slides$en {
|
||||||
_Translations$intro$slides$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
_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.validityDays' => 'Validez del aval (díes)',
|
||||||
'wot.reset' => 'Reafitar a valores Duniter',
|
'wot.reset' => 'Reafitar a valores Duniter',
|
||||||
'wot.saved' => 'Guardáu',
|
'wot.saved' => 'Guardáu',
|
||||||
|
'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}',
|
||||||
_ => null,
|
_ => 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$chat$en chat = Translations$chat$en.internal(_root);
|
||||||
late final Translations$trust$en trust = Translations$trust$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$wot$en wot = Translations$wot$en.internal(_root);
|
||||||
|
late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// Path: app
|
||||||
|
|
@ -1495,6 +1496,18 @@ class Translations$wot$en {
|
||||||
String get saved => 'Saved';
|
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
|
// Path: intro.slides
|
||||||
class Translations$intro$slides$en {
|
class Translations$intro$slides$en {
|
||||||
Translations$intro$slides$en.internal(this._root);
|
Translations$intro$slides$en.internal(this._root);
|
||||||
|
|
@ -2369,6 +2382,7 @@ extension on Translations {
|
||||||
'wot.validityDays' => 'Vouch validity (days)',
|
'wot.validityDays' => 'Vouch validity (days)',
|
||||||
'wot.reset' => 'Reset to Duniter defaults',
|
'wot.reset' => 'Reset to Duniter defaults',
|
||||||
'wot.saved' => 'Saved',
|
'wot.saved' => 'Saved',
|
||||||
|
'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}',
|
||||||
_ => null,
|
_ => 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$chat$es chat = _Translations$chat$es._(_root);
|
||||||
@override late final _Translations$trust$es trust = _Translations$trust$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$wot$es wot = _Translations$wot$es._(_root);
|
||||||
|
@override late final _Translations$notifications$es notifications = _Translations$notifications$es._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// Path: app
|
||||||
|
|
@ -796,6 +797,16 @@ class _Translations$wot$es extends Translations$wot$en {
|
||||||
@override String get saved => 'Guardado';
|
@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
|
// Path: intro.slides
|
||||||
class _Translations$intro$slides$es extends Translations$intro$slides$en {
|
class _Translations$intro$slides$es extends Translations$intro$slides$en {
|
||||||
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_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.validityDays' => 'Validez del aval (días)',
|
||||||
'wot.reset' => 'Restablecer a valores Duniter',
|
'wot.reset' => 'Restablecer a valores Duniter',
|
||||||
'wot.saved' => 'Guardado',
|
'wot.saved' => 'Guardado',
|
||||||
|
'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}',
|
||||||
_ => null,
|
_ => 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$chat$pt chat = _Translations$chat$pt._(_root);
|
||||||
@override late final _Translations$trust$pt trust = _Translations$trust$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$wot$pt wot = _Translations$wot$pt._(_root);
|
||||||
|
@override late final _Translations$notifications$pt notifications = _Translations$notifications$pt._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// Path: app
|
||||||
|
|
@ -793,6 +794,16 @@ class _Translations$wot$pt extends Translations$wot$en {
|
||||||
@override String get saved => 'Guardado';
|
@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
|
// Path: intro.slides
|
||||||
class _Translations$intro$slides$pt extends Translations$intro$slides$en {
|
class _Translations$intro$slides$pt extends Translations$intro$slides$en {
|
||||||
_Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_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.validityDays' => 'Validade do aval (dias)',
|
||||||
'wot.reset' => 'Repor para valores Duniter',
|
'wot.reset' => 'Repor para valores Duniter',
|
||||||
'wot.saved' => 'Guardado',
|
'wot.saved' => 'Guardado',
|
||||||
|
'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}',
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@ import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
import 'message_store.dart';
|
import 'message_store.dart';
|
||||||
|
import 'notification_service.dart';
|
||||||
import 'profile_cache.dart';
|
import 'profile_cache.dart';
|
||||||
import 'social_service.dart';
|
import 'social_service.dart';
|
||||||
import 'social_settings.dart';
|
import 'social_settings.dart';
|
||||||
|
import 'unread_service.dart';
|
||||||
|
|
||||||
/// App-wide inbox listener for private messages (NIP-17).
|
/// App-wide inbox listener for private messages (NIP-17).
|
||||||
///
|
///
|
||||||
|
|
@ -26,15 +29,21 @@ class InboxService {
|
||||||
required SocialSettings settings,
|
required SocialSettings settings,
|
||||||
required MessageStore store,
|
required MessageStore store,
|
||||||
ProfileCache? profileCache,
|
ProfileCache? profileCache,
|
||||||
|
UnreadService? unread,
|
||||||
|
NotificationService? notifications,
|
||||||
}) : _social = social,
|
}) : _social = social,
|
||||||
_settings = settings,
|
_settings = settings,
|
||||||
_store = store,
|
_store = store,
|
||||||
_profileCache = profileCache;
|
_profileCache = profileCache,
|
||||||
|
_unread = unread,
|
||||||
|
_notifications = notifications;
|
||||||
|
|
||||||
final SocialService _social;
|
final SocialService _social;
|
||||||
final SocialSettings _settings;
|
final SocialSettings _settings;
|
||||||
final MessageStore _store;
|
final MessageStore _store;
|
||||||
final ProfileCache? _profileCache;
|
final ProfileCache? _profileCache;
|
||||||
|
final UnreadService? _unread;
|
||||||
|
final NotificationService? _notifications;
|
||||||
|
|
||||||
final _changes = StreamController<void>.broadcast();
|
final _changes = StreamController<void>.broadcast();
|
||||||
SocialSession? _session;
|
SocialSession? _session;
|
||||||
|
|
@ -101,7 +110,29 @@ class InboxService {
|
||||||
final stored = await _store.append(message.fromPubkey, message);
|
final stored = await _store.append(message.fromPubkey, message);
|
||||||
if (!stored) return; // a re-delivered duplicate
|
if (!stored) return; // a re-delivered duplicate
|
||||||
if (!_changes.isClosed) _changes.add(null);
|
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 {
|
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 '../i18n/strings.g.dart';
|
||||||
import 'seed_glyph.dart';
|
import 'seed_glyph.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
import 'unread_badge.dart';
|
||||||
|
|
||||||
/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is
|
/// The app's navigation drawer (redesign screen 05). A white sheet: Inventory is
|
||||||
/// the live destination (green seed glyph), the social items (market, profile,
|
/// the live destination (green seed glyph), the social items (market, profile,
|
||||||
|
|
@ -56,7 +57,7 @@ class AppDrawer extends StatelessWidget {
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
_DrawerItem(
|
_DrawerItem(
|
||||||
icon: const Icon(Icons.chat_bubble),
|
icon: const UnreadBadge(child: Icon(Icons.chat_bubble)),
|
||||||
label: t.menu.chat,
|
label: t.menu.chat,
|
||||||
onTap: marketEnabled
|
onTap: marketEnabled
|
||||||
? () {
|
? () {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../services/profile_cache.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
import 'unread_badge.dart';
|
||||||
|
|
||||||
/// The messages inbox: past conversations, newest first. Shows each peer's
|
/// 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.
|
/// 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) {
|
itemBuilder: (context, i) {
|
||||||
final c = items[i];
|
final c = items[i];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const CircleAvatar(
|
leading: UnreadBadge(
|
||||||
backgroundColor: seedAvatar,
|
peer: c.peerPubkey,
|
||||||
child: Icon(Icons.person, color: seedOnAvatar),
|
child: const CircleAvatar(
|
||||||
|
backgroundColor: seedAvatar,
|
||||||
|
child: Icon(Icons.person, color: seedOnAvatar),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(_names[c.peerPubkey] ??
|
title: Text(_names[c.peerPubkey] ??
|
||||||
shortPubkey(c.peerPubkey)),
|
shortPubkey(c.peerPubkey)),
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../di/injector.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/message_store.dart';
|
import '../services/message_store.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../services/trust_referents.dart';
|
import '../services/trust_referents.dart';
|
||||||
|
import '../services/unread_service.dart';
|
||||||
import '../services/wot_settings.dart';
|
import '../services/wot_settings.dart';
|
||||||
import '../state/messages_cubit.dart';
|
import '../state/messages_cubit.dart';
|
||||||
import '../state/trust_cubit.dart';
|
import '../state/trust_cubit.dart';
|
||||||
|
|
@ -59,9 +61,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
String? _peerG1;
|
String? _peerG1;
|
||||||
final _input = TextEditingController();
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,6 +178,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
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();
|
_messages?.close();
|
||||||
_trust?.close();
|
_trust?.close();
|
||||||
_session?.close();
|
_session?.close();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import '../i18n/strings.g.dart';
|
||||||
import 'app_drawer.dart';
|
import 'app_drawer.dart';
|
||||||
import 'seed_glyph.dart';
|
import 'seed_glyph.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
import 'unread_badge.dart';
|
||||||
|
|
||||||
/// The main menu (redesign screen 00): a sprout logo in a soft green disc over a
|
/// 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
|
/// faint seed-glyph watermark, with "Your inventory" as the primary green call
|
||||||
|
|
@ -21,7 +22,20 @@ class HomeScreen extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
return Scaffold(
|
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),
|
drawer: AppDrawer(marketEnabled: marketEnabled),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
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
|
# Network reachability (BSD-3) to show a global "you're offline" banner — the
|
||||||
# social layer needs a connection; the inventory works regardless.
|
# social layer needs a connection; the inventory works regardless.
|
||||||
connectivity_plus: ^6.1.0
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,30 @@ import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/services/inbox_service.dart';
|
import 'package:tane/services/inbox_service.dart';
|
||||||
import 'package:tane/services/message_store.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_service.dart';
|
||||||
import 'package:tane/services/social_settings.dart';
|
import 'package:tane/services/social_settings.dart';
|
||||||
|
import 'package:tane/services/unread_service.dart';
|
||||||
|
|
||||||
import '../support/test_support.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,
|
/// 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
|
/// 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.
|
/// through the [InboxService.ingest] seam so no relay/network is involved.
|
||||||
|
|
@ -52,4 +71,52 @@ void main() {
|
||||||
expect(await store.history('alice'), hasLength(1));
|
expect(await store.history('alice'), hasLength(1));
|
||||||
await sub.cancel();
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_localizations:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -1305,6 +1329,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.16"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.1"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue