From f5f63abc66a6465ec1ca84a37d1813b5108747f1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Jul 2026 11:36:57 +0200 Subject: [PATCH] Updated on 2026-08-14 --- features/address-book/CLAUDE.md | 63 +++++++++++++++++++ .../addaddress/model/AddAddressModel.kt | 13 +++- .../addaddress/model/AddAddressModelTest.kt | 26 ++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 features/address-book/CLAUDE.md diff --git a/features/address-book/CLAUDE.md b/features/address-book/CLAUDE.md new file mode 100644 index 0000000000..1accc1a050 --- /dev/null +++ b/features/address-book/CLAUDE.md @@ -0,0 +1,63 @@ +# Address Book Feature + +Per-wallet recipient contacts (name, avatar color, a set of `address + network (+ memo)` entries). Opened two +ways: standalone from Settings (browse/manage), and embedded in the Send screen as a recipient picker that +returns a chosen address back to Send. + +## Navigation + +Entry point `AddressBookComponent`; the `AddressBookOpenMode` (in `common/routing`, via `AppRoute.AddressBook`) +decides the initial screen: +- `Default` — browse/manage from Settings. +- `ContactSelection(networkId)` — from Send; a tap returns an address for that network. +- `WithContactCreation(address, networkId)` — from Send-success; opens a new contact with the address attached. + +`DefaultAddressBookComponent` owns a single `childStack(AddressBookRoute)`. Children never reference each other: +`AddressBookChildFactory` builds each one and wires its callbacks to one `AddressBookClickIntents` object the +container implements. + +``` +AddressBookRoute + ├─ List(mode) → AddressBookListModel (mode Default vs Selector only changes what a tap does) + ├─ EditContact(...) → EditContactModel (+ childSlot: PortfolioSelector & AddressInfo bottom sheets) + ├─ AddAddress(...) → AddAddressModel + └─ SelectNetworks → SelectNetworksModel +``` + +Two more components are used **outside** this stack, embedded directly in the Send screen (not reached via +`AddressBookRoute`): `AddressBookContactsBlockComponent` (the up-to-5 contacts block) and +`AddressSelectorComponent` (bottom sheet to pick one of several addresses of a contact). + +## Cross-scope result delivery + +Screens live in independent model scopes, so results are handed over out-of-band, not through nav args. Three +separate singleton mechanisms, each with its own contract: + +- **`AddressBookResultHolder`** (`StateFlow`): AddAddress → EditContact. `ConfirmedAddress.replaces` carries the + entry being superseded in the edit-address flow (swap-in-place). Producer sets, consumer observes then + `clear()`s so it isn't re-applied on resubscription. +- **`SelectNetworksResultHolder`** (`StateFlow`): SelectNetworks → AddAddress, same produce→observe→`clear()`. +- **`DefaultContactSelectionTrigger`** (implements `ContactSelectionTrigger` + `ContactSelectionListener`): + full-list pick → Send. Deliberately a **no-replay `SharedFlow`** (1-item buffer, `tryEmit`), not a holder — + a one-shot signal with nothing retained, so the picker can close even if Send isn't collecting, and there is + nothing to `clear()`. + +Holders are `clear()`ed at the start of a session (`DefaultAddressBookComponent.init`, `AddAddressModel.init`) +so a leftover result from a previous session isn't replayed. + +## Gotchas + +**Memo prefill can't be written synchronously.** In the edit-address flow, address validation is debounced, so +right after `prefillData()` no network is selected yet and `UpdateAddressValidationTransformer` keeps the memo +field hidden and **blanks its value** (`resolveMemoField` clears `value=""` whenever no extras-supporting network +is selected). Setting the memo eagerly is therefore wiped before the field is shown. +`AddAddressModel.prefillMemoOnceVisible` waits for `memoField.isVisible` to flip true, then writes the memo once. +Preserve this ordering when touching prefill/validation (regression test: `Prefill › GIVEN prefilled memo on +extras network …`). + +**`resolveMemoField` clears the memo value on hide by design.** Deselecting the extras network (or changing to a +non-extras chain) intentionally blanks the memo; `validateAndConfirm` guards with `isVisible && isNotEmpty` so a +stale hidden value never leaks into a `ValidatedAddress`. + +**`ContactAddressEntriesConverter` collapses entries by address and takes the first non-null memo of the group.** +There is no per-network memo — if that ever changes, this converter (and `ValidatedAddress.memo`) needs revisiting. \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt index e424f56c14..2417c72fa5 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt @@ -125,7 +125,18 @@ internal class AddAddressModel @Inject constructor( val prefillAddress = params.prefillAddress ?: return onAddressChange(prefillAddress) selectedNetworkIds.value = params.prefillNetworkIds.toSet().ifEmpty { null } - params.prefillMemo?.let(::onMemoChange) + prefillMemoOnceVisible(params.prefillMemo) + } + + private fun prefillMemoOnceVisible(memo: String?) { + if (memo.isNullOrEmpty()) return + state + .map { it.memoField.isVisible } + .distinctUntilChanged() + .filter { isVisible -> isVisible } + .take(1) + .onEach { onMemoChange(memo) } + .launchIn(modelScope) } private fun updateInitialState() { diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt index ad500b11a5..69a9977951 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt @@ -583,6 +583,32 @@ internal class AddAddressModelTest { assertThat(confirmed?.networkIds) .containsExactly(Blockchain.Ethereum.toNetworkId(), Blockchain.BSC.toNetworkId()) } + + // Regression: the debounced validation used to wipe the memo before the field became visible. + @Test + fun `GIVEN prefilled memo on extras network WHEN created THEN memo restored AND confirmed`() = runTest { + // Arrange — XRP supports a destination tag, and the address+network are prefilled (edit-address flow). + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP) + val model = createModel( + testScope = this, + params = params( + prefillAddress = ADDRESS, + prefillNetworkIds = listOf(Blockchain.XRP.toNetworkId()), + prefillMemo = "123456", + onConfirm = { address, _ -> confirmed = address }, + ), + ) + + // Act + advanceUntilIdle() + + // Assert — the memo survives into the visible field and is confirmed. + assertThat(model.state.value.memoField.isVisible).isTrue() + assertThat(model.state.value.memoField.value).isEqualTo("123456") + model.state.value.buttonUM.onClick() + assertThat(confirmed?.memo).isEqualTo("123456") + } } @Nested