diff --git a/feature/savedcards/.gitignore b/feature/savedcards/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/savedcards/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/savedcards/build.gradle.kts b/feature/savedcards/build.gradle.kts
new file mode 100644
index 00000000..fe569fb5
--- /dev/null
+++ b/feature/savedcards/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ alias(libs.plugins.mifospay.android.feature)
+ alias(libs.plugins.mifospay.android.library.compose)
+}
+
+android {
+ namespace = "org.mifospay.savedcards"
+}
+
+dependencies {
+ implementation(projects.core.data)
+ implementation(projects.mifospay)
+}
\ No newline at end of file
diff --git a/feature/savedcards/consumer-rules.pro b/feature/savedcards/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/savedcards/proguard-rules.pro b/feature/savedcards/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/savedcards/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/savedcards/src/androidTest/java/org/mifospay/savedcards/ExampleInstrumentedTest.kt b/feature/savedcards/src/androidTest/java/org/mifospay/savedcards/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..f2b80536
--- /dev/null
+++ b/feature/savedcards/src/androidTest/java/org/mifospay/savedcards/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package org.mifospay.savedcards
+
+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.savedcards.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/feature/savedcards/src/main/AndroidManifest.xml b/feature/savedcards/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/feature/savedcards/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/AddCardDialogSheet.kt b/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/AddCardDialogSheet.kt
new file mode 100644
index 00000000..2e71b297
--- /dev/null
+++ b/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/AddCardDialogSheet.kt
@@ -0,0 +1,227 @@
+package org.mifospay.feature.savedcards
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.text.KeyboardOptions
+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.Modifier
+import androidx.compose.ui.platform.LocalContext
+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 com.mifospay.core.model.entity.savedcards.Card
+import org.mifospay.R
+import org.mifospay.common.CreditCardUtils
+import org.mifospay.core.designsystem.component.MifosBottomSheet
+import org.mifospay.core.designsystem.component.MifosButton
+import org.mifospay.core.designsystem.component.MifosOutlinedTextField
+import org.mifospay.core.designsystem.utils.ExpirationDateMask
+import java.util.Calendar
+
+@Composable
+fun AddCardDialogSheet(
+ cancelClicked: () -> Unit,
+ addClicked: (Card) -> Unit,
+ onDismiss: () -> Unit
+) {
+
+ MifosBottomSheet(
+ content = {
+ AddCardDialogSheetContent(
+ cancelClicked = cancelClicked,
+ addClicked = addClicked
+ )
+ },
+ onDismiss = onDismiss
+ )
+}
+
+@Composable
+fun AddCardDialogSheetContent(cancelClicked: () -> Unit, addClicked: (Card) -> Unit) {
+
+ val context = LocalContext.current
+ var firstName by rememberSaveable { mutableStateOf("") }
+ var lastName by rememberSaveable { mutableStateOf("") }
+ var creditCardNumber by rememberSaveable { mutableStateOf("") }
+ var expiration by rememberSaveable { mutableStateOf("") }
+ var cvv by rememberSaveable { mutableStateOf("") }
+ var firstNameValidator by rememberSaveable { mutableStateOf(null) }
+ var lastNameValidator by rememberSaveable { mutableStateOf(null) }
+ var creditCardNumberValidator by rememberSaveable { mutableStateOf(null) }
+ var expirationValidator by rememberSaveable { mutableStateOf(null) }
+ var cvvValidator by rememberSaveable { mutableStateOf(null) }
+
+
+ LaunchedEffect(key1 = firstName) {
+ firstNameValidator = when {
+ firstName.trim().isEmpty() -> context.getString(R.string.all_fields_required)
+ else -> null
+ }
+ }
+
+ LaunchedEffect(key1 = lastName) {
+ lastNameValidator = when {
+ lastName.trim().isEmpty() -> context.getString(R.string.all_fields_required)
+ else -> null
+ }
+ }
+
+ LaunchedEffect(key1 = creditCardNumber) {
+ creditCardNumberValidator = when {
+ creditCardNumber.trim().isEmpty() -> context.getString(R.string.all_fields_required)
+ creditCardNumber.length < 16 -> context.getString(R.string.invalid_credit_card)
+ !CreditCardUtils.validateCreditCardNumber(creditCardNumber) -> context.getString(R.string.invalid_credit_card)
+ else -> null
+ }
+ }
+
+ LaunchedEffect(key1 = expiration) {
+ expirationValidator = when {
+ expiration.trim().isEmpty() -> context.getString(R.string.all_fields_required)
+ expiration.length < 4 -> context.getString(R.string.expiry_date_length_error)
+ (expiration.substring(2, 4) == Calendar.getInstance()[Calendar.YEAR].toString()
+ .substring(2, 4) && expiration.substring(0, 2)
+ .toInt() < Calendar.getInstance()[Calendar.MONTH] + 1) || expiration.substring(0, 2)
+ .toInt() > 12 -> context.getString(R.string.invalid_expiry_date)
+
+ else -> null
+ }
+ }
+
+ LaunchedEffect(key1 = cvv) {
+ cvvValidator = when {
+ cvv.trim().isEmpty() -> context.getString(R.string.all_fields_required)
+ cvv.length < 3 -> context.getString(R.string.cvv_length_error)
+ else -> null
+ }
+ }
+
+ fun validateAllFields(): Boolean {
+ when {
+ firstNameValidator != null -> {
+ Toast.makeText(context, firstNameValidator, Toast.LENGTH_SHORT).show()
+ return false
+ }
+
+ lastNameValidator != null -> {
+ Toast.makeText(context, lastNameValidator, Toast.LENGTH_SHORT).show()
+ return false
+ }
+
+ creditCardNumberValidator != null -> {
+ Toast.makeText(context, creditCardNumberValidator, Toast.LENGTH_SHORT).show()
+ return false
+ }
+
+ expirationValidator != null -> {
+ Toast.makeText(context, expirationValidator, Toast.LENGTH_SHORT).show()
+ return false
+ }
+
+ cvvValidator != null -> {
+ Toast.makeText(context, cvvValidator, Toast.LENGTH_SHORT).show()
+ return false
+ }
+
+ else -> {
+ return true
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ MifosOutlinedTextField(
+ value = firstName,
+ onValueChange = { firstName = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = R.string.first_name
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ MifosOutlinedTextField(
+ value = lastName,
+ onValueChange = { lastName = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = R.string.last_name
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ MifosOutlinedTextField(
+ value = creditCardNumber,
+ onValueChange = { if (it.length <= 16) creditCardNumber = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = R.string.credit_card_number,
+ keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.NumberPassword)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(text = stringResource(id = R.string.expiry_date))
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ MifosOutlinedTextField(
+ value = expiration,
+ onValueChange = { if (it.length <= 4) expiration = it },
+ modifier = Modifier.weight(1f),
+ label = R.string.mm_yy,
+ visualTransformation = ExpirationDateMask(),
+ keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.NumberPassword)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ MifosOutlinedTextField(
+ value = cvv,
+ onValueChange = { if (it.length <= 3) cvv = it },
+ modifier = Modifier.weight(1f),
+ label = R.string.cvv,
+ keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.NumberPassword)
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ MifosButton(onClick = { cancelClicked() }) {
+ Text(text = stringResource(id = R.string.cancel))
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ MifosButton(onClick = {
+ val card = Card(
+ cardNumber = creditCardNumber,
+ cvv = cvv,
+ expiryDate = expiration,
+ firstName = firstName,
+ lastName = lastName
+ )
+ if (validateAllFields()) {
+ addClicked(card)
+ }
+ }) {
+ Text(text = stringResource(id = R.string.add))
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AddCardDialogSheetContentPreview() {
+ AddCardDialogSheetContent(cancelClicked = {}, addClicked = {})
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AddCardDialogSheetPreview() {
+ AddCardDialogSheet(cancelClicked = {}, addClicked = {}, onDismiss = {})
+}
\ No newline at end of file
diff --git a/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsFragment.kt b/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsFragment.kt
new file mode 100644
index 00000000..d12fe926
--- /dev/null
+++ b/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsFragment.kt
@@ -0,0 +1,66 @@
+package org.mifospay.feature.savedcards
+
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import dagger.hilt.android.AndroidEntryPoint
+import org.mifospay.base.BaseFragment
+import org.mifospay.databinding.FragmentCardsBinding
+import org.mifospay.core.designsystem.theme.MifosTheme
+import org.mifospay.savedcards.ui.AddCardDialog
+
+/**
+ * This is the UI component of the SavedCards Architecture.
+ * @author ankur
+ * @since 21/May/2018
+ */
+@AndroidEntryPoint
+class CardsFragment : BaseFragment() {
+
+ private lateinit var binding: FragmentCardsBinding
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = FragmentCardsBinding.inflate(inflater, container, false)
+
+ binding.composeViewCards.apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ MifosTheme {
+ Surface(modifier = Modifier.fillMaxWidth()) {
+ CardsScreen(
+ onEditCard = {
+ // TODO open Add card flow with edit flag to allow edit the card
+ }
+ )
+ }
+ }
+ }
+ }
+ return binding.root
+ }
+
+ private fun onAddBtnClicked() {
+ if (activity != null) {
+ activity?.startActivity(Intent(activity, AddCardDialog::class.java))
+ }
+ }
+
+ companion object {
+ fun newInstance(): CardsFragment {
+ val args = Bundle()
+ val fragment = CardsFragment()
+ fragment.arguments = args
+ return fragment
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsScreen.kt b/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsScreen.kt
new file mode 100644
index 00000000..684a86e9
--- /dev/null
+++ b/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsScreen.kt
@@ -0,0 +1,451 @@
+package org.mifospay.feature.savedcards
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material3.Card
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.res.stringResource
+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.mifospay.core.model.entity.savedcards.Card
+import org.mifospay.R
+import org.mifospay.core.designsystem.component.MfLoadingWheel
+import org.mifospay.core.designsystem.component.MifosDialogBox
+import org.mifospay.core.designsystem.theme.MifosTheme
+import org.mifospay.core.ui.EmptyContentScreen
+import org.mifospay.core.ui.utility.AddCardChip
+import org.mifospay.savedcards.presenter.CardsScreenViewModel
+import org.mifospay.savedcards.presenter.CardsUiEvent
+import org.mifospay.savedcards.presenter.CardsUiState
+
+enum class CardMenuAction {
+ EDIT, DELETE, CANCEL
+}
+
+@Composable
+fun CardsScreen(
+ viewModel: CardsScreenViewModel = hiltViewModel(),
+ onEditCard: (Card) -> Unit
+) {
+ val cardState by viewModel.cardState.collectAsStateWithLifecycle()
+ val cardListUiState by viewModel.cardListUiState.collectAsStateWithLifecycle()
+
+ var showCardBottomSheet by rememberSaveable { mutableStateOf(false) }
+ var showConfirmDeleteDialog by rememberSaveable { mutableStateOf(false) }
+
+ var deleteCardID by rememberSaveable { mutableStateOf(null) }
+
+
+ if (showCardBottomSheet) {
+ AddCardDialogSheet(
+ cancelClicked = {
+ showCardBottomSheet = false
+ },
+ addClicked = {
+ showCardBottomSheet = false
+ viewModel.addCard(it)
+ },
+ onDismiss = {
+ showCardBottomSheet = false
+ }
+ )
+ }
+
+ MifosDialogBox(
+ showDialogState = showConfirmDeleteDialog,
+ onDismiss = { showConfirmDeleteDialog = false },
+ title = R.string.delete_card,
+ confirmButtonText = R.string.yes,
+ onConfirm = {
+ deleteCardID?.let { viewModel.deleteCard(it) }
+ showConfirmDeleteDialog = false
+ },
+ dismissButtonText = R.string.no,
+ message = R.string.confirm_delete_card
+ )
+
+ CardsScreen(
+ cardState = cardState,
+ cardListUiState = cardListUiState,
+ onEditCard = onEditCard,
+ onDeleteCard = {
+ showConfirmDeleteDialog = true
+ deleteCardID = it.id
+ },
+ onAddBtn = { showCardBottomSheet = true },
+ updateQuery = {
+ viewModel.updateSearchQuery(it)
+ }
+ )
+}
+
+@Composable
+fun CardsScreen(
+ cardState: CardsUiState,
+ cardListUiState: CardsUiState,
+ onEditCard: (Card) -> Unit,
+ onDeleteCard: (Card) -> Unit,
+ onAddBtn: () -> Unit,
+ updateQuery: (String) -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ when (cardState) {
+ CardsUiState.Loading -> {
+ MfLoadingWheel(
+ contentDesc = stringResource(R.string.loading),
+ backgroundColor = Color.White
+ )
+ }
+
+ is CardsUiState.Empty -> {
+ NoCardAddCardsScreen(onAddBtn)
+ }
+
+ is CardsUiState.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 CardsUiState.CreditCardForm -> {
+ CardsScreenContent(
+ cardList = (cardListUiState as CardsUiState.CreditCardForm).cards,
+ onAddBtn = onAddBtn,
+ onDeleteCard = onDeleteCard,
+ onEditCard = onEditCard,
+ updateQuery = updateQuery
+ )
+ }
+
+ is CardsUiState.Success -> {
+ when (cardState.cardsUiEvent) {
+ CardsUiEvent.CARD_ADDED_SUCCESSFULLY -> {
+ Toast.makeText(
+ LocalContext.current,
+ stringResource(id = R.string.card_added_successfully),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ CardsUiEvent.CARD_UPDATED_SUCCESSFULLY -> {
+ Toast.makeText(
+ LocalContext.current,
+ stringResource(id = R.string.card_updated_successfully),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ CardsUiEvent.CARD_DELETED_SUCCESSFULLY -> {
+ Toast.makeText(
+ LocalContext.current,
+ stringResource(id = R.string.card_deleted_successfully),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CardsScreenContent(
+ cardList: List,
+ onEditCard: (Card) -> Unit,
+ onDeleteCard: (Card) -> Unit,
+ onAddBtn: () -> Unit,
+ updateQuery: (String) -> Unit
+) {
+ val query by rememberSaveable { mutableStateOf("") }
+ Box(
+ modifier = Modifier
+ ) {
+ Column {
+ SearchBarScreen(
+ query = query,
+ onQueryChange = { q ->
+ updateQuery(q)
+ },
+ onSearch = {},
+ onClearQuery = { updateQuery("") }
+ )
+ CardsList(
+ cards = cardList,
+ onMenuItemClick = { card, menuItem ->
+ when (menuItem) {
+ CardMenuAction.EDIT -> onEditCard(card)
+ CardMenuAction.DELETE -> onDeleteCard(card)
+ CardMenuAction.CANCEL -> Unit
+ }
+ }
+ )
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .align(Alignment.BottomCenter)
+ .background(color = Color.White)
+ ) {
+ AddCardChip(
+ modifier = Modifier.align(Alignment.Center),
+ onAddBtn = onAddBtn,
+ text = R.string.add_cards,
+ btnText = R.string.add_cards
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchBarScreen(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ onSearch: (String) -> Unit,
+ onClearQuery: () -> Unit
+) {
+ SearchBar(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp, horizontal = 16.dp),
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = onSearch,
+ active = false,
+ onActiveChange = { },
+ placeholder = {
+ Text(text = stringResource(R.string.search))
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Filled.Search,
+ contentDescription = stringResource(R.string.search)
+ )
+ },
+ trailingIcon = {
+ IconButton(
+ onClick = onClearQuery
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = stringResource(R.string.close)
+ )
+ }
+ }
+ ) {}
+}
+
+@Composable
+fun CardsList(
+ cards: List,
+ onMenuItemClick: (Card, CardMenuAction) -> Unit
+) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ ) {
+ items(cards) { card ->
+ CardItem(card = card) { clickedCard, menuItem ->
+ onMenuItemClick(clickedCard, menuItem)
+ }
+ }
+ }
+}
+
+@Composable
+fun CardItem(card: Card, onMenuItemClick: (Card, CardMenuAction) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ Card(
+ modifier = Modifier
+ .clickable { expanded = true }
+ .background(color = Color.White)
+ .fillMaxWidth()
+ .padding(10.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row {
+ Column {
+ Row {
+ Text(text = card.firstName, style = MaterialTheme.typography.bodyMedium)
+ Spacer(modifier = Modifier.height(6.dp))
+ Text(text = card.lastName, style = MaterialTheme.typography.bodyMedium)
+ }
+ Spacer(modifier = Modifier.height(6.dp))
+ Text(
+ text = "${stringResource(R.string.card_number)} ${card.cardNumber}",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(text = card.expiryDate, style = MaterialTheme.typography.bodyMedium)
+ }
+ Spacer(modifier = Modifier.height(38.dp))
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.background(MaterialTheme.colorScheme.background)
+ ) {
+ DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.edit_card)) },
+ onClick = {
+ onMenuItemClick(card, CardMenuAction.EDIT)
+ expanded = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.delete_card)) },
+ onClick = {
+ onMenuItemClick(card, CardMenuAction.DELETE)
+ expanded = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.cancel)) },
+ onClick = {
+ onMenuItemClick(card, CardMenuAction.CANCEL)
+ expanded = false
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun NoCardAddCardsScreen(onAddBtn: () -> Unit) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = stringResource(R.string.add_cards))
+ AddCardChip(
+ modifier = Modifier,
+ onAddBtn = onAddBtn,
+ text = R.string.add_cards,
+ btnText = R.string.add_cards
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CardsScreenWithSampleDataPreview() {
+ MifosTheme {
+ CardsScreen(
+ cardState = CardsUiState.CreditCardForm(sampleCards),
+ cardListUiState = CardsUiState.CreditCardForm(sampleCards),
+ onEditCard = {},
+ onDeleteCard = {},
+ updateQuery = {},
+ onAddBtn = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CardsScreenEmptyPreview() {
+ MifosTheme {
+ CardsScreen(
+ cardState = CardsUiState.Empty,
+ cardListUiState = CardsUiState.CreditCardForm(sampleCards),
+ onEditCard = {},
+ onDeleteCard = {},
+ updateQuery = {},
+ onAddBtn = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CardsScreenErrorPreview() {
+ MifosTheme {
+ CardsScreen(
+ cardState = CardsUiState.Error,
+ cardListUiState = CardsUiState.CreditCardForm(sampleCards),
+ onEditCard = {},
+ onDeleteCard = {},
+ updateQuery = {},
+ onAddBtn = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CardsScreenLoadingPreview() {
+ MifosTheme {
+ CardsScreen(
+ cardState = CardsUiState.Loading,
+ cardListUiState = CardsUiState.CreditCardForm(sampleCards),
+ onEditCard = {},
+ onDeleteCard = {},
+ updateQuery = {},
+ onAddBtn = {}
+ )
+ }
+}
+
+val sampleCards = List(7) { index ->
+ Card(
+ cardNumber = "**** **** **** ${index + 1000}",
+ cvv = "${index + 100}",
+ expiryDate = "$index /0$index/202$index",
+ firstName = "ABC",
+ lastName = " XYZ",
+ id = index
+ )
+}
diff --git a/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsScreenViewModel.kt b/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsScreenViewModel.kt
new file mode 100644
index 00000000..3cd2dd2d
--- /dev/null
+++ b/feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsScreenViewModel.kt
@@ -0,0 +1,162 @@
+package org.mifospay.feature.savedcards
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.mifospay.core.model.entity.savedcards.Card
+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.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import org.mifospay.core.data.base.UseCase
+import org.mifospay.core.data.base.UseCaseHandler
+import org.mifospay.core.data.domain.usecase.savedcards.AddCard
+import org.mifospay.core.data.domain.usecase.savedcards.DeleteCard
+import org.mifospay.core.data.domain.usecase.savedcards.EditCard
+import org.mifospay.core.data.domain.usecase.savedcards.FetchSavedCards
+import org.mifospay.data.local.LocalRepository
+import javax.inject.Inject
+
+@HiltViewModel
+class CardsScreenViewModel @Inject constructor(
+ private val mUseCaseHandler: UseCaseHandler,
+ private val mLocalRepository: LocalRepository,
+ private val addCardUseCase: AddCard,
+ private val fetchSavedCardsUseCase: FetchSavedCards,
+ private val editCardUseCase: EditCard,
+ private val deleteCardUseCase: DeleteCard
+) : ViewModel() {
+
+ private val _searchQuery = MutableStateFlow("")
+ private val searchQuery: StateFlow = _searchQuery.asStateFlow()
+
+ private val _cardState = MutableStateFlow(CardsUiState.Loading)
+ val cardState: StateFlow = _cardState.asStateFlow()
+
+ init {
+ fetchSavedCards()
+ }
+
+ val cardListUiState: StateFlow = searchQuery
+ .map { q ->
+ when (_cardState.value) {
+ is CardsUiState.CreditCardForm -> {
+ val cardList = (cardState.value as CardsUiState.CreditCardForm).cards
+ val filterCards = cardList.filter {
+ it.cardNumber.lowercase().contains(q.lowercase())
+ it.firstName.lowercase().contains(q.lowercase())
+ it.lastName.lowercase().contains(q.lowercase())
+ it.cvv.lowercase().contains(q.lowercase())
+ it.expiryDate.lowercase().contains(q.lowercase())
+ }
+ CardsUiState.CreditCardForm(filterCards)
+ }
+
+ else -> CardsUiState.CreditCardForm(arrayListOf())
+ }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = CardsUiState.CreditCardForm(arrayListOf())
+ )
+
+ fun updateSearchQuery(query: String) {
+ _searchQuery.update { query }
+ }
+
+ fun fetchSavedCards() {
+ fetchSavedCardsUseCase.walletRequestValues = FetchSavedCards.RequestValues(
+ mLocalRepository.clientDetails.clientId
+ )
+ val requestValues = fetchSavedCardsUseCase.walletRequestValues
+ mUseCaseHandler.execute(fetchSavedCardsUseCase, requestValues,
+ object : UseCase.UseCaseCallback {
+ override fun onSuccess(response: FetchSavedCards.ResponseValue) {
+ response.cardList.let { _cardState.value = CardsUiState.CreditCardForm(it) }
+ }
+
+ override fun onError(message: String) {
+ _cardState.value = CardsUiState.Error
+ }
+ })
+ }
+
+ fun addCard(card: Card) {
+ addCardUseCase.walletRequestValues = AddCard.RequestValues(
+ mLocalRepository.clientDetails.clientId,
+ card
+ )
+ val requestValues = addCardUseCase.walletRequestValues
+ mUseCaseHandler.execute(addCardUseCase, requestValues,
+ object : UseCase.UseCaseCallback {
+ override fun onSuccess(response: AddCard.ResponseValue) {
+ _cardState.value = CardsUiState.Success(CardsUiEvent.CARD_ADDED_SUCCESSFULLY)
+ fetchSavedCards()
+ }
+
+ override fun onError(message: String) {
+ _cardState.value = CardsUiState.Error
+ }
+ })
+ }
+
+ fun editCard(card: Card) {
+ editCardUseCase.walletRequestValues = EditCard.RequestValues(
+ mLocalRepository.clientDetails.clientId.toInt(),
+ card
+ )
+ val requestValues = editCardUseCase.walletRequestValues
+ mUseCaseHandler.execute(editCardUseCase, requestValues,
+ object : UseCase.UseCaseCallback {
+ override fun onSuccess(response: EditCard.ResponseValue) {
+ _cardState.value = CardsUiState.Success(CardsUiEvent.CARD_UPDATED_SUCCESSFULLY)
+ fetchSavedCards()
+ }
+
+ override fun onError(message: String) {
+ _cardState.value = CardsUiState.Error
+ }
+ })
+ }
+
+ fun deleteCard(cardId: Int) {
+ deleteCardUseCase.walletRequestValues = DeleteCard.RequestValues(
+ mLocalRepository.clientDetails.clientId.toInt(),
+ cardId
+ )
+ val requestValues = deleteCardUseCase.walletRequestValues
+ mUseCaseHandler.execute(deleteCardUseCase, requestValues,
+ object : UseCase.UseCaseCallback {
+ override fun onSuccess(response: DeleteCard.ResponseValue) {
+ _cardState.value = CardsUiState.Success(CardsUiEvent.CARD_DELETED_SUCCESSFULLY)
+ fetchSavedCards()
+ }
+
+ override fun onError(message: String) {
+ _cardState.value = CardsUiState.Error
+ }
+ })
+ }
+}
+
+
+sealed interface CardsUiState {
+ data class CreditCardForm(
+ val cards: List
+ ) : CardsUiState
+
+ data object Empty : CardsUiState
+ data object Error : CardsUiState
+ data object Loading : CardsUiState
+ data class Success(val cardsUiEvent: CardsUiEvent) : CardsUiState
+}
+
+enum class CardsUiEvent {
+ CARD_ADDED_SUCCESSFULLY,
+ CARD_UPDATED_SUCCESSFULLY,
+ CARD_DELETED_SUCCESSFULLY
+}
\ No newline at end of file
diff --git a/feature/savedcards/src/test/java/org/mifospay/savedcards/ExampleUnitTest.kt b/feature/savedcards/src/test/java/org/mifospay/savedcards/ExampleUnitTest.kt
new file mode 100644
index 00000000..ee0eea6c
--- /dev/null
+++ b/feature/savedcards/src/test/java/org/mifospay/savedcards/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package org.mifospay.savedcards
+
+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 7d3147b7..560830b1 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -41,3 +41,6 @@ include(":feature:make-transfer")
include(":feature:send-money")
include(":feature:notification")
include(":feature:editpassword")
+
+
+include(":feature:savedcards")