Skip to content

Commit 98c446c

Browse files
committed
chore(session): decompose SessionController into focused delegates
Extract RealSessionController domain logic into 4 delegates (BillPresentationDelegate, CashLinkDelegate, CodeScanDelegate, 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) for narrower API surfaces. Includes 28 passing tests, KDoc on all source files, and updated architecture docs (DI, separation-of-concerns, testing). Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent ae1eb56 commit 98c446c

15 files changed

Lines changed: 1956 additions & 782 deletions

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,28 @@ 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?)
3336
fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)? = null)
3437
}
3538

39+
interface SessionController : BillOperations, CodeScanOperations, CashLinkOperations {
40+
val state: StateFlow<SessionState>
41+
fun onAppInForeground()
42+
fun onAppInBackground()
43+
}
44+
3645
data class SessionState(
3746
val vibrateOnScan: Boolean = false,
3847
val hasGiveableBalance: Boolean = false,
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package com.flipcash.app.session.internal
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.toast.SessionToastController
15+
import com.flipcash.app.tokens.TokenCoordinator
16+
import com.flipcash.core.R
17+
import com.flipcash.libs.coroutines.DispatcherProvider
18+
import com.flipcash.services.user.UserManager
19+
import com.getcode.manager.BottomBarManager
20+
import com.getcode.opencode.model.accounts.AccountCluster
21+
import com.getcode.util.resources.ResourceHelper
22+
import com.getcode.util.vibration.Vibrator
23+
import com.getcode.utils.ErrorUtils
24+
import com.getcode.utils.TraceType
25+
import com.getcode.utils.network.NetworkConnectivityListener
26+
import com.getcode.utils.trace
27+
import kotlinx.coroutines.CoroutineScope
28+
import kotlinx.coroutines.SupervisorJob
29+
import kotlinx.coroutines.channels.BufferOverflow
30+
import kotlinx.coroutines.flow.MutableSharedFlow
31+
import kotlinx.coroutines.flow.SharedFlow
32+
import kotlinx.coroutines.flow.StateFlow
33+
import kotlinx.coroutines.flow.asSharedFlow
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 [RealSessionController]
50+
* shell collects these and dispatches to the appropriate delegate.
51+
*
52+
* @see 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 = MutableSharedFlow<Event>(
76+
extraBufferCapacity = 1,
77+
onBufferOverflow = BufferOverflow.DROP_OLDEST,
78+
)
79+
val events: SharedFlow<Event> = _events.asSharedFlow()
80+
81+
override val billState: StateFlow<BillState> get() = billController.state
82+
83+
override fun showBill(bill: Bill) {
84+
if (bill.amount.nativeAmount.decimalValue == 0.0) return
85+
val owner = userManager.accountCluster ?: return
86+
87+
if (!networkObserver.isConnected) {
88+
return ErrorUtils.showNetworkError(resources)
89+
}
90+
91+
when (bill) {
92+
is Bill.Cash -> {
93+
when (bill.kind) {
94+
Bill.Kind.airdrop -> {
95+
billController.update {
96+
it.copy(
97+
primaryAction = null,
98+
secondaryAction = null,
99+
)
100+
}
101+
}
102+
103+
Bill.Kind.cash -> {
104+
if (bill.didReceive) {
105+
billController.update {
106+
it.copy(
107+
primaryAction = null,
108+
secondaryAction = null,
109+
)
110+
}
111+
} else {
112+
billController.update {
113+
it.copy(
114+
primaryAction = BillState.Action.SendAsLink(
115+
action = {
116+
billController.cancelAwaitForGrab()
117+
_events.tryEmit(Event.SendAsLinkRequested(bill, owner))
118+
}
119+
),
120+
secondaryAction = BillState.Action.Cancel(
121+
action = { dismissBill(PutInWallet) }
122+
),
123+
)
124+
}
125+
}
126+
awaitBillGrab(bill, owner)
127+
}
128+
}
129+
}
130+
}
131+
}
132+
133+
override fun dismissBill(action: BillDeterminationResult) {
134+
scope.launch {
135+
stateHolder.update { it.copy(billResult = action) }
136+
billController.reset()
137+
toastController.consumeQueue()
138+
}
139+
}
140+
141+
internal fun awaitBillGrab(bill: Bill, owner: AccountCluster) {
142+
analytics.transferStart(Analytics.Transfer.Initiate.GiveBillStart)
143+
billController.awaitGrab(
144+
amount = bill.amount,
145+
token = bill.token,
146+
verifiedState = (bill as? Bill.Cash)?.verifiedState,
147+
nonce = (bill as? Bill.Cash)?.nonce?.takeIf { it.isNotEmpty() },
148+
owner = owner,
149+
onGrabbed = { amount ->
150+
tokenCoordinator.subtract(bill.token, amount)
151+
analytics.transfer(Analytics.Transfer.GiveBill, bill.amount)
152+
toastController.enqueue(bill.amount, isDeposit = false)
153+
dismissBill(Grabbed)
154+
vibrator.vibrate()
155+
_events.tryEmit(Event.RefreshFeed)
156+
},
157+
onTimeout = {
158+
dismissBill(action = PutInWallet)
159+
},
160+
onError = {
161+
analytics.transfer(
162+
event = Analytics.Transfer.GiveBill,
163+
amount = bill.amount,
164+
successful = false,
165+
error = it
166+
)
167+
dismissBill(action = PutInWallet)
168+
BottomBarManager.showError(
169+
title = resources.getString(R.string.error_title_CashReturnedToWallet),
170+
message = resources.getString(R.string.error_description_CashReturnedToWallet)
171+
)
172+
},
173+
present = { (data, nonce) ->
174+
if (!bill.didReceive) {
175+
trace(
176+
tag = "Session",
177+
message = "Pull out cash",
178+
metadata = {
179+
"underlying quarks" to bill.amount.underlyingTokenAmount.quarks
180+
"native amount" to bill.amount.nativeAmount.formatted()
181+
"fx" to bill.amount.rate.fx
182+
"currency" to bill.amount.rate.currency.name
183+
"token mint" to bill.amount.mint
184+
},
185+
type = TraceType.User,
186+
)
187+
}
188+
presentBillToUser(data, nonce, bill)
189+
},
190+
)
191+
}
192+
193+
private fun presentBillToUser(data: List<Byte>, nonce: List<Byte>, bill: Bill) {
194+
if (billController.state.value.bill != null) return
195+
196+
val presentedBill = when (bill) {
197+
is Bill.Cash -> bill.copy(
198+
data = data,
199+
nonce = nonce,
200+
)
201+
}
202+
203+
billController.update {
204+
it.copy(
205+
bill = presentedBill,
206+
valuation = PaymentValuation(bill.amount.nativeAmount),
207+
)
208+
}
209+
210+
val style: BillDeterminationResult =
211+
if (bill.didReceive) Grabbed else PutInWallet
212+
213+
stateHolder.update { it.copy(billResult = style) }
214+
215+
if (bill.didReceive) {
216+
toastController.enqueue(bill.amount, isDeposit = true)
217+
vibrator.vibrate(duration = 50)
218+
}
219+
}
220+
}

0 commit comments

Comments
 (0)