Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 11:36:57 +02:00
parent e2e81b0810
commit f5f63abc66
3 changed files with 101 additions and 1 deletions

View file

@ -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.

View file

@ -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() {

View file

@ -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