Skip to content

Commit

Permalink
Implement primal legend purchase monitor
Browse files Browse the repository at this point in the history
  • Loading branch information
AleksandarIlic committed Nov 12, 2024
1 parent a9e7ace commit 6caa51c
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@ enum class PrimalVerb(val identifier: String) {
WALLET_PURCHASE_MEMBERSHIP("membership_purchase_product"),
WALLET_MEMBERSHIP_PRODUCTS("membership_products"),
WALLET_MEMBERSHIP_CANCEL("membership_cancel_product"),
WALLET_MEMBERSHIP_PURCHASE_MONITOR("membership_purchase_monitor"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ enum class NostrEventKind(val value: Int) {
PrimalWalletUpdatedAt(value = 10_000_317),
PrimalMembershipNameAvailable(value = 10_000_600),
PrimalMembershipLegendPaymentInstructions(value = 10_000_601),
PrimalMembershipPurchaseMonitor(value = 10_000_602),
PrimalMembershipStatus(value = 10_000_603),
PrimalAppState(value = 10_000_999),
PrimalLongFormContent(value = 10_030_023),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package net.primal.android.premium.api.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class MembershipPurchaseMonitorRequestBody(
@SerialName("membership_quote_id") val membershipQuoteId: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package net.primal.android.premium.api.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class MembershipPurchaseMonitorResponse(
@SerialName("id") val id: String?,
@SerialName("specification") val specification: String?,
@SerialName("created_at") val createdAt: String?,
@SerialName("requester_pubkey") val requesterPubkey: String?,
@SerialName("completed_at") val completedAt: String?,
@SerialName("receiver_pubkey") val receiverPubkey: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ class PremiumBecomeLegendContract {
val selectedAmountInBtc: BigDecimal = minLegendThresholdInBtc,
val bitcoinAddress: String? = null,
val qrCodeValue: String? = null,
val membershipQuoteId: String? = null,
)

sealed class UiEvent {
data object ShowAmountEditor : UiEvent()
data object GoBackToIntro : UiEvent()
data object ShowPaymentInstructions : UiEvent()
data object ShowSuccess : UiEvent()
data class UpdateSelectedAmount(val newAmount: Float) : UiEvent()
data object StartPurchaseMonitor : UiEvent()
data object StopPurchaseMonitor : UiEvent()
}

enum class BecomeLegendStage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import net.primal.android.core.compose.runtime.DisposableLifecycleObserverEffect
import net.primal.android.premium.legend.PremiumBecomeLegendContract.BecomeLegendStage
import net.primal.android.premium.legend.ui.amount.BecomeLegendAmountStage
import net.primal.android.premium.legend.ui.intro.BecomeLegendIntroStage
Expand All @@ -23,6 +25,14 @@ import net.primal.android.theme.AppTheme
fun PremiumBecomeLegendScreen(viewModel: PremiumBecomeLegendViewModel, onClose: () -> Unit) {
val state = viewModel.state.collectAsState()

DisposableLifecycleObserverEffect(viewModel) {
when (it) {
Lifecycle.Event.ON_START -> viewModel.setEvent(PremiumBecomeLegendContract.UiEvent.StartPurchaseMonitor)
Lifecycle.Event.ON_STOP -> viewModel.setEvent(PremiumBecomeLegendContract.UiEvent.StopPurchaseMonitor)
else -> Unit
}
}

PremiumBecomeLegendScreen(
state = state.value,
eventPublisher = viewModel::setEvent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,20 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.encodeToString
import net.primal.android.core.serialization.json.NostrJson
import net.primal.android.networking.di.PrimalWalletApiClient
import net.primal.android.networking.primal.PrimalApiClient
import net.primal.android.networking.primal.PrimalCacheFilter
import net.primal.android.networking.primal.PrimalSocketSubscription
import net.primal.android.networking.primal.PrimalVerb
import net.primal.android.networking.sockets.errors.WssException
import net.primal.android.nostr.ext.takeContentOrNull
import net.primal.android.nostr.model.NostrEventKind
import net.primal.android.premium.api.model.MembershipPurchaseMonitorRequestBody
import net.primal.android.premium.api.model.MembershipPurchaseMonitorResponse
import net.primal.android.premium.legend.PremiumBecomeLegendContract.Companion.LEGEND_THRESHOLD_IN_USD
import net.primal.android.premium.legend.PremiumBecomeLegendContract.UiEvent
import net.primal.android.premium.legend.PremiumBecomeLegendContract.UiState
Expand All @@ -26,6 +39,7 @@ class PremiumBecomeLegendViewModel @Inject constructor(
private val activeAccountStore: ActiveAccountStore,
private val walletRepository: WalletRepository,
private val premiumRepository: PremiumRepository,
@PrimalWalletApiClient private val walletApiClient: PrimalApiClient,
) : ViewModel() {

private val _state = MutableStateFlow(UiState())
Expand All @@ -35,36 +49,16 @@ class PremiumBecomeLegendViewModel @Inject constructor(
private val events: MutableSharedFlow<UiEvent> = MutableSharedFlow()
fun setEvent(event: UiEvent) = viewModelScope.launch { events.emit(event) }

private var monitorSubscription: PrimalSocketSubscription<MembershipPurchaseMonitorResponse>? = null
private var monitorMutex = Mutex()

init {
observeEvents()
observeActiveAccount()
fetchExchangeRate()
fetchLegendPaymentInstructions()
}

private fun fetchLegendPaymentInstructions() {
_state.value.membership?.premiumName?.let { primalName ->
viewModelScope.launch {
try {
val response = premiumRepository.fetchPrimalLegendPaymentInstructions(
userId = activeAccountStore.activeUserId(),
primalName = primalName,
)

setState {
copy(
minLegendThresholdInBtc = response.amountBtc.toBigDecimal(),
selectedAmountInBtc = response.amountBtc.toBigDecimal(),
bitcoinAddress = response.qrCode.parseBitcoinPaymentInstructions()?.address,
)
}
} catch (error: WssException) {
Timber.e(error)
}
}
}
}

private fun observeEvents() =
viewModelScope.launch {
events.collect {
Expand All @@ -83,10 +77,6 @@ class PremiumBecomeLegendViewModel @Inject constructor(
copy(stage = PremiumBecomeLegendContract.BecomeLegendStage.Payment)
}

UiEvent.ShowSuccess -> setState {
copy(stage = PremiumBecomeLegendContract.BecomeLegendStage.Success)
}

is UiEvent.UpdateSelectedAmount -> {
val newAmountInBtc = it.newAmount.toBigDecimal().setScale(8, RoundingMode.HALF_UP)
setState {
Expand All @@ -96,6 +86,10 @@ class PremiumBecomeLegendViewModel @Inject constructor(
)
}
}

UiEvent.StartPurchaseMonitor -> startPurchaseMonitorIfStopped()

UiEvent.StopPurchaseMonitor -> stopPurchaseMonitor()
}
}
}
Expand Down Expand Up @@ -141,4 +135,86 @@ class PremiumBecomeLegendViewModel @Inject constructor(
}
}
}

private fun fetchLegendPaymentInstructions() {
_state.value.membership?.premiumName?.let { primalName ->
viewModelScope.launch {
try {
val response = premiumRepository.fetchPrimalLegendPaymentInstructions(
userId = activeAccountStore.activeUserId(),
primalName = primalName,
)

setState {
copy(
minLegendThresholdInBtc = response.amountBtc.toBigDecimal(),
selectedAmountInBtc = response.amountBtc.toBigDecimal(),
bitcoinAddress = response.qrCode.parseBitcoinPaymentInstructions()?.address,
membershipQuoteId = response.membershipQuoteId,
)
}

startPurchaseMonitorIfStopped()
} catch (error: WssException) {
Timber.e(error)
}
}
}
}

private fun subscribeToPurchaseMonitor(quoteId: String) =
PrimalSocketSubscription.launch(
scope = viewModelScope,
primalApiClient = walletApiClient,
cacheFilter = PrimalCacheFilter(
primalVerb = PrimalVerb.WALLET_MEMBERSHIP_PURCHASE_MONITOR,
optionsJson = NostrJson.encodeToString(
MembershipPurchaseMonitorRequestBody(membershipQuoteId = quoteId),
),
),
transformer = {
if (primalEvent?.kind == NostrEventKind.PrimalMembershipPurchaseMonitor.value) {
primalEvent.takeContentOrNull<MembershipPurchaseMonitorResponse>()
} else {
null
}
},
) {
if (it.completedAt != null) {
fetchMembershipStatus()
setState { copy(stage = PremiumBecomeLegendContract.BecomeLegendStage.Success) }
stopPurchaseMonitor()
}
}

private fun startPurchaseMonitorIfStopped() {
viewModelScope.launch {
monitorMutex.withLock {
if (monitorSubscription == null) {
val quoteId = _state.value.membershipQuoteId
if (quoteId != null) {
monitorSubscription = subscribeToPurchaseMonitor(quoteId = quoteId)
}
}
}
}
}

private fun stopPurchaseMonitor() {
viewModelScope.launch {
monitorMutex.withLock {
monitorSubscription?.unsubscribe()
monitorSubscription = null
}
}
}

private fun fetchMembershipStatus() =
viewModelScope.launch {
try {
premiumRepository.fetchMembershipStatus(activeAccountStore.activeUserId())
} catch (error: WssException) {
Timber.w(error)
}
}
}

0 comments on commit 6caa51c

Please sign in to comment.