The inventory screen carried its own const AppDrawer() (no marketEnabled),
so opening the menu from inventory greyed out market/chat/profile — the
recurring 'phase 1' look. It was also reached via go() (route replace),
which is why it showed a hamburger instead of a back arrow like the market.
Make it coherent with the market: reach inventory via push() (from the
home card and the drawer), and drop its drawer so the AppBar shows a back
arrow returning to the home hub. The drawer now lives only on the home
screen, which always passes marketEnabled — the wrong-drawer bug class is
gone. Update the home_screen test to assert the spoke has a back arrow and
no hamburger.
Discovery searched the full 5-char geohash (~2.4 km) with an exact Nostr tag
match, so two devices in adjacent cells never found each other even though
publish emits the full NIP-52 prefix ladder. Search now coarsens the area to a
configurable precision (SocialSettings.searchPrecision, default 4 ~20 km) via
searchPrefix(), with a human-worded 'how far' selector (Very close / Around
here / My region -> 5/4/3) in the market setup sheet.
Also: surface a clear 'couldn't reach the servers' message when no relay
accepts a share (instead of 'shared 0'), and add wss://relay.comunes.org as the
first default relay (reliable community home; public relays are backup).
Tests: settings precision (default/clamp/roundtrip), searchPrefix helper,
neighbour-cell discovery regression (offers_cubit + commons_core nostr
transports), and the range-selector widget test.
Skip the ~1.5 MB catalog parse and the table scan on every launch — the
main startup cost. `seedBundledIfNeeded` re-seeds only when the recorded
version differs from `speciesCatalogVersion`, writing the version after
the seed commits so an interrupted seed retries next launch.
Adds `parseSpeciesCatalogVersion` and a test keeping the constant in sync
with the asset's `version` field.
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.
Match the inventory patterns the market was missing:
- Search: OffersState gains a `query` + `visibleOffers` getter and
OffersCubit.search() filters the already-discovered offers locally by
summary (case-insensitive), never re-hitting the transport. The filter
survives a refresh. Rounded search field mirrors inventory's.
- Pull-to-refresh: RefreshIndicator re-runs discovery for the current
area (empty state included, so a swipe re-scans). Text filter kept.
- i18n: market.searchHint / market.noMatches (en/es/pt).
Note: the shared slang output (strings*.g.dart, incl. the generated
Asturian locale) is regenerated wholesale, so it also carries the
in-progress `ast` locale keys already present in the working tree.
Feedback: the market sometimes looked 'disabled'. Now the offline/empty states
are distinct and honest:
- No working relay (defaults exist, so it's a reachability issue) → 'Can't reach
the community servers right now' + a Retry.
- Online but no area set → 'Set your area' prompt (you'd otherwise see a bare
'nothing nearby').
- Area set + nothing found → the existing empty state.
i18n en/es/pt; offline tests updated; analyzer clean.
From on-device feedback:
- Area is no longer shown as a raw geohash 'name'. 'Use my location' is the
primary action; a human status line says whether your area is set (coarse,
never exact). The geohash code + the relay servers moved under 'Advanced'
(code field labelled 'a code like sp3e9 — not a place name'), which also
brings back node/relay selection that had been fully hidden.
- Location denial no longer yanks you into system settings — returns quietly
with an actionable inline message.
- Discovery no longer spins forever when nobody's sharing nearby: a timeout
resolves 'searching' to the empty state.
- Home card 'Open market' -> 'Market' + 'Discover and share seeds nearby'
(the word 'open' confused).
i18n en/es/pt; analyzer clean; the use-my-location test asserts the status flip.
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).
Chat was only reachable via an offer's 'Message' button, and the drawer 'Chat'
sat on 'coming soon' — so messaging/trust were effectively hidden.
- MessageStore keeps a conversation index (the keystore has no key enumeration)
and exposes conversations() — peers newest-active-first with their last line.
- ChatListScreen: the messages inbox (tap a conversation → /chat/:pubkey).
- Drawer 'Chat' is now a live destination -> /messages (gated like Market).
- i18n en/es/pt. conversations() covered by a plain test (no widget hang).
Analyzer clean.
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.
Even with fixed pumps, driving discovery through testWidgets hangs here (the
transient 'searching' spinner never lets pumpAndSettle settle). Discovery logic
is already covered by offers_cubit_test (plain test + pumpEventQueue). Keep only
the offline/setup widget tests, which have no live stream. Reported by the user.
A network-wide 'N people vouch' count invites spam and means little. Now the
trust banner computes whether the peer is within YOUR circle — you vouch for
them, or someone you vouch for does (friend-of-a-friend) — via the pure
WebOfTrust rule (seeds={you}, threshold 1, distance 2). When known, the banner
turns green with 'In your circle' (else it falls back to the count).
The full member policy (threshold/distance/bootstrap) stays open (network-trust
§2); this is a deliberately loose 'people near you' heuristic.
Analyzer clean; trust cubit test adds a friend-of-a-friend vs stranger case
(run flutter test locally to confirm — the widget suite is too slow to run here).
The 'searching' state shows a CircularProgressIndicator, so pumpAndSettle never
settled and the widget test hung ~10 min. Pump a couple of fixed frames instead
(the buffered offers deliver on a microtask), and close the fake discover stream
so it's finite. Reported by the user.
Three feedback fixes:
- Servers are fully hidden now — relays are managed automatically (default
community servers; the pool skips dead ones). Sharing setup shows only 'your
area'; no jargon-y URL field. (3 default relays for redundancy.)
- Drawer 'Market' is a live destination (was stuck on 'coming soon'), gated on
the social layer being wired like the home card.
- My own listings in the market carry a 'You' badge (and no 'message yourself'
button), so it's clear which offers are mine.
Analyzer clean. Note: could not run the flutter widget suite here (10+ min
compile after the geolocator native dep in this sandbox) — run 'flutter test'
locally to confirm; the You-badge test was simplified to be robust.
Feedback: the blank 'web address' field was unusable — nobody knows what to put,
so the market never worked at first launch.
- SocialSettings ships default public relays (nos.lol, relay.damus.io), applied
automatically for new users so the market works day one. An explicitly empty
choice still turns the network off (privacy). Exposure is minimal: offers are
opt-in and carry only a coarse geohash.
- Sharing setup now shows the servers as a short PICK-LIST (checkboxes, pre-
selected) instead of a blank field; a community's own server goes under a small
'add another' escape hatch. Intro reworded — no need to know a community.
- Tests updated (defaults) and pinned offline (setRelayUrls([])) so widget tests
don't hit the network. 22 green, analyzer clean.
- TrustCubit: reads how many vouch for the peer + whether YOU do; toggleVouch
certifies/revokes over TrustTransport; never vouches for self; offline-safe.
Kept simple — the full known-member rule (threshold+distance) and its
bootstrap set are still-open policy (network-trust §2); a live count + your
vouch is the honest first step.
- Chat screen now opens ONE session carrying both messaging AND trust (the
shared-connection shape), and shows a slim vouch banner: 'Vouched for by N' +
an 'I know this person' / 'You vouch for them' toggle.
- i18n en/es/pt (human words: 'I know this person', not 'certify').
Tests: 4 trust cubit + messaging + chat, 10 green; analyzer clean.
- MessagesCubit: 1:1 conversation over MessageTransport. Filters the shared
inbox to the peer; tags our own sends with our pubkey (NIP-17 wraps don't come
back to the sender) so the UI can align bubbles; degrades gracefully offline.
- ChatScreen (mockup 10): message bubbles (mine right / peer left) + a composer;
opens a session lazily, shows 'set up sharing' when there's no relay.
- Entry point: a 'Message' button on each offer card (hidden on your own
listings) → /chat/:pubkey.
- Routing + i18n en/es/pt.
Tests: 5 cubit (send/receive/order/filter/offline) + chat offline + market, green.
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.
Makes the coarse area human, per feedback that a typed code is jargon.
- CoarseLocationProvider interface + geolocator-backed impl (BSD-3): low
accuracy only, returns null on any denial/error (degrades quietly). Behind an
interface so it's fakeable and the plugin stays at the composition root.
- Config sheet: a 'Use my approximate location' button (shown only when a
provider is wired) fills the area from the device position, reduced to a
5-char geohash via commons_core's Geohash — a precise fix never leaves the
device. 'Couldn't get your location' on failure.
- Threaded via TaneApp(location); main wires GeolocatorCoarseLocation.
- Platform: ACCESS_COARSE_LOCATION (Android) + NSLocationWhenInUseUsageDescription
(iOS), both scoped to the coarse-area use.
- i18n en/es/pt. Widget test fills the area from a fake location. 8 tests green.
The other half of the market: share your marked lots as offers.
- VarietyRepository.shareableLots(): non-private lots WITH their id (stable,
updatable offer id) + the variety label. Private lots never leave the device.
- OffersCubit.publishLots(): maps each via OfferMapper and publishes, tagged
with the coarse area + signed by the derived key; counts acceptances; no-op
offline or with no area.
- Market screen: a 'share my seeds' action (online only) — publishes, then
re-discovers to show them; 'mark some seeds first' when nothing's shared.
- i18n en/es/pt.
14 tests green (repo query, cubit publish, market UI); 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.
Wires the 'coming soon' market card to a real discovery screen (mockups 04/05):
- MarketScreen: opens an OfferTransport lazily; local-first, so it degrades to a
human 'set up sharing' prompt when no relay/area is set or the net is down.
- Discovers offers by the saved coarse area, lists them with human reciprocity
labels (gift/swap/for sale), 'near you' (never exact location), price only for
sales. Config sheet to set area + community servers (no bundled public relays).
- Threaded via TaneApp(social, socialSettings) — null keeps the card 'coming
soon' (so Block 1 tests pass unchanged); main wires it live.
- i18n en/es/pt (market.*), reuses share.* for type labels.
3 market widget tests + home tests green; app_seeds analyzes clean.
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.
The testable core of the offers UI slice, transport-agnostic:
- OfferMapper: pure app-sharing-intent (OfferStatus) -> network Offer/OfferType
mapping; keeps price only for sales, refuses to publish private lots.
- OffersCubit: discover(geohash) streams results; publish() reports the verdict;
depends on the OfferTransport interface (fake in tests) and degrades
gracefully offline (null transport = social layer unavailable, never throws).
8 tests green (fake in-memory transport, no relay). Screen/routing/i18n come
next — they need two product decisions (coarse-area capture, relay list).
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).
Infer the catalog species a free-text variety label names ("Maiz de la
abuela" -> Zea mays) and prefill the category from the species family.
- Pure, testable matcher (domain/species_autoclassify.dart): whole-word,
accent/case-insensitive, Unicode-aware (any script), longest-name-wins,
ambiguous names left unclassified, light plural fold.
- Quick-add and draft naming auto-link the species when the field is empty
(non-destructive; an explicit category is kept).
- Edit sheet offers a one-tap suggestion from the typed name.
Tests: matcher unit, repository integration, SpeciesRepository.classifyLabel,
and an edit-sheet widget test.
The repository clock started fresh at Hlc.zero on every launch and was
never re-seeded from the database. After a restart, if the device wall
clock lagged behind stamps already stored (e.g. rows imported from a
peer whose clock ran ahead), a local edit was stamped *older* than the
row it edited and silently lost last-writer-wins — so lot changes made
on one device did not update when the backup was imported on another.
Re-seed the clock lazily from the newest stored `updatedAt` before the
first local stamp of the session, mirroring what importInventory already
does with the incoming max. _stamp() is now async.
Tests: add a lot-level LWW re-import test (the variety case was covered,
the lot case was not) and a restart-regression test that reproduces the
lagging-wall-clock scenario.
Grow the bundled species catalog from 14 hand-curated entries to ~1200
edible/cultivated species, internationalized in 13 languages (es, en, fr,
de, it, pt, ca, gl, eu, ar, zh, ja, ru — Latin + Arabic RTL + CJK + Cyrillic).
- Add a reproducible generator (tool/gen_species_catalog.dart) that queries
Wikidata (CC0, no attribution burden) in two phases, filters out
non-vernacular noise (author citations, ranks, initials) and applies a
relevance floor, then merges hand-curated, authoritative core-crop data
(tool/curated_overrides.json: names, family, viability_years). GBIF is used
only as an identifier. The generated species.json (v3) is committed.
- Carry wikidata_qid and gbif_key through the parse/seed pipeline; the columns
already existed, so no DB migration.
- Rewrite seedBundled to one read + one batch (was a SELECT per species on
every startup — a real cost at ~1200 rows) and keep it idempotent with
backfill of the new reference fields.
- Move species search filtering to SQL (LIKE) so a large catalog is not pulled
into memory on every keystroke.
- Cover the generator transform, the generated asset, and the new fields with
tests.
- Android release signing reads a gitignored key.properties, falling back
to debug keys so contributors can still build --release.
- CHANGELOG.md (human, newest-first) + Fastlane/F-Droid metadata (en/es)
with descriptions derived from docs/intro.md.
- docs/release.md: the by-hand build + manual smoke checklist (no CI yet).
- inventory_scale_test guards the list query against N+1 at 3000 varieties.
- Add rtl_smoke_test covering inventory, detail (lot sheet) and settings
under Directionality.rtl, with an Arabic label and vernacular name.
- Ship pt.i18n.json (Portuguese) as proof the locale pipeline scales beyond
es/en; wire its language tile in Settings.
- docs/i18n.md documents the Weblate-ready flow for adding locales.
Add a read-only "Learn more" section on the variety detail that surfaces
verified external references for the linked species, built deterministically
from bundled identifiers — no titles bundled, no network, offline-first:
- GBIF taxon page from the backbone key
- Wikipedia via Wikidata Special:GoToLinkedPage (locale-aware, RTL-safe,
falls back to the Wikidata item when a language lacks the article)
- Wikispecies from the scientific name
Enrich the bundled catalog (species.json v3) with gbif_key + wikidata_qid for
all 14 species, cross-checked (each QID carries GBIF ID P846 = the key).
Schema already had the columns; seedBundled now persists and backfills them.
The grower's own manual links stay but are demoted (progressive disclosure):
the section only shows when links exist and "Add link" is a low-emphasis
button, since hand-adding a link is the rarer power move.
Pure-Dart link builder is unit-tested; repo persistence/backfill and the
detail-screen section are covered too.
'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.