Skip to content

Commit 76b834c

Browse files
committed
chore(session): decompose SessionController into focused delegates
Extract RealSessionController domain logic into 5 delegates (BillPresentationDelegate, CodeScanDelegate, CashLinkDelegate, DepositDelegate, GiftCardSharingDelegate) with a shared SessionStateHolder. The controller becomes a thin shell that wires delegates and merges their event flows. Adds sub-interfaces (BillOperations, CodeScanOperations, CashLinkOperations, DepositOperations) for narrower API surfaces. DepositDelegate owns deposit option presentation, USDC sweep lifecycle, and the depositFirstUx feature flag — extracted from CashLinkDelegate and the shell. CashLinkDelegate is now stateless with no coroutine scope. Cross-delegate events use Channel(UNLIMITED) for guaranteed delivery (replacing SharedFlow). Includes 58 passing tests, KDoc on all source files, and updated architecture docs. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent e0993f2 commit 76b834c

23 files changed

Lines changed: 2993 additions & 803 deletions

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,4 +794,7 @@
794794
<string name="label_fromYourContacts">From Your Contacts</string>
795795
<string name="label_addContact">Add Contact</string>
796796

797+
<string name="prompt_title_cancelCashLinkPostShare">Are You Sure?</string>
798+
<string name="prompt_description_cancelCashLinkPostShare">Anyone you sent the link to won\'t be able to collect the cash</string>
799+
797800
</resources>

apps/flipcash/shared/session/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ android {
99
dependencies {
1010
testImplementation(kotlin("test"))
1111
testImplementation(libs.bundles.unit.testing)
12+
testImplementation(testFixtures(project(":libs:coroutines")))
1213

1314
implementation(project(":apps:flipcash:shared:chat"))
1415
implementation(project(":apps:flipcash:shared:contacts"))

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,31 @@ sealed interface BillDeterminationResult {
2020
data object Grabbed : BillDeterminationResult, ActedUpon
2121
data object PutInWallet : BillDeterminationResult, ActedUpon
2222

23-
interface SessionController {
24-
val state: StateFlow<SessionState>
23+
interface BillOperations {
2524
val billState: StateFlow<BillState>
26-
fun onAppInForeground()
27-
fun onAppInBackground()
28-
fun onCameraScanning(scanning: Boolean)
2925
fun showBill(bill: Bill)
3026
fun dismissBill(action: BillDeterminationResult)
27+
}
28+
29+
interface CodeScanOperations {
30+
fun onCameraScanning(scanning: Boolean)
3131
fun onCodeScan(code: ScannableKikCode)
32+
}
33+
34+
interface CashLinkOperations {
3235
fun openCashLink(cashLink: String?)
36+
}
37+
38+
interface DepositOperations {
3339
fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)? = null)
3440
}
3541

42+
interface SessionController : BillOperations, CodeScanOperations, CashLinkOperations, DepositOperations {
43+
val state: StateFlow<SessionState>
44+
fun onAppInForeground()
45+
fun onAppInBackground()
46+
}
47+
3648
data class SessionState(
3749
val vibrateOnScan: Boolean = false,
3850
val hasGiveableBalance: Boolean = false,

apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt

Lines changed: 98 additions & 781 deletions
Large diffs are not rendered by default.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.flipcash.app.session.internal
2+
3+
import com.flipcash.app.session.SessionState
4+
import kotlinx.coroutines.flow.MutableStateFlow
5+
import kotlinx.coroutines.flow.StateFlow
6+
import kotlinx.coroutines.flow.asStateFlow
7+
import kotlinx.coroutines.flow.update
8+
import javax.inject.Inject
9+
import javax.inject.Singleton
10+
11+
/**
12+
* Thread-safe holder for the current [SessionState].
13+
*
14+
* Injected into every session delegate and the [RealSessionController] shell so they
15+
* share a single source of truth for session-wide state (auth flags, camera state,
16+
* feature flags, balances, etc.) without any delegate holding the raw
17+
* `MutableStateFlow`.
18+
*/
19+
@Singleton
20+
class SessionStateHolder @Inject constructor() {
21+
private val _state = MutableStateFlow(SessionState())
22+
23+
/** Observable session state, collected by `RealSessionController.state`. */
24+
val state: StateFlow<SessionState> = _state.asStateFlow()
25+
26+
/** Snapshot of the current state — useful in non-suspending contexts. */
27+
val current: SessionState get() = _state.value
28+
29+
/** Atomically update the state via a transform function. */
30+
fun update(transform: (SessionState) -> SessionState) { _state.update(transform) }
31+
32+
/** Reset to the default [SessionState] (e.g. on logout). */
33+
fun reset() { _state.value = SessionState() }
34+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package com.flipcash.app.session.internal.delegates
2+
3+
import com.flipcash.app.analytics.Analytics
4+
import com.flipcash.app.analytics.FlipcashAnalyticsService
5+
import com.flipcash.app.core.bill.Bill
6+
import com.flipcash.app.core.bill.BillState
7+
import com.flipcash.app.core.bill.PaymentValuation
8+
import com.flipcash.app.core.internal.bill.BillController
9+
import com.flipcash.app.core.internal.errors.showNetworkError
10+
import com.flipcash.app.session.BillDeterminationResult
11+
import com.flipcash.app.session.BillOperations
12+
import com.flipcash.app.session.Grabbed
13+
import com.flipcash.app.session.PutInWallet
14+
import com.flipcash.app.session.internal.SessionStateHolder
15+
import com.flipcash.app.session.internal.toast.SessionToastController
16+
import com.flipcash.app.tokens.TokenCoordinator
17+
import com.flipcash.core.R
18+
import com.flipcash.libs.coroutines.DispatcherProvider
19+
import com.flipcash.services.user.UserManager
20+
import com.getcode.manager.BottomBarManager
21+
import com.getcode.opencode.model.accounts.AccountCluster
22+
import com.getcode.util.resources.ResourceHelper
23+
import com.getcode.util.vibration.Vibrator
24+
import com.getcode.utils.ErrorUtils
25+
import com.getcode.utils.TraceType
26+
import com.getcode.utils.network.NetworkConnectivityListener
27+
import com.getcode.utils.trace
28+
import kotlinx.coroutines.CoroutineScope
29+
import kotlinx.coroutines.SupervisorJob
30+
import kotlinx.coroutines.channels.Channel
31+
import kotlinx.coroutines.flow.Flow
32+
import kotlinx.coroutines.flow.StateFlow
33+
import kotlinx.coroutines.flow.consumeAsFlow
34+
import kotlinx.coroutines.launch
35+
import javax.inject.Inject
36+
import javax.inject.Singleton
37+
38+
/**
39+
* Implements [BillOperations] — creating, presenting, and dismissing cash bills.
40+
*
41+
* This delegate handles the full lifecycle of a bill that the user "gives":
42+
* 1. Validates the amount and network connectivity.
43+
* 2. Configures bill actions (Send-as-Link, Cancel).
44+
* 3. Starts the "await grab" flow that renders the Kik Code and waits for a scan.
45+
* 4. On grab: subtracts the token balance, enqueues a toast, and emits [Event.RefreshFeed].
46+
* 5. On Send-as-Link tap: cancels the grab and emits [Event.SendAsLinkRequested] so
47+
* the shell can route to [GiftCardSharingDelegate].
48+
*
49+
* Cross-delegate communication is handled via [events]; the [com.flipcash.app.session.internal.RealSessionController]
50+
* shell collects these and dispatches to the appropriate delegate.
51+
*
52+
* @see com.flipcash.app.session.internal.RealSessionController
53+
*/
54+
@Singleton
55+
class BillPresentationDelegate @Inject constructor(
56+
private val billController: BillController,
57+
private val stateHolder: SessionStateHolder,
58+
private val toastController: SessionToastController,
59+
private val tokenCoordinator: TokenCoordinator,
60+
private val analytics: FlipcashAnalyticsService,
61+
private val vibrator: Vibrator,
62+
private val resources: ResourceHelper,
63+
private val networkObserver: NetworkConnectivityListener,
64+
private val userManager: UserManager,
65+
dispatchers: DispatcherProvider,
66+
) : BillOperations {
67+
68+
sealed interface Event {
69+
data class SendAsLinkRequested(val bill: Bill.Cash, val owner: AccountCluster) : Event
70+
data object RefreshFeed : Event
71+
}
72+
73+
private val scope = CoroutineScope(dispatchers.IO + SupervisorJob())
74+
75+
private val _events = Channel<Event>(Channel.UNLIMITED)
76+
val events: Flow<Event> = _events.consumeAsFlow()
77+
78+
override val billState: StateFlow<BillState> get() = billController.state
79+
80+
override fun showBill(bill: Bill) {
81+
if (bill.amount.nativeAmount.decimalValue == 0.0) return
82+
val owner = userManager.accountCluster ?: return
83+
84+
if (!networkObserver.isConnected) {
85+
return ErrorUtils.showNetworkError(resources)
86+
}
87+
88+
when (bill) {
89+
is Bill.Cash -> {
90+
when (bill.kind) {
91+
Bill.Kind.airdrop -> {
92+
billController.update {
93+
it.copy(
94+
primaryAction = null,
95+
secondaryAction = null,
96+
)
97+
}
98+
}
99+
100+
Bill.Kind.cash -> {
101+
if (bill.didReceive) {
102+
billController.update {
103+
it.copy(
104+
primaryAction = null,
105+
secondaryAction = null,
106+
)
107+
}
108+
} else {
109+
billController.update {
110+
it.copy(
111+
primaryAction = BillState.Action.SendAsLink(
112+
action = {
113+
billController.cancelAwaitForGrab()
114+
_events.trySend(Event.SendAsLinkRequested(bill, owner))
115+
}
116+
),
117+
secondaryAction = BillState.Action.Cancel(
118+
action = { dismissBill(PutInWallet) }
119+
),
120+
)
121+
}
122+
}
123+
awaitBillGrab(bill, owner)
124+
}
125+
}
126+
}
127+
}
128+
}
129+
130+
override fun dismissBill(action: BillDeterminationResult) {
131+
scope.launch {
132+
stateHolder.update { it.copy(billResult = action) }
133+
billController.reset()
134+
toastController.consumeQueue()
135+
}
136+
}
137+
138+
internal fun awaitBillGrab(bill: Bill, owner: AccountCluster) {
139+
analytics.transferStart(Analytics.Transfer.Initiate.GiveBillStart)
140+
billController.awaitGrab(
141+
amount = bill.amount,
142+
token = bill.token,
143+
verifiedState = (bill as? Bill.Cash)?.verifiedState,
144+
nonce = (bill as? Bill.Cash)?.nonce?.takeIf { it.isNotEmpty() },
145+
owner = owner,
146+
onGrabbed = { amount ->
147+
tokenCoordinator.subtract(bill.token, amount)
148+
analytics.transfer(Analytics.Transfer.GiveBill, bill.amount)
149+
toastController.enqueue(bill.amount, isDeposit = false)
150+
dismissBill(Grabbed)
151+
vibrator.vibrate()
152+
_events.trySend(Event.RefreshFeed)
153+
},
154+
onTimeout = {
155+
dismissBill(action = PutInWallet)
156+
},
157+
onError = {
158+
analytics.transfer(
159+
event = Analytics.Transfer.GiveBill,
160+
amount = bill.amount,
161+
successful = false,
162+
error = it
163+
)
164+
dismissBill(action = PutInWallet)
165+
BottomBarManager.showError(
166+
title = resources.getString(R.string.error_title_CashReturnedToWallet),
167+
message = resources.getString(R.string.error_description_CashReturnedToWallet)
168+
)
169+
},
170+
present = { (data, nonce) ->
171+
if (!bill.didReceive) {
172+
trace(
173+
tag = "Session",
174+
message = "Pull out cash",
175+
metadata = {
176+
"underlying quarks" to bill.amount.underlyingTokenAmount.quarks
177+
"native amount" to bill.amount.nativeAmount.formatted()
178+
"fx" to bill.amount.rate.fx
179+
"currency" to bill.amount.rate.currency.name
180+
"token mint" to bill.amount.mint
181+
},
182+
type = TraceType.User,
183+
)
184+
}
185+
presentBillToUser(data, nonce, bill)
186+
},
187+
)
188+
}
189+
190+
private fun presentBillToUser(data: List<Byte>, nonce: List<Byte>, bill: Bill) {
191+
if (billController.state.value.bill != null) return
192+
193+
val presentedBill = when (bill) {
194+
is Bill.Cash -> bill.copy(
195+
data = data,
196+
nonce = nonce,
197+
)
198+
}
199+
200+
billController.update {
201+
it.copy(
202+
bill = presentedBill,
203+
valuation = PaymentValuation(bill.amount.nativeAmount),
204+
)
205+
}
206+
207+
val style: BillDeterminationResult =
208+
if (bill.didReceive) Grabbed else PutInWallet
209+
210+
stateHolder.update { it.copy(billResult = style) }
211+
212+
if (bill.didReceive) {
213+
toastController.enqueue(bill.amount, isDeposit = true)
214+
vibrator.vibrate(duration = 50)
215+
}
216+
}
217+
}

0 commit comments

Comments
 (0)