diff --git a/feature/accounts/.gitignore b/feature/accounts/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/accounts/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/accounts/build.gradle.kts b/feature/accounts/build.gradle.kts new file mode 100644 index 000000000..c01dc3616 --- /dev/null +++ b/feature/accounts/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.mifospay.android.feature) + alias(libs.plugins.mifospay.android.library.compose) +} + +android { + namespace = "org.mifospay.feature.bank.accounts" +} + +dependencies { + implementation(projects.core.data) + implementation(libs.compose.material) + implementation(libs.androidx.appcompat) + + //Remove the following implementations after completing migration + implementation(projects.mifospay) + implementation("com.mifos.mobile:mifos-passcode:0.3.0@aar") +} \ No newline at end of file diff --git a/feature/accounts/consumer-rules.pro b/feature/accounts/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/feature/accounts/proguard-rules.pro b/feature/accounts/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/feature/accounts/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/accounts/src/androidTest/java/org/mifospay/feature/bank/accounts/ExampleInstrumentedTest.kt b/feature/accounts/src/androidTest/java/org/mifospay/feature/bank/accounts/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..ce5ba16b6 --- /dev/null +++ b/feature/accounts/src/androidTest/java/org/mifospay/feature/bank/accounts/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package org.mifospay.feature.bank.accounts + +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.bank.accounts.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/accounts/src/main/AndroidManifest.xml b/feature/accounts/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a5918e68a --- /dev/null +++ b/feature/accounts/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountViewModel.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountViewModel.kt new file mode 100644 index 000000000..ba981a2eb --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountViewModel.kt @@ -0,0 +1,114 @@ +package org.mifospay.feature.bank.accounts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mifospay.core.model.domain.BankAccountDetails +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Random +import javax.inject.Inject + +@HiltViewModel +class AccountViewModel @Inject constructor() : ViewModel() { + + private val _bankAccountDetailsList = MutableStateFlow>(emptyList()) + val bankAccountDetailsList: StateFlow> = _bankAccountDetailsList + + private val _accountsUiState = MutableStateFlow(AccountsUiState.Loading) + val accountsUiState: StateFlow = _accountsUiState + + init { + fetchLinkedAccount() + } + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow get() = _isRefreshing.asStateFlow() + + fun refresh() { + viewModelScope.launch { + _isRefreshing.emit(true) + fetchLinkedAccount() + _isRefreshing.emit(false) + } + } + + private val mRandom = Random() + + private fun fetchLinkedAccount() { + viewModelScope.launch { + _accountsUiState.value = AccountsUiState.Loading + delay(2000) + val linkedAccounts = fetchSampleLinkedAccounts() + _bankAccountDetailsList.value = linkedAccounts + _accountsUiState.value = if (linkedAccounts.isEmpty()) { + AccountsUiState.Empty + } else { + AccountsUiState.LinkedAccounts(linkedAccounts) + } + } + } + + private fun fetchSampleLinkedAccounts(): List { + return listOf( + BankAccountDetails( + "SBI", "Ankur Sharma", "New Delhi", + mRandom.nextInt().toString() + " ", "Savings" + ), + BankAccountDetails( + "HDFC", "Mandeep Singh", "Uttar Pradesh", + mRandom.nextInt().toString() + " ", "Savings" + ), + BankAccountDetails( + "ANDHRA", "Rakesh anna", "Telegana", + mRandom.nextInt().toString() + " ", "Savings" + ), + BankAccountDetails( + "PNB", "luv Pro", "Gujrat", + mRandom.nextInt().toString() + " ", "Savings" + ), + BankAccountDetails( + "HDF", "Harry potter", "Hogwarts", + mRandom.nextInt().toString() + " ", "Savings" + ), + BankAccountDetails( + "GCI", "JIGME", "JAMMU", + mRandom.nextInt().toString() + " ", "Savings" + ), + BankAccountDetails( + "FCI", "NISHU BOII", "ASSAM", + mRandom.nextInt().toString() + " ", "Savings" + ) + ) + } + + fun addBankAccount(bankAccountDetails: BankAccountDetails) { + viewModelScope.launch { + val updatedList = _bankAccountDetailsList.value.toMutableList().apply { + add(bankAccountDetails) + } + _bankAccountDetailsList.value = updatedList + _accountsUiState.value = AccountsUiState.LinkedAccounts(updatedList) + } + } + + fun updateBankAccount(index: Int, bankAccountDetails: BankAccountDetails) { + viewModelScope.launch { + val updatedList = _bankAccountDetailsList.value.toMutableList().apply { + this[index] = bankAccountDetails + } + _bankAccountDetailsList.value = updatedList + _accountsUiState.value = AccountsUiState.LinkedAccounts(updatedList) + } + } +} + +sealed class AccountsUiState { + data object Loading : AccountsUiState() + data object Empty : AccountsUiState() + data object Error : AccountsUiState() + data class LinkedAccounts(val linkedAccounts: List) : AccountsUiState() +} diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountsItem.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountsItem.kt new file mode 100644 index 000000000..3a732a4e9 --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountsItem.kt @@ -0,0 +1,83 @@ +package org.mifospay.feature.bank.accounts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mifospay.core.model.domain.BankAccountDetails +import org.mifospay.R +import org.mifospay.core.designsystem.component.MifosCard +import org.mifospay.core.designsystem.theme.mifosText +import org.mifospay.core.designsystem.theme.styleMedium16sp + +@Composable +fun AccountsItem( + bankAccountDetails: BankAccountDetails, + onAccountClicked: () -> Unit +) { + MifosCard( + onClick = { onAccountClicked.invoke() }, + colors = CardDefaults.cardColors(Color.White) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_bank), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 16.dp, end = 16.dp) + .size(39.dp) + ) + + Column { + Text( + text = bankAccountDetails.accountholderName.toString(), + color = mifosText, + ) + Text( + text = bankAccountDetails.bankName.toString(), + modifier = Modifier.padding(top = 4.dp), + style = styleMedium16sp.copy(mifosText) + ) + } + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = bankAccountDetails.branch.toString(), + modifier = Modifier.padding(16.dp), + fontSize = 12.sp, + color = mifosText + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AccountsItemPreview() { + AccountsItem( + bankAccountDetails = BankAccountDetails("A", "B", "C"), + onAccountClicked = {} + ) +} \ No newline at end of file diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountsScreen.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountsScreen.kt new file mode 100644 index 000000000..8202a3dcb --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/AccountsScreen.kt @@ -0,0 +1,236 @@ +package org.mifospay.feature.bank.accounts + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mifospay.core.model.domain.BankAccountDetails +import org.mifospay.R +import org.mifospay.common.Constants +import org.mifospay.core.designsystem.component.MfLoadingWheel +import org.mifospay.core.ui.EmptyContentScreen +import org.mifospay.core.ui.utility.AddCardChip +import org.mifospay.feature.bank.accounts.details.BankAccountDetailActivity +import org.mifospay.feature.bank.accounts.link.LinkBankAccountActivity + +@Composable +fun AccountsScreen( + viewModel: AccountViewModel = hiltViewModel() +) { + val accountsUiState by viewModel.accountsUiState.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val bankAccountDetailsList by viewModel.bankAccountDetailsList.collectAsStateWithLifecycle() + + val context = LocalContext.current + val updateBankAccountLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val bundle = result.data?.extras + if (bundle != null) { + val bankAccountDetails = bundle.getParcelable(Constants.UPDATED_BANK_ACCOUNT) + val index = bundle.getInt(Constants.INDEX) + if (bankAccountDetails != null) { + viewModel.updateBankAccount(index, bankAccountDetails) + } + } + } + } + + AccountScreen( + accountsUiState = accountsUiState, + onAddAccount = { + val intent = Intent(context, LinkBankAccountActivity::class.java) + context.startActivity(intent) + }, + bankAccountDetailsList = bankAccountDetailsList, + isRefreshing = isRefreshing, + onRefresh = { + viewModel.refresh() + }, + onUpdateAccount = { bankAccountDetails, index -> + val intent = Intent(context, BankAccountDetailActivity::class.java).apply { + putExtra(Constants.BANK_ACCOUNT_DETAILS, bankAccountDetails) + putExtra(Constants.INDEX, index) + } + updateBankAccountLauncher.launch(intent) + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AccountScreen( + accountsUiState: AccountsUiState, + onAddAccount: () -> Unit, + bankAccountDetailsList: List, + isRefreshing: Boolean, + onRefresh: () -> Unit, + onUpdateAccount: (BankAccountDetails, Int) -> Unit +) { + val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh) + Box(Modifier.pullRefresh(pullRefreshState)) { + Column(modifier = Modifier.fillMaxSize()) { + when (accountsUiState) { + AccountsUiState.Empty -> { + NoLinkedAccountsScreen { onAddAccount.invoke() } + } + + AccountsUiState.Error -> { + EmptyContentScreen( + modifier = Modifier, + title = stringResource(id = R.string.error_oops), + subTitle = stringResource(id = R.string.unexpected_error_subtitle), + iconTint = Color.Black, + iconImageVector = Icons.Rounded.Info + ) + } + + is AccountsUiState.LinkedAccounts -> { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) { + item { + Text( + text = stringResource(id = R.string.linked_bank_account), + fontSize = 16.sp, + color = colorResource(id = R.color.colorTextPrimary), + modifier = Modifier.padding(top = 48.dp, start = 24.dp) + ) + } + items(bankAccountDetailsList) { bankAccountDetails -> + val index = bankAccountDetailsList.indexOf(bankAccountDetails) + AccountsItem( + bankAccountDetails = bankAccountDetails, + onAccountClicked = { + onUpdateAccount(bankAccountDetails, index) + } + ) + HorizontalDivider( + modifier = Modifier.padding(8.dp) + ) + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .background(color = Color.White) + ) { + AddCardChip( + modifier = Modifier.align(Alignment.Center), + onAddBtn = onAddAccount, + text = R.string.add_account, + btnText = R.string.add_cards + ) + } + } + } + } + + AccountsUiState.Loading -> { + MfLoadingWheel( + contentDesc = stringResource(R.string.loading), + backgroundColor = Color.White + ) + } + + else -> {} + } + } + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } +} + +@Composable +fun NoLinkedAccountsScreen(onAddBtn: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = stringResource(R.string.no_linked_bank_accounts)) + AddCardChip( + modifier = Modifier, + onAddBtn = onAddBtn, + text = R.string.add_account, + btnText = R.string.add_cards + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AccountScreenLoadingPreview() { + AccountScreen(accountsUiState = AccountsUiState.Loading, {}, emptyList(), false, {}, { _, _ -> }) +} + +@Preview(showBackground = true) +@Composable +private fun AccountEmptyScreenPreview() { + AccountScreen(accountsUiState = AccountsUiState.Empty, {}, emptyList(), false, {}, { _, _ -> }) +} + +@Preview(showBackground = true) +@Composable +private fun AccountListScreenPreview() { + AccountScreen( + accountsUiState = AccountsUiState.LinkedAccounts(sampleLinkedAccount), + {}, + sampleLinkedAccount, + false, + {}, + { _, _ -> } + ) +} + +@Preview(showBackground = true) +@Composable +private fun AccountErrorScreenPreview() { + AccountScreen(accountsUiState = AccountsUiState.Error, {}, emptyList(), false, {}, { _, _ -> }) +} + +val sampleLinkedAccount = List(10) { + BankAccountDetails( + "SBI", "Ankur Sharma", "New Delhi", + "XXXXXXXX9990XXX " + " ", "Savings" + ) +} diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/details/BankAccountDetailActivity.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/details/BankAccountDetailActivity.kt new file mode 100644 index 000000000..b22b12c29 --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/details/BankAccountDetailActivity.kt @@ -0,0 +1,81 @@ +package org.mifospay.feature.bank.accounts.details + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import com.mifospay.core.model.domain.BankAccountDetails +import dagger.hilt.android.AndroidEntryPoint +import org.mifospay.bank.setupUpi.ui.SetupUpiPinActivity +import org.mifospay.base.BaseActivity +import org.mifospay.common.Constants +import org.mifospay.theme.MifosTheme +import org.mifospay.utils.Toaster + +@AndroidEntryPoint +class BankAccountDetailActivity : BaseActivity() { + + private lateinit var bankAccountDetails: BankAccountDetails + private var index = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bankAccountDetails = intent.extras?.getParcelable(Constants.BANK_ACCOUNT_DETAILS)!! + index = intent.extras!!.getInt(Constants.INDEX) + + setContent { + MifosTheme { + BankAccountDetailScreen( + bankAccountDetails = bankAccountDetails, + onSetupUpiPin = { onSetupUpiPinClicked() }, + onChangeUpiPin = { onChangeUpiPinClicked() }, + onForgotUpiPin = { onForgotUpiPinClicked() }, + navigateBack = { onBackPressed() } + ) + } + } + } + + private fun onSetupUpiPinClicked() { + startSetupActivity(Constants.SETUP, index) + } + + private fun onChangeUpiPinClicked() { + if (bankAccountDetails.isUpiEnabled) { + startSetupActivity(Constants.CHANGE, index) + } else { + showToast(Constants.SETUP_UPI_PIN) + } + } + + private fun onForgotUpiPinClicked() { + if (bankAccountDetails.isUpiEnabled) { + startSetupActivity(Constants.FORGOT, index) + } else { + showToast(Constants.SETUP_UPI_PIN) + } + } + + private fun startSetupActivity(type: String, index: Int) { + val intent = Intent(this@BankAccountDetailActivity, SetupUpiPinActivity::class.java) + intent.putExtra(Constants.BANK_ACCOUNT_DETAILS, bankAccountDetails) + intent.putExtra(Constants.TYPE, type) + intent.putExtra(Constants.INDEX, index) + startActivityForResult(intent, SETUP_UPI_REQUEST_CODE) + } + + private fun showToast(message: String?) { + Toaster.showToast(this, message) + } + + override fun onBackPressed() { + val intent = Intent() + intent.putExtra(Constants.UPDATED_BANK_ACCOUNT, bankAccountDetails) + intent.putExtra(Constants.INDEX, index) + setResult(RESULT_OK, intent) + super.onBackPressed() + } + + companion object { + const val SETUP_UPI_REQUEST_CODE = 2 + } +} \ No newline at end of file diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/details/BankAccountDetailScreen.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/details/BankAccountDetailScreen.kt new file mode 100644 index 000000000..5cf7956cb --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/details/BankAccountDetailScreen.kt @@ -0,0 +1,220 @@ +package org.mifospay.feature.bank.accounts.details + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mifospay.core.model.domain.BankAccountDetails +import org.mifospay.R +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.theme.mifosText +import org.mifospay.core.designsystem.theme.styleMedium16sp + +@Composable +fun BankAccountDetailScreen( + bankAccountDetails: BankAccountDetails, + onSetupUpiPin: () -> Unit, + onChangeUpiPin: () -> Unit, + onForgotUpiPin: () -> Unit, + navigateBack: () -> Unit +) { + BankAccountDetailScreen( + bankName = bankAccountDetails.bankName.toString(), + accountHolderName = bankAccountDetails.accountholderName.toString(), + branchName = bankAccountDetails.branch.toString(), + ifsc = bankAccountDetails.ifsc.toString(), + type = bankAccountDetails.type.toString(), + isUpiEnabled = bankAccountDetails.isUpiEnabled, + onSetupUpiPin = onSetupUpiPin, + onChangeUpiPin = onChangeUpiPin, + onForgotUpiPin = onForgotUpiPin, + navigateBack = navigateBack + ) +} + +@Composable +fun BankAccountDetailScreen( + bankName: String, + accountHolderName: String, + branchName: String, + ifsc: String, + type: String, + isUpiEnabled: Boolean, + onSetupUpiPin: () -> Unit, + onChangeUpiPin: () -> Unit, + onForgotUpiPin: () -> Unit, + navigateBack: () -> Unit +) { + Column(modifier = Modifier.fillMaxSize()) { + MifosTopBar(topBarTitle = R.string.bank_account_details) { navigateBack.invoke() } + Column( + modifier = Modifier + .padding(20.dp) + .border(2.dp, Color.Black) + .padding(20.dp) + ) { + BankAccountDetailRows( + modifier = Modifier.fillMaxWidth(), + detail = R.string.bank_name, + detailValue = bankName + ) + BankAccountDetailRows( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + detail = R.string.ac_holder_name, + detailValue = accountHolderName + ) + BankAccountDetailRows( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + detail = R.string.branch_name, + detailValue = branchName + ) + BankAccountDetailRows( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + detail = R.string.ifsc, + detailValue = ifsc + ) + BankAccountDetailRows( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + detail = R.string.type, + detailValue = type + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + + BankAccountDetailButton( + btnText = R.string.setup_upi, + onClick = { onSetupUpiPin.invoke() }, + isUpiEnabled = !isUpiEnabled, + hasTrailingIcon = false + ) + + BankAccountDetailButton( + btnText = R.string.delete_bank, + onClick = {}, + isUpiEnabled = !isUpiEnabled + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + BankAccountDetailButton( + btnText = R.string.change_upi_pin, + onClick = { onChangeUpiPin.invoke() }, + isUpiEnabled = isUpiEnabled, + modifier = Modifier.fillMaxWidth() + ) + BankAccountDetailButton( + btnText = R.string.forgot_upi_pin, + onClick = { onForgotUpiPin.invoke() }, + isUpiEnabled = isUpiEnabled, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +fun BankAccountDetailRows( + modifier: Modifier, detail: Int, detailValue: String +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = detail), modifier = Modifier.padding(end = 10.dp) + ) + Text(text = detailValue, style = styleMedium16sp) + } +} + +@Composable +fun BankAccountDetailButton( + modifier: Modifier = Modifier, + btnText: Int, + onClick: () -> Unit, + isUpiEnabled: Boolean, + hasTrailingIcon: Boolean = false +) { + if (isUpiEnabled) { + Button( + onClick = { onClick.invoke() }, + colors = ButtonDefaults.buttonColors(Color.White), + modifier = modifier + .padding(start = 20.dp, end = 20.dp), + contentPadding = PaddingValues(20.dp), + border = BorderStroke(2.dp, Color.Black) + ) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = btnText), + style = TextStyle(color = mifosText, fontSize = 18.sp) + ) + if (hasTrailingIcon) { + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + tint = Color.Black + ) + } + } + } + } +} + + +@Preview(showBackground = true) +@Composable +private fun BankAccountDetailUpiDisabledPreview() { + BankAccountDetailScreen("Mifos Bank", + "Mifos Account Holder", + "Mifos Branch", + "IFSC", + "type", + false, + {}, {}, {}, {} + ) +} + +@Preview(showBackground = true) +@Composable +private fun BankAccountDetailUpiEnabledPreview() { + BankAccountDetailScreen("Mifos Bank", + "Mifos Account Holder", + "Mifos Branch", + "IFSC", + "type", + true, + {}, {}, {}, {} + ) +} \ No newline at end of file diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountActivity.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountActivity.kt new file mode 100644 index 000000000..a81f92063 --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountActivity.kt @@ -0,0 +1,23 @@ +package org.mifospay.feature.bank.accounts.link + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import org.mifospay.theme.MifosTheme + + +@AndroidEntryPoint +class LinkBankAccountActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MifosTheme { + LinkBankAccountRoute( + onBackClick = { finish() } + ) + } + } + } +} + diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountScreen.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountScreen.kt new file mode 100644 index 000000000..00d3d2eb3 --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountScreen.kt @@ -0,0 +1,322 @@ +package org.mifospay.feature.bank.accounts.link + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowColumn +import androidx.compose.foundation.layout.FlowRow +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.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.mifospay.R +import org.mifospay.bank.choose_sim.ChooseSimDialogSheet +import org.mifospay.core.designsystem.component.MfLoadingWheel +import org.mifospay.core.designsystem.component.MfOverlayLoadingWheel +import org.mifospay.core.designsystem.component.MifosCard +import org.mifospay.core.designsystem.component.MifosOutlinedTextField +import org.mifospay.core.designsystem.component.MifosTopAppBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.domain.model.Bank +import org.mifospay.domain.model.BankType +import org.mifospay.theme.MifosTheme +import org.mifospay.core.ui.DevicePreviews + + +@Composable +fun LinkBankAccountRoute( + viewModel: LinkBankAccountViewModel = hiltViewModel(), + onBackClick: () -> Unit +) { + val bankUiState by viewModel.bankListUiState.collectAsStateWithLifecycle() + var showSimBottomSheet by rememberSaveable { mutableStateOf(false) } + var showOverlyProgressBar by rememberSaveable { mutableStateOf(false) } + + if (showSimBottomSheet) { + ChooseSimDialogSheet { selectedSim -> + showSimBottomSheet = false + if (selectedSim != -1) { + showOverlyProgressBar = true + viewModel.fetchBankAccountDetails { + showOverlyProgressBar = false + onBackClick() + } + } + } + } + + LinkBankAccountScreen( + bankUiState = bankUiState, + showOverlyProgressBar = showOverlyProgressBar, + onBankSearch = { query -> + viewModel.updateSearchQuery(query) + }, + onBankSelected = { + viewModel.updateSelectedBank(it) + showSimBottomSheet = true + }, + onBackClick = onBackClick + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LinkBankAccountScreen( + bankUiState: BankUiState, + showOverlyProgressBar: Boolean, + onBankSearch: (String) -> Unit, + onBankSelected: (Bank) -> Unit, + onBackClick: () -> Unit +) { + + Scaffold( + modifier = Modifier.background(color = Color.White), + topBar = { + MifosTopAppBar( + titleRes = R.string.link_bank_account, + navigationIcon = MifosIcons.Back, + navigationIconContentDescription = "Back icon", + onNavigationClick = onBackClick, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.White, + ), + ) + }) { paddingValues -> + Box( + modifier = Modifier.padding(paddingValues) + ) { + when (bankUiState) { + is BankUiState.Loading -> { + MfLoadingWheel( + contentDesc = stringResource(R.string.loading), + backgroundColor = Color.White + ) + } + + is BankUiState.Success -> { + BankListScreenContent( + banks = bankUiState.banks, + onBankSearch = onBankSearch, + onBankSelected = onBankSelected + ) + } + } + + if (showOverlyProgressBar) { + MfOverlayLoadingWheel() + } + } + } +} + +@Composable +fun BankListScreenContent( + banks: List, + onBankSearch: (String) -> Unit, + onBankSelected: (Bank) -> Unit +) { + var searchQuery by rememberSaveable { mutableStateOf("") } + Column( + modifier = Modifier + .fillMaxSize() + .background(color = Color.White) + .verticalScroll(rememberScrollState()) + ) { + MifosOutlinedTextField(modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = searchQuery, + onValueChange = { + searchQuery = it + onBankSearch(it) + }, + label = R.string.search, + trailingIcon = { + Icon(imageVector = Icons.Filled.Search, contentDescription = null) + }) + + if (searchQuery.isBlank()) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(id = R.string.popular_banks), + style = TextStyle(Color.Black, fontWeight = FontWeight.Medium), + modifier = Modifier.padding(start = 16.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + PopularBankGridBody( + banks = banks.filter { it.bankType == BankType.POPULAR }, + onBankSelected = onBankSelected + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(id = R.string.other_banks), + style = TextStyle(Color.Black, fontWeight = FontWeight.Medium), + modifier = Modifier.padding(start = 16.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + BankListBody( + banks = if (searchQuery.isBlank()) { + banks.filter { it.bankType == BankType.OTHER } + } else banks, + onBankSelected = onBankSelected + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PopularBankGridBody( + banks: List, + onBankSelected: (Bank) -> Unit +) { + MifosCard( + modifier = Modifier, + shape = RoundedCornerShape(0.dp), + elevation = 2.dp, + colors = CardDefaults.cardColors(Color.White) + ) { + FlowRow( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + maxItemsInEachRow = 3 + ) { + banks.forEach { + PopularBankItemBody( + modifier = Modifier.weight(1f), + bank = it, + onBankSelected = onBankSelected + ) + } + } + } +} + +@Composable +fun PopularBankItemBody( + modifier: Modifier, + bank: Bank, + onBankSelected: (Bank) -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier + .fillMaxSize() + .clickable { + onBankSelected(bank) + }, + ) { + Image( + modifier = Modifier + .size(58.dp) + .padding(bottom = 4.dp, top = 16.dp), + painter = painterResource(id = bank.image), + contentDescription = bank.name, + ) + Text( + text = bank.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp) + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun BankListBody( + banks: List, + onBankSelected: (Bank) -> Unit +) { + FlowColumn { + banks.forEach { bank -> + BankListItemBody(bank = bank, onBankSelected = onBankSelected) + } + } +} + +@Composable +fun BankListItemBody( + bank: Bank, + onBankSelected: (Bank) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .clickable { onBankSelected(bank) } + ) { + HorizontalDivider() + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 8.dp, bottom = 8.dp) + ) { + Image( + modifier = Modifier.size(32.dp), + painter = painterResource(id = bank.image), + contentDescription = bank.name, + ) + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + text = bank.name, style = TextStyle(fontSize = 14.sp) + ) + } + } +} + +@DevicePreviews +@Composable +private fun LinkBankAccountScreenPreview( + @PreviewParameter(LinkBankUiStatePreviewParameterProvider::class) + bankUiState: BankUiState, +) { + MifosTheme { + LinkBankAccountScreen( + bankUiState = bankUiState, + showOverlyProgressBar = false, + onBankSelected = { }, + onBankSearch = { }, + onBackClick = { } + ) + } +} diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountViewModel.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountViewModel.kt new file mode 100644 index 000000000..2107c0562 --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankAccountViewModel.kt @@ -0,0 +1,97 @@ +package org.mifospay.feature.bank.accounts.link + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mifospay.core.model.domain.BankAccountDetails +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.mifospay.R +import org.mifospay.core.data.repository.local.MifosLocalAssetRepository +import org.mifospay.domain.model.Bank +import org.mifospay.domain.model.BankType +import java.util.Random +import javax.inject.Inject + +@HiltViewModel +class LinkBankAccountViewModel @Inject constructor( + localAssetRepository: MifosLocalAssetRepository +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + private var selectedBank by mutableStateOf(null) + + private val _bankAccountDetails: MutableStateFlow = MutableStateFlow(null) + val bankAccountDetails: StateFlow = _bankAccountDetails.asStateFlow() + + fun updateSearchQuery(query: String) { + _searchQuery.update { query } + } + + fun updateSelectedBank(bank: Bank) { + selectedBank = bank + } + + val bankListUiState: StateFlow = combine( + _searchQuery, + localAssetRepository.getBanks(), + ::Pair + ).map { searchQueryAndBanks -> + val searchQuery = searchQueryAndBanks.first + val localBanks = searchQueryAndBanks.second.map { + Bank(it, R.drawable.ic_bank, BankType.OTHER) + } + val banks = ArrayList().apply { + addAll(popularBankList()) + addAll(localBanks) + }.distinctBy { it.name } + BankUiState.Success( + banks.filter { it.name.contains(searchQuery.lowercase(), ignoreCase = true) } + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = BankUiState.Loading, + ) + + private fun popularBankList(): List { + return listOf( + Bank("RBL Bank", R.drawable.logo_rbl, BankType.POPULAR), + Bank("SBI Bank", R.drawable.logo_sbi, BankType.POPULAR), + Bank("PNB Bank", R.drawable.logo_pnb, BankType.POPULAR), + Bank("HDFC Bank", R.drawable.logo_hdfc, BankType.POPULAR), + Bank("ICICI Bank", R.drawable.logo_icici, BankType.POPULAR), + Bank("AXIS Bank", R.drawable.logo_axis, BankType.POPULAR) + ) + } + + fun fetchBankAccountDetails(onBankDetailsSuccess: () -> Unit) { + // TODO:: UPI API implement, Implement with real API, + // It revert back to Account Screen after successful BankAccount Add + _bankAccountDetails.update { + BankAccountDetails( + selectedBank?.name, "Ankur Sharma", "New Delhi", + mRandom.nextInt().toString() + " ", "Savings" + ) + } + onBankDetailsSuccess.invoke() + } + + companion object { + private val mRandom = Random() + } +} + +sealed interface BankUiState { + data class Success(val banks: List = emptyList()) : BankUiState + data object Loading : BankUiState +} diff --git a/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankUiStatePreviewParameterProvider.kt b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankUiStatePreviewParameterProvider.kt new file mode 100644 index 000000000..3886db795 --- /dev/null +++ b/feature/accounts/src/main/kotlin/org/mifospay/feature/bank/accounts/link/LinkBankUiStatePreviewParameterProvider.kt @@ -0,0 +1,25 @@ +package org.mifospay.feature.bank.accounts.link + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import org.mifospay.R +import org.mifospay.domain.model.Bank +import org.mifospay.domain.model.BankType + +class LinkBankUiStatePreviewParameterProvider : PreviewParameterProvider { + + val banks = ArrayList().apply { + add(Bank("RBL Bank", R.drawable.logo_rbl, BankType.POPULAR)) + add(Bank("SBI Bank", R.drawable.logo_sbi, BankType.POPULAR)) + add(Bank("PNB Bank", R.drawable.logo_pnb, BankType.POPULAR)) + add(Bank("HDFC Bank", R.drawable.logo_hdfc, BankType.POPULAR)) + add(Bank("ICICI Bank", R.drawable.logo_icici, BankType.POPULAR)) + add(Bank("AXIS Bank", R.drawable.logo_axis, BankType.POPULAR)) + add(Bank("HDFC Bank", R.drawable.ic_bank, BankType.OTHER)) + add(Bank("ICICI Bank", R.drawable.ic_bank, BankType.OTHER)) + add(Bank("AXIS Bank", R.drawable.ic_bank, BankType.OTHER)) + } + + override val values: Sequence = sequenceOf( + BankUiState.Success(banks = banks) + ) +} \ No newline at end of file diff --git a/feature/accounts/src/test/java/org/mifospay/feature/bank/accounts/ExampleUnitTest.kt b/feature/accounts/src/test/java/org/mifospay/feature/bank/accounts/ExampleUnitTest.kt new file mode 100644 index 000000000..0bb0f96e9 --- /dev/null +++ b/feature/accounts/src/test/java/org/mifospay/feature/bank/accounts/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package org.mifospay.feature.bank.accounts + +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) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 454b4fd51..b8e5231aa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -48,5 +48,6 @@ include(":feature:invoices") include(":feature:invoices") include(":feature:settings") include(":feature:profile") +include(":feature:accounts") include(":feature:standing-instruction") include(":feature:payments")