Skip to content

Commit f0a736f

Browse files
committed
fix(direct-send): replace lossy SharedFlow resolve event with StateFlow state
The resolve result was emitted via a SharedFlow (replay=0), which could be missed if the amount entry screen had not subscribed yet -- causing a spurious contact not on Flipcash error. Move resolve status into a ResolveState sealed interface on the ViewModel state (StateFlow, always replays current value). The amount screen now reads resolve state reactively and handles Pending/Failed/Resolved cases proactively. Also embeds the DeviceContact in ResolveState to eliminate a separate event-based collector, and extracts a fallback display name into a string resource. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 65ed2e5 commit f0a736f

3 files changed

Lines changed: 57 additions & 48 deletions

File tree

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@
740740

741741
<string name="prompt_title_fundsSentToContact">Cash Sent Successfully</string>
742742
<string name="prompt_description_fundsSentToContact">%1$s is on its way to %2$s</string>
743+
<string name="subtitle_yourSelectedRecipient">your selected recipient</string>
743744

744745
<string name="error_title_cashFailedToSend">Something Went Wrong</string>
745746
<string name="error_description_cashFailedToSend">We were unable to send your cash. Please try again</string>

apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/SendFlowScreen.kt

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@ package com.flipcash.app.directsend
33
import androidx.compose.runtime.Composable
44
import androidx.compose.runtime.LaunchedEffect
55
import androidx.compose.runtime.getValue
6-
import androidx.compose.runtime.mutableStateOf
7-
import androidx.compose.runtime.remember
8-
import androidx.compose.runtime.setValue
96
import androidx.hilt.navigation.compose.hiltViewModel
107
import androidx.lifecycle.compose.collectAsStateWithLifecycle
118
import androidx.navigation3.runtime.NavEntry
129
import androidx.navigation3.runtime.NavKey
1310
import androidx.navigation3.runtime.entryProvider
14-
import com.flipcash.app.contacts.device.DeviceContact
1511
import com.flipcash.app.core.AppRoute
1612
import com.flipcash.app.core.send.SendResult
1713
import com.flipcash.app.core.send.SendStep
@@ -35,7 +31,6 @@ import com.getcode.navigation.flow.rememberFlowNavigator
3531
import com.getcode.navigation.results.NavResultStateRegistry
3632
import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher
3733
import com.getcode.opencode.model.financial.Fiat
38-
import com.getcode.solana.keys.PublicKey
3934
import com.getcode.util.resources.LocalResources
4035
import kotlinx.coroutines.flow.filterIsInstance
4136
import kotlinx.coroutines.flow.launchIn
@@ -91,34 +86,28 @@ private fun SendAmountEntryScreen() {
9186
val sharedState by sharedVm.stateFlow.collectAsStateWithLifecycle()
9287
val resources = LocalResources.current
9388

94-
var contact by remember {
95-
mutableStateOf<DeviceContact?>(null)
96-
}
97-
98-
var resolvedAuthority by remember {
99-
mutableStateOf<PublicKey?>(null)
100-
}
101-
102-
LaunchedEffect(Unit) {
103-
sharedVm.eventFlow
104-
.filterIsInstance<SendFlowViewModel.Event.ResolveCompleted>()
105-
.onEach {
106-
contact = it.contact
107-
resolvedAuthority = it.authority
108-
}
109-
.launchIn(this)
89+
LaunchedEffect(sharedState.resolveState) {
90+
if (sharedState.resolveState is SendFlowViewModel.ResolveState.Failed) {
91+
BottomBarManager.showAlert(
92+
title = resources.getString(R.string.error_title_contactNotOnFlipcash),
93+
message = resources.getString(R.string.error_description_contactNotOnFlipcash),
94+
onDismiss = { flowNavigator.back() }
95+
)
96+
}
11097
}
11198

11299
LaunchedEffect(sharedVm) {
113100
sharedVm.eventFlow
114101
.filterIsInstance<SendFlowViewModel.Event.SendComplete>()
115102
.onEach { event ->
103+
val displayName = sharedState.resolveState.contact?.displayName
104+
?: resources.getString(R.string.subtitle_yourSelectedRecipient)
116105
BottomBarManager.showInfo(
117106
title = resources.getString(R.string.prompt_title_fundsSentToContact),
118107
message = resources.getString(
119108
R.string.prompt_description_fundsSentToContact,
120109
event.amount.formatted(rule = Fiat.FormattingRule.Truncated),
121-
contact?.displayName ?: "your selected recipient"
110+
displayName,
122111
),
123112
onDismiss = { flowNavigator.back() }
124113
)
@@ -143,21 +132,20 @@ private fun SendAmountEntryScreen() {
143132
when (result) {
144133
AmountEntryResult.Cancelled -> flowNavigator.back()
145134
is AmountEntryResult.Confirmed -> {
146-
val destination = resolvedAuthority
147-
if (destination == null) {
148-
BottomBarManager.showAlert(
149-
title = resources.getString(R.string.error_title_contactNotOnFlipcash),
150-
message = resources.getString(R.string.error_description_contactNotOnFlipcash),
151-
onDismiss = { flowNavigator.back() }
152-
)
153-
} else {
154-
sharedVm.dispatchEvent(
155-
SendFlowViewModel.Event.OnSendRequested(
156-
amount = result.amount,
157-
token = result.token,
158-
destinationOwner = destination,
135+
when (val resolve = sharedState.resolveState) {
136+
is SendFlowViewModel.ResolveState.Resolved -> {
137+
sharedVm.dispatchEvent(
138+
SendFlowViewModel.Event.OnSendRequested(
139+
amount = result.amount,
140+
token = result.token,
141+
destinationOwner = resolve.authority,
142+
)
159143
)
160-
)
144+
}
145+
// Resolve still in flight — slide resets, user can retry
146+
is SendFlowViewModel.ResolveState.Pending -> Unit
147+
// Failed case handled by LaunchedEffect above
148+
else -> Unit
161149
}
162150
}
163151
}

apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ internal class SendFlowViewModel @Inject constructor(
6161
updateStateForEvent = updateStateForEvent,
6262
) {
6363

64+
sealed interface ResolveState {
65+
val contact: DeviceContact? get() = null
66+
data object Idle : ResolveState
67+
data class Pending(override val contact: DeviceContact) : ResolveState
68+
data class Resolved(override val contact: DeviceContact, val authority: PublicKey) : ResolveState
69+
data class Failed(override val contact: DeviceContact) : ResolveState
70+
}
71+
6472
data class State @OptIn(ExperimentalMaterial3Api::class) constructor(
6573
val steps: List<SendStep> = listOf(SendStep.ContactList),
6674
val currentStep: SendStep? = null,
@@ -69,6 +77,7 @@ internal class SendFlowViewModel @Inject constructor(
6977
val contactSyncState: LoadingSuccessState = LoadingSuccessState(),
7078
val listItems: List<ContactListItem> = emptyList(),
7179
val sendProgress: LoadingSuccessState = LoadingSuccessState(),
80+
val resolveState: ResolveState = ResolveState.Idle,
7281
)
7382

7483
sealed interface Event {
@@ -108,7 +117,6 @@ internal class SendFlowViewModel @Inject constructor(
108117
val success: Boolean = false,
109118
) : Event
110119
data class SendComplete(val amount: Fiat) : Event
111-
data object ContactNotResolved : Event
112120
}
113121

114122
init {
@@ -223,11 +231,16 @@ internal class SendFlowViewModel @Inject constructor(
223231
.onEach { event -> contactCoordinator.removeContact(event.e164) }
224232
.launchIn(viewModelScope)
225233

226-
contactCoordinator.state
227-
.filter { it.hasDiscoveredFlipcashContacts && it.flipcashE164s.isNotEmpty() }
228-
.filter { stateFlow.value.currentStep is SendStep.ContactList }
234+
combine(
235+
contactCoordinator.state,
236+
stateFlow.map { it.currentStep },
237+
) { contactState, currentStep ->
238+
contactState to currentStep
239+
}
240+
.filter { (cs, _) -> cs.hasDiscoveredFlipcashContacts && cs.flipcashE164s.isNotEmpty() }
241+
.filter { (_, step) -> step is SendStep.ContactList }
229242
.take(1)
230-
.onEach { contactState ->
243+
.onEach { (contactState, _) ->
231244
val count = contactState.flipcashE164s.size
232245
contactCoordinator.consumeContactsDiscovery()
233246
BottomBarManager.showInfo(
@@ -283,15 +296,12 @@ internal class SendFlowViewModel @Inject constructor(
283296
},
284297
onFailure = { Result.failure(it) }
285298
).onSuccess { amount ->
286-
timber.log.Timber.d("directTransfer success, dispatching checkmark")
287299
dispatchEvent(Event.SendStateUpdated(success = true))
288300
delay(400)
289-
timber.log.Timber.d("dispatching SendComplete")
290301
dispatchEvent(
291302
Dispatchers.Main,
292303
Event.SendComplete(amount.localFiat.nativeAmount)
293304
)
294-
timber.log.Timber.d("SendComplete dispatched")
295305
}.onFailure {
296306
dispatchEvent(Event.SendStateUpdated())
297307
BottomBarManager.showError(
@@ -362,10 +372,21 @@ internal class SendFlowViewModel @Inject constructor(
362372
is Event.OnItemsPopulated -> { state -> state.copy(listItems = event.items) }
363373
is Event.OnContactClicked -> { state -> state }
364374
is Event.SendInvite -> { state -> state }
365-
is Event.SendCashToContact -> { state -> state }
375+
is Event.SendCashToContact -> { state ->
376+
state.copy(resolveState = ResolveState.Pending(event.contact))
377+
}
366378
is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
367-
is Event.ResolveCompleted -> { state -> state }
368-
is Event.ResolveFailed -> { state -> state }
379+
is Event.ResolveCompleted -> { state ->
380+
state.copy(resolveState = ResolveState.Resolved(event.contact, event.authority))
381+
}
382+
is Event.ResolveFailed -> { state ->
383+
val contact = state.resolveState.contact
384+
if (contact != null) {
385+
state.copy(resolveState = ResolveState.Failed(contact))
386+
} else {
387+
state
388+
}
389+
}
369390
is Event.OnSendRequested -> { state -> state }
370391
is Event.SendStateUpdated -> { state ->
371392
state.copy(
@@ -376,7 +397,6 @@ internal class SendFlowViewModel @Inject constructor(
376397
)
377398
}
378399
is Event.SendComplete -> { state -> state }
379-
Event.ContactNotResolved -> { state -> state }
380400
}
381401
}
382402
}

0 commit comments

Comments
 (0)