fix: Pay-Money feature module

This commit is contained in:
Rajan Maurya 2024-05-27 08:55:33 -04:00 committed by Rajan Maurya
parent ba8bda44ca
commit 0b5889bc33
14 changed files with 592 additions and 34 deletions

View File

@ -1,24 +0,0 @@
package org.mifospay.feature.make.transfer
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.mifospay.feature.make.transfer", appContext.packageName)
}
}

View File

@ -44,7 +44,8 @@ import org.mifospay.core.designsystem.component.MifosOverlayLoadingWheel
@Composable
fun MakeTransferScreenRoute(
viewModel: MakeTransferViewModel = hiltViewModel()
viewModel: MakeTransferViewModel = hiltViewModel(),
onDismiss: () -> Unit
) {
val fetchPayeeClient by viewModel.fetchPayeeClient.collectAsStateWithLifecycle()
val makeTransferState by viewModel.makeTransferState.collectAsStateWithLifecycle()
@ -58,7 +59,8 @@ fun MakeTransferScreenRoute(
toClientId,
transferAmount
)
}
},
onDismiss = onDismiss
)
}
@ -67,6 +69,7 @@ fun MakeTransferScreen(
uiState: MakeTransferState,
showTransactionStatus: ShowTransactionStatus,
makeTransfer: (Long, Double) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
when (uiState) {
@ -97,7 +100,8 @@ fun MakeTransferScreen(
externalId,
transferAmount,
showTransactionStatus,
makeTransfer = makeTransfer
makeTransfer = makeTransfer,
onDismiss = onDismiss
)
}
}
@ -113,6 +117,7 @@ fun MakeTransferBottomSheetContent(
transferAmount: Double,
showTransactionStatus: ShowTransactionStatus,
makeTransfer: (Long, Double) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState()
@ -125,6 +130,7 @@ fun MakeTransferBottomSheetContent(
sheetState = sheetState,
onDismissRequest = {
showBottomSheet = false
onDismiss.invoke()
},
dragHandle = { BottomSheetDefaults.DragHandle() },
) {
@ -340,7 +346,8 @@ fun PreviewWithMakeTransferContentLoading() {
showSuccessStatus = false,
showErrorStatus = false
),
makeTransfer = { _, _ -> }
makeTransfer = { _, _ -> },
onDismiss = { }
)
}
@ -359,7 +366,8 @@ fun PreviewWithMakeTransferContentSuccess() {
showSuccessStatus = false,
showErrorStatus = false
),
makeTransfer = { _, _ -> }
makeTransfer = { _, _ -> },
onDismiss = { }
)
}
@ -391,7 +399,8 @@ fun PreviewMakeTransferBottomSheetContent() {
showSuccessStatus = false,
showErrorStatus = false
),
makeTransfer = { _, _ -> }
makeTransfer = { _, _ -> },
onDismiss = { }
)
}
@ -404,7 +413,8 @@ fun PreviewWithMakeTransferContentError() {
showSuccessStatus = false,
showErrorStatus = false
),
makeTransfer = { _, _ -> }
makeTransfer = { _, _ -> },
onDismiss = { }
)
}

View File

@ -30,7 +30,9 @@ fun NavController.navigateToMakeTransferScreen(
navigate(route, navOptions)
}
fun NavGraphBuilder.makeTransferScreen() {
fun NavGraphBuilder.makeTransferScreen(
onDismiss: () -> Unit
) {
composable(
route = MAKE_TRANSFER_ROUTE,
arguments = listOf(
@ -46,6 +48,8 @@ fun NavGraphBuilder.makeTransferScreen() {
}
)
) {
MakeTransferScreenRoute()
MakeTransferScreenRoute(
onDismiss = onDismiss
)
}
}

1
feature/send-money/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,19 @@
plugins {
alias(libs.plugins.mifospay.android.feature)
alias(libs.plugins.mifospay.android.library.compose)
}
android {
namespace = "org.mifospay.feature.send.money"
}
dependencies {
implementation(projects.core.data)
// we need it for country picker library
implementation("androidx.compose.material:material:1.6.0")
implementation(libs.compose.country.code.picker) // remove after moving auth code to module
// Google Bar code scanner
implementation(libs.google.play.services.code.scanner)
}

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -0,0 +1,97 @@
package org.mifospay.feature.send.money
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.mifospay.core.data.base.UseCase
import org.mifospay.core.data.base.UseCaseHandler
import org.mifospay.core.data.domain.usecase.account.FetchAccount
import org.mifospay.core.data.repository.local.LocalRepository
import javax.inject.Inject
@HiltViewModel
class SendPaymentViewModel @Inject constructor(
private val useCaseHandler: UseCaseHandler,
private val localRepository: LocalRepository,
private val fetchAccount: FetchAccount
) : ViewModel() {
private val _showProgress = MutableStateFlow(false)
val showProgress: StateFlow<Boolean> = _showProgress
private val _vpa = MutableStateFlow("")
val vpa: StateFlow<String> = _vpa
private val _mobile = MutableStateFlow("")
val mobile: StateFlow<String> = _mobile
init {
fetchVpa()
fetchMobile()
}
fun updateProgressState(isVisible: Boolean) {
_showProgress.update { isVisible }
}
private fun fetchVpa() {
viewModelScope.launch {
_vpa.value = localRepository.clientDetails.externalId.toString()
}
}
private fun fetchMobile() {
viewModelScope.launch {
_mobile.value = localRepository.preferencesHelper.mobile.toString()
}
}
fun checkSelfTransfer(
selfVpa: String?,
selfMobile: String?,
externalIdOrMobile: String?,
sendMethodType: SendMethodType,
): Boolean {
return when (sendMethodType) {
SendMethodType.VPA -> {
selfVpa.takeIf { !it.isNullOrEmpty() }?.let { it == externalIdOrMobile } ?: false
}
SendMethodType.MOBILE -> {
selfMobile.takeIf { !it.isNullOrEmpty() }?.let { it == externalIdOrMobile } ?: false
}
}
}
fun checkBalanceAvailabilityAndTransfer(
externalId: String?,
transferAmount: Double,
onAnyError: (Int) -> Unit,
proceedWithTransferFlow: (String, Double) -> Unit
) {
updateProgressState(true)
useCaseHandler.execute(fetchAccount,
FetchAccount.RequestValues(localRepository.clientDetails.clientId),
object : UseCase.UseCaseCallback<FetchAccount.ResponseValue> {
override fun onSuccess(response: FetchAccount.ResponseValue) {
updateProgressState(false)
if (transferAmount > response.account.balance) {
onAnyError(R.string.insufficient_balance)
} else {
if (externalId != null) {
proceedWithTransferFlow(externalId, transferAmount)
}
}
}
override fun onError(message: String) {
updateProgressState(false)
onAnyError.invoke(R.string.error_fetching_balance)
}
})
}
}

View File

@ -0,0 +1,388 @@
package org.mifospay.feature.send.money
import android.content.Context
import android.net.Uri
import android.provider.ContactsContract
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.togitech.ccp.component.TogiCountryCodePicker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.mifospay.core.designsystem.component.MfOutlinedTextField
import org.mifospay.core.designsystem.component.MfOverlayLoadingWheel
import org.mifospay.core.designsystem.component.MifosButton
import org.mifospay.core.designsystem.component.MifosNavigationTopAppBar
import org.mifospay.core.designsystem.theme.styleMedium16sp
import org.mifospay.core.designsystem.theme.styleNormal18sp
enum class SendMethodType {
VPA, MOBILE
}
@Composable
fun SendScreenRoute(
viewModel: SendPaymentViewModel = hiltViewModel(),
showToolBar: Boolean,
onBackClick: () -> Unit,
proceedWithMakeTransferFlow: (String, String) -> Unit
) {
val context = LocalContext.current
val selfVpa by viewModel.vpa.collectAsStateWithLifecycle()
val selfMobile by viewModel.mobile.collectAsStateWithLifecycle()
val showProgress by viewModel.showProgress.collectAsStateWithLifecycle()
fun showToast(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
SendMoneyScreen(
showToolBar = showToolBar,
onBackClick = onBackClick,
showProgress = showProgress,
onSubmit = { amount, externalIdOrMobile, sendMethodType ->
if (!viewModel.checkSelfTransfer(
selfVpa = selfVpa,
selfMobile = selfMobile,
sendMethodType = sendMethodType,
externalIdOrMobile = externalIdOrMobile
)
) {
viewModel.checkBalanceAvailabilityAndTransfer(
externalId = selfVpa,
transferAmount = amount.toDouble(),
onAnyError = {
showToast(context.getString(it))
},
proceedWithTransferFlow = { externalId, transferAmount ->
proceedWithMakeTransferFlow.invoke(externalIdOrMobile, transferAmount.toString())
}
)
} else {
showToast(context.getString(R.string.self_amount_transfer_is_not_allowed))
}
}
)
}
@Composable
fun SendMoneyScreen(
showToolBar: Boolean,
showProgress: Boolean,
onSubmit: (String, String, SendMethodType) -> Unit,
onBackClick: () -> Unit,
) {
val context = LocalContext.current
var amount by rememberSaveable { mutableStateOf("") }
var vpa by rememberSaveable { mutableStateOf("") }
var mobileNumber by rememberSaveable { mutableStateOf("") }
var isValidMobileNumber by rememberSaveable { mutableStateOf(false) }
var sendMethodType by rememberSaveable { mutableStateOf(SendMethodType.VPA) }
var isValidInfo by rememberSaveable { mutableStateOf(false) }
var contactUri by rememberSaveable { mutableStateOf<Uri?>(null) }
fun validateInfo() {
isValidInfo = when (sendMethodType) {
SendMethodType.VPA -> amount.isNotEmpty() && vpa.isNotEmpty()
SendMethodType.MOBILE -> {
isValidMobileNumber && mobileNumber.isNotEmpty() && amount.isNotEmpty()
}
}
}
val contactLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickContact()
) { uri: Uri? ->
uri?.let { contactUri = uri }
}
LaunchedEffect(key1 = contactUri) {
contactUri?.let {
mobileNumber = getContactPhoneNumber(it, context)
}
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted: Boolean ->
if (isGranted) {
contactLauncher.launch(null)
} else {
// Handle permission denial
}
}
)
val options = GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(
Barcode.FORMAT_QR_CODE,
Barcode.FORMAT_AZTEC
)
.build()
val scanner = GmsBarcodeScanning.getClient(context, options)
fun startScan() {
scanner.startScan()
.addOnSuccessListener { barcode ->
barcode.rawValue?.let {
vpa = it
}
}
.addOnCanceledListener {
// Task canceled
}
.addOnFailureListener { e ->
// Task failed with an exception
e.localizedMessage?.let { Log.d("SendMoney: Barcode scan failed", it) }
}
}
Box {
Column(Modifier.fillMaxSize()) {
if (showToolBar) {
MifosNavigationTopAppBar(
titleRes = R.string.send,
onNavigationClick = onBackClick
)
}
Text(
modifier = Modifier.padding(start = 20.dp, top = 20.dp),
text = stringResource(id = R.string.select_transfer_method),
style = styleNormal18sp
)
Column(modifier = Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp, bottom = 20.dp)
) {
VpaMobileChip(
selected = sendMethodType == SendMethodType.VPA,
onClick = { sendMethodType = SendMethodType.VPA },
label = stringResource(id = R.string.vpa)
)
Spacer(modifier = Modifier.width(8.dp))
VpaMobileChip(
selected = sendMethodType == SendMethodType.MOBILE,
onClick = { sendMethodType = SendMethodType.MOBILE },
label = stringResource(id = R.string.mobile)
)
}
MfOutlinedTextField(
value = amount,
onValueChange = {
amount = it
validateInfo()
},
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
label = stringResource(id = R.string.amount),
modifier = Modifier.fillMaxWidth()
)
when (sendMethodType) {
SendMethodType.VPA -> {
MfOutlinedTextField(
value = vpa,
onValueChange = {
vpa = it
validateInfo()
},
label = stringResource(id = R.string.virtual_payment_address),
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
IconButton(onClick = {
startScan()
}) {
Icon(
imageVector = Icons.Filled.QrCode2,
contentDescription = "Scan QR",
tint = Color.Blue
)
}
}
)
}
SendMethodType.MOBILE -> {
EnterPhoneScreen(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
initialPhoneNumber = mobileNumber,
onNumberUpdated = { _, fullPhone, valid ->
if (valid) {
mobileNumber = fullPhone
}
isValidMobileNumber = valid
validateInfo()
}
)
}
}
Spacer(modifier = Modifier.height(16.dp))
MifosButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.align(Alignment.CenterHorizontally),
color = Color.Black,
enabled = isValidInfo,
onClick = {
if (!isValidInfo) return@MifosButton
onSubmit(
amount,
when (sendMethodType) {
SendMethodType.VPA -> vpa
SendMethodType.MOBILE -> mobileNumber
},
sendMethodType
)
//TODO: Navigate to MakeTransferScreenRoute
},
contentPadding = PaddingValues(12.dp)
) {
Text(
stringResource(id = R.string.submit),
style = styleMedium16sp.copy(color = Color.White)
)
}
}
}
if (showProgress) {
MfOverlayLoadingWheel(
contentDesc = stringResource(id = R.string.please_wait)
)
}
}
}
@Composable
fun EnterPhoneScreen(
modifier: Modifier,
initialPhoneNumber: String? = null,
onNumberUpdated: (String, String, Boolean) -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
TogiCountryCodePicker(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colorScheme.primary
),
initialPhoneNumber = initialPhoneNumber,
onValueChange = { (code, phone), isValid ->
onNumberUpdated(phone, code + phone, isValid)
},
label = { Text(stringResource(id = R.string.phone_number)) },
keyboardActions = KeyboardActions { keyboardController?.hide() }
)
}
@Composable
fun VpaMobileChip(selected: Boolean, onClick: () -> Unit, label: String) {
MifosButton(
onClick = onClick,
color = if (selected) Color.Black else Color.LightGray,
modifier = Modifier
.padding(4.dp)
.wrapContentSize()
) {
Text(
modifier = Modifier.padding(top = 4.dp, bottom = 4.dp),
text = label,
)
}
}
suspend fun getContactPhoneNumber(uri: Uri, context: Context): String {
val contactId: String = uri.lastPathSegment ?: return ""
return withContext(Dispatchers.IO) {
val phoneCursor = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
arrayOf(contactId),
null
)
phoneCursor?.use { cursor ->
if (cursor.moveToFirst()) {
val phoneNumberIndex =
cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
cursor.getString(phoneNumberIndex)
} else {
""
}
} ?: ""
}
}
@Preview(showSystemUi = true, showBackground = true)
@Composable
fun SendMoneyScreenWithToolBarPreview() {
SendMoneyScreen(
onSubmit = { _, _, _ -> },
onBackClick = {},
showProgress = false,
showToolBar = true
)
}
@Preview(showSystemUi = true, showBackground = true)
@Composable
fun SendMoneyScreenWithoutToolBarPreview() {
SendMoneyScreen(
onSubmit = { _, _, _ -> },
onBackClick = {},
showProgress = false,
showToolBar = false
)
}

View File

@ -0,0 +1,26 @@
package org.mifospay.feature.send.money.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import org.mifospay.feature.send.money.SendScreenRoute
const val SEND_MONEY_ROUTE = "send_money_route"
fun NavController.navigateToSendMoneyScreen(
navOptions: NavOptions? = null
) = navigate(SEND_MONEY_ROUTE, navOptions)
fun NavGraphBuilder.sendMoneyScreen(
proceedWithMakeTransferFlow: (String, String?) -> Unit,
onBackClick: () -> Unit
) {
composable(route = SEND_MONEY_ROUTE) {
SendScreenRoute(
showToolBar = true,
onBackClick = onBackClick,
proceedWithMakeTransferFlow = proceedWithMakeTransferFlow
)
}
}

View File

@ -0,0 +1,14 @@
<resources>
<string name="insufficient_balance">Insufficient balance</string>
<string name="error_fetching_balance">Error fetching balance</string>
<string name="self_amount_transfer_is_not_allowed">Self Account transfer is not allowed</string>
<string name="send">Send</string>
<string name="select_transfer_method">Select transfer method</string>
<string name="vpa">VPA</string>
<string name="mobile">Mobile number</string>
<string name="amount">Amount</string>
<string name="virtual_payment_address">Virtual Payment Address</string>
<string name="submit">Submit</string>
<string name="please_wait">Please wait…</string>
<string name="phone_number">Phone Number</string>
</resources>

View File

@ -0,0 +1,17 @@
package org.mifospay.feature.send.money
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@ -28,6 +28,7 @@ import javax.inject.Inject
/**
* Created by naman on 30/8/17.
* Moved to the feature/make-transfer
*/
@AndroidEntryPoint
class MakeTransferFragment : BottomSheetDialogFragment(), TransferContract.TransferView {

View File

@ -74,7 +74,9 @@ fun MifosNavHost(
navController.navigateToMakeTransferScreen(externalId, transferAmount)
}
)
makeTransferScreen()
makeTransferScreen(
onDismiss = navController::popBackStack
)
}
}

View File

@ -36,3 +36,4 @@ include(":core:analytics")
include(":feature:passcode")
include(":feature:auth")
include(":feature:make-transfer")
include(":feature:send-money")