From a6bf5f3da42b09dfcd203dcdf65b21d266c33dff Mon Sep 17 00:00:00 2001 From: Aditya Kumdale <95978829+AdityaKumdale@users.noreply.github.com> Date: Mon, 17 Jun 2024 22:41:54 +0530 Subject: [PATCH] moved savedcards to feature module (#1660) --- feature/savedcards/.gitignore | 1 + feature/savedcards/build.gradle.kts | 13 + feature/savedcards/consumer-rules.pro | 0 feature/savedcards/proguard-rules.pro | 21 + .../savedcards/ExampleInstrumentedTest.kt | 24 + .../savedcards/src/main/AndroidManifest.xml | 4 + .../feature/savedcards/AddCardDialogSheet.kt | 227 +++++++++ .../feature/savedcards/CardsFragment.kt | 66 +++ .../feature/savedcards/CardsScreen.kt | 451 ++++++++++++++++++ .../savedcards/CardsScreenViewModel.kt | 162 +++++++ .../mifospay/savedcards/ExampleUnitTest.kt | 17 + settings.gradle.kts | 3 + 12 files changed, 989 insertions(+) create mode 100644 feature/savedcards/.gitignore create mode 100644 feature/savedcards/build.gradle.kts create mode 100644 feature/savedcards/consumer-rules.pro create mode 100644 feature/savedcards/proguard-rules.pro create mode 100644 feature/savedcards/src/androidTest/java/org/mifospay/savedcards/ExampleInstrumentedTest.kt create mode 100644 feature/savedcards/src/main/AndroidManifest.xml create mode 100644 feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/AddCardDialogSheet.kt create mode 100644 feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsFragment.kt create mode 100644 feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsScreen.kt create mode 100644 feature/savedcards/src/main/kotlin/org/mifospay/feature/savedcards/CardsScreenViewModel.kt create mode 100644 feature/savedcards/src/test/java/org/mifospay/savedcards/ExampleUnitTest.kt 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")