Private messages (NIP-17) arrived in the foreground with no signal: no
unread count, no badge, no OS notification. Hook both into the single
InboxService.ingest choke point.
- UnreadService: per-peer "last read" in the keystore (timestamps+pubkey
only, no message text), live count via a changes stream, active-peer
suppression so the open chat never badges or notifies.
- UnreadBadge: reusable, RTL-aware (Flutter Badge) — on the home hamburger,
the drawer "Chat" item, and per-conversation in the messages list.
- Mark-read on chat open (ChatScreen).
- NotificationService over flutter_local_notifications: generic
"New message from <name>" (no message text, for privacy), payload = pubkey,
tap opens /chat/:pubkey. No-op on web/Windows; Android POST_NOTIFICATIONS.
- i18n notifications.newMessageFrom (en/es/pt/ast).
Background/push stays out of scope (foreground-only by design).
Tests: UnreadService, NotificationService (mock plugin), InboxService hooks
(new peer notifies/counts; duplicate, own, and open-chat do not), and a
UnreadBadge widget test. dart analyze clean; commons_core green.
Slice 4 of Block 2. The pure rule was already parameterised; this adds the
policy, cold-start and UI around it.
- commons_core: WotParams (sigQty/stepMax/sigValidity, Duniter defaults) +
WebOfTrust.membersWith; npubToHex helper (NIP-19 decode) for adding roots.
- WotSettings (keystore): the active parameters, configurable — a young
network loosens them and tightens as it grows, as Ğ1 did. Defaults Duniter.
- TrustReferents: the bootstrap 'seeds' membership is measured from — a
bundled asset (empty until real founders are curated, no invented keys)
unioned with referents the user adds by npub/QR. Honest cold-start.
- TrustCubit: computes the full membership verdict against referents+params
alongside the personal circle, and exposes a TrustTier (networkMember >
inYourCircle > vouched > unknown). Certifications issued with the active
validity (they expire and renew, Duniter rule).
- UI: chat trust badge by tier; a 'Network of trust' screen (manage roots +
advanced params) reached from the profile. i18n en/es/pt/ast.
Tests: WotParams/membersWith, npubToHex, TrustReferents, WotSettings, and
TrustCubit tiers/membership. Resolves the WoT-parameters decision
(open-decisions §B). Trust net stays empty/undetermined until seeded — by
design; users bootstrap their own roots.
Adds pseudonymous, switchable social identities derived from the SAME root
seed via an account index (NostrKeyDerivation.deriveFromSeed(seed, account)).
HKDF is one-way so accounts are unlinkable to the Ğ1 key; account 0 is the
original identity, byte-for-byte unchanged (no rotation for current users),
and every account regenerates from the single seed — so switching adds
nothing to back up.
- SocialAccountStore: keystore-backed active account + max created.
- Per-identity stores (chats, profile, name cache) namespaced by account
scope (account 0 = legacy keys, no migration) so identities never mix.
- switchSocialAccount() re-derives the identity, re-scopes the stores and
restarts the inbox listener; RestartWidget rebuilds the tree to pick up
the new social singletons. DB/inventory untouched.
- Profile 'Your identities' switcher: list, create, switch (with a note
that messages/contacts are kept separate per identity). i18n en/es/pt/ast.
Tests: account-indexed derivation (legacy 0 unchanged, accounts distinct
yet deterministic, negatives rejected); SocialAccountStore; per-identity
store scope isolation. Resolves the flagged 'change identity' decision
(open-decisions §B).
Known: switching resets navigation to home (full tree rebuild).
Add multi-select to the inventory and a "Print labels" flow that renders a
PDF sheet of labels (small stickers or large cards), each carrying the seed's
common name, variety, scientific name, year/origin and a tanemaki://seed QR
that holds the seed's data offline (future scan-to-import).
- SeedLabelCodec: compact, versioned tanemaki://seed URI (encode/decode)
- LabelSheetService: pure-Dart PDF grid (pdf + barcode + DejaVu), RTL-aware
- VarietyRepository.labelRows: one row per lot, locale-picked common name
- InventoryCubit selection mode; contextual app bar + label print sheet
- i18n en/es/pt (printLabels.*)
Tests: codec, PDF service (both formats + RTL), labelRows, selection cubit,
inventory widget selection.
Reception only ran inside an open ChatScreen for a known peer, so a first
message from a new peer was invisible: nothing in local storage -> nothing
in the inbox list -> you never opened the chat -> you never subscribed.
Add InboxService: one long-lived NIP-17 inbox subscription for the whole
app (foreground), persisting every incoming message to MessageStore and
firing a 'changes' signal the inbox list live-reloads on. Reconnects when
the network returns; degrades to nothing offline. Started from main when a
social identity exists.
Make MessageStore.append idempotent (dedup by sender+timestamp+text) and
serialized behind a write lock — the global listener and an open chat's own
subscription now write the same conversation concurrently and relays
re-deliver stored gift wraps on every resubscribe. Tests for both.
Known trade-offs (follow-ups): foreground-only (no push yet); each of
InboxService/ChatScreen/MarketScreen opens its own RelayPool (a shared
connection is a later optimization).
The idempotency guard used SocialService as its sentinel, but that was
registered mid-cascade, so a run that crashed part-way could leave a
half-built container the next run would reuse (or hit 'already
registered' on) — intermittently falling back to the inventory-only look
with market/chat/profile greyed out.
- Guard on a dedicated _DepsReady marker registered LAST, so the sentinel
means the WHOLE container is wired, never a partial one.
- Reset a half-wired container (crashed prior run) before rebuilding,
instead of reusing it. Safe: main aborts before runApp on a partial run.
- Make the social key derivation non-fatal: on failure the app opens on
inventory (social hidden) rather than blanking. main tolerates an
absent SocialService.
Adds Asturianu, translated in full (345 strings), plus the wiring to
make a minority language work end to end — the OS language picker is an
unreliable way to reach it, so the in-app choice must be explicit and
remembered.
- Model Asturian as the real `ast` locale (honest, international-by-design),
not a fake `es-AST` region — slang rejects a 3-letter region anyway.
- Flutter ships no Material/Cupertino/intl localizations for `ast`, so map
the framework chrome to Spanish (`materialLocaleFor`); the app's own text
stays Asturian via slang. Fixes an "Invalid locale ast" crash when the
auto-backup date was formatted with intl.
- Persist the picked language in the keystore (LocaleStore) and restore it
at startup so it survives restarts and wins over the device locale.
- Settings: add the Asturianu tile; compare the full AppLocale so `es` and
`ast` aren't both marked selected.
Tests: LocaleStore round-trip, locale switch + Spanish-chrome fallback,
and a regression for the intl date crash under Asturian.
Feedback: at times the social items showed greyed ('like phase 1'), only
inventory active. Cause: a hot restart / Android Activity restart re-runs main
while GetIt still holds the singletons; re-registration threw, main aborted
before rebuilding TaneApp with the social layer, leaving the pre-Block-2 tree.
configureDependencies now returns early if already configured, so re-entry
succeeds and TaneApp is always built with the social layer wired.
Closes the profile loop — peers now appear by name, not a raw key.
- ProfileCache: keystore-backed cache of peers' published display names (works
offline) + shortPubkey() fallback.
- Chat: the app bar shows the peer's name (cached first, then freshened from
their kind:0 over the session); falls back to a short key.
- Inbox: each conversation shows the cached name, and missing names are resolved
over one session and cached.
- Wired via DI + TaneApp(profileCache).
Analyzer clean; ProfileCache covered by a plain test.
Drawer 'Profile' is now live.
- commons_core: ProfileTransport + UserProfile + NostrProfileTransport (kind:0
publish/fetch), exposed as SocialSession.profile. Round-trip tested against the
in-process relay (fast dart test).
- app: ProfileStore (local name/about, keystore) + ProfileScreen — shows your
shareable npub with copy, edits display name + 'about', saves locally and (when
online) publishes kind:0 so peers can recognise you instead of a raw key.
- Drawer 'Profile' -> /profile (gated like Market/Chat).
- i18n en/es/pt. ProfileStore + profile transport covered by plain/dart tests.
Analyzer clean (both packages).
Messages were in-memory only — gone on leaving the conversation.
- MessageStore: keystore-backed (no plaintext), a capped per-peer JSON list
(last 200). Simple; the encrypted Drift DB is the eventual home at scale.
- MessagesCubit: loads saved history on start (subscribes FIRST, then loads, so
a message arriving during the load isn't dropped) and persists every sent and
received message. Wired via DI + TaneApp(messageStore).
Tests (plain 'test', no hang risk): MessageStore round-trip/per-peer/cap, and a
cubit test that reopens a fresh cubit and sees the saved conversation. Analyzer
clean; run 'flutter test' locally to confirm.
Sender-side offline delivery hardening.
- OfferOutbox: a keystore-backed (no plaintext) queue of lot ids to publish
later. Stores ids, not offers, so on flush each offer is rebuilt from the
lot's CURRENT state — an offline edit is respected, a deleted lot drops out.
- flushOutbox(): on reconnect, republishes queued lots and clears them (no-op
offline; drops ids whose lot is gone).
- Market screen: sharing offline parks the lots ('we'll share these when you're
connected'); opening the market online auto-flushes the queue first.
- Wired via DI + TaneApp(outbox); i18n en/es/pt.
Tests: 4 outbox + 3 flush + market UI, 20 green. Analyzer clean for new code.
A silent safety net against in-app data loss or DB corruption, with zero
friction. Driven off the app lifecycle (startup + when hidden), it writes a
sealed .tanemaki copy into the app's private storage once a week, keeping the
newest 3 (rotating). Reuses the manual backup's encryption via the extracted
ExportImportService.buildSealedBackup(); never throws, so a failed copy can't
crash startup or backgrounding. Not registered on web (no file storage).
Settings shows a quiet reassurance line with the date of the last automatic
copy (localised via intl), hidden where no automatic backup exists.
Persistence + wiring the offers screen needs, per the product decisions:
- SocialSettings (keystore-backed, no plaintext): coarse area geohash +
configurable relay URLs, both empty by default (no bundled public relays —
don't leak metadata to third parties). Registered in DI.
- createOffersCubit(): opens a SocialSession on the first configured relay when
online, else a null-transport cubit that degrades gracefully. OffersCubit now
closes the owning session on dispose.
4 settings tests green; offers/social suites still green.
Third slice: bridge Block 1's root seed to the Block 2 transport foundation.
- SocialService: derives the secp256k1 Nostr identity from the SAME root seed
the recovery QR backs up (no extra backup), exposes npub/pubkey, and opens a
SocialSession = one shared connection carrying all three transports.
- injector.dart: registers SocialService in get_it. Local-first — derivation is
cheap + offline; no relay is contacted at startup. Replaces the old
"nodeId slice of the seed is the social pubkey" placeholder; keeps the CRDT
author id distinct (unifying it would rewrite authorship — a later decision).
- Test: deterministic identity from the seed hex, matches commons_core, rejects
malformed hex. 5 tests green; app_seeds analyzes clean (0 errors).
'Save a backup' now writes a sealed .tanemaki file (AES-256-GCM under a
key HKDF-derived from the root seed; format TANEBK v1, open and stable).
Restoring on the same identity is silent; a copy sealed by another
identity asks for the printed recovery code (TANE1 base32, typo-tolerant)
and adopts that seed — recovering your bank recovers you. Legacy plain
exports still restore. Settings gains a recovery sheet (QR + code PDF).
BackupBox/RecoveryCode live in commons_core, pure Dart, TDD.
The local half of the original Fase 2 (no network, no Offer entity yet):
- Lot sheet gains a 'do you share it?' choice (gift/swap first, sale last,
never the default); declaring plenty of abundance reveals it with a nudge.
- Lot lines and list tiles badge shared batches; an 'I share' filter and
a printable PDF catalog (paper bridge for fairs) round out the view.
- Catalog PDF embeds DejaVu (Latin-ext/Cyrillic/Greek); fonts are a
per-locale resource like the OCR packs.
Lower the bulk-digitization cliff with two more routes on top of the
already-landed CSV import and "save and add another":
- Photo-first drafts (capture now, catalogue later): burst-capture
photos (camera or multi-gallery) into unnamed draft varieties, shown
in a "to catalogue" tray, hidden from the main list until named.
Adds Variety.isDraft (schema), addDraftVariety/watchDrafts/nameDraft,
the triage sheet and the inventory banner.
- On-device OCR label suggestion (Tesseract, offline, no Google): a
"Suggest name from photo" button in the naming dialog behind a
LabelTextExtractor interface (Tesseract on Android/iOS, no-op
elsewhere). Reads the largest print via hOCR bounding boxes, drops
boilerplate/low-confidence noise, preprocesses (grayscale, contrast,
upscale) and sweeps rotations (0-315 deg) so tilted packets still
read. Bundles tessdata_fast eng+spa; validated on-device against real
packets. The photo is written to a temp file deleted immediately in a
finally block (the plugin needs a path) - a bounded, documented
exception to no-plaintext-at-rest.
This commit also carries the co-developed schema evolution v5 to v8 that
shares these files (organic flag, species viability years, crop
calendar, lot provenance/abundance/preservation format, condition
checks) plus their exports/migrations and i18n.
Tests: CSV/draft/OCR unit + widget + migration green in isolation.
Note: the full widget suite currently hangs (>10 min) - under investigation.
Interchange export/import for Phase 1 (data-model §7):
- JSON: canonical, versioned envelope (formatVersion 1) with all sync
metadata verbatim, photos embedded as base64, tombstones excluded.
Species are re-resolved on import by scientific name (catalog ids are
per-install). Reader tolerates unknown fields/enum values (§5.2) and
rejects newer format versions with a clear error.
- CSV: export-only spreadsheet flatten, one row per lot, RFC 4180
escaping, never any photo bytes.
- Import merges by UUIDv7 id in one transaction: insert-if-unknown
preserving original stamps, last-writer-wins by packed HLC for known
mutable rows, append-only movements; afterwards the local clock
receiveEvent()s the newest imported stamp so it never runs behind.
- Settings gains a Backup section (export CSV/JSON, import JSON with
confirmation); file dialogs behind a FileService interface backed by
file_picker (MIT).
- Tests: codec round-trip and tolerance, reconciler LWW, repository
export→import round-trip (fresh DB, idempotent re-import, newer-local
wins, clock monotonicity, species re-resolution), backup widget flows.
Add a small curated catalog of Iberian horticultural species and let a variety
be linked to it from the edit sheet.
- assets/catalog/species.json: 14 species with botanical family and ES/EN
common names (wikidata_qid/gbif_key deferred to the varilla enrichment).
- SpeciesRepository: idempotent seedBundled (is_bundled rows, keyed by
scientific name) + search by scientific/common name with a locale-best label.
Seeded on startup from DI.
- VarietyRepository.linkSpecies: sets species_id and prefills category from the
species' family when empty (never overwrites an existing category).
VarietyDetail now carries the scientific name.
- Edit sheet gains a live species-search field; the detail view shows the
scientific name (italic). i18n strings added (ES/EN).
Tests: catalog parse, idempotent seeding, search by scientific/common name,
linkSpecies prefill semantics, and a widget test for the autocomplete → link →
scientific-name-shown flow. Full suite: 32 passing, 0 skipped.
Stand up the Tanemaki monorepo and the first end-to-end vertical slice of
Block 1 (offline, encrypted inventory): add a seed → see it in a categorized,
searchable list → it persists → reopen and it's still there.
Architecture (state management like G1nkgo, adapted to Tane's reality):
- flutter_bloc (Cubit-first), but the encrypted Drift DB is the single source
of truth; cubits stream from repositories (no hydrated_bloc/Hive, which would
write plaintext at rest).
- get_it composition root; go_router; slang i18n (ES/EN, Weblate-friendly JSON).
Workspace & core:
- pub workspace: packages/commons_core (pure Dart) + apps/app_seeds (Flutter).
- commons_core primitives: UUIDv7 IdGen, Hybrid Logical Clock, Quantity value
type (+ plant-aware QuantityKind), IdentityService root-seed stub.
Data & security:
- Drift schemaVersion=1 with all 10 Block-1 tables + common CRDT columns
(HLC updated_at, last_author, tombstones); Movement append-only.
- SQLCipher via an injectable executor that refuses to open a plaintext DB;
DB key + root seed in the OS keystore (separate secrets).
- Exported drift_schema_v1.json + migration scaffold.
Tests (near-TDD; nothing merges without tests):
- commons_core units (24), Drift migration test, "no plaintext at rest"
security guard (runs where SQLCipher is present, skips otherwise),
repository, widget, full quick-add flow, file-reopen persistence, and a
no-hardcoded-strings i18n guard. GitLab CI: format + analyze + test + coverage.
Follow-on Block-1 stories (item detail/edit, species catalog, germination UI)
and all of Block 2 remain out of scope.