diff --git a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsAction.kt b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsAction.kt new file mode 100644 index 0000000000..d69d3be66f --- /dev/null +++ b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsAction.kt @@ -0,0 +1,17 @@ +package com.gemwallet.android.features.rewards.presents + +import com.gemwallet.android.features.rewards.viewmodels.models.RewardsSheetType +import uniffi.gemstone.GemRewardsRedemption + +internal sealed interface RewardsAction { + data object Refresh : RewardsAction + data object Close : RewardsAction + data object SelectWallet : RewardsAction + data object InviteFriends : RewardsAction + data object CodeHandled : RewardsAction + data object DismissSheet : RewardsAction + data class ShowSheet(val sheet: RewardsSheetType) : RewardsAction + data class CreateReferral(val username: String) : RewardsAction + data class UseCode(val code: String) : RewardsAction + data class Redeem(val redemption: GemRewardsRedemption) : RewardsAction +} diff --git a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsScene.kt b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsScene.kt index 57f15764eb..965d2100e3 100644 --- a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsScene.kt +++ b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsScene.kt @@ -16,11 +16,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,9 +29,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.gemwallet.android.features.rewards.presents.components.rewardsHead import com.gemwallet.android.features.rewards.presents.components.rewardsInfo -import com.gemwallet.android.features.rewards.presents.dialogs.CreateRewardsCodeDialog -import com.gemwallet.android.features.rewards.presents.dialogs.RedeemRewardsCodeDialog +import com.gemwallet.android.features.rewards.presents.dialogs.RewardsCodeDialog import com.gemwallet.android.features.rewards.viewmodels.models.RewardsSectionUIModel +import com.gemwallet.android.features.rewards.viewmodels.models.RewardsSheetType import com.gemwallet.android.model.AuthRequest import com.gemwallet.android.model.text import com.gemwallet.android.ui.R @@ -48,7 +44,6 @@ import com.gemwallet.android.ui.components.list_item.ListItemModel import com.gemwallet.android.ui.components.list_item.listItem import com.gemwallet.android.ui.components.screen.PullToRefreshBox import com.gemwallet.android.ui.components.screen.Scene -import com.gemwallet.android.ui.components.screen.showSnackbar import com.gemwallet.android.ui.icons.AppIcons import com.gemwallet.android.ui.models.ListPosition import com.gemwallet.android.ui.models.buttonState @@ -58,7 +53,6 @@ import com.gemwallet.android.ui.theme.WalletTheme import com.gemwallet.android.ui.theme.paddingDefault import com.gemwallet.android.ui.theme.paddingSmall import com.gemwallet.android.ui.theme.sceneContentPadding -import kotlinx.coroutines.launch import uniffi.gemstone.GemEmptyStateKind import uniffi.gemstone.GemIncomingCode import uniffi.gemstone.GemListRow @@ -72,7 +66,7 @@ import uniffi.gemstone.GemRewardsWallet private val referralCodeMaxWidth = 250.dp @Composable -fun RewardsScene( +internal fun RewardsScene( state: GemLoadState, isRefreshing: Boolean, wallet: GemRewardsWallet?, @@ -84,44 +78,19 @@ fun RewardsScene( inviteDescription: String, sections: List, redemptions: List, + sheet: RewardsSheetType?, + isLoading: Boolean, incomingCode: GemIncomingCode? = null, - onUsername: (String, (Throwable?) -> Unit) -> Unit, - onCode: (String, (Throwable?) -> Unit) -> Unit, - onCodeHandled: () -> Unit, - onRefresh: () -> Unit, - onWallet: () -> Unit, - onRedeem: (GemRewardsRedemption) -> Unit, - onClose: () -> Unit, - onInvite: () -> Unit, - onError: (Throwable) -> Unit, snackbar: SnackbarHostState = remember { SnackbarHostState() }, + onAction: (RewardsAction) -> Unit, ) { val context = LocalContext.current - var getStartedDialogShow by remember(inviteAction) { mutableStateOf(false) } - var codeDialogShow by remember { mutableStateOf(false) } - var codeRetryShow by remember { mutableStateOf(false) } - val referralCode = when (incomingCode) { - is GemIncomingCode.Activate -> incomingCode.code - is GemIncomingCode.Confirm -> incomingCode.code - null -> null - } - - val successStr = stringResource(R.string.common_done) - val scope = rememberCoroutineScope() - - val onCodeResult = fun (error: Throwable?) { - when (error) { - null -> scope.launch { snackbar.showSnackbar(successStr, R.drawable.ic_check_circle) } - else -> onError(error) - } - } - LaunchedEffect(incomingCode) { val code = (incomingCode as? GemIncomingCode.Activate)?.code ?: return@LaunchedEffect - context.requestAuth(AuthRequest.Default, onCancel = { codeRetryShow = true }) { - onCodeHandled() - onCode(code, onCodeResult) + context.requestAuth(AuthRequest.Default, onCancel = { onAction(RewardsAction.ShowSheet(RewardsSheetType.ActivateCode(code))) }) { + onAction(RewardsAction.CodeHandled) + onAction(RewardsAction.UseCode(code)) } } @@ -136,7 +105,7 @@ fun RewardsScene( .padding(horizontal = sceneContentPadding()) .clip(RoundedCornerShape(paddingDefault)) .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(paddingDefault)) - .clickable(onWallet) + .clickable { onAction(RewardsAction.SelectWallet) } .padding(start = paddingDefault, end = paddingSmall) .padding(vertical = paddingSmall), verticalAlignment = Alignment.CenterVertically, @@ -149,13 +118,13 @@ fun RewardsScene( ) Icon( imageVector = AppIcons.KeyboardArrowDown, - contentDescription = "select wallet", + contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, ) } } }, - onClose = onClose, + onClose = { onAction(RewardsAction.Close) }, ) { if (state == GemLoadState.Loading) { Box(modifier = Modifier.fillMaxSize()) { @@ -165,9 +134,11 @@ fun RewardsScene( } PullToRefreshBox( isRefreshing = isRefreshing, - onRefresh = onRefresh, + onRefresh = { onAction(RewardsAction.Refresh) }, ) { - val onGetStarted: () -> Unit = { getStartedDialogShow = true } + val onGetStarted: () -> Unit = { onAction(RewardsAction.ShowSheet(RewardsSheetType.CreateCode)) } + val onShare: () -> Unit = { onAction(RewardsAction.InviteFriends) } + val onRedeem: (GemRewardsRedemption) -> Unit = { onAction(RewardsAction.Redeem(it)) } LazyColumn(modifier = Modifier.fillMaxSize()) { when (state) { GemLoadState.NoData -> { @@ -187,7 +158,7 @@ fun RewardsScene( intro = introItems, action = inviteAction, onGetStarted = onGetStarted, - onShare = onInvite, + onShare = onShare, ) if (canUseReferralCode) { @@ -200,7 +171,7 @@ fun RewardsScene( containerColor = Color.White, contentColor = Color.Black, ), - ) { codeDialogShow = true } + ) { onAction(RewardsAction.ShowSheet(RewardsSheetType.ActivateCode(""))) } } Spacer8() Text( @@ -223,7 +194,7 @@ fun RewardsScene( title = stringResource(R.string.transfer_confirm), state = buttonState(enabled = pending.isEnabled), ) { - context.requestAuth(AuthRequest.Default) { onCode(pending.code, onCodeResult) } + context.requestAuth(AuthRequest.Default) { onAction(RewardsAction.UseCode(pending.code)) } } } } @@ -234,19 +205,26 @@ fun RewardsScene( } } - CreateRewardsCodeDialog(isVisible = getStartedDialogShow, onUsername = onUsername) { - getStartedDialogShow = false - } + RewardsCodeDialog( + isVisible = sheet == RewardsSheetType.CreateCode, + title = stringResource(R.string.rewards_create_referral_code_title), + label = stringResource(R.string.rewards_username), + text = "", + info = stringResource(R.string.rewards_create_referral_code_info), + isLoading = isLoading, + onDone = { onAction(RewardsAction.CreateReferral(it)) }, + onDismiss = { onAction(RewardsAction.DismissSheet) }, + ) - RedeemRewardsCodeDialog( - isVisible = codeDialogShow || codeRetryShow || incomingCode is GemIncomingCode.Confirm, - referralCode = referralCode, - onCode = onCode, - ) { - codeDialogShow = false - codeRetryShow = false - onCodeHandled() - } + RewardsCodeDialog( + isVisible = sheet is RewardsSheetType.ActivateCode, + title = stringResource(R.string.rewards_referral_code), + label = stringResource(R.string.rewards_referral_code), + text = (sheet as? RewardsSheetType.ActivateCode)?.code.orEmpty(), + isLoading = isLoading, + onDone = { onAction(RewardsAction.UseCode(it)) }, + onDismiss = { onAction(RewardsAction.DismissSheet) }, + ) } @Preview @@ -265,15 +243,9 @@ private fun RewardsScenePreview() { inviteDescription = "Invite friends and earn 100 points", sections = emptyList(), redemptions = emptyList(), - onUsername = { _, _ -> }, - onCode = { _, _ -> }, - onCodeHandled = {}, - onRefresh = {}, - onWallet = {}, - onRedeem = {}, - onClose = {}, - onInvite = {}, - onError = {}, + sheet = null, + isLoading = false, + onAction = {}, ) } } @@ -294,15 +266,9 @@ private fun RewardsSceneNoRewardsPreview() { inviteDescription = "Invite friends and earn 100 points", sections = emptyList(), redemptions = emptyList(), - onUsername = { _, _ -> }, - onCode = { _, _ -> }, - onCodeHandled = {}, - onRefresh = {}, - onWallet = {}, - onRedeem = {}, - onClose = {}, - onInvite = {}, - onError = {}, + sheet = null, + isLoading = false, + onAction = {}, ) } } diff --git a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsScreen.kt b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsScreen.kt index 287cf22cf2..2016a470ee 100644 --- a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsScreen.kt +++ b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/RewardsScreen.kt @@ -13,13 +13,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.gemwallet.android.ext.errorText import com.gemwallet.android.features.rewards.viewmodels.RewardsViewModel import com.gemwallet.android.model.AuthRequest import com.gemwallet.android.ui.R @@ -27,26 +25,23 @@ import com.gemwallet.android.ui.components.InfoBottomSheet import com.gemwallet.android.ui.components.infoSheet import com.gemwallet.android.ui.components.list_item.walletSections import com.gemwallet.android.ui.components.screen.ModalBottomSheet -import com.gemwallet.android.ui.components.screen.showSnackbar -import com.gemwallet.android.ui.localization.text +import com.gemwallet.android.ui.components.screen.ToastEffect import com.gemwallet.android.ui.requestAuth import com.gemwallet.android.ui.shareText -import kotlinx.coroutines.launch @Composable fun RewardsScreen(onClose: () -> Unit, viewModel: RewardsViewModel = hiltViewModel()) { val infoSheet by viewModel.infoSheet.collectAsStateWithLifecycle() InfoBottomSheet(item = infoSheet?.infoSheet(), onClose = { viewModel.infoSheet.value = null }) val snackbar = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() + ToastEffect(viewModel.toastEvents, snackbar) val context = LocalContext.current - val insufficientPointsMessage = stringResource(R.string.rewards_insufficient_points) - val doneMessage = stringResource(R.string.common_done) var isShowSelectWallets by remember { mutableStateOf(false) } - var showErrorDialog by remember { mutableStateOf(null) } - var showMessageDialog by remember { mutableStateOf(null) } + val error by viewModel.error.collectAsStateWithLifecycle() + val sheet by viewModel.sheet.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val wallet by viewModel.wallet.collectAsStateWithLifecycle() val referralLink by viewModel.referralLink.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle() @@ -77,34 +72,36 @@ fun RewardsScreen(onClose: () -> Unit, viewModel: RewardsViewModel = hiltViewMod inviteDescription = inviteDescription, sections = sections, redemptions = redemptions, - onInvite = { - viewModel.inviteFriends( - onInvite = { context.shareText(subject = referralLink.orEmpty(), text = shareText.orEmpty(), chooserTitle = shareTitle) }, - ) - }, - onUsername = viewModel::createReferral, - onCode = viewModel::useCode, - onCodeHandled = viewModel::onCodeHandled, - onRefresh = viewModel::sync, - onWallet = { isShowSelectWallets = true }, - onRedeem = { - if (!it.canRedeem) { - showMessageDialog = insufficientPointsMessage - return@RewardsScene - } - context.requestAuth(AuthRequest.Default) { - viewModel.redeem(it) { error -> - if (error == null) { - scope.launch { snackbar.showSnackbar(doneMessage, R.drawable.ic_check_circle) } - } else { - showErrorDialog = error - } + sheet = sheet, + isLoading = isLoading, + snackbar = snackbar, + onAction = { action -> + when (action) { + RewardsAction.Refresh -> viewModel.sync() + + RewardsAction.Close -> onClose() + + RewardsAction.SelectWallet -> isShowSelectWallets = true + + RewardsAction.InviteFriends -> viewModel.inviteFriends { + context.shareText(subject = referralLink.orEmpty(), text = shareText.orEmpty(), chooserTitle = shareTitle) + } + + RewardsAction.CodeHandled -> viewModel.onCodeHandled() + + RewardsAction.DismissSheet -> viewModel.dismissSheet() + + is RewardsAction.ShowSheet -> viewModel.showSheet(action.sheet) + + is RewardsAction.CreateReferral -> viewModel.createReferral(action.username) + + is RewardsAction.UseCode -> viewModel.useCode(action.code) + + is RewardsAction.Redeem -> viewModel.onSelectRedemption(action.redemption) { + context.requestAuth(AuthRequest.Default) { viewModel.redeem(action.redemption.id) } } } }, - onClose = onClose, - onError = { showErrorDialog = it }, - snackbar = snackbar, ) ModalBottomSheet( @@ -121,25 +118,13 @@ fun RewardsScreen(onClose: () -> Unit, viewModel: RewardsViewModel = hiltViewMod } } - if (showMessageDialog != null) { - AlertDialog( - containerColor = MaterialTheme.colorScheme.background, - onDismissRequest = { showMessageDialog = null }, - text = { Text(showMessageDialog.orEmpty()) }, - confirmButton = { - Button({ showMessageDialog = null }) { Text(stringResource(R.string.common_cancel)) } - }, - ) - } - - if (showErrorDialog != null) { - val message = showErrorDialog?.errorText()?.text(LocalContext.current) ?: stringResource(R.string.transaction_status_failed) + error?.let { message -> AlertDialog( containerColor = MaterialTheme.colorScheme.background, - onDismissRequest = { showErrorDialog = null }, + onDismissRequest = viewModel::clearError, text = { Text(message) }, confirmButton = { - Button({ showErrorDialog = null }) { Text(stringResource(R.string.common_cancel)) } + Button(viewModel::clearError) { Text(stringResource(R.string.common_cancel)) } }, ) } diff --git a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/components/RewardsHead.kt b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/components/RewardsHead.kt index b8ec10536f..123aa33764 100644 --- a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/components/RewardsHead.kt +++ b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/components/RewardsHead.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.sp import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.buttons.MainActionButton import com.gemwallet.android.ui.components.list_item.listItem @@ -40,7 +39,7 @@ internal fun LazyListScope.rewardsHead(description: String, intro: List MainActionButton( onClick = onShare, ) { - Icon(AppIcons.Share, contentDescription = "share") + Icon(AppIcons.Share, contentDescription = null) Spacer8() Text(stringResource(R.string.rewards_invite_friends_title)) } diff --git a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/CreateRewardsCodeDialog.kt b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/CreateRewardsCodeDialog.kt deleted file mode 100644 index 227be0520f..0000000000 --- a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/CreateRewardsCodeDialog.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.gemwallet.android.features.rewards.presents.dialogs - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import com.gemwallet.android.ext.errorText -import com.gemwallet.android.model.AuthRequest -import com.gemwallet.android.ui.R -import com.gemwallet.android.ui.components.GemTextField -import com.gemwallet.android.ui.components.filters.FormDialog -import com.gemwallet.android.ui.components.progress.CircularProgressIndicator16 -import com.gemwallet.android.ui.localization.text -import com.gemwallet.android.ui.requestAuth -import com.gemwallet.android.ui.theme.Spacer16 -import com.gemwallet.android.ui.theme.Spacer8 -import com.gemwallet.android.ui.theme.paddingDefault - -@Composable -internal fun CreateRewardsCodeDialog(isVisible: Boolean, onUsername: (String, (Throwable?) -> Unit) -> Unit, onDismiss: () -> Unit) { - var username by remember { mutableStateOf("") } - var showError by remember { mutableStateOf(null) } - var showProgress by remember { mutableStateOf(false) } - val context = LocalContext.current - - val focusRequester = remember { FocusRequester() } - - val dismissDialog: () -> Unit = { - onDismiss() - username = "" - } - val doneAction: () -> Unit = done@{ - if (username.isEmpty() || showProgress) return@done - showProgress = true - context.requestAuth(AuthRequest.Default, onCancel = { showProgress = false }) { - onUsername(username) { - showProgress = false - if (it == null) { - dismissDialog() - } else { - showError = it - } - } - } - } - val done: @Composable () -> Unit = { - TextButton( - onClick = doneAction, - enabled = username.isNotEmpty() && !showProgress, - ) { - if (showProgress) { - CircularProgressIndicator16() - } else { - Text(stringResource(R.string.common_done)) - } - } - } - - FormDialog( - isVisible = isVisible, - title = stringResource(R.string.rewards_create_referral_code_title), - onDismiss = dismissDialog, - doneAction = done, - ) { - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - GemTextField( - modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), - label = stringResource(id = R.string.rewards_username), - value = username, - onValueChange = { - username = it - }, - singleLine = true, - keyboardActions = KeyboardActions(onDone = { doneAction() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - ) - Spacer8() - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = paddingDefault), - text = stringResource(R.string.rewards_create_referral_code_info), - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Companion.Center, - ) - Spacer16() - } - - if (showError != null) { - AlertDialog( - onDismissRequest = { showError = null }, - containerColor = MaterialTheme.colorScheme.background, - confirmButton = { - Button({ showError = null }) { - Text(stringResource(R.string.common_cancel)) - } - }, - text = { - Text(showError?.errorText()?.text(LocalContext.current) ?: return@AlertDialog) - }, - ) - } -} diff --git a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/RedeemRewardsCodeDialog.kt b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/RewardsCodeDialog.kt similarity index 52% rename from android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/RedeemRewardsCodeDialog.kt rename to android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/RewardsCodeDialog.kt index ac52e29bf0..6ff916b079 100644 --- a/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/RedeemRewardsCodeDialog.kt +++ b/android/features/rewards/presents/src/main/kotlin/com/gemwallet/android/features/rewards/presents/dialogs/RewardsCodeDialog.kt @@ -1,10 +1,9 @@ package com.gemwallet.android.features.rewards.presents.dialogs import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -20,91 +19,73 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import com.gemwallet.android.ext.errorText +import androidx.compose.ui.text.style.TextAlign import com.gemwallet.android.model.AuthRequest import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.GemTextField import com.gemwallet.android.ui.components.filters.FormDialog import com.gemwallet.android.ui.components.progress.CircularProgressIndicator16 -import com.gemwallet.android.ui.localization.text import com.gemwallet.android.ui.requestAuth import com.gemwallet.android.ui.theme.Spacer16 +import com.gemwallet.android.ui.theme.Spacer8 +import com.gemwallet.android.ui.theme.paddingDefault @Composable -fun RedeemRewardsCodeDialog(isVisible: Boolean, referralCode: String?, onCode: (String, (Throwable?) -> Unit) -> Unit, onDismiss: () -> Unit) { - var code by remember(referralCode) { mutableStateOf(referralCode ?: "") } - var showError by remember { mutableStateOf(null) } - var showProgress by remember { mutableStateOf(false) } +internal fun RewardsCodeDialog(isVisible: Boolean, title: String, label: String, text: String, isLoading: Boolean, onDone: (String) -> Unit, onDismiss: () -> Unit, info: String? = null) { + var input by remember(isVisible, text) { mutableStateOf(text) } val context = LocalContext.current val focusRequester = remember { FocusRequester() } - val dismissDialog: () -> Unit = { - onDismiss() - code = "" - } val doneAction: () -> Unit = done@{ - if (code.isEmpty() || showProgress) return@done - showProgress = true - context.requestAuth(AuthRequest.Default, onCancel = { showProgress = false }) { - onCode(code) { - showProgress = false - if (it == null) { - dismissDialog() - } else { - showError = it - } - } - } + if (input.isEmpty() || isLoading) return@done + context.requestAuth(AuthRequest.Default) { onDone(input) } } val done: @Composable () -> Unit = { TextButton( onClick = doneAction, - enabled = code.isNotEmpty() && !showProgress, + enabled = input.isNotEmpty() && !isLoading, ) { - if (showProgress) { + if (isLoading) { CircularProgressIndicator16() } else { Text(stringResource(R.string.common_done)) } } } + FormDialog( isVisible = isVisible, - title = stringResource(R.string.rewards_referral_code), - onDismiss = dismissDialog, + title = title, + onDismiss = onDismiss, doneAction = done, ) { LaunchedEffect(Unit) { focusRequester.requestFocus() } GemTextField( - modifier = Modifier - .focusRequester(focusRequester) - .fillMaxWidth(), - label = stringResource(id = R.string.rewards_referral_code), - value = code, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + label = label, + value = input, onValueChange = { - code = it + input = it }, + singleLine = true, keyboardActions = KeyboardActions(onDone = { doneAction() }), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - singleLine = true, ) + info?.let { + Spacer8() + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = paddingDefault), + text = it, + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } Spacer16() } - if (showError != null) { - AlertDialog( - onDismissRequest = { showError = null }, - containerColor = MaterialTheme.colorScheme.background, - confirmButton = { - Button({ showError = null }) { - Text(stringResource(android.R.string.ok)) - } - }, - text = { - Text(showError?.errorText()?.text(LocalContext.current) ?: return@AlertDialog) - }, - ) - } } diff --git a/android/features/rewards/viewmodels/src/main/kotlin/com/gemwallet/android/features/rewards/viewmodels/RewardsViewModel.kt b/android/features/rewards/viewmodels/src/main/kotlin/com/gemwallet/android/features/rewards/viewmodels/RewardsViewModel.kt index 0019fd49d8..4c91a61fd7 100644 --- a/android/features/rewards/viewmodels/src/main/kotlin/com/gemwallet/android/features/rewards/viewmodels/RewardsViewModel.kt +++ b/android/features/rewards/viewmodels/src/main/kotlin/com/gemwallet/android/features/rewards/viewmodels/RewardsViewModel.kt @@ -7,19 +7,26 @@ import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.IoDispatcher import com.gemwallet.android.application.session.cases.GetSession import com.gemwallet.android.data.services.store.queries.WalletsQuery +import com.gemwallet.android.ext.errorText import com.gemwallet.android.ext.runCatchingCancellable import com.gemwallet.android.ext.toGem import com.gemwallet.android.features.rewards.viewmodels.models.RewardsSectionUIModel +import com.gemwallet.android.features.rewards.viewmodels.models.RewardsSheetType import com.gemwallet.android.features.rewards.viewmodels.models.sectionModels +import com.gemwallet.android.ui.R import com.gemwallet.android.ui.localization.string +import com.gemwallet.android.ui.localization.text +import com.gemwallet.android.ui.models.ToastEmitter +import com.gemwallet.android.ui.models.ToastEmitterImpl +import com.gemwallet.android.ui.models.ToastMessage import com.gemwallet.android.ui.models.navigation.RouteArgument import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -50,10 +57,17 @@ class RewardsViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, @param:ApplicationContext private val context: Context, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, -) : ViewModel() { +) : ViewModel(), + ToastEmitter by ToastEmitterImpl() { val infoSheet = MutableStateFlow(null) + private val errorState = MutableStateFlow(null) + val error: StateFlow = errorState.asStateFlow() + + private val loadingState = MutableStateFlow(false) + val isLoading: StateFlow = loadingState.asStateFlow() + fun inviteFriends(onInvite: () -> Unit) { if (!service.isAvailable()) { infoSheet.value = GemInfoTopic.RegionUnavailable @@ -79,6 +93,12 @@ class RewardsViewModel @Inject constructor( val incomingCode: StateFlow = viewState.map { it.incomingCode } .stateIn(viewModelScope, SharingStarted.Eagerly, null) + private val sheetState = MutableStateFlow(null) + + val sheet: StateFlow = combine(sheetState, incomingCode) { sheet, code -> + sheet ?: (code as? GemIncomingCode.Confirm)?.let { RewardsSheetType.ActivateCode(it.code) } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + private val rewardsState = viewState.map { it.rewards } .stateIn(viewModelScope, SharingStarted.Eagerly, viewState.value.rewards) @@ -106,10 +126,10 @@ class RewardsViewModel @Inject constructor( val sections: StateFlow> = rewardsState.map { it.sectionModels(context) } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - val redemptions: StateFlow> = rewardsState.map { state -> state.redemptions } + val redemptions: StateFlow> = rewardsState.map { it.redemptions } .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - val referralLink = rewardsState.map { it.referralLink } + val referralLink: StateFlow = rewardsState.map { it.referralLink } .stateIn(viewModelScope, SharingStarted.Eagerly, null) init { @@ -136,39 +156,82 @@ class RewardsViewModel @Inject constructor( session.update { it.onResult(result) } } - fun createReferral(username: String, callback: (Throwable?) -> Unit) { + fun showSheet(sheet: RewardsSheetType) { + sheetState.value = sheet + } + + fun dismissSheet() { + sheetState.value = null + onCodeHandled() + } + + fun createReferral(username: String) { + if (loadingState.value) return val walletId = session.value.wallet?.id ?: return + loadingState.value = true viewModelScope.launch(ioDispatcher) { - runCatchingCancellable { service.createReferral(walletId, username) } - .onSuccess { rewards -> session.update { it.onRewards(walletId, rewards) } } - .report(callback) + try { + runCatchingCancellable { service.createReferral(walletId, username) } + .onSuccess { rewards -> + session.update { it.onRewards(walletId, rewards) } + dismissSheet() + } + .onFailure(::showError) + } finally { + loadingState.value = false + } } } - fun useCode(code: String, callback: (Throwable?) -> Unit) { + fun useCode(code: String) { + if (loadingState.value) return val walletId = session.value.wallet?.id ?: return + loadingState.value = true viewModelScope.launch(ioDispatcher) { - runCatchingCancellable { service.useReferralCode(walletId, code) } - .onSuccess { rewards -> session.update { it.onRewards(walletId, rewards) } } - .report(callback) + try { + runCatchingCancellable { service.useReferralCode(walletId, code) } + .onSuccess { rewards -> + session.update { it.onRewards(walletId, rewards) } + dismissSheet() + showDoneToast() + } + .onFailure(::showError) + } finally { + loadingState.value = false + } } } - fun redeem(redemption: GemRewardsRedemption, callback: (Throwable?) -> Unit) { + fun onSelectRedemption(redemption: GemRewardsRedemption, onRedeem: () -> Unit) { + if (!redemption.canRedeem) { + errorState.value = context.getString(R.string.rewards_insufficient_points) + return + } + onRedeem() + } + + fun redeem(redemptionId: String) { val walletId = session.value.wallet?.id ?: return viewModelScope.launch(ioDispatcher) { - runCatchingCancellable { service.redeem(walletId, redemption.id) } - .onSuccess { sync() } - .report(callback) + runCatchingCancellable { service.redeem(walletId, redemptionId) } + .onSuccess { + showDoneToast() + sync() + } + .onFailure(::showError) } } - private suspend fun Result.report(callback: (Throwable?) -> Unit) = withContext(Dispatchers.Main) { - callback(exceptionOrNull()) - } + fun clearError() = errorState.update { null } fun onCodeHandled() { session.update { it.onCodeHandled() } savedStateHandle[RouteArgument.Code.key] = null } + + private fun showError(error: Throwable) { + errorState.value = error.errorText().text(context) + } + + private fun showDoneToast() = emitToast(ToastMessage(context.getString(R.string.common_done), R.drawable.ic_check_circle)) } diff --git a/android/features/rewards/viewmodels/src/main/kotlin/com/gemwallet/android/features/rewards/viewmodels/models/RewardsSheetType.kt b/android/features/rewards/viewmodels/src/main/kotlin/com/gemwallet/android/features/rewards/viewmodels/models/RewardsSheetType.kt new file mode 100644 index 0000000000..dabcefe201 --- /dev/null +++ b/android/features/rewards/viewmodels/src/main/kotlin/com/gemwallet/android/features/rewards/viewmodels/models/RewardsSheetType.kt @@ -0,0 +1,6 @@ +package com.gemwallet.android.features.rewards.viewmodels.models + +sealed interface RewardsSheetType { + data object CreateCode : RewardsSheetType + data class ActivateCode(val code: String) : RewardsSheetType +} diff --git a/android/features/rewards/viewmodels/src/test/kotlin/com/gemwallet/android/features/rewards/viewmodels/RewardsViewModelTest.kt b/android/features/rewards/viewmodels/src/test/kotlin/com/gemwallet/android/features/rewards/viewmodels/RewardsViewModelTest.kt index 3e36977730..ef794a75cd 100644 --- a/android/features/rewards/viewmodels/src/test/kotlin/com/gemwallet/android/features/rewards/viewmodels/RewardsViewModelTest.kt +++ b/android/features/rewards/viewmodels/src/test/kotlin/com/gemwallet/android/features/rewards/viewmodels/RewardsViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.session.cases.GetSession import com.gemwallet.android.data.services.store.queries.WalletsQuery +import com.gemwallet.android.features.rewards.viewmodels.models.RewardsSheetType import com.gemwallet.android.model.Session import com.gemwallet.android.testkit.MainDispatcherRule import com.gemwallet.android.testkit.mockAccount @@ -27,10 +28,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule @@ -79,7 +82,7 @@ class RewardsViewModelTest { runCurrent() assertNull(viewModel.pendingReferral.value) - viewModel.useCode("friend") {} + viewModel.useCode("friend") runCurrent() assertEquals("friend", viewModel.pendingReferral.value?.code) @@ -165,6 +168,25 @@ class RewardsViewModelTest { assertEquals("a refresh does not drop a code the user has not answered", GemIncomingCode.Confirm("friend"), viewModel.incomingCode.value) assertEquals("friend", savedStateHandle.get(RouteArgument.Code.key)) + assertEquals(RewardsSheetType.ActivateCode("friend"), viewModel.sheet.value) + } finally { + viewModel.viewModelScope.cancel() + } + } + + @Test + fun `closing the code sheet drops a link code`() = runTest(testDispatcher) { + wallets.value = listOf(wallet, secondWallet) + val savedStateHandle = SavedStateHandle(mapOf(RouteArgument.Code.key to "friend")) + val viewModel = createViewModel(savedStateHandle) + + try { + runCurrent() + viewModel.dismissSheet() + runCurrent() + + assertNull(viewModel.sheet.value) + assertNull(savedStateHandle.get(RouteArgument.Code.key)) } finally { viewModel.viewModelScope.cancel() } @@ -246,6 +268,76 @@ class RewardsViewModelTest { } } + @Test + fun `a used code closes the sheet and shows done`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + try { + runCurrent() + viewModel.showSheet(RewardsSheetType.ActivateCode("")) + viewModel.useCode("friend") + runCurrent() + + assertNull(viewModel.sheet.value) + assertEquals(R.drawable.ic_check_circle, viewModel.toastEvents.first().image) + assertNull(viewModel.error.value) + } finally { + viewModel.viewModelScope.cancel() + } + } + + @Test + fun `a second submit while a code is being used is ignored`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + try { + runCurrent() + viewModel.useCode("friend") + viewModel.useCode("friend") + runCurrent() + + coVerify(exactly = 1) { service.useReferralCode(wallet.id.id, "friend") } + } finally { + viewModel.viewModelScope.cancel() + } + } + + @Test + fun `a failed code keeps the sheet open and shows the error`() = runTest(testDispatcher) { + coEvery { service.useReferralCode(any(), any()) } throws GemServiceException.Api("code already used") + val viewModel = createViewModel() + + try { + runCurrent() + viewModel.showSheet(RewardsSheetType.ActivateCode("")) + viewModel.useCode("friend") + runCurrent() + + assertEquals(RewardsSheetType.ActivateCode(""), viewModel.sheet.value) + assertEquals("code already used", viewModel.error.value) + assertFalse(viewModel.isLoading.value) + } finally { + viewModel.viewModelScope.cancel() + } + } + + @Test + fun `a failed redemption shows the error`() = runTest(testDispatcher) { + coEvery { service.redeem(any(), any()) } throws GemServiceException.Api("out of stock") + val viewModel = createViewModel() + + try { + runCurrent() + viewModel.redeem("option-7") + runCurrent() + + assertEquals("out of stock", viewModel.error.value) + coVerify { service.redeem(wallet.id.id, "option-7") } + } finally { + viewModel.viewModelScope.cancel() + } + } + private fun rewards(code: String? = null, points: Int = 0, usedReferralCode: String? = null, verifyAfter: Long? = null) = mockRewards( code = code, inviteRewardPoints = 100,