Adds test/ui/small_screen_overflow_test.dart: pumps the full screens at a
320x568 (iPhone-SE-class) viewport in the longest-text locales (es/pt/ast);
a RenderFlex overflow fails the test, so a clipped title or save button is
caught. It found — and this fixes — a real bug: the Home menu (a fixed,
non-scrolling Center>Column) clipped ~80px on small phones in every locale.
Home is now wrapped in LayoutBuilder + SingleChildScrollView +
ConstrainedBox(minHeight: maxHeight): centered when it fits, scrolls when it
doesn't, never clipped.
Also makes the test harness faithful: wrapScreen/wrapDetail now map the
locale through materialLocaleFor (ast->es) exactly like the app, so Asturian
screens don't throw on MaterialLocalizations (AppBar tooltips) — that had
masqueraded as an overflow.
Scope notes in the test: full screens only (modal bottom sheets get an
unbounded height in widget tests -> false huge overflow; they already scroll
their body), and the variety-detail screen is skipped (its ~2px hit is on a
DISPOSED/DEFUNCT RenderFlex — a transient during the cubit rebuild, not the
stable layout).
Diagnosed the recurring 'tests hang' pain by measurement: the logic layer
is fast and reliable (dart test commons_core ~1s/82 tests; flutter test
test/services ~11s/124 tests, green), and only WIDGET tests hang — because
pumpAndSettle() waits for a screen to stop scheduling frames and a live
source (Drift/Nostr stream, periodic Timer, connectivity/plugin) never does,
so it sits at the 10-minute default and the run looks stuck.
- apps/app_seeds/dart_test.yaml sets timeout: 90s so such a test FAILS fast
and names itself instead of blocking the whole run.
- CLAUDE.md now spells out the split-by-layer workflow (logic via
dart test/flutter test test/services; widget tests targeted + timed out;
never pumpAndSettle a live screen).
Reusing the backup snapshot for sync had two bugs that would bite real use:
1. Deletions didn't propagate. exportInventory() filters isDeleted=false
(user backups are tombstone-free by design) and the JSON codec dropped
isDeleted (omitted on encode, hardcoded false on decode) — so deleting a
variety on one device never reached the others. Now the codec round-trips
isDeleted (emitted only when true, so backups stay byte-identical and it's
backward compatible), and a new exportForSync() includes tombstones. The
importer already merges them LWW-correctly.
2. Photos bloated events. The snapshot embeds photo bytes as base64, so a
real inventory would blow past relay event-size limits and sync would
silently fail. exportForSync() omits attachments entirely (photos stay
device-local; media sync is a separate, deferred concern).
buildSnapshot() now uses exportForSync(); backups keep using exportInventory
(photos in, tombstones out) unchanged.
Tests: a deletion replicates as a tombstone; the sync snapshot carries no
photo bytes while a sealed backup still does.
Wires the encrypted sync transport into a working replication between one
identity's own devices, reusing the CRDT-ready model (HLC + tombstones +
LWW) the data model was built for.
- SocialSession gains a 5th interface, sync, over the shared connection,
built with a per-install deviceId + the app's inventory namespace.
- DeviceIdStore: a stable random per-install id in the keystore — distinct
from the seed-derived CRDT node id, which is IDENTICAL across a user's
devices (so it couldn't tell them apart / they'd clobber each other's
replaceable record).
- InventorySnapshotIO (implemented by ExportImportService): build/apply the
inventory as the SAME interchange JSON as backups but UNSEALED — the sync
transport does the encryption, so it must not double-seal. Apply = the
existing idempotent LWW importInventory.
- SyncService: on (re)connect publishes this device's snapshot + subscribes
to the others'; a local edit (debounced, off tableUpdates) republishes;
incoming snapshots merge idempotently; skips its own echo; a byte check
skips re-publishing an unchanged snapshot so the ping-pong converges.
Foreground only, like the inbox.
- DI/Bootstrap/switchSocialAccount wire and lifecycle it per identity.
Tests: DeviceIdStore, ExportImportService snapshot round-trip (unsealed +
idempotent), SyncService.handleRemote (applies others', skips own).
Follow-ups: deltas vs full-snapshot pushes; background sync; conflict UI.
The most foundational Block-2+ gap: the data model was built CRDT-ready
(HLC, tombstones, LWW, append-only Movement) and the import reconciler
proves the merge — but nothing replicated a device's inventory to your
OTHER devices. Only manual file backup/restore moved data between devices.
This lands the transport foundation in commons_core (seed-agnostic, on the
shared connection — the 'one connection, N interfaces' shape):
- SyncTransport: moves an opaque, encrypted snapshot blob between ONE
identity's own devices. The core carries bytes + does the crypto; the app
decides what the bytes are and how to merge them.
- NostrSyncTransport: NIP-78 app-data (kind 30078, addressable/replaceable
per device via the d-tag), content NIP-44-encrypted TO SELF, discovery
filtered to your own author key. Each device keeps one replaceable record.
Tests (MiniRelay, pure Dart): a snapshot replicates to the identity's other
device; the relay only ever sees ciphertext; another identity neither
receives nor could decrypt; re-push replaces; two devices keep their own.
Confirms the open-decisions §D.4 guarantee (sync leaks neither the key nor
plaintext). NEXT slice (app): a SyncService that serializes the inventory
(reusing ExportImportService), pushes on mutation, and runs the existing
idempotent importInventory on receive; a stable per-install device id;
wire sync into SocialSession.
Instead of showing a retry button on the first failure, silently re-open
the inventory stream with exponential backoff (250ms→4s, 6 attempts,
staying in the loading spinner). The startup DB-not-ready race clears in
well under a second, so the user just sees the spinner briefly and the
list appears — no tap needed. Only after the retry budget is exhausted do
we surface the manual-retry error.
Tests cover auto-recovery, no error flicker during retries, and the
give-up path (via fake_async to fast-forward the backoff).
The inventory stream subscription had no onError handler, so a transient
failure at startup (e.g. the encrypted DB not yet ready) went unhandled
and left loading=true forever — the spinner that only a restart cleared.
Handle stream errors: drop out of loading, surface an error state, and
offer a retry that re-opens the stream. Add a _LoadError view (i18n en/
es/ast/pt) and cover both the failure and the retry-recovers paths.
Every feature (offers, messaging, trust, profile, the inbox listener) used
to open its OWN RelayPool via social.openSession — several sockets to the
same relays, more battery, and reconnection logic living only in the inbox
listener.
Add SocialConnection: ONE shared session per identity, lazily connected and
reused by all. It watches connectivity — dropping the session when the
network goes and reconnecting when it returns, announcing each change on a
sessions stream so the inbox listener re-subscribes automatically. Callers
get the session via connection.session() and never close it (the connection
owns its lifecycle); recreated/disposed on an identity switch.
- InboxService now consumes the shared connection (subscribes on its
sessions stream) instead of owning its own socket + connectivity code.
- createOffersCubit + the chat/market/profile screens use the shared
connection; dead createMessagesCubit/createTrustCubit factories removed.
- DI registers SocialConnection per identity; Bootstrap starts it (after
the inbox subscribes); switchSocialAccount disposes + recreates it.
Tests: SocialConnection lifecycle (reuse, concurrent connect, offline drop +
reconnect, unreachable retry) with a fake opener + online stream; inbox test
updated. nostr added as a dev_dependency for the channel fake.
Market offers carry a small base64 data-URI thumbnail embedded in the Nostr
event (no media server); full photo stays in the encrypted inventory. Blossom
alternative parked on branch parked/offer-photos-blossom.
# Conflicts:
# apps/app_seeds/lib/i18n/strings.g.dart
Replace the Blossom media-server approach with a fully decentralized one:
generate a small (~320px) JPEG thumbnail from the lot's cover photo and
embed it in the offer event as a data: URI, so photos ride the relays with
no server and no IP leak. The full photo stays in the encrypted inventory.
- offer_thumbnail.dart: downscale-until-it-fits data-URI builder + decoder
- market_widgets: render data: URIs from memory, http(s) URLs from network
- offers_cubit: publish a thumbnail (best-effort) in place of an upload
- drop MediaTransport/Blossom from commons_core (http/crypto deps) and the
media-server setting; parked on branch parked/offer-photos-blossom
Relay event-size limits cap the image to a thumbnail; higher-res sharing
would need the parked Blossom path.
Wire the Blossom MediaTransport into publishing: shareable lots carry
their varietyId, publishLots uploads the lot's cover photo and puts the
returned URL on the offer (best-effort — a hosting failure still publishes
the offer, just without a photo). Add a configurable media server (Comunes
default, empty to opt out) to settings and the market advanced config.
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.
Add a MediaTransport interface (sibling of Offer/Message/Trust transports)
and a Blossom (BUD-01/02) backend: sign a kind-24242 upload auth with the
same secp256k1 identity, PUT the bytes over HTTP, return the content-
addressed URL. Idempotent by SHA-256; http.Client injected for tests.
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.
Render offer.imageUrl (already carried by NIP-99) as a thumbnail on each
market card and a hero image on the offer detail page. Remote images
degrade gracefully: a spinner while loading, a neutral placeholder when
the media server is unreachable. Publishing the photo comes next.
The pre-init BootstrapSplash passed the raw device locale to its
MaterialApp, so booting under an Asturian (ast) device locale threw
the 'locale not supported by localization delegates' warning — the
Material/Cupertino delegates ship no ast. The main app already routes
ast to Spanish framework chrome via materialLocaleFor; apply the same
mapping in the splash. Drop @visibleForTesting since it's now shared
production code.
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).
Each label in the print sheet gets an editable copy count, suggested from
the lot's latest condition-check container count (3 jars → 3 labels), and
falling back to 1. Type or step the count; the total updates live and Save
is disabled at zero. On save each seed expands into that many identical
labels.
- SeedLabelEntry.suggestedCopies; labelRows reads the latest container count
- label print sheet: per-row −/number/+ stepper, live total, expand on save
- tests: repository suggestedCopies + print-sheet copy chooser
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.
The vecino/market view had three gaps:
- Publishing inventory duplicated the listing. discover() appended every
offer the relay streamed, but relays legitimately resend an addressable
NIP-99 event (stored copy + live echo on publish). Merge by
(authorPubkeyHex, id) so each offer appears once; two authors reusing a
lot id stay distinct.
- No product detail. Tapping a card jumped straight to chat. Add a
read-only "product page" (MarketOfferDetailScreen) with title, mode,
category, eco badge, coarse location, price, a "Shared by" seller
section (profile fetched by pubkey) and the message button. Reached via
a new /market/offer route; cards are now fully tappable. Shows only what
the network already carries — no photos/description added to the wire.
- No filters. Add a filter bar (type, category, eco) mirroring inventory,
each chip group shown only when a discovered offer can match it. To make
the eco filter real, publish the organic flag on the wire: Offer.isOrganic
-> NIP-99 'organic' tag -> OfferMapper -> ShareableLot.
Tests: commons_core organic round-trip; app-side dedup, two-author
separation, filter logic, mapper passthrough; detail-screen widget tests.
i18n keys added to en/es/pt/ast and slang regenerated.
main() awaited all of configureDependencies() (open the encrypted DB,
derive the social key, seed/scan the species catalog) before the first
runApp, so nothing painted until init finished — startup jank visible as
skipped frames. Now main() runs runApp immediately with a Bootstrap
widget that shows a native-splash-matching green splash while the heavy
work runs, then swaps in TaneApp. On failure it shows a localized error
with a retry (configureDependencies is idempotent, so retry is safe).
Adds a bootstrap i18n group (en/es/pt/ast) and a widget test for the
splash/error frame.
Duplicated bubbles came from the cubit emitting every inbox event to the
screen regardless of whether it was already shown. On opening a chat the
saved message loads from history AND the live subscription hands back the
same stored gift wrap the relay re-delivers — two bubbles. (The store was
already idempotent, but the cubit ignored that for display.)
MessagesCubit now keeps a seen-set (sender+timestamp+text): a re-delivered
or already-loaded message is skipped, and pre-existing duplicates in old
saved history collapse on load too. Arrival order preserved (no resort).
Tests: re-delivered wrap shown once; history-raced-by-live shown once.
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 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.
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.
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: 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.
Feedback: offline, the social layer stops working with no clear reason. A slim
amber strip now shows above every screen when there's no network (connectivity_
plus), so it's obvious WHY sharing is paused — the inventory keeps working.
Wrapped once via the MaterialApp builder; connectivity check is guarded so it
no-ops where the plugin is absent (tests/unsupported platforms). i18n en/es/pt.
Analyzer clean.
User-flagged (on-device feedback): no way to change the social identity today
(derived once from the root seed at startup). Records the two options —
pseudonymous social key (recommended) vs full root-seed reset — and the runtime
re-derivation implications, for a future round.
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.
The optional Ğ1 (free currency) integration, per g1-integration.md.
- Profile: an optional 'Ğ1 address' field, persisted locally and published in
your NIP-01 kind:0 metadata (separate from the Nostr key).
- commons_core: UserProfile/ProfileTransport carry a 'g1' field (round-trip
tested).
- Chat: when the peer published a Ğ1 address, a 'Pay in Ğ1' app-bar action opens
their wallet (url_launcher) so value moves in the wallet, not the app — copies
the address as a fallback. No payment rails in-app (sharing-model §4.3).
NOTE (vjrj): the wallet deep-link uses the Cesium web wallet as a universal
fallback; swap the URI in chat_screen._payG1 for the Ğ1nkgo/Ğecko scheme you
prefer. Analyzer clean (both packages); profile transport test covers g1.