Updated on 2026-08-14
This commit is contained in:
parent
e2e81b0810
commit
f5f63abc66
3 changed files with 101 additions and 1 deletions
63
features/address-book/CLAUDE.md
Normal file
63
features/address-book/CLAUDE.md
Normal 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.
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue