diff --git a/bank-sdk/sdk/build.gradle.kts b/bank-sdk/sdk/build.gradle.kts index 9cfc94ad7..b1173dbdc 100644 --- a/bank-sdk/sdk/build.gradle.kts +++ b/bank-sdk/sdk/build.gradle.kts @@ -135,6 +135,10 @@ dependencies { implementation(libs.koin.android) implementation(libs.koin.androidx.compose) + implementation(libs.orbitmvi.test) + implementation(libs.orbitmvi.compose) + implementation(libs.orbitmvi.viewmodel) + testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) diff --git a/bank-sdk/sdk/detekt-baseline.xml b/bank-sdk/sdk/detekt-baseline.xml index 92023d97f..b2bdaf5e9 100644 --- a/bank-sdk/sdk/detekt-baseline.xml +++ b/bank-sdk/sdk/detekt-baseline.xml @@ -137,7 +137,7 @@ NewLineAtEndOfFile:SkontoFragment.kt$net.gini.android.bank.sdk.capture.skonto.SkontoFragment.kt NewLineAtEndOfFile:SkontoFragmentContract.kt$net.gini.android.bank.sdk.capture.skonto.SkontoFragmentContract.kt NewLineAtEndOfFile:SkontoFragmentListener.kt$net.gini.android.bank.sdk.capture.skonto.SkontoFragmentListener.kt - NewLineAtEndOfFile:SkontoFragmentViewModel.kt$net.gini.android.bank.sdk.capture.skonto.SkontoFragmentViewModel.kt + NewLineAtEndOfFile:SkontoFragmentViewModel.kt$net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoFragmentViewModel.kt NewLineAtEndOfFile:SkontoInfoDialogColors.kt$net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoInfoDialogColors.kt NewLineAtEndOfFile:SkontoInvoiceScanSectionColors.kt$net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoInvoiceScanSectionColors.kt NewLineAtEndOfFile:SkontoNavigationBarBottomAdapter.kt$net.gini.android.bank.sdk.capture.skonto.SkontoNavigationBarBottomAdapter.kt diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoFragment.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoFragment.kt index c330040c3..85852d8db 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoFragment.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoFragment.kt @@ -2,7 +2,6 @@ package net.gini.android.bank.sdk.capture.digitalinvoice.skonto -import android.annotation.SuppressLint import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.icu.util.Calendar import android.os.Bundle @@ -44,7 +43,6 @@ import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -55,7 +53,6 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -65,23 +62,26 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.findNavController import androidx.navigation.fragment.navArgs import net.gini.android.bank.sdk.GiniBank import net.gini.android.bank.sdk.R +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args.DigitalInvoiceSkontoResultArgs import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.DigitalInvoiceSkontoScreenColors import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.section.DigitalInvoiceSkontoFooterSectionColors import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.section.DigitalInvoiceSkontoInfoDialogColors import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.section.DigitalInvoiceSkontoInvoicePreviewSectionColors import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.section.DigitalInvoiceSkontoSectionColors +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.mapper.toErrorMessage +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.DigitalInvoiceSkontoViewModel import net.gini.android.bank.sdk.capture.skonto.model.SkontoData import net.gini.android.bank.sdk.capture.skonto.model.SkontoEdgeCase import net.gini.android.bank.sdk.di.koin.giniBankViewModel import net.gini.android.bank.sdk.util.disallowScreenshots +import net.gini.android.bank.sdk.util.ui.keyboardAsState import net.gini.android.capture.Amount import net.gini.android.capture.GiniCapture import net.gini.android.capture.internal.util.ActivityHelper @@ -93,8 +93,11 @@ import net.gini.android.capture.ui.components.topbar.GiniTopBar import net.gini.android.capture.ui.components.topbar.GiniTopBarColors import net.gini.android.capture.ui.theme.GiniTheme import net.gini.android.capture.ui.theme.modifier.tabletMaxWidth +import net.gini.android.capture.util.compose.keyboardPadding import net.gini.android.capture.view.InjectedViewAdapterInstance import org.koin.core.parameter.parametersOf +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect import java.math.BigDecimal import java.math.RoundingMode import java.time.LocalDate @@ -149,12 +152,9 @@ class DigitalInvoiceSkontoFragment : Fragment() { isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, customBottomNavBarAdapter = customBottomNavBarAdapter, navigateBack = { - setFragmentResult(REQUEST_KEY, Bundle().apply { - putParcelable( - RESULT_KEY, - viewModel.provideFragmentResult() - ) - } + setFragmentResult( + REQUEST_KEY, + bundleOf(RESULT_KEY to it) ) findNavController().popBackStack() }, @@ -182,27 +182,11 @@ class DigitalInvoiceSkontoFragment : Fragment() { } } -@Composable -@SuppressLint("ComposableNaming") -private fun DigitalInvoiceSkontoViewModel.collectSideEffect( - action: (DigitalInvoiceSkontoSideEffect) -> Unit -) { - - val lifecycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(sideEffectFlow, lifecycleOwner) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - sideEffectFlow.collect { - action(it) - } - } - } -} @Composable private fun ScreenContent( isBottomNavigationBarEnabled: Boolean, - navigateBack: () -> Unit, + navigateBack: (DigitalInvoiceSkontoResultArgs) -> Unit, navigateToHelpScreen: () -> Unit, navigateToInvoiceScreen: (documentId: String, infoTextLines: List) -> Unit, viewModel: DigitalInvoiceSkontoViewModel, @@ -211,16 +195,24 @@ private fun ScreenContent( screenColorScheme: DigitalInvoiceSkontoScreenColors = DigitalInvoiceSkontoScreenColors.colors(), ) { - BackHandler { navigateBack() } + BackHandler { viewModel.onBackClicked() } - val state by viewModel.stateFlow.collectAsState() + val state by viewModel.collectAsState() + val keyboardState by keyboardAsState() + + LaunchedEffect(keyboardState) { + viewModel.onKeyboardStateChanged(keyboardState) + } viewModel.collectSideEffect { when (it) { - is DigitalInvoiceSkontoSideEffect.OpenInvoiceScreen -> + is SkontoSideEffect.OpenInvoiceScreen -> navigateToInvoiceScreen(it.documentId, it.infoTextLines) - DigitalInvoiceSkontoSideEffect.OpenHelpScreen -> + is SkontoSideEffect.NavigateBack -> + navigateBack(it.args) + + SkontoSideEffect.OpenHelpScreen -> navigateToHelpScreen() } } @@ -232,18 +224,18 @@ private fun ScreenContent( onSkontoAmountChange = viewModel::onSkontoAmountFieldChanged, onDueDateChanged = viewModel::onSkontoDueDateChanged, isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, - onBackClicked = navigateBack, + onBackClicked = viewModel::onBackClicked, onInfoBannerClicked = viewModel::onInfoBannerClicked, onInfoDialogDismissed = viewModel::onInfoDialogDismissed, onInvoiceClicked = viewModel::onInvoiceClicked, customBottomNavBarAdapter = customBottomNavBarAdapter, - onHelpClicked = viewModel::onHelpClicked + onHelpClicked = viewModel::onHelpClicked, ) } @Composable private fun ScreenStateContent( - state: DigitalInvoiceSkontoScreenState, + state: SkontoScreenState, isBottomNavigationBarEnabled: Boolean, onSkontoAmountChange: (BigDecimal) -> Unit, onDueDateChanged: (LocalDate) -> Unit, @@ -257,7 +249,7 @@ private fun ScreenStateContent( screenColorScheme: DigitalInvoiceSkontoScreenColors = DigitalInvoiceSkontoScreenColors.colors() ) { when (state) { - is DigitalInvoiceSkontoScreenState.Ready -> ScreenReadyState( + is SkontoScreenState.Ready -> ScreenReadyState( modifier = modifier, state = state, screenColorScheme = screenColorScheme, @@ -277,7 +269,7 @@ private fun ScreenStateContent( @Composable private fun ScreenReadyState( - state: DigitalInvoiceSkontoScreenState.Ready, + state: SkontoScreenState.Ready, onBackClicked: () -> Unit, onInvoiceClicked: () -> Unit, onHelpClicked: () -> Unit, @@ -292,6 +284,8 @@ private fun ScreenReadyState( ) { val scrollState = rememberScrollState() + val keyboardPadding by keyboardPadding(108.dp, scrollState) + Scaffold( modifier = modifier, containerColor = screenColorScheme.backgroundColor, @@ -316,7 +310,8 @@ private fun ScreenReadyState( Column( modifier = Modifier .padding(it) - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .padding(bottom = keyboardPadding), horizontalAlignment = Alignment.CenterHorizontally, ) { Column( @@ -352,6 +347,7 @@ private fun ScreenReadyState( onDueDateChanged = onDueDateChanged, edgeCase = state.edgeCase, onInfoBannerClicked = onInfoBannerClicked, + skontoAmountValidationError = state.skontoAmountValidationError, ) } } @@ -534,9 +530,11 @@ private fun SkontoSection( onInfoBannerClicked: () -> Unit, edgeCase: SkontoEdgeCase?, colors: DigitalInvoiceSkontoSectionColors, + skontoAmountValidationError: SkontoScreenState.Ready.SkontoAmountValidationError?, modifier: Modifier = Modifier, ) { val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + val resources = LocalContext.current.resources var isDatePickerVisible by remember { mutableStateOf(false) } Card( @@ -651,6 +649,10 @@ private fun SkontoSection( ) } }, + isError = skontoAmountValidationError != null, + supportingText = skontoAmountValidationError?.toErrorMessage( + resources = resources, + ) ) val dueDateOnClickSource = remember { MutableInteractionSource() } @@ -891,7 +893,7 @@ private fun Float.formatAsDiscountPercentage(): String { return "${value.toString().trimEnd('0').trimEnd('.')}%" } -private val previewState = DigitalInvoiceSkontoScreenState.Ready( +private val previewState = SkontoScreenState.Ready( isSkontoSectionActive = true, paymentInDays = 14, skontoPercentage = BigDecimal("3"), @@ -901,4 +903,5 @@ private val previewState = DigitalInvoiceSkontoScreenState.Ready( paymentMethod = SkontoData.SkontoPaymentMethod.PayPal, edgeCase = SkontoEdgeCase.PayByCashOnly, edgeCaseInfoDialogVisible = false, + skontoAmountValidationError = null ) diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenModule.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenModule.kt index 1565686d0..b409fe926 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenModule.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenModule.kt @@ -1,6 +1,15 @@ package net.gini.android.bank.sdk.capture.digitalinvoice.skonto import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args.DigitalInvoiceSkontoArgs +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.validation.DigitalInvoiceSkontoAmountValidator +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.DigitalInvoiceSkontoViewModel +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.SkontoScreenInitialStateFactory +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.BackClickIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.InfoBannerInteractionIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.InvoiceClickIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.KeyboardStateChangeIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.SkontoAmountFieldChangeIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.SkontoDueDateChangeIntent import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -8,11 +17,41 @@ val digitalInvoiceSkontoScreenModule = module { viewModel { (data: DigitalInvoiceSkontoArgs) -> DigitalInvoiceSkontoViewModel( args = data, - getSkontoDiscountPercentageUseCase = get(), - getSkontoEdgeCaseUseCase = get(), - getSkontoRemainingDaysUseCase = get(), + skontoScreenInitialStateFactory = get(), + invoiceClickIntent = get(), + backClickIntent = get(), + infoBannerInteractionIntent = get(), + keyboardStateChangeIntent = get(), + skontoDueDateChangeIntent = get(), + skontoAmountFieldChangeIntent = get() + ) + } + factory { DigitalInvoiceSkontoAmountValidator() } + factory { + SkontoScreenInitialStateFactory( + getSkontoEdgeCaseUseCase = get() + ) + } + factory { + InvoiceClickIntent( lastAnalyzedDocumentProvider = get(), - skontoInvoicePreviewTextLinesFactory = get() + skontoInvoicePreviewTextLinesFactory = get(), ) } + factory { BackClickIntent() } + factory { InfoBannerInteractionIntent() } + factory { KeyboardStateChangeIntent() } + factory { + SkontoDueDateChangeIntent( + getSkontoRemainingDaysUseCase = get(), + getSkontoEdgeCaseUseCase = get(), + ) + } + factory { + SkontoAmountFieldChangeIntent( + digitalInvoiceSkontoAmountValidator = get(), + getSkontoDiscountPercentageUseCase = get(), + ) + } + } diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoViewModel.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoViewModel.kt deleted file mode 100644 index cb5d917f9..000000000 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoViewModel.kt +++ /dev/null @@ -1,166 +0,0 @@ -package net.gini.android.bank.sdk.capture.digitalinvoice.skonto - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args.DigitalInvoiceSkontoArgs -import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args.DigitalInvoiceSkontoResultArgs -import net.gini.android.bank.sdk.capture.skonto.factory.lines.SkontoInvoicePreviewTextLinesFactory -import net.gini.android.bank.sdk.capture.skonto.model.SkontoData -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDiscountPercentageUseCase -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoEdgeCaseUseCase -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoRemainingDaysUseCase -import net.gini.android.capture.analysis.LastAnalyzedDocumentProvider -import java.math.BigDecimal -import java.time.LocalDate - -internal class DigitalInvoiceSkontoViewModel( - args: DigitalInvoiceSkontoArgs, - private val lastAnalyzedDocumentProvider: LastAnalyzedDocumentProvider, - private val getSkontoDiscountPercentageUseCase: GetSkontoDiscountPercentageUseCase, - private val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase, - private val getSkontoRemainingDaysUseCase: GetSkontoRemainingDaysUseCase, - private val skontoInvoicePreviewTextLinesFactory: SkontoInvoicePreviewTextLinesFactory, -) : ViewModel() { - - val stateFlow: MutableStateFlow = - MutableStateFlow(createInitalState(args.data, args.isSkontoSectionActive)) - - val sideEffectFlow: MutableSharedFlow = MutableSharedFlow() - - internal fun provideFragmentResult(): DigitalInvoiceSkontoResultArgs { - val currentState = - stateFlow.value as? DigitalInvoiceSkontoScreenState.Ready - ?: error("Can't extract result. State is not ready") - - return DigitalInvoiceSkontoResultArgs( - skontoData = SkontoData( - skontoAmountToPay = currentState.skontoAmount, - skontoDueDate = currentState.discountDueDate, - skontoPercentageDiscounted = currentState.skontoPercentage, - skontoRemainingDays = currentState.paymentInDays, - fullAmountToPay = currentState.fullAmount, - skontoPaymentMethod = currentState.paymentMethod, - ), - isSkontoEnabled = currentState.isSkontoSectionActive, - ) - } - - private fun createInitalState( - data: SkontoData, - isSkontoSectionActive: Boolean, - ): DigitalInvoiceSkontoScreenState.Ready { - - - val discount = data.skontoPercentageDiscounted - - val paymentMethod = - data.skontoPaymentMethod ?: SkontoData.SkontoPaymentMethod.Unspecified - val edgeCase = getSkontoEdgeCaseUseCase.execute(data.skontoDueDate, paymentMethod) - - return DigitalInvoiceSkontoScreenState.Ready( - isSkontoSectionActive = isSkontoSectionActive, - paymentInDays = data.skontoRemainingDays, - skontoPercentage = discount, - skontoAmount = data.skontoAmountToPay, - discountDueDate = data.skontoDueDate, - fullAmount = data.fullAmountToPay, - paymentMethod = paymentMethod, - edgeCase = edgeCase, - edgeCaseInfoDialogVisible = edgeCase != null, - ) - } - - fun onSkontoAmountFieldChanged(newValue: BigDecimal) = viewModelScope.launch { - val currentState = - stateFlow.value as? DigitalInvoiceSkontoScreenState.Ready ?: return@launch - - if (newValue > currentState.fullAmount.value) { - stateFlow.emit( - currentState.copy(skontoAmount = currentState.skontoAmount) - ) - return@launch - } - - val discount = getSkontoDiscountPercentageUseCase.execute( - newValue, - currentState.fullAmount.value - ) - - val newSkontoAmount = currentState.skontoAmount.copy(value = newValue) - - stateFlow.emit( - currentState.copy( - skontoAmount = newSkontoAmount, - skontoPercentage = discount, - ) - ) - } - - fun onSkontoDueDateChanged(newDate: LocalDate) = viewModelScope.launch { - val currentState = - stateFlow.value as? DigitalInvoiceSkontoScreenState.Ready ?: return@launch - val newPayInDays = getSkontoRemainingDaysUseCase.execute(newDate) - stateFlow.emit( - currentState.copy( - discountDueDate = newDate, - paymentInDays = newPayInDays, - edgeCase = getSkontoEdgeCaseUseCase.execute( - dueDate = newDate, - paymentMethod = currentState.paymentMethod - ) - ) - ) - } - - fun onInfoBannerClicked() = viewModelScope.launch { - val currentState = - stateFlow.value as? DigitalInvoiceSkontoScreenState.Ready ?: return@launch - stateFlow.emit( - currentState.copy( - edgeCaseInfoDialogVisible = true, - ) - ) - } - - fun onInfoDialogDismissed() = viewModelScope.launch { - val currentState = - stateFlow.value as? DigitalInvoiceSkontoScreenState.Ready ?: return@launch - stateFlow.emit( - currentState.copy( - edgeCaseInfoDialogVisible = false, - ) - ) - } - - fun onInvoiceClicked() = viewModelScope.launch { - val currentState = - stateFlow.value as? DigitalInvoiceSkontoScreenState.Ready ?: return@launch - val documentId = lastAnalyzedDocumentProvider.provide()?.giniApiDocumentId ?: return@launch - - val skontoData = SkontoData( - skontoAmountToPay = currentState.skontoAmount, - skontoDueDate = currentState.discountDueDate, - skontoPercentageDiscounted = currentState.skontoPercentage, - skontoRemainingDays = currentState.paymentInDays, - fullAmountToPay = currentState.fullAmount, - skontoPaymentMethod = currentState.paymentMethod, - ) - val infoTextLines = skontoInvoicePreviewTextLinesFactory.create( - skontoData - ) - - sideEffectFlow.emit( - DigitalInvoiceSkontoSideEffect.OpenInvoiceScreen( - documentId, - infoTextLines - ) - ) - } - - fun onHelpClicked() = viewModelScope.launch { - sideEffectFlow.emit(DigitalInvoiceSkontoSideEffect.OpenHelpScreen) - } -} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenState.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/SkontoScreenState.kt similarity index 66% rename from bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenState.kt rename to bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/SkontoScreenState.kt index 191168660..ae58cd1a0 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenState.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/SkontoScreenState.kt @@ -6,25 +6,24 @@ import net.gini.android.capture.Amount import java.math.BigDecimal import java.time.LocalDate -internal sealed class DigitalInvoiceSkontoScreenState { +internal sealed interface SkontoScreenState { data class Ready( val isSkontoSectionActive: Boolean, val paymentInDays: Int, val skontoPercentage: BigDecimal, val skontoAmount: Amount, + val skontoAmountValidationError: SkontoAmountValidationError?, val fullAmount: Amount, val discountDueDate: LocalDate, val paymentMethod: SkontoData.SkontoPaymentMethod, val edgeCase: SkontoEdgeCase?, val edgeCaseInfoDialogVisible: Boolean, - ) : DigitalInvoiceSkontoScreenState() -} - -internal sealed interface DigitalInvoiceSkontoSideEffect { - data class OpenInvoiceScreen(val documentId: String, val infoTextLines: List) : - DigitalInvoiceSkontoSideEffect + ) : SkontoScreenState { - object OpenHelpScreen : DigitalInvoiceSkontoSideEffect + sealed interface SkontoAmountValidationError { + object SkontoAmountMoreThanFullAmount : SkontoAmountValidationError + } + } } diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/SkontoSideEffect.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/SkontoSideEffect.kt new file mode 100644 index 000000000..bf604c3f2 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/SkontoSideEffect.kt @@ -0,0 +1,12 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args.DigitalInvoiceSkontoResultArgs + +internal sealed interface SkontoSideEffect { + data class OpenInvoiceScreen(val documentId: String, val infoTextLines: List) : + SkontoSideEffect + + object OpenHelpScreen : SkontoSideEffect + + data class NavigateBack(val args: DigitalInvoiceSkontoResultArgs) : SkontoSideEffect +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/mapper/SkontoAmountValidationErrorMapper.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/mapper/SkontoAmountValidationErrorMapper.kt new file mode 100644 index 000000000..14f268638 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/mapper/SkontoAmountValidationErrorMapper.kt @@ -0,0 +1,14 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.mapper + +import android.content.res.Resources +import net.gini.android.bank.sdk.R +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState + +internal fun SkontoScreenState.Ready.SkontoAmountValidationError.toErrorMessage( + resources: Resources, +): String = when (this) { + is SkontoScreenState.Ready.SkontoAmountValidationError.SkontoAmountMoreThanFullAmount -> + resources.getString( + R.string.gbs_skonto_section_discount_field_amount_validation_error_skonto_amount_more_than_full_amount + ) +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/validation/DigitalInvoiceSkontoAmountValidator.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/validation/DigitalInvoiceSkontoAmountValidator.kt new file mode 100644 index 000000000..2b7ab84d2 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/validation/DigitalInvoiceSkontoAmountValidator.kt @@ -0,0 +1,15 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.validation + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import java.math.BigDecimal + +internal class DigitalInvoiceSkontoAmountValidator { + + operator fun invoke(newSkontoAmount: BigDecimal, fullAmount: BigDecimal) + : SkontoScreenState.Ready.SkontoAmountValidationError? = when { + newSkontoAmount > fullAmount -> + SkontoScreenState.Ready.SkontoAmountValidationError.SkontoAmountMoreThanFullAmount + + else -> null + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/DigitalInvoiceSkontoViewModel.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/DigitalInvoiceSkontoViewModel.kt new file mode 100644 index 000000000..32b12b3ba --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/DigitalInvoiceSkontoViewModel.kt @@ -0,0 +1,60 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel + +import androidx.lifecycle.ViewModel +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoSideEffect +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args.DigitalInvoiceSkontoArgs +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.BackClickIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.InfoBannerInteractionIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.InvoiceClickIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.KeyboardStateChangeIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.SkontoAmountFieldChangeIntent +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent.SkontoDueDateChangeIntent +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import java.math.BigDecimal +import java.time.LocalDate + +internal typealias SkontoContainerHost = + ContainerHost + +internal class DigitalInvoiceSkontoViewModel( + args: DigitalInvoiceSkontoArgs, + skontoScreenInitialStateFactory: SkontoScreenInitialStateFactory, + private val invoiceClickIntent: InvoiceClickIntent, + private val backClickIntent: BackClickIntent, + private val infoBannerInteractionIntent: InfoBannerInteractionIntent, + private val keyboardStateChangeIntent: KeyboardStateChangeIntent, + private val skontoDueDateChangeIntent: SkontoDueDateChangeIntent, + private val skontoAmountFieldChangeIntent: SkontoAmountFieldChangeIntent, +) : ViewModel(), SkontoContainerHost { + + override val container: Container = + container(skontoScreenInitialStateFactory.create(args.data, args.isSkontoSectionActive)) + + fun onSkontoAmountFieldChanged(newValue: BigDecimal) = + with(skontoAmountFieldChangeIntent) { run(newValue) } + + fun onSkontoDueDateChanged(newDate: LocalDate) = + with(skontoDueDateChangeIntent) { run(newDate) } + + fun onKeyboardStateChanged(isVisible: Boolean) = + with(keyboardStateChangeIntent) { run(isVisible) } + + fun onInfoBannerClicked() = + with(infoBannerInteractionIntent) { runClick() } + + fun onInfoDialogDismissed() = + with(infoBannerInteractionIntent) { runDismiss() } + + fun onInvoiceClicked() = + with(invoiceClickIntent) { run() } + + fun onBackClicked() = + with(backClickIntent) { run() } + + + fun onHelpClicked() = + intent { postSideEffect(SkontoSideEffect.OpenHelpScreen) } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/SkontoScreenInitialStateFactory.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/SkontoScreenInitialStateFactory.kt new file mode 100644 index 000000000..1c20e8e23 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/SkontoScreenInitialStateFactory.kt @@ -0,0 +1,30 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoEdgeCaseUseCase + +internal class SkontoScreenInitialStateFactory( + private val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase, +) { + + fun create(data: SkontoData, isSkontoSectionActive: Boolean): SkontoScreenState.Ready { + val discount = data.skontoPercentageDiscounted + + val paymentMethod = data.skontoPaymentMethod ?: SkontoData.SkontoPaymentMethod.Unspecified + val edgeCase = getSkontoEdgeCaseUseCase.execute(data.skontoDueDate, paymentMethod) + + return SkontoScreenState.Ready( + isSkontoSectionActive = isSkontoSectionActive, + paymentInDays = data.skontoRemainingDays, + skontoPercentage = discount, + skontoAmount = data.skontoAmountToPay, + discountDueDate = data.skontoDueDate, + fullAmount = data.fullAmountToPay, + paymentMethod = paymentMethod, + edgeCase = edgeCase, + edgeCaseInfoDialogVisible = edgeCase != null, + skontoAmountValidationError = null, + ) + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/BackClickIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/BackClickIntent.kt new file mode 100644 index 000000000..b4858ab59 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/BackClickIntent.kt @@ -0,0 +1,27 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoSideEffect +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args.DigitalInvoiceSkontoResultArgs +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.SkontoContainerHost +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData + +internal class BackClickIntent { + + fun SkontoContainerHost.run() = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + val args = DigitalInvoiceSkontoResultArgs( + skontoData = SkontoData( + skontoAmountToPay = state.skontoAmount, + skontoDueDate = state.discountDueDate, + skontoPercentageDiscounted = state.skontoPercentage, + skontoRemainingDays = state.paymentInDays, + fullAmountToPay = state.fullAmount, + skontoPaymentMethod = state.paymentMethod, + ), + isSkontoEnabled = state.isSkontoSectionActive, + ) + + postSideEffect(SkontoSideEffect.NavigateBack(args)) + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/InfoBannerInteractionIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/InfoBannerInteractionIntent.kt new file mode 100644 index 000000000..2d1ec94d8 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/InfoBannerInteractionIntent.kt @@ -0,0 +1,25 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.SkontoContainerHost + +internal class InfoBannerInteractionIntent { + + fun SkontoContainerHost.runClick() = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + reduce { + state.copy( + edgeCaseInfoDialogVisible = true, + ) + } + } + + fun SkontoContainerHost.runDismiss() = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + reduce { + state.copy( + edgeCaseInfoDialogVisible = false, + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/InvoiceClickIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/InvoiceClickIntent.kt new file mode 100644 index 000000000..e77d9aea0 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/InvoiceClickIntent.kt @@ -0,0 +1,34 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoSideEffect +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.SkontoContainerHost +import net.gini.android.bank.sdk.capture.skonto.factory.lines.SkontoInvoicePreviewTextLinesFactory +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.capture.analysis.LastAnalyzedDocumentProvider + +internal class InvoiceClickIntent( + private val lastAnalyzedDocumentProvider: LastAnalyzedDocumentProvider, + private val skontoInvoicePreviewTextLinesFactory: SkontoInvoicePreviewTextLinesFactory +) { + + fun SkontoContainerHost.run() = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + + val documentId = lastAnalyzedDocumentProvider.provide()?.giniApiDocumentId ?: return@intent + + val skontoData = SkontoData( + skontoAmountToPay = state.skontoAmount, + skontoDueDate = state.discountDueDate, + skontoPercentageDiscounted = state.skontoPercentage, + skontoRemainingDays = state.paymentInDays, + fullAmountToPay = state.fullAmount, + skontoPaymentMethod = state.paymentMethod, + ) + val infoTextLines = skontoInvoicePreviewTextLinesFactory.create( + skontoData + ) + + postSideEffect(SkontoSideEffect.OpenInvoiceScreen(documentId, infoTextLines)) + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/KeyboardStateChangeIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/KeyboardStateChangeIntent.kt new file mode 100644 index 000000000..c7032a599 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/KeyboardStateChangeIntent.kt @@ -0,0 +1,17 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.SkontoContainerHost + +internal class KeyboardStateChangeIntent { + + fun SkontoContainerHost.run(visible: Boolean) = intent { + if (visible) return@intent + val state = state as? SkontoScreenState.Ready ?: return@intent + reduce { + state.copy( + skontoAmountValidationError = null + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/SkontoAmountFieldChangeIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/SkontoAmountFieldChangeIntent.kt new file mode 100644 index 000000000..c014244d1 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/SkontoAmountFieldChangeIntent.kt @@ -0,0 +1,50 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.validation.DigitalInvoiceSkontoAmountValidator +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.SkontoContainerHost +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDiscountPercentageUseCase +import java.math.BigDecimal + +internal class SkontoAmountFieldChangeIntent( + private val digitalInvoiceSkontoAmountValidator: DigitalInvoiceSkontoAmountValidator, + private val getSkontoDiscountPercentageUseCase: GetSkontoDiscountPercentageUseCase, +) { + + fun SkontoContainerHost.run(newValue: BigDecimal) = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + + if (newValue == state.skontoAmount.value) return@intent + + val skontoAmountValidationError = digitalInvoiceSkontoAmountValidator( + newValue, + state.fullAmount.value + ) + + if (skontoAmountValidationError != null) { + reduce { + state.copy( + skontoAmount = state.skontoAmount, + skontoAmountValidationError = SkontoScreenState + .Ready.SkontoAmountValidationError.SkontoAmountMoreThanFullAmount + ) + } + return@intent + } + + val discount = getSkontoDiscountPercentageUseCase.execute( + newValue, + state.fullAmount.value + ) + + val newSkontoAmount = state.skontoAmount.copy(value = newValue) + + reduce { + state.copy( + skontoAmount = newSkontoAmount, + skontoPercentage = discount, + skontoAmountValidationError = null, + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/SkontoDueDateChangeIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/SkontoDueDateChangeIntent.kt new file mode 100644 index 000000000..9316c1982 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/viewmodel/intent/SkontoDueDateChangeIntent.kt @@ -0,0 +1,28 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.viewmodel.SkontoContainerHost +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoEdgeCaseUseCase +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoRemainingDaysUseCase +import java.time.LocalDate + +internal class SkontoDueDateChangeIntent( + private val getSkontoRemainingDaysUseCase: GetSkontoRemainingDaysUseCase, + private val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase, +) { + + fun SkontoContainerHost.run(newDate: LocalDate) = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + val newPayInDays = getSkontoRemainingDaysUseCase.execute(newDate) + reduce { + state.copy( + discountDueDate = newDate, + paymentInDays = newPayInDays, + edgeCase = getSkontoEdgeCaseUseCase.execute( + dueDate = newDate, + paymentMethod = state.paymentMethod + ) + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragment.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragment.kt index 8194ca7c8..b0337bd15 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragment.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragment.kt @@ -1,111 +1,27 @@ -@file:OptIn(ExperimentalMaterial3Api::class) - package net.gini.android.bank.sdk.capture.skonto -import android.annotation.SuppressLint -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.icu.util.Calendar import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.widget.FrameLayout -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SelectableDates -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.findNavController import androidx.navigation.fragment.navArgs import net.gini.android.bank.sdk.GiniBank import net.gini.android.bank.sdk.R -import net.gini.android.bank.sdk.capture.skonto.colors.SkontoScreenColors -import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoFooterSectionColors -import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoInfoDialogColors -import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoInvoicePreviewSectionColors -import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoSectionColors -import net.gini.android.bank.sdk.capture.skonto.colors.section.WithoutSkontoSectionColors -import net.gini.android.bank.sdk.capture.skonto.formatter.SkontoDiscountPercentageFormatter -import net.gini.android.bank.sdk.capture.skonto.model.SkontoData -import net.gini.android.bank.sdk.capture.skonto.model.SkontoEdgeCase -import net.gini.android.bank.sdk.capture.util.currencyFormatterWithoutSymbol +import net.gini.android.bank.sdk.capture.skonto.formatter.AmountFormatter +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoFragmentViewModel import net.gini.android.bank.sdk.di.getGiniBankKoin -import net.gini.android.bank.sdk.transactiondocs.ui.dialog.attachdoc.AttachDocumentToTransactionDialog import net.gini.android.bank.sdk.util.disallowScreenshots -import net.gini.android.capture.Amount import net.gini.android.capture.GiniCapture import net.gini.android.capture.internal.util.ActivityHelper.forcePortraitOrientationOnPhones import net.gini.android.capture.internal.util.CancelListener -import net.gini.android.capture.ui.components.button.filled.GiniButton -import net.gini.android.capture.ui.components.picker.date.GiniDatePickerDialog -import net.gini.android.capture.ui.components.switcher.GiniSwitch -import net.gini.android.capture.ui.components.textinput.GiniTextInput -import net.gini.android.capture.ui.components.textinput.amount.GiniAmountTextInput -import net.gini.android.capture.ui.components.tooltip.GiniTooltipBox -import net.gini.android.capture.ui.components.topbar.GiniTopBar -import net.gini.android.capture.ui.components.topbar.GiniTopBarColors import net.gini.android.capture.ui.theme.GiniTheme -import net.gini.android.capture.ui.theme.modifier.tabletMaxWidth -import net.gini.android.capture.ui.theme.typography.bold import net.gini.android.capture.view.InjectedViewAdapterInstance import org.koin.core.parameter.parametersOf -import java.math.BigDecimal -import java.time.LocalDate -import java.time.format.DateTimeFormatter class SkontoFragment : Fragment() { @@ -114,6 +30,7 @@ class SkontoFragment : Fragment() { private val viewModel: SkontoFragmentViewModel by getGiniBankKoin().inject { parametersOf(args.data) } + private val amountFormatter : AmountFormatter by getGiniBankKoin().inject() lateinit var cancelListener: CancelListener @@ -154,7 +71,7 @@ class SkontoFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { GiniTheme { - ScreenContent( + SkontoScreenContent( viewModel = viewModel, isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, customBottomNavBarAdapter = customBottomNavBarAdapter, @@ -177,949 +94,10 @@ class SkontoFragment : Fragment() { navigateToHelp = { findNavController().navigate(SkontoFragmentDirections.toSkontoHelpFragment()) }, + amountFormatter = amountFormatter, ) } } } } } - -@Composable -@SuppressLint("ComposableNaming") -private fun SkontoFragmentViewModel.collectSideEffect( - action: (SkontoFragmentContract.SideEffect) -> Unit -) { - - val lifecycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(sideEffectFlow, lifecycleOwner) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - sideEffectFlow.collect { - action(it) - } - } - } -} - -@Composable -private fun ScreenContent( - navigateBack: () -> Unit, - navigateToHelp: () -> Unit, - viewModel: SkontoFragmentViewModel, - modifier: Modifier = Modifier, - screenColorScheme: SkontoScreenColors = SkontoScreenColors.colors(), - isBottomNavigationBarEnabled: Boolean, - customBottomNavBarAdapter: InjectedViewAdapterInstance?, - navigateToInvoiceScreen: (documentId: String, infoTextLines: List) -> Unit, -) { - - BackHandler { navigateBack() } - - val state by viewModel.stateFlow.collectAsState() - - viewModel.collectSideEffect { - when (it) { - is SkontoFragmentContract.SideEffect.OpenInvoiceScreen -> - navigateToInvoiceScreen(it.documentId, it.infoTextLines) - } - } - - ScreenStateContent( - modifier = modifier, - state = state, - screenColorScheme = screenColorScheme, - onDiscountSectionActiveChange = viewModel::onSkontoActiveChanged, - onSkontoAmountChange = viewModel::onSkontoAmountFieldChanged, - onDueDateChanged = viewModel::onSkontoDueDateChanged, - onFullAmountChange = viewModel::onFullAmountFieldChanged, - isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, - onBackClicked = navigateBack, - onHelpClicked = navigateToHelp, - customBottomNavBarAdapter = customBottomNavBarAdapter, - onProceedClicked = viewModel::onProceedClicked, - onInfoBannerClicked = viewModel::onInfoBannerClicked, - onInfoDialogDismissed = viewModel::onInfoDialogDismissed, - onInvoiceClicked = viewModel::onInvoiceClicked, - onConfirmAttachTransactionDocClicked = viewModel::onConfirmAttachTransactionDocClicked, - onCancelAttachTransactionDocClicked = viewModel::onCancelAttachTransactionDocClicked, - ) -} - -@Composable -private fun ScreenStateContent( - state: SkontoFragmentContract.State, - onDiscountSectionActiveChange: (Boolean) -> Unit, - onSkontoAmountChange: (BigDecimal) -> Unit, - onFullAmountChange: (BigDecimal) -> Unit, - onDueDateChanged: (LocalDate) -> Unit, - onBackClicked: () -> Unit, - onHelpClicked: () -> Unit, - onProceedClicked: () -> Unit, - isBottomNavigationBarEnabled: Boolean, - customBottomNavBarAdapter: InjectedViewAdapterInstance?, - onInfoBannerClicked: () -> Unit, - onInfoDialogDismissed: () -> Unit, - onInvoiceClicked: () -> Unit, - onConfirmAttachTransactionDocClicked: (alwaysAttach: Boolean) -> Unit, - onCancelAttachTransactionDocClicked: () -> Unit, - modifier: Modifier = Modifier, - screenColorScheme: SkontoScreenColors = SkontoScreenColors.colors() -) { - when (state) { - is SkontoFragmentContract.State.Ready -> ScreenReadyState( - modifier = modifier, - state = state, - screenColorScheme = screenColorScheme, - onDiscountSectionActiveChange = onDiscountSectionActiveChange, - onDiscountAmountChange = onSkontoAmountChange, - onDueDateChanged = onDueDateChanged, - onFullAmountChange = onFullAmountChange, - onBackClicked = onBackClicked, - onHelpClicked = onHelpClicked, - isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, - customBottomNavBarAdapter = customBottomNavBarAdapter, - onProceedClicked = onProceedClicked, - onInfoBannerClicked = onInfoBannerClicked, - onInfoDialogDismissed = onInfoDialogDismissed, - onInvoiceClicked = onInvoiceClicked, - onConfirmAttachTransactionDocClicked = onConfirmAttachTransactionDocClicked, - onCancelAttachTransactionDocClicked = onCancelAttachTransactionDocClicked, - ) - } - -} - -@Composable -private fun ScreenReadyState( - onConfirmAttachTransactionDocClicked: (alwaysAttach: Boolean) -> Unit, - onCancelAttachTransactionDocClicked: () -> Unit, - onBackClicked: () -> Unit, - onHelpClicked: () -> Unit, - onProceedClicked: () -> Unit, - onInvoiceClicked: () -> Unit, - state: SkontoFragmentContract.State.Ready, - onDiscountSectionActiveChange: (Boolean) -> Unit, - onDiscountAmountChange: (BigDecimal) -> Unit, - onDueDateChanged: (LocalDate) -> Unit, - onFullAmountChange: (BigDecimal) -> Unit, - isBottomNavigationBarEnabled: Boolean, - customBottomNavBarAdapter: InjectedViewAdapterInstance?, - modifier: Modifier = Modifier, - onInfoBannerClicked: () -> Unit, - onInfoDialogDismissed: () -> Unit, - discountPercentageFormatter: SkontoDiscountPercentageFormatter = SkontoDiscountPercentageFormatter(), - screenColorScheme: SkontoScreenColors = SkontoScreenColors.colors(), -) { - - val scrollState = rememberScrollState() - Scaffold(modifier = modifier, - containerColor = screenColorScheme.backgroundColor, - topBar = { - TopAppBar( - isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, - colors = screenColorScheme.topAppBarColors, - onBackClicked = onBackClicked, - onHelpClicked = onHelpClicked - ) - }, - bottomBar = { - FooterSection( - colors = screenColorScheme.footerSectionColors, - discountValue = state.skontoPercentage, - totalAmount = state.totalAmount, - isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, - onBackClicked = onBackClicked, - onHelpClicked = onHelpClicked, - customBottomNavBarAdapter = customBottomNavBarAdapter, - onProceedClicked = onProceedClicked, - isSkontoSectionActive = state.isSkontoSectionActive, - savedAmount = state.savedAmount, - discountPercentageFormatter = discountPercentageFormatter, - ) - }) { - Column( - modifier = Modifier - .padding(it) - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - val invoicePreviewPaddingTop = - if (LocalContext.current.resources.getBoolean(net.gini.android.capture.R.bool.gc_is_tablet)) { - 64.dp - } else { - 8.dp - } - InvoicePreviewSection( - modifier = Modifier - .padding(top = invoicePreviewPaddingTop) - .tabletMaxWidth(), - colorScheme = screenColorScheme.invoiceScanSectionColors, - onClick = onInvoiceClicked, - ) - SkontoSection( - modifier = Modifier - .padding(top = 8.dp) - .tabletMaxWidth(), - colors = screenColorScheme.skontoSectionColors, - amount = state.skontoAmount, - dueDate = state.discountDueDate, - infoPaymentInDays = state.paymentInDays, - infoDiscountValue = state.skontoPercentage, - onActiveChange = onDiscountSectionActiveChange, - isActive = state.isSkontoSectionActive, - onSkontoAmountChange = onDiscountAmountChange, - onDueDateChanged = onDueDateChanged, - edgeCase = state.skontoEdgeCase, - onInfoBannerClicked = onInfoBannerClicked, - discountPercentageFormatter = discountPercentageFormatter, - ) - WithoutSkontoSection( - modifier = Modifier.tabletMaxWidth(), - colors = screenColorScheme.withoutSkontoSectionColors, - isActive = !state.isSkontoSectionActive, - amount = state.fullAmount, - onFullAmountChange = onFullAmountChange, - ) - } - } - - if (state.edgeCaseInfoDialogVisible) { - val text = when (state.skontoEdgeCase) { - SkontoEdgeCase.PayByCashToday, - SkontoEdgeCase.PayByCashOnly -> - stringResource(id = R.string.gbs_skonto_section_info_dialog_pay_cash_message) - - SkontoEdgeCase.SkontoExpired -> - stringResource( - id = R.string.gbs_skonto_section_info_dialog_date_expired_message, - discountPercentageFormatter.format(state.skontoPercentage.toFloat()) - ) - - SkontoEdgeCase.SkontoLastDay -> - stringResource( - id = R.string.gbs_skonto_section_info_dialog_pay_today_message, - ) - - null -> "" - } - InfoDialog( - text = text, - colors = screenColorScheme.infoDialogColors, - onDismissRequest = onInfoDialogDismissed - ) - } - - if (state.transactionDialogVisible) { - AttachDocumentToTransactionDialog( - onDismiss = onCancelAttachTransactionDocClicked, - onConfirm = onConfirmAttachTransactionDocClicked - ) - } - } -} - -@Composable -private fun TopAppBar( - onBackClicked: () -> Unit, - onHelpClicked: () -> Unit, - modifier: Modifier = Modifier, - isBottomNavigationBarEnabled: Boolean, - colors: GiniTopBarColors, -) { - GiniTopBar( - modifier = modifier, - colors = colors, - title = stringResource(id = R.string.gbs_skonto_screen_title), - navigationIcon = { - AnimatedVisibility(visible = !isBottomNavigationBarEnabled) { - NavigationActionBack( - modifier = Modifier.padding(start = 16.dp, end = 32.dp), - onClick = onBackClicked - ) - } - }, - actions = { - AnimatedVisibility(visible = !isBottomNavigationBarEnabled) { - NavigationActionHelp( - modifier = Modifier.padding(start = 20.dp, end = 12.dp), - onClick = onHelpClicked - ) - } - }) -} - -@Composable -private fun NavigationActionBack( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - GiniTooltipBox( - tooltipText = stringResource( - id = R.string.gbs_skonto_screen_content_description_back - ) - ) { - IconButton( - modifier = modifier - .width(24.dp) - .height(24.dp), - onClick = onClick - ) { - Icon( - painter = painterResource(id = net.gini.android.capture.R.drawable.gc_action_bar_back), - contentDescription = stringResource( - id = R.string.gbs_skonto_screen_content_description_back - ), - ) - } - } -} - -@Composable -private fun NavigationActionHelp( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - GiniTooltipBox( - tooltipText = stringResource( - id = R.string.gbs_skonto_screen_content_description_help - ) - ) { - IconButton( - modifier = modifier - .width(24.dp) - .height(24.dp), - onClick = onClick - ) { - Icon( - painter = painterResource(R.drawable.gbs_help_question_icon), - contentDescription = stringResource( - id = R.string.gbs_skonto_screen_content_description_help - ), - ) - } - } -} - -@Composable -private fun InvoicePreviewSection( - modifier: Modifier = Modifier, - colorScheme: SkontoInvoicePreviewSectionColors, - onClick: () -> Unit, -) { - Card( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick), - shape = RectangleShape, - colors = CardDefaults.cardColors(containerColor = colorScheme.cardBackgroundColor) - ) { - Row( - modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .background(colorScheme.iconBackgroundColor, shape = RoundedCornerShape(4.dp)) - ) { - Icon( - modifier = Modifier - .size(40.dp) - .padding(8.dp), - painter = painterResource(id = R.drawable.gbs_icon_document), - contentDescription = null, - tint = colorScheme.iconTint, - ) - } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .weight(0.1f) - ) { - Text( - text = stringResource(id = R.string.gbs_skonto_section_invoice_preview_title), - style = GiniTheme.typography.subtitle1, - color = colorScheme.titleTextColor - ) - Text( - text = stringResource(id = R.string.gbs_skonto_invoice_section_preview_subtitle), - style = GiniTheme.typography.body2, - color = colorScheme.subtitleTextColor - ) - } - - Icon( - painter = painterResource(id = R.drawable.gbs_arrow_right), - contentDescription = null, - tint = colorScheme.arrowTint - ) - } - - } -} - -@Composable -private fun SkontoSection( - colors: SkontoSectionColors, - amount: Amount, - dueDate: LocalDate, - infoPaymentInDays: Int, - infoDiscountValue: BigDecimal, - onActiveChange: (Boolean) -> Unit, - onSkontoAmountChange: (BigDecimal) -> Unit, - onDueDateChanged: (LocalDate) -> Unit, - onInfoBannerClicked: () -> Unit, - edgeCase: SkontoEdgeCase?, - modifier: Modifier = Modifier, - isActive: Boolean, - discountPercentageFormatter: SkontoDiscountPercentageFormatter = SkontoDiscountPercentageFormatter() -) { - val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") - - var isDatePickerVisible by remember { mutableStateOf(false) } - Card( - modifier = modifier, - shape = RectangleShape, - colors = CardDefaults.cardColors(containerColor = colors.cardBackgroundColor) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.gbs_skonto_section_discount_title), - style = GiniTheme.typography.subtitle1, - color = colors.titleTextColor, - ) - Box { - androidx.compose.animation.AnimatedVisibility(visible = isActive) { - Text( - text = stringResource(id = R.string.gbs_skonto_section_discount_hint_label_enabled), - style = GiniTheme.typography.subtitle2, - color = colors.enabledHintTextColor, - ) - } - } - - Spacer(Modifier.weight(1f)) - - - GiniSwitch( - checked = isActive, - onCheckedChange = onActiveChange, - ) - } - val animatedDiscountAmount by animateFloatAsState( - targetValue = infoDiscountValue.toFloat(), - label = "discountAmount" - ) - - val remainingDaysText = - if (infoPaymentInDays != 0) { - pluralStringResource( - id = R.plurals.days, - count = infoPaymentInDays, - infoPaymentInDays.toString() - ) - } else { - stringResource(id = R.string.days_zero) - } - - val infoBannerText = when (edgeCase) { - SkontoEdgeCase.PayByCashOnly -> - stringResource( - id = R.string.gbs_skonto_section_discount_info_banner_pay_cash_message, - discountPercentageFormatter.format(animatedDiscountAmount), - remainingDaysText - ) - - SkontoEdgeCase.PayByCashToday -> - stringResource( - id = R.string.gbs_skonto_section_discount_info_banner_pay_cash_today_message, - discountPercentageFormatter.format(animatedDiscountAmount) - ) - - SkontoEdgeCase.SkontoExpired -> - stringResource( - id = R.string.gbs_skonto_section_discount_info_banner_date_expired_message, - discountPercentageFormatter.format(animatedDiscountAmount) - ) - - SkontoEdgeCase.SkontoLastDay -> - stringResource( - id = R.string.gbs_skonto_section_discount_info_banner_pay_today_message, - discountPercentageFormatter.format(animatedDiscountAmount) - ) - - else -> stringResource( - id = R.string.gbs_skonto_section_discount_info_banner_normal_message, - remainingDaysText, - discountPercentageFormatter.format(animatedDiscountAmount) - ) - } - - InfoBanner( - text = infoBannerText, - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp), - colors = when (edgeCase) { - SkontoEdgeCase.SkontoLastDay, - SkontoEdgeCase.PayByCashToday, - SkontoEdgeCase.PayByCashOnly -> colors.warningInfoBannerColors - - SkontoEdgeCase.SkontoExpired -> colors.errorInfoBannerColors - else -> colors.successInfoBannerColors - }, - onClicked = onInfoBannerClicked, - clickable = edgeCase != null, - ) - GiniAmountTextInput( - amount = amount.value, - currencyCode = amount.currency.name, - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - enabled = isActive, - colors = colors.amountFieldColors, - onValueChange = { onSkontoAmountChange(it) }, - label = stringResource(id = R.string.gbs_skonto_section_discount_field_amount_hint), - trailingContent = { - AnimatedVisibility(visible = isActive) { - Text( - text = amount.currency.name, - style = GiniTheme.typography.subtitle1, - ) - } - }, - ) - - val dueDateOnClickSource = remember { MutableInteractionSource() } - val pressed by dueDateOnClickSource.collectIsPressedAsState() - - LaunchedEffect(key1 = pressed) { - if (pressed) { - isDatePickerVisible = true - } - } - - GiniTextInput( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - .focusable(false), - enabled = isActive, - interactionSource = dueDateOnClickSource, - readOnly = true, - colors = colors.dueDateTextFieldColor, - onValueChange = { /* Ignored */ }, - text = dueDate.format(dateFormatter), - label = stringResource(id = R.string.gbs_skonto_section_discount_field_due_date_hint), - trailingContent = { - androidx.compose.animation.AnimatedVisibility(visible = isActive) { - Icon( - painter = painterResource(id = R.drawable.gbs_icon_calendar), - contentDescription = null, - ) - } - }, - ) - } - } - - if (isDatePickerVisible) { - GiniDatePickerDialog( - onDismissRequest = { isDatePickerVisible = false }, - onSaved = { - isDatePickerVisible = false - onDueDateChanged(it) - }, - date = dueDate, - selectableDates = getSkontoSelectableDates() - ) - } -} - -private fun getSkontoSelectableDates() = object : SelectableDates { - - val minDateCalendar = Calendar.getInstance().apply { - set(Calendar.MILLISECONDS_IN_DAY, 0) - } - - val maxDateCalendar = Calendar.getInstance().apply { - add(Calendar.MONTH, 6) - } - - val minTime = minDateCalendar.timeInMillis - val maxTime = maxDateCalendar.timeInMillis - - override fun isSelectableDate(utcTimeMillis: Long): Boolean { - return (minTime..maxTime).contains(utcTimeMillis) - } - - override fun isSelectableYear(year: Int): Boolean { - return (minDateCalendar.get(Calendar.YEAR)..maxDateCalendar.get(Calendar.YEAR)) - .contains(year) - } -} - -@Composable -private fun InfoBanner( - colors: SkontoSectionColors.InfoBannerColors, - text: String, - clickable: Boolean, - onClicked: () -> Unit, - modifier: Modifier = Modifier, - icon: Painter = painterResource(id = R.drawable.gbs_icon_important_info), -) { - Row( - modifier = modifier - .background( - color = colors.backgroundColor, RoundedCornerShape(8.dp) - ) - .clickable(onClick = onClicked, enabled = clickable), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier.padding(8.dp), - painter = icon, - contentDescription = null, - tint = colors.iconTint, - ) - - Text( - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp), - text = text, - style = GiniTheme.typography.subtitle2, - color = colors.textColor, - ) - } -} - -@Composable -private fun InfoDialog( - text: String, - colors: SkontoInfoDialogColors, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - Dialog( - properties = DialogProperties(), - onDismissRequest = onDismissRequest - ) { - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors( - containerColor = colors.cardBackgroundColor - ) - ) { - Text( - modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp), - text = text, - style = GiniTheme.typography.caption1 - ) - Button( - modifier = Modifier - .padding(16.dp) - .align(Alignment.End), - onClick = onDismissRequest, - shape = RoundedCornerShape(4.dp), - colors = ButtonDefaults.textButtonColors( - contentColor = colors.buttonTextColor, - ), - ) { - Text( - modifier = Modifier, - text = stringResource(id = R.string.gbs_skonto_section_info_dialog_ok_button_text), - style = GiniTheme.typography.button - ) - } - } - } -} - -@Composable -private fun WithoutSkontoSection( - colors: WithoutSkontoSectionColors, - amount: Amount, - modifier: Modifier = Modifier, - onFullAmountChange: (BigDecimal) -> Unit, - isActive: Boolean, -) { - Card( - modifier = modifier, - shape = RectangleShape, - colors = CardDefaults.cardColors(containerColor = colors.cardBackgroundColor) - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.gbs_skonto_section_without_discount_title), - style = GiniTheme.typography.subtitle1, - color = colors.titleTextColor, - ) - AnimatedVisibility(visible = isActive) { - Text( - text = stringResource(id = R.string.gbs_skonto_section_discount_hint_label_enabled), - modifier = Modifier.weight(0.1f), - style = GiniTheme.typography.subtitle2, - color = colors.enabledHintTextColor, - ) - } - } - GiniAmountTextInput( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - enabled = isActive, - colors = colors.amountFieldColors, - amount = amount.value, - currencyCode = amount.currency.name, - onValueChange = onFullAmountChange, - label = stringResource(id = R.string.gbs_skonto_section_without_discount_field_amount_hint), - trailingContent = { - AnimatedVisibility(visible = isActive) { - Text( - text = amount.currency.name, - style = GiniTheme.typography.subtitle1, - ) - } - }, - ) - } - } -} - -@Composable -private fun FooterSection( - totalAmount: Amount, - savedAmount: Amount, - discountValue: BigDecimal, - colors: SkontoFooterSectionColors, - isBottomNavigationBarEnabled: Boolean, - isSkontoSectionActive: Boolean, - onBackClicked: () -> Unit, - onHelpClicked: () -> Unit, - onProceedClicked: () -> Unit, - modifier: Modifier = Modifier, - customBottomNavBarAdapter: InjectedViewAdapterInstance?, - discountPercentageFormatter: SkontoDiscountPercentageFormatter, -) { - val animatedTotalAmount by animateFloatAsState( - targetValue = totalAmount.value.toFloat(), label = "totalAmount" - ) - val animatedSavedAmount by animateFloatAsState( - targetValue = savedAmount.value.toFloat(), label = "savedAmount" - ) - val animatedDiscountAmount by animateFloatAsState( - targetValue = discountValue.toFloat(), label = "discountAmount" - ) - val totalPriceText = - "${ - currencyFormatterWithoutSymbol().format(animatedTotalAmount).trim() - } ${totalAmount.currency.name}" - - val savedAmountText = - stringResource( - id = R.string.gbs_skonto_section_footer_label_save, - "${ - currencyFormatterWithoutSymbol().format(animatedSavedAmount).trim() - } ${savedAmount.currency.name}" - ) - - val discountLabelText = stringResource( - id = R.string.gbs_skonto_section_footer_label_discount, - discountPercentageFormatter.format(animatedDiscountAmount) - ) - - if (customBottomNavBarAdapter != null) { - val ctx = LocalContext.current - AndroidView(factory = { - customBottomNavBarAdapter.viewAdapter.onCreateView(FrameLayout(ctx)) - }, update = { - with(customBottomNavBarAdapter.viewAdapter) { - setOnProceedClickListener(onProceedClicked) - setOnBackClickListener(onBackClicked) - setOnHelpClickListener(onHelpClicked) - onTotalAmountUpdated(totalPriceText) - onSkontoPercentageBadgeUpdated(discountLabelText) - onSkontoPercentageBadgeVisibilityUpdate(isSkontoSectionActive) - onSkontoSavingsAmountUpdated(savedAmountText) - onSkontoSavingsAmountVisibilityUpdated(isSkontoSectionActive) - } - }) - } else { - Card( - modifier = modifier.fillMaxWidth(), - shape = RectangleShape, - colors = CardDefaults.cardColors(containerColor = colors.cardBackgroundColor), - ) { - Column( - modifier = Modifier - .tabletMaxWidth() - .align(Alignment.CenterHorizontally), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Column( - modifier = Modifier - .padding(start = 20.dp, end = 20.dp, top = 20.dp) - ) { - Row { - Text( - modifier = Modifier.weight(0.1f), - text = stringResource(id = R.string.gbs_skonto_section_footer_title), - style = GiniTheme.typography.body1, - color = colors.titleTextColor, - ) - AnimatedVisibility( - visible = isSkontoSectionActive - ) { - Box( - modifier = Modifier - .height(IntrinsicSize.Min) - - .background( - colors.discountLabelColorScheme.backgroundColor, - RoundedCornerShape(4.dp) - ), - ) { - Text( - modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), - text = discountLabelText, - style = GiniTheme.typography.caption1, - color = colors.discountLabelColorScheme.textColor, - ) - } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = totalPriceText, - style = GiniTheme.typography.headline5.bold(), - color = colors.amountTextColor, - ) - } - AnimatedVisibility( - visible = isSkontoSectionActive - ) { - Text( - text = savedAmountText, - style = GiniTheme.typography.caption1, - color = colors.savedAmountTextColor, - ) - } - } - val buttonPaddingStart = if (isBottomNavigationBarEnabled) 16.dp else 20.dp - val buttonPaddingEnd = if (isBottomNavigationBarEnabled) 16.dp else 20.dp - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - AnimatedVisibility(visible = isBottomNavigationBarEnabled) { - NavigationActionBack( - modifier = Modifier.padding(start = 16.dp), - onClick = onBackClicked - ) - } - GiniButton( - modifier = Modifier - .weight(0.1f) - .padding(start = buttonPaddingStart, end = buttonPaddingEnd), - text = stringResource(id = R.string.gbs_skonto_section_footer_continue_button_text), - onClick = onProceedClicked, - giniButtonColors = colors.continueButtonColors - ) - AnimatedVisibility(visible = isBottomNavigationBarEnabled) { - NavigationActionHelp( - modifier = Modifier.padding(end = 20.dp), - onClick = onHelpClicked - ) - } - } - } - } - } -} - -@Composable -@Preview -private fun ScreenReadyStatePreviewLight() { - ScreenReadyStatePreview() -} - -@Composable -@Preview(uiMode = UI_MODE_NIGHT_YES) -private fun ScreenReadyStatePreviewDark() { - ScreenReadyStatePreview() -} - -@Composable -private fun ScreenReadyStatePreview() { - GiniTheme { - val context = LocalContext.current - - var state by remember { mutableStateOf(previewState()) } - ScreenReadyState( - state = state, - onDiscountSectionActiveChange = { - state = state.copy(isSkontoSectionActive = !state.isSkontoSectionActive) - }, - onDiscountAmountChange = {}, - onDueDateChanged = {}, - onFullAmountChange = {}, - onBackClicked = {}, - onHelpClicked = {}, - isBottomNavigationBarEnabled = false, - onProceedClicked = {}, - customBottomNavBarAdapter = null, - onInfoDialogDismissed = {}, - onInfoBannerClicked = {}, - onInvoiceClicked = {}, - onCancelAttachTransactionDocClicked = { - - }, - onConfirmAttachTransactionDocClicked = { - - } - ) - } -} - -private fun previewState() = SkontoFragmentContract.State.Ready( - isSkontoSectionActive = true, - paymentInDays = 14, - skontoPercentage = BigDecimal("3"), - skontoAmount = Amount.parse("97:EUR"), - discountDueDate = LocalDate.now(), - fullAmount = Amount.parse("100:EUR"), - totalAmount = Amount.parse("97:EUR"), - paymentMethod = SkontoData.SkontoPaymentMethod.PayPal, - skontoEdgeCase = SkontoEdgeCase.PayByCashOnly, - edgeCaseInfoDialogVisible = false, - savedAmount = Amount.parse("3:EUR"), - transactionDialogVisible = false, -) \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentContract.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentContract.kt deleted file mode 100644 index f66823596..000000000 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentContract.kt +++ /dev/null @@ -1,34 +0,0 @@ -package net.gini.android.bank.sdk.capture.skonto - -import net.gini.android.bank.sdk.capture.skonto.model.SkontoData -import net.gini.android.bank.sdk.capture.skonto.model.SkontoEdgeCase -import net.gini.android.capture.Amount -import java.math.BigDecimal -import java.time.LocalDate - -internal object SkontoFragmentContract { - - sealed class State { - data class Ready( - val isSkontoSectionActive: Boolean, - val paymentInDays: Int, - val skontoPercentage: BigDecimal, - val skontoAmount: Amount, - val discountDueDate: LocalDate, - val fullAmount: Amount, - val totalAmount: Amount, - val savedAmount: Amount, - val paymentMethod: SkontoData.SkontoPaymentMethod, - val skontoEdgeCase: SkontoEdgeCase?, - val edgeCaseInfoDialogVisible: Boolean, - val transactionDialogVisible: Boolean, - ) : State() - } - - sealed interface SideEffect { - data class OpenInvoiceScreen( - val documentId: String, - val infoTextLines: List, - ) : SideEffect - } -} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentViewModel.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentViewModel.kt deleted file mode 100644 index c5c90b439..000000000 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentViewModel.kt +++ /dev/null @@ -1,265 +0,0 @@ -package net.gini.android.bank.sdk.capture.skonto - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import net.gini.android.bank.sdk.capture.extractions.skonto.SkontoExtractionsHandler -import net.gini.android.bank.sdk.capture.skonto.factory.lines.SkontoInvoicePreviewTextLinesFactory -import net.gini.android.bank.sdk.capture.skonto.model.SkontoData -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoAmountUseCase -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDefaultSelectionStateUseCase -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDiscountPercentageUseCase -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoEdgeCaseUseCase -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoRemainingDaysUseCase -import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoSavedAmountUseCase -import net.gini.android.bank.sdk.transactiondocs.internal.usecase.GetTransactionDocShouldBeAutoAttachedUseCase -import net.gini.android.bank.sdk.transactiondocs.internal.usecase.GetTransactionDocsFeatureEnabledUseCase -import net.gini.android.bank.sdk.transactiondocs.internal.usecase.TransactionDocDialogCancelAttachUseCase -import net.gini.android.bank.sdk.transactiondocs.internal.usecase.TransactionDocDialogConfirmAttachUseCase -import net.gini.android.capture.Amount -import net.gini.android.capture.analysis.LastAnalyzedDocumentProvider -import net.gini.android.capture.provider.LastExtractionsProvider -import java.math.BigDecimal -import java.time.LocalDate - -internal class SkontoFragmentViewModel( - private val data: SkontoData, - private val getSkontoDiscountPercentageUseCase: GetSkontoDiscountPercentageUseCase, - private val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase, - private val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase, - private val getSkontoAmountUseCase: GetSkontoAmountUseCase, - private val getSkontoRemainingDaysUseCase: GetSkontoRemainingDaysUseCase, - private val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase, - private val skontoExtractionsHandler: SkontoExtractionsHandler, - private val lastAnalyzedDocumentProvider: LastAnalyzedDocumentProvider, - private val skontoInvoicePreviewTextLinesFactory: SkontoInvoicePreviewTextLinesFactory, - private val lastExtractionsProvider: LastExtractionsProvider, - private val transactionDocDialogConfirmAttachUseCase: TransactionDocDialogConfirmAttachUseCase, - private val transactionDocDialogCancelAttachUseCase: TransactionDocDialogCancelAttachUseCase, - private val getTransactionDocShouldBeAutoAttachedUseCase: GetTransactionDocShouldBeAutoAttachedUseCase, - private val getTransactionDocsFeatureEnabledUseCase: GetTransactionDocsFeatureEnabledUseCase, -) : ViewModel() { - - val stateFlow: MutableStateFlow = - MutableStateFlow(createInitalState(data)) - - val sideEffectFlow: MutableSharedFlow = MutableSharedFlow() - - private var listener: SkontoFragmentListener? = null - - fun setListener(listener: SkontoFragmentListener?) { - this.listener = listener - } - - fun onProceedClicked() = viewModelScope.launch { - val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - if (!getTransactionDocsFeatureEnabledUseCase()) { - openExtractionsScreen() - return@launch - } - if (getTransactionDocShouldBeAutoAttachedUseCase()) { - onConfirmAttachTransactionDocClicked(true) - } else { - stateFlow.emit(currentState.copy(transactionDialogVisible = true)) - } - } - - fun onConfirmAttachTransactionDocClicked(alwaysAttach: Boolean) = viewModelScope.launch { - transactionDocDialogConfirmAttachUseCase(alwaysAttach) - openExtractionsScreen() - } - - fun onCancelAttachTransactionDocClicked() = viewModelScope.launch { - transactionDocDialogCancelAttachUseCase() - openExtractionsScreen() - } - - private fun openExtractionsScreen() { - val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return - skontoExtractionsHandler.updateExtractions( - totalAmount = currentState.totalAmount, - skontoPercentage = currentState.skontoPercentage, - skontoAmount = currentState.skontoAmount, - paymentInDays = currentState.paymentInDays, - discountDueDate = currentState.discountDueDate.toString(), - ) - lastExtractionsProvider.update(skontoExtractionsHandler.getExtractions().toMutableMap()) - listener?.onPayInvoiceWithSkonto( - skontoExtractionsHandler.getExtractions(), - skontoExtractionsHandler.getCompoundExtractions() - ) - } - - private fun createInitalState( - data: SkontoData, - ): SkontoFragmentContract.State.Ready { - - val discount = data.skontoPercentageDiscounted - - val paymentMethod = data.skontoPaymentMethod ?: SkontoData.SkontoPaymentMethod.Unspecified - val edgeCase = getSkontoEdgeCaseUseCase.execute(data.skontoDueDate, paymentMethod) - - val isSkontoSectionActive = getSkontoDefaultSelectionStateUseCase.execute(edgeCase) - - val totalAmount = - if (isSkontoSectionActive) data.skontoAmountToPay else data.fullAmountToPay - - val savedAmountValue = getSkontoSavedAmountUseCase.execute( - data.skontoAmountToPay.value, - data.fullAmountToPay.value - ) - val savedAmount = Amount(savedAmountValue, data.fullAmountToPay.currency) - - return SkontoFragmentContract.State.Ready( - isSkontoSectionActive = isSkontoSectionActive, - paymentInDays = data.skontoRemainingDays, - skontoPercentage = discount, - skontoAmount = data.skontoAmountToPay, - discountDueDate = data.skontoDueDate, - fullAmount = data.fullAmountToPay, - totalAmount = totalAmount, - paymentMethod = paymentMethod, - skontoEdgeCase = edgeCase, - edgeCaseInfoDialogVisible = edgeCase != null, - savedAmount = savedAmount, - transactionDialogVisible = false, - ) - } - - fun onSkontoActiveChanged(newValue: Boolean) = viewModelScope.launch { - val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - val totalAmount = if (newValue) currentState.skontoAmount else currentState.fullAmount - val discount = getSkontoDiscountPercentageUseCase.execute( - currentState.skontoAmount.value, - currentState.fullAmount.value - ) - - stateFlow.emit( - currentState.copy( - isSkontoSectionActive = newValue, - totalAmount = totalAmount, - skontoPercentage = discount - ) - ) - } - - fun onSkontoAmountFieldChanged(newValue: BigDecimal) = viewModelScope.launch { - val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - - if (newValue > currentState.fullAmount.value) { - stateFlow.emit( - currentState.copy(skontoAmount = currentState.skontoAmount) - ) - return@launch - } - - val discount = getSkontoDiscountPercentageUseCase.execute( - newValue, - currentState.fullAmount.value - ) - - val totalAmount = if (currentState.isSkontoSectionActive) - newValue - else currentState.fullAmount.value - - val newSkontoAmount = currentState.skontoAmount.copy(value = newValue) - val newTotalAmount = currentState.totalAmount.copy(value = totalAmount) - - val savedAmountValue = getSkontoSavedAmountUseCase.execute( - newSkontoAmount.value, - currentState.fullAmount.value - ) - - val savedAmount = Amount(savedAmountValue, currentState.fullAmount.currency) - - stateFlow.emit( - currentState.copy( - skontoAmount = newSkontoAmount, - skontoPercentage = discount, - totalAmount = newTotalAmount, - savedAmount = savedAmount, - ) - ) - } - - fun onSkontoDueDateChanged(newDate: LocalDate) = viewModelScope.launch { - val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - val newPayInDays = getSkontoRemainingDaysUseCase.execute(newDate) - stateFlow.emit( - currentState.copy( - discountDueDate = newDate, - paymentInDays = newPayInDays, - skontoEdgeCase = getSkontoEdgeCaseUseCase.execute( - dueDate = newDate, - paymentMethod = currentState.paymentMethod - ) - ) - ) - } - - fun onFullAmountFieldChanged(newValue: BigDecimal) = viewModelScope.launch { - val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - val totalAmount = - if (currentState.isSkontoSectionActive) currentState.skontoAmount.value else newValue - - val discount = currentState.skontoPercentage - - val skontoAmount = getSkontoAmountUseCase.execute(newValue, discount) - - val savedAmountValue = getSkontoSavedAmountUseCase.execute( - skontoAmount, - newValue - ) - - val savedAmount = Amount(savedAmountValue, currentState.fullAmount.currency) - - stateFlow.emit( - currentState.copy( - skontoAmount = currentState.skontoAmount.copy(value = skontoAmount), - fullAmount = currentState.fullAmount.copy(value = newValue), - totalAmount = currentState.totalAmount.copy(value = totalAmount), - savedAmount = savedAmount, - ) - ) - } - - fun onInfoBannerClicked() = viewModelScope.launch { - val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - stateFlow.emit( - currentState.copy( - edgeCaseInfoDialogVisible = true, - ) - ) - } - - fun onInfoDialogDismissed() = viewModelScope.launch { - val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - stateFlow.emit( - currentState.copy( - edgeCaseInfoDialogVisible = false, - ) - ) - } - - fun onInvoiceClicked() = viewModelScope.launch { - val currentState = - stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - val skontoData = SkontoData( - skontoAmountToPay = currentState.skontoAmount, - skontoDueDate = currentState.discountDueDate, - skontoPercentageDiscounted = currentState.skontoPercentage, - skontoRemainingDays = currentState.paymentInDays, - fullAmountToPay = currentState.fullAmount, - skontoPaymentMethod = currentState.paymentMethod, - ) - val documentId = lastAnalyzedDocumentProvider.provide()?.giniApiDocumentId ?: return@launch - sideEffectFlow.emit( - SkontoFragmentContract.SideEffect.OpenInvoiceScreen( - documentId, - skontoInvoicePreviewTextLinesFactory.create(skontoData) - ) - ) - } -} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenContent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenContent.kt new file mode 100644 index 000000000..f5ac54aa7 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenContent.kt @@ -0,0 +1,1053 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package net.gini.android.bank.sdk.capture.skonto + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.icu.util.Calendar +import android.widget.FrameLayout +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +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.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import net.gini.android.bank.sdk.R +import net.gini.android.bank.sdk.capture.skonto.colors.SkontoScreenColors +import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoFooterSectionColors +import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoInfoDialogColors +import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoInvoicePreviewSectionColors +import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoSectionColors +import net.gini.android.bank.sdk.capture.skonto.colors.section.WithoutSkontoSectionColors +import net.gini.android.bank.sdk.capture.skonto.formatter.AmountFormatter +import net.gini.android.bank.sdk.capture.skonto.formatter.SkontoDiscountPercentageFormatter +import net.gini.android.bank.sdk.capture.skonto.mapper.toErrorMessage +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.model.SkontoEdgeCase +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoFragmentViewModel +import net.gini.android.bank.sdk.capture.util.currencyFormatterWithoutSymbol +import net.gini.android.bank.sdk.transactiondocs.ui.dialog.attachdoc.AttachDocumentToTransactionDialog +import net.gini.android.bank.sdk.util.ui.keyboardAsState +import net.gini.android.capture.Amount +import net.gini.android.capture.ui.components.button.filled.GiniButton +import net.gini.android.capture.ui.components.picker.date.GiniDatePickerDialog +import net.gini.android.capture.ui.components.switcher.GiniSwitch +import net.gini.android.capture.ui.components.textinput.GiniTextInput +import net.gini.android.capture.ui.components.textinput.amount.GiniAmountTextInput +import net.gini.android.capture.ui.components.tooltip.GiniTooltipBox +import net.gini.android.capture.ui.components.topbar.GiniTopBar +import net.gini.android.capture.ui.components.topbar.GiniTopBarColors +import net.gini.android.capture.ui.theme.GiniTheme +import net.gini.android.capture.ui.theme.modifier.tabletMaxWidth +import net.gini.android.capture.ui.theme.typography.bold +import net.gini.android.capture.util.compose.keyboardPadding +import net.gini.android.capture.view.InjectedViewAdapterInstance +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect +import java.math.BigDecimal +import java.time.LocalDate +import java.time.format.DateTimeFormatter + + +@Composable +internal fun SkontoScreenContent( + isBottomNavigationBarEnabled: Boolean, + navigateBack: () -> Unit, + navigateToHelp: () -> Unit, + amountFormatter: AmountFormatter, + viewModel: SkontoFragmentViewModel, + customBottomNavBarAdapter: InjectedViewAdapterInstance?, + navigateToInvoiceScreen: (documentId: String, infoTextLines: List) -> Unit, + modifier: Modifier = Modifier, + screenColorScheme: SkontoScreenColors = SkontoScreenColors.colors(), +) { + + BackHandler { navigateBack() } + + val state by viewModel.collectAsState() + viewModel.collectSideEffect { + when (it) { + is SkontoScreenSideEffect.OpenInvoiceScreen -> + navigateToInvoiceScreen(it.documentId, it.infoTextLines) + } + } + + val keyboardState by keyboardAsState() + + LaunchedEffect(keyboardState) { + viewModel.onKeyboardStateChanged(keyboardState) + } + + ScreenStateContent( + modifier = modifier, + state = state, + screenColorScheme = screenColorScheme, + onDiscountSectionActiveChange = viewModel::onSkontoActiveChanged, + onSkontoAmountChange = viewModel::onSkontoAmountFieldChanged, + onDueDateChanged = viewModel::onSkontoDueDateChanged, + onFullAmountChange = viewModel::onFullAmountFieldChanged, + isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, + onBackClicked = navigateBack, + onHelpClicked = navigateToHelp, + customBottomNavBarAdapter = customBottomNavBarAdapter, + onProceedClicked = viewModel::onProceedClicked, + onInfoBannerClicked = viewModel::onInfoBannerClicked, + onInfoDialogDismissed = viewModel::onInfoDialogDismissed, + onInvoiceClicked = viewModel::onInvoiceClicked, + onConfirmAttachTransactionDocClicked = viewModel::onConfirmAttachTransactionDocClicked, + onCancelAttachTransactionDocClicked = viewModel::onCancelAttachTransactionDocClicked, + amountFormatter = amountFormatter, + ) +} + +@Composable +private fun ScreenStateContent( + state: SkontoScreenState, + amountFormatter: AmountFormatter, + onDiscountSectionActiveChange: (Boolean) -> Unit, + onSkontoAmountChange: (BigDecimal) -> Unit, + onFullAmountChange: (BigDecimal) -> Unit, + onDueDateChanged: (LocalDate) -> Unit, + onBackClicked: () -> Unit, + onHelpClicked: () -> Unit, + onProceedClicked: () -> Unit, + isBottomNavigationBarEnabled: Boolean, + onInfoBannerClicked: () -> Unit, + onInfoDialogDismissed: () -> Unit, + onInvoiceClicked: () -> Unit, + onConfirmAttachTransactionDocClicked: (alwaysAttach: Boolean) -> Unit, + onCancelAttachTransactionDocClicked: () -> Unit, + customBottomNavBarAdapter: InjectedViewAdapterInstance?, + modifier: Modifier = Modifier, + screenColorScheme: SkontoScreenColors = SkontoScreenColors.colors() +) { + when (state) { + is SkontoScreenState.Ready -> ScreenReadyState( + amountFormatter = amountFormatter, + modifier = modifier, + state = state, + screenColorScheme = screenColorScheme, + onDiscountSectionActiveChange = onDiscountSectionActiveChange, + onDiscountAmountChange = onSkontoAmountChange, + onDueDateChanged = onDueDateChanged, + onFullAmountChange = onFullAmountChange, + onBackClicked = onBackClicked, + onHelpClicked = onHelpClicked, + isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, + customBottomNavBarAdapter = customBottomNavBarAdapter, + onProceedClicked = onProceedClicked, + onInfoBannerClicked = onInfoBannerClicked, + onInfoDialogDismissed = onInfoDialogDismissed, + onInvoiceClicked = onInvoiceClicked, + onConfirmAttachTransactionDocClicked = onConfirmAttachTransactionDocClicked, + onCancelAttachTransactionDocClicked = onCancelAttachTransactionDocClicked, + ) + } + +} + +@Composable +private fun ScreenReadyState( + isBottomNavigationBarEnabled: Boolean, + state: SkontoScreenState.Ready, + amountFormatter: AmountFormatter, + onConfirmAttachTransactionDocClicked: (alwaysAttach: Boolean) -> Unit, + onCancelAttachTransactionDocClicked: () -> Unit, + onBackClicked: () -> Unit, + onHelpClicked: () -> Unit, + onProceedClicked: () -> Unit, + onInvoiceClicked: () -> Unit, + onDiscountSectionActiveChange: (Boolean) -> Unit, + onDiscountAmountChange: (BigDecimal) -> Unit, + onDueDateChanged: (LocalDate) -> Unit, + onFullAmountChange: (BigDecimal) -> Unit, + onInfoBannerClicked: () -> Unit, + onInfoDialogDismissed: () -> Unit, + customBottomNavBarAdapter: InjectedViewAdapterInstance?, + modifier: Modifier = Modifier, + discountPercentageFormatter: SkontoDiscountPercentageFormatter = SkontoDiscountPercentageFormatter(), + screenColorScheme: SkontoScreenColors = SkontoScreenColors.colors(), +) { + val scrollState = rememberScrollState() + val keyboardPadding by keyboardPadding(108.dp, scrollState) + + Scaffold(modifier = modifier, + containerColor = screenColorScheme.backgroundColor, + topBar = { + TopAppBar( + isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, + colors = screenColorScheme.topAppBarColors, + onBackClicked = onBackClicked, + onHelpClicked = onHelpClicked + ) + }, + bottomBar = { + FooterSection( + colors = screenColorScheme.footerSectionColors, + discountValue = state.skontoPercentage, + totalAmount = state.totalAmount, + isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, + onBackClicked = onBackClicked, + onHelpClicked = onHelpClicked, + customBottomNavBarAdapter = customBottomNavBarAdapter, + onProceedClicked = onProceedClicked, + isSkontoSectionActive = state.isSkontoSectionActive, + savedAmount = state.savedAmount, + discountPercentageFormatter = discountPercentageFormatter, + ) + }) { + Column( + modifier = Modifier + .padding(it) + .verticalScroll(scrollState) + .fillMaxSize() + .padding(bottom = keyboardPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val invoicePreviewPaddingTop = + if (LocalContext.current.resources.getBoolean(net.gini.android.capture.R.bool.gc_is_tablet)) { + 64.dp + } else { + 8.dp + } + InvoicePreviewSection( + modifier = Modifier + .padding(top = invoicePreviewPaddingTop) + .tabletMaxWidth(), + colorScheme = screenColorScheme.invoiceScanSectionColors, + onClick = onInvoiceClicked, + ) + SkontoSection( + amountFormatter = amountFormatter, + modifier = Modifier + .padding(top = 8.dp) + .tabletMaxWidth(), + colors = screenColorScheme.skontoSectionColors, + amount = state.skontoAmount, + dueDate = state.discountDueDate, + infoPaymentInDays = state.paymentInDays, + infoDiscountValue = state.skontoPercentage, + onActiveChange = onDiscountSectionActiveChange, + isActive = state.isSkontoSectionActive, + onSkontoAmountChange = onDiscountAmountChange, + onDueDateChanged = onDueDateChanged, + edgeCase = state.edgeCase, + onInfoBannerClicked = onInfoBannerClicked, + discountPercentageFormatter = discountPercentageFormatter, + skontoAmountValidationError = state.skontoAmountValidationError, + ) + WithoutSkontoSection( + modifier = Modifier.tabletMaxWidth(), + colors = screenColorScheme.withoutSkontoSectionColors, + isActive = !state.isSkontoSectionActive, + amount = state.fullAmount, + onFullAmountChange = onFullAmountChange, + amountFormatter = amountFormatter, + fullAmountValidationError = state.fullAmountValidationError, + ) + } + } + + if (state.edgeCaseInfoDialogVisible) { + val text = when (state.edgeCase) { + SkontoEdgeCase.PayByCashToday, + SkontoEdgeCase.PayByCashOnly -> + stringResource(id = R.string.gbs_skonto_section_info_dialog_pay_cash_message) + + SkontoEdgeCase.SkontoExpired -> + stringResource( + id = R.string.gbs_skonto_section_info_dialog_date_expired_message, + discountPercentageFormatter.format(state.skontoPercentage.toFloat()) + ) + + SkontoEdgeCase.SkontoLastDay -> + stringResource( + id = R.string.gbs_skonto_section_info_dialog_pay_today_message, + ) + + null -> "" + } + InfoDialog( + text = text, + colors = screenColorScheme.infoDialogColors, + onDismissRequest = onInfoDialogDismissed + ) + } + + if (state.transactionDialogVisible) { + AttachDocumentToTransactionDialog( + onDismiss = onCancelAttachTransactionDocClicked, + onConfirm = onConfirmAttachTransactionDocClicked + ) + } + } +} + +@Composable +private fun TopAppBar( + onBackClicked: () -> Unit, + onHelpClicked: () -> Unit, + colors: GiniTopBarColors, + isBottomNavigationBarEnabled: Boolean, + modifier: Modifier = Modifier, +) { + GiniTopBar( + modifier = modifier, + colors = colors, + title = stringResource(id = R.string.gbs_skonto_screen_title), + navigationIcon = { + AnimatedVisibility(visible = !isBottomNavigationBarEnabled) { + NavigationActionBack( + modifier = Modifier.padding(start = 16.dp, end = 32.dp), + onClick = onBackClicked + ) + } + }, + actions = { + AnimatedVisibility(visible = !isBottomNavigationBarEnabled) { + NavigationActionHelp( + modifier = Modifier.padding(start = 20.dp, end = 12.dp), + onClick = onHelpClicked + ) + } + }) +} + +@Composable +private fun NavigationActionHelp( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + GiniTooltipBox( + tooltipText = stringResource( + id = R.string.gbs_skonto_screen_content_description_help + ) + ) { + IconButton( + modifier = modifier + .width(24.dp) + .height(24.dp), + onClick = onClick + ) { + Icon( + painter = painterResource(R.drawable.gbs_help_question_icon), + contentDescription = stringResource( + id = R.string.gbs_skonto_screen_content_description_help + ), + ) + } + } +} + +@Composable +private fun NavigationActionBack( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + GiniTooltipBox( + tooltipText = stringResource( + id = R.string.gbs_skonto_screen_content_description_back + ) + ) { + IconButton( + modifier = modifier + .width(24.dp) + .height(24.dp), + onClick = onClick + ) { + Icon( + painter = painterResource(id = net.gini.android.capture.R.drawable.gc_action_bar_back), + contentDescription = stringResource( + id = R.string.gbs_skonto_screen_content_description_back + ), + ) + } + } +} + +@Composable +private fun InvoicePreviewSection( + onClick: () -> Unit, + colorScheme: SkontoInvoicePreviewSectionColors, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RectangleShape, + colors = CardDefaults.cardColors(containerColor = colorScheme.cardBackgroundColor) + ) { + Row( + modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(colorScheme.iconBackgroundColor, shape = RoundedCornerShape(4.dp)) + ) { + Icon( + modifier = Modifier + .size(40.dp) + .padding(8.dp), + painter = painterResource(id = R.drawable.gbs_icon_document), + contentDescription = null, + tint = colorScheme.iconTint, + ) + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .weight(0.1f) + ) { + Text( + text = stringResource(id = R.string.gbs_skonto_section_invoice_preview_title), + style = GiniTheme.typography.subtitle1, + color = colorScheme.titleTextColor + ) + Text( + text = stringResource(id = R.string.gbs_skonto_invoice_section_preview_subtitle), + style = GiniTheme.typography.body2, + color = colorScheme.subtitleTextColor + ) + } + + Icon( + painter = painterResource(id = R.drawable.gbs_arrow_right), + contentDescription = null, + tint = colorScheme.arrowTint + ) + } + + } +} + +@Composable +private fun SkontoSection( + isActive: Boolean, + amountFormatter: AmountFormatter, + colors: SkontoSectionColors, + amount: Amount, + dueDate: LocalDate, + infoPaymentInDays: Int, + infoDiscountValue: BigDecimal, + onActiveChange: (Boolean) -> Unit, + onSkontoAmountChange: (BigDecimal) -> Unit, + onDueDateChanged: (LocalDate) -> Unit, + onInfoBannerClicked: () -> Unit, + edgeCase: SkontoEdgeCase?, + skontoAmountValidationError: SkontoScreenState.Ready.SkontoAmountValidationError?, + modifier: Modifier = Modifier, + discountPercentageFormatter: SkontoDiscountPercentageFormatter = SkontoDiscountPercentageFormatter(), +) { + val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + val resources = LocalContext.current.resources + + var isDatePickerVisible by remember { mutableStateOf(false) } + Card( + modifier = modifier, + shape = RectangleShape, + colors = CardDefaults.cardColors(containerColor = colors.cardBackgroundColor) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.gbs_skonto_section_discount_title), + style = GiniTheme.typography.subtitle1, + color = colors.titleTextColor, + ) + Box { + androidx.compose.animation.AnimatedVisibility(visible = isActive) { + Text( + text = stringResource(id = R.string.gbs_skonto_section_discount_hint_label_enabled), + style = GiniTheme.typography.subtitle2, + color = colors.enabledHintTextColor, + ) + } + } + + Spacer(Modifier.weight(1f)) + + + GiniSwitch( + checked = isActive, + onCheckedChange = onActiveChange, + ) + } + val animatedDiscountAmount by animateFloatAsState( + targetValue = infoDiscountValue.toFloat(), + label = "discountAmount" + ) + + val remainingDaysText = + if (infoPaymentInDays != 0) { + pluralStringResource( + id = R.plurals.days, + count = infoPaymentInDays, + infoPaymentInDays.toString() + ) + } else { + stringResource(id = R.string.days_zero) + } + + val infoBannerText = when (edgeCase) { + SkontoEdgeCase.PayByCashOnly -> + stringResource( + id = R.string.gbs_skonto_section_discount_info_banner_pay_cash_message, + discountPercentageFormatter.format(animatedDiscountAmount), + remainingDaysText + ) + + SkontoEdgeCase.PayByCashToday -> + stringResource( + id = R.string.gbs_skonto_section_discount_info_banner_pay_cash_today_message, + discountPercentageFormatter.format(animatedDiscountAmount) + ) + + SkontoEdgeCase.SkontoExpired -> + stringResource( + id = R.string.gbs_skonto_section_discount_info_banner_date_expired_message, + discountPercentageFormatter.format(animatedDiscountAmount) + ) + + SkontoEdgeCase.SkontoLastDay -> + stringResource( + id = R.string.gbs_skonto_section_discount_info_banner_pay_today_message, + discountPercentageFormatter.format(animatedDiscountAmount) + ) + + else -> stringResource( + id = R.string.gbs_skonto_section_discount_info_banner_normal_message, + remainingDaysText, + discountPercentageFormatter.format(animatedDiscountAmount) + ) + } + + InfoBanner( + text = infoBannerText, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + colors = when (edgeCase) { + SkontoEdgeCase.SkontoLastDay, + SkontoEdgeCase.PayByCashToday, + SkontoEdgeCase.PayByCashOnly -> colors.warningInfoBannerColors + + SkontoEdgeCase.SkontoExpired -> colors.errorInfoBannerColors + else -> colors.successInfoBannerColors + }, + onClicked = onInfoBannerClicked, + clickable = edgeCase != null, + ) + GiniAmountTextInput( + amount = amount.value, + currencyCode = amount.currency.name, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + enabled = isActive, + colors = colors.amountFieldColors, + onValueChange = { onSkontoAmountChange(it) }, + label = stringResource(id = R.string.gbs_skonto_section_discount_field_amount_hint), + trailingContent = { + AnimatedVisibility(visible = isActive) { + Text( + text = amount.currency.name, + style = GiniTheme.typography.subtitle1, + ) + } + }, + isError = skontoAmountValidationError != null, + supportingText = skontoAmountValidationError?.toErrorMessage( + resources = resources, + amountFormatter = amountFormatter + ) + ) + + val dueDateOnClickSource = remember { MutableInteractionSource() } + val pressed by dueDateOnClickSource.collectIsPressedAsState() + + LaunchedEffect(key1 = pressed) { + if (pressed) { + isDatePickerVisible = true + } + } + + GiniTextInput( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .focusable(false), + enabled = isActive, + interactionSource = dueDateOnClickSource, + readOnly = true, + colors = colors.dueDateTextFieldColor, + onValueChange = { /* Ignored */ }, + text = dueDate.format(dateFormatter), + label = stringResource(id = R.string.gbs_skonto_section_discount_field_due_date_hint), + trailingContent = { + androidx.compose.animation.AnimatedVisibility(visible = isActive) { + Icon( + painter = painterResource(id = R.drawable.gbs_icon_calendar), + contentDescription = null, + ) + } + }, + ) + } + } + + if (isDatePickerVisible) { + GiniDatePickerDialog( + onDismissRequest = { isDatePickerVisible = false }, + onSaved = { + isDatePickerVisible = false + onDueDateChanged(it) + }, + date = dueDate, + selectableDates = getSkontoSelectableDates() + ) + } +} + +private fun getSkontoSelectableDates() = object : SelectableDates { + + val minDateCalendar = Calendar.getInstance().apply { + set(Calendar.MILLISECONDS_IN_DAY, 0) + } + + val maxDateCalendar = Calendar.getInstance().apply { + add(Calendar.MONTH, 6) + } + + val minTime = minDateCalendar.timeInMillis + val maxTime = maxDateCalendar.timeInMillis + + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return (minTime..maxTime).contains(utcTimeMillis) + } + + override fun isSelectableYear(year: Int): Boolean { + return (minDateCalendar.get(Calendar.YEAR)..maxDateCalendar.get(Calendar.YEAR)) + .contains(year) + } +} + +@Composable +private fun InfoBanner( + colors: SkontoSectionColors.InfoBannerColors, + text: String, + clickable: Boolean, + onClicked: () -> Unit, + modifier: Modifier = Modifier, + icon: Painter = painterResource(id = R.drawable.gbs_icon_important_info), +) { + Row( + modifier = modifier + .background( + color = colors.backgroundColor, RoundedCornerShape(8.dp) + ) + .clickable(onClick = onClicked, enabled = clickable), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.padding(8.dp), + painter = icon, + contentDescription = null, + tint = colors.iconTint, + ) + + Text( + modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp), + text = text, + style = GiniTheme.typography.subtitle2, + color = colors.textColor, + ) + } +} + +@Composable +private fun InfoDialog( + text: String, + colors: SkontoInfoDialogColors, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + Dialog( + properties = DialogProperties(), + onDismissRequest = onDismissRequest + ) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = colors.cardBackgroundColor + ) + ) { + Text( + modifier = Modifier.padding(top = 24.dp, start = 16.dp, end = 16.dp), + text = text, + style = GiniTheme.typography.caption1 + ) + Button( + modifier = Modifier + .padding(16.dp) + .align(Alignment.End), + onClick = onDismissRequest, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = colors.buttonTextColor, + ), + ) { + Text( + modifier = Modifier, + text = stringResource(id = R.string.gbs_skonto_section_info_dialog_ok_button_text), + style = GiniTheme.typography.button + ) + } + } + } +} + +@Composable +private fun WithoutSkontoSection( + isActive: Boolean, + onFullAmountChange: (BigDecimal) -> Unit, + colors: WithoutSkontoSectionColors, + amount: Amount, + amountFormatter: AmountFormatter, + fullAmountValidationError: SkontoScreenState.Ready.FullAmountValidationError?, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + + Card( + modifier = modifier, + shape = RectangleShape, + colors = CardDefaults.cardColors(containerColor = colors.cardBackgroundColor) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.gbs_skonto_section_without_discount_title), + style = GiniTheme.typography.subtitle1, + color = colors.titleTextColor, + ) + AnimatedVisibility(visible = isActive) { + Text( + text = stringResource(id = R.string.gbs_skonto_section_discount_hint_label_enabled), + modifier = Modifier.weight(0.1f), + style = GiniTheme.typography.subtitle2, + color = colors.enabledHintTextColor, + ) + } + } + GiniAmountTextInput( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + enabled = isActive, + colors = colors.amountFieldColors, + amount = amount.value, + currencyCode = amount.currency.name, + onValueChange = onFullAmountChange, + label = stringResource(id = R.string.gbs_skonto_section_without_discount_field_amount_hint), + trailingContent = { + AnimatedVisibility(visible = isActive) { + Text( + text = amount.currency.name, + style = GiniTheme.typography.subtitle1, + ) + } + }, + isError = fullAmountValidationError != null, + supportingText = fullAmountValidationError?.toErrorMessage( + resources = resources, + amountFormatter = amountFormatter + ) + ) + } + } +} + +@Composable +private fun FooterSection( + totalAmount: Amount, + savedAmount: Amount, + discountValue: BigDecimal, + colors: SkontoFooterSectionColors, + isBottomNavigationBarEnabled: Boolean, + isSkontoSectionActive: Boolean, + onBackClicked: () -> Unit, + onHelpClicked: () -> Unit, + onProceedClicked: () -> Unit, + discountPercentageFormatter: SkontoDiscountPercentageFormatter, + customBottomNavBarAdapter: InjectedViewAdapterInstance?, + modifier: Modifier = Modifier, +) { + val animatedTotalAmount by animateFloatAsState( + targetValue = totalAmount.value.toFloat(), label = "totalAmount" + ) + val animatedSavedAmount by animateFloatAsState( + targetValue = savedAmount.value.toFloat(), label = "savedAmount" + ) + val animatedDiscountAmount by animateFloatAsState( + targetValue = discountValue.toFloat(), label = "discountAmount" + ) + val totalPriceText = + "${ + currencyFormatterWithoutSymbol().format(animatedTotalAmount).trim() + } ${totalAmount.currency.name}" + + val savedAmountText = + stringResource( + id = R.string.gbs_skonto_section_footer_label_save, + "${ + currencyFormatterWithoutSymbol().format(animatedSavedAmount).trim() + } ${savedAmount.currency.name}" + ) + + val discountLabelText = stringResource( + id = R.string.gbs_skonto_section_footer_label_discount, + discountPercentageFormatter.format(animatedDiscountAmount) + ) + + if (customBottomNavBarAdapter != null) { + val ctx = LocalContext.current + AndroidView(factory = { + customBottomNavBarAdapter.viewAdapter.onCreateView(FrameLayout(ctx)) + }, update = { + with(customBottomNavBarAdapter.viewAdapter) { + setOnProceedClickListener(onProceedClicked) + setOnBackClickListener(onBackClicked) + setOnHelpClickListener(onHelpClicked) + onTotalAmountUpdated(totalPriceText) + onSkontoPercentageBadgeUpdated(discountLabelText) + onSkontoPercentageBadgeVisibilityUpdate(isSkontoSectionActive) + onSkontoSavingsAmountUpdated(savedAmountText) + onSkontoSavingsAmountVisibilityUpdated(isSkontoSectionActive) + } + }) + } else { + Card( + modifier = modifier.fillMaxWidth(), + shape = RectangleShape, + colors = CardDefaults.cardColors(containerColor = colors.cardBackgroundColor), + ) { + Column( + modifier = Modifier + .tabletMaxWidth() + .align(Alignment.CenterHorizontally), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier + .padding(start = 20.dp, end = 20.dp, top = 20.dp) + ) { + Row { + Text( + modifier = Modifier.weight(0.1f), + text = stringResource(id = R.string.gbs_skonto_section_footer_title), + style = GiniTheme.typography.body1, + color = colors.titleTextColor, + ) + AnimatedVisibility( + visible = isSkontoSectionActive + ) { + Box( + modifier = Modifier + .height(IntrinsicSize.Min) + + .background( + colors.discountLabelColorScheme.backgroundColor, + RoundedCornerShape(4.dp) + ), + ) { + Text( + modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), + text = discountLabelText, + style = GiniTheme.typography.caption1, + color = colors.discountLabelColorScheme.textColor, + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = totalPriceText, + style = GiniTheme.typography.headline5.bold(), + color = colors.amountTextColor, + ) + } + AnimatedVisibility( + visible = isSkontoSectionActive + ) { + Text( + text = savedAmountText, + style = GiniTheme.typography.caption1, + color = colors.savedAmountTextColor, + ) + } + } + val buttonPaddingStart = if (isBottomNavigationBarEnabled) 16.dp else 20.dp + val buttonPaddingEnd = if (isBottomNavigationBarEnabled) 16.dp else 20.dp + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + AnimatedVisibility(visible = isBottomNavigationBarEnabled) { + NavigationActionBack( + modifier = Modifier.padding(start = 16.dp), + onClick = onBackClicked + ) + } + GiniButton( + modifier = Modifier + .weight(0.1f) + .padding(start = buttonPaddingStart, end = buttonPaddingEnd), + text = stringResource(id = R.string.gbs_skonto_section_footer_continue_button_text), + onClick = onProceedClicked, + giniButtonColors = colors.continueButtonColors + ) + AnimatedVisibility(visible = isBottomNavigationBarEnabled) { + NavigationActionHelp( + modifier = Modifier.padding(end = 20.dp), + onClick = onHelpClicked + ) + } + } + } + } + } +} + +@Composable +@Preview +private fun ScreenReadyStatePreviewLight() { + ScreenReadyStatePreview() +} + +@Composable +@Preview(uiMode = UI_MODE_NIGHT_YES) +private fun ScreenReadyStatePreviewDark() { + ScreenReadyStatePreview() +} + +@Composable +private fun ScreenReadyStatePreview() { + GiniTheme { + var state by remember { mutableStateOf(previewState()) } + ScreenReadyState( + state = state, + onDiscountSectionActiveChange = { + state = state.copy(isSkontoSectionActive = !state.isSkontoSectionActive) + }, + onDiscountAmountChange = {}, + onDueDateChanged = {}, + onFullAmountChange = {}, + onBackClicked = {}, + onHelpClicked = {}, + isBottomNavigationBarEnabled = true, + onProceedClicked = {}, + customBottomNavBarAdapter = null, + onInfoDialogDismissed = {}, + onInfoBannerClicked = {}, + onInvoiceClicked = {}, + onCancelAttachTransactionDocClicked = { + + }, + onConfirmAttachTransactionDocClicked = { + + }, + amountFormatter = AmountFormatter(currencyFormatterWithoutSymbol()) + ) + } +} + +private fun previewState() = SkontoScreenState.Ready( + isSkontoSectionActive = true, + paymentInDays = 14, + skontoPercentage = BigDecimal("3"), + skontoAmount = Amount.parse("97:EUR"), + discountDueDate = LocalDate.now(), + fullAmount = Amount.parse("100:EUR"), + totalAmount = Amount.parse("97:EUR"), + paymentMethod = SkontoData.SkontoPaymentMethod.PayPal, + edgeCase = SkontoEdgeCase.PayByCashOnly, + edgeCaseInfoDialogVisible = false, + savedAmount = Amount.parse("3:EUR"), + transactionDialogVisible = true, + skontoAmountValidationError = null, + fullAmountValidationError = null, +) diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenModule.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenModule.kt index 047686259..993404d97 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenModule.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenModule.kt @@ -2,6 +2,20 @@ package net.gini.android.bank.sdk.capture.skonto import net.gini.android.bank.sdk.capture.skonto.factory.lines.SkontoInvoicePreviewTextLinesFactory import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoAmountValidator +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoFullAmountValidator +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoFragmentViewModel +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenInitialStateFactory +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.FullAmountChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.InfoBannerInteractionIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.InvoiceClickIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.KeyboardStateChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.ProceedClickedIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoActiveChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoAmountFieldChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoDueDateChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.TransactionDocDialogDecisionIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.subintent.OpenExtractionsScreenSubIntent import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -10,26 +24,93 @@ val skontoScreenModule = module { viewModel { (data: SkontoData) -> SkontoFragmentViewModel( data = data, + skontoScreenInitialStateFactory = get(), + proceedClickedIntent = get(), + skontoActiveChangeIntent = get(), + keyboardStateChangeIntent = get(), + skontoAmountFieldChangeIntent = get(), + invoiceClickIntent = get(), + fullAmountChangeIntent = get(), + skontoDueDateChangeIntent = get(), + transactionDocDialogDecisionIntent = get(), + infoBannerInteractionIntent = get() + ) + } + factory { + SkontoInvoicePreviewTextLinesFactory( + resources = androidContext().resources, + amountFormatter = get() + ) + } + factory { + SkontoAmountValidator() + } + factory { + SkontoFullAmountValidator() + } + factory { + SkontoScreenInitialStateFactory( + getSkontoSavedAmountUseCase = get(), + getSkontoEdgeCaseUseCase = get(), + getSkontoDefaultSelectionStateUseCase = get() + ) + } + factory { + ProceedClickedIntent( + openExtractionsScreenSubIntent = get(), + getTransactionDocShouldBeAutoAttachedUseCase = get(), + getTransactionDocsFeatureEnabledUseCase = get(), + transactionDocDialogConfirmAttachUseCase = get() + ) + } + factory { + InvoiceClickIntent( + lastAnalyzedDocumentProvider = get(), + skontoInvoicePreviewTextLinesFactory = get() + ) + } + factory { + FullAmountChangeIntent( + skontoFullAmountValidator = get(), getSkontoAmountUseCase = get(), + getSkontoSavedAmountUseCase = get() + ) + } + factory { + InfoBannerInteractionIntent() + } + factory { + KeyboardStateChangeIntent() + } + factory { + SkontoActiveChangeIntent( + getSkontoDiscountPercentageUseCase = get() + ) + } + factory { + SkontoAmountFieldChangeIntent( + skontoAmountValidator = get(), getSkontoDiscountPercentageUseCase = get(), - getSkontoEdgeCaseUseCase = get(), getSkontoSavedAmountUseCase = get(), + ) + } + factory { + SkontoDueDateChangeIntent( getSkontoRemainingDaysUseCase = get(), - getSkontoDefaultSelectionStateUseCase = get(), - skontoExtractionsHandler = get(), - lastAnalyzedDocumentProvider = get(), - skontoInvoicePreviewTextLinesFactory = get(), - lastExtractionsProvider = get(), + getSkontoEdgeCaseUseCase = get(), + ) + } + factory { + TransactionDocDialogDecisionIntent( + openExtractionsScreenSubIntent = get(), transactionDocDialogConfirmAttachUseCase = get(), transactionDocDialogCancelAttachUseCase = get(), - getTransactionDocShouldBeAutoAttachedUseCase = get(), - getTransactionDocsFeatureEnabledUseCase = get(), ) } factory { - SkontoInvoicePreviewTextLinesFactory( - resources = androidContext().resources, - amountFormatter = get() + OpenExtractionsScreenSubIntent( + skontoExtractionsHandler = get(), + lastExtractionsProvider = get() ) } } \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenSideEffect.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenSideEffect.kt new file mode 100644 index 000000000..9d4556bfb --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenSideEffect.kt @@ -0,0 +1,8 @@ +package net.gini.android.bank.sdk.capture.skonto + +internal sealed interface SkontoScreenSideEffect { + data class OpenInvoiceScreen( + val documentId: String, + val infoTextLines: List, + ) : SkontoScreenSideEffect +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenState.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenState.kt new file mode 100644 index 000000000..6af359a4c --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoScreenState.kt @@ -0,0 +1,37 @@ +package net.gini.android.bank.sdk.capture.skonto + +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.model.SkontoEdgeCase +import net.gini.android.capture.Amount +import java.math.BigDecimal +import java.time.LocalDate + +internal sealed interface SkontoScreenState { + + data class Ready( + val isSkontoSectionActive: Boolean, + val paymentInDays: Int, + val skontoPercentage: BigDecimal, + val skontoAmount: Amount, + val skontoAmountValidationError: SkontoAmountValidationError?, + val discountDueDate: LocalDate, + val fullAmount: Amount, + val fullAmountValidationError: FullAmountValidationError?, + val totalAmount: Amount, + val savedAmount: Amount, + val paymentMethod: SkontoData.SkontoPaymentMethod, + val edgeCase: SkontoEdgeCase?, + val edgeCaseInfoDialogVisible: Boolean, + val transactionDialogVisible: Boolean, + ) : SkontoScreenState { + + sealed interface SkontoAmountValidationError { + object SkontoAmountMoreThanFullAmount : SkontoAmountValidationError + object SkontoAmountLimitExceeded : SkontoAmountValidationError + } + + sealed interface FullAmountValidationError { + object FullAmountLimitExceeded : FullAmountValidationError + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/mapper/FullAmountValidationErrorMapper.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/mapper/FullAmountValidationErrorMapper.kt new file mode 100644 index 000000000..7914b9901 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/mapper/FullAmountValidationErrorMapper.kt @@ -0,0 +1,22 @@ +package net.gini.android.bank.sdk.capture.skonto.mapper + +import android.content.res.Resources +import net.gini.android.bank.sdk.R +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.formatter.AmountFormatter +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoAmountValidator +import net.gini.android.capture.Amount + +private val maxAmount = + Amount.parse("${SkontoAmountValidator.SKONTO_AMOUNT_LIMIT}:EUR") + +internal fun SkontoScreenState.Ready.FullAmountValidationError.toErrorMessage( + resources: Resources, + amountFormatter: AmountFormatter, +): String = when (this) { + is SkontoScreenState.Ready.FullAmountValidationError.FullAmountLimitExceeded -> + resources.getString( + R.string.gbs_skonto_section_without_discount_field_amount_validation_error_limit_exceeded, + amountFormatter.format(maxAmount) + ) +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/mapper/SkontoAmountValidationErrorMapper.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/mapper/SkontoAmountValidationErrorMapper.kt new file mode 100644 index 000000000..3decb5231 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/mapper/SkontoAmountValidationErrorMapper.kt @@ -0,0 +1,27 @@ +package net.gini.android.bank.sdk.capture.skonto.mapper + +import android.content.res.Resources +import net.gini.android.bank.sdk.R +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.formatter.AmountFormatter +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoAmountValidator +import net.gini.android.capture.Amount + +private val maxAmount = + Amount.parse("${SkontoAmountValidator.SKONTO_AMOUNT_LIMIT}:EUR") + +internal fun SkontoScreenState.Ready.SkontoAmountValidationError.toErrorMessage( + resources: Resources, + amountFormatter: AmountFormatter, +): String = when (this) { + is SkontoScreenState.Ready.SkontoAmountValidationError.SkontoAmountMoreThanFullAmount -> + resources.getString( + R.string.gbs_skonto_section_discount_field_amount_validation_error_skonto_amount_more_than_full_amount + ) + + SkontoScreenState.Ready.SkontoAmountValidationError.SkontoAmountLimitExceeded -> + resources.getString( + R.string.gbs_skonto_section_discount_field_amount_validation_error_limit_exceeded, + amountFormatter.format(maxAmount) + ) +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/validation/SkontoAmountValidator.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/validation/SkontoAmountValidator.kt new file mode 100644 index 000000000..6b91f6e30 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/validation/SkontoAmountValidator.kt @@ -0,0 +1,24 @@ +package net.gini.android.bank.sdk.capture.skonto.validation + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import java.math.BigDecimal + +internal class SkontoAmountValidator { + + fun execute( + newSkontoAmount: BigDecimal, + fullAmount: BigDecimal + ): SkontoScreenState.Ready.SkontoAmountValidationError? = when { + newSkontoAmount > fullAmount -> + SkontoScreenState.Ready.SkontoAmountValidationError.SkontoAmountMoreThanFullAmount + + newSkontoAmount > BigDecimal(SKONTO_AMOUNT_LIMIT) -> + SkontoScreenState.Ready.SkontoAmountValidationError.SkontoAmountLimitExceeded + + else -> null + } + + companion object { + internal const val SKONTO_AMOUNT_LIMIT = "99999.99" + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/validation/SkontoFullAmountValidator.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/validation/SkontoFullAmountValidator.kt new file mode 100644 index 000000000..832844a62 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/validation/SkontoFullAmountValidator.kt @@ -0,0 +1,21 @@ +package net.gini.android.bank.sdk.capture.skonto.validation + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import java.math.BigDecimal + +internal class SkontoFullAmountValidator { + + fun execute( + fullAmount: BigDecimal + ): SkontoScreenState.Ready.FullAmountValidationError? = when { + + fullAmount > BigDecimal(SKONTO_AMOUNT_LIMIT) -> + SkontoScreenState.Ready.FullAmountValidationError.FullAmountLimitExceeded + + else -> null + } + + companion object { + private const val SKONTO_AMOUNT_LIMIT = "99999.99" + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/SkontoFragmentViewModel.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/SkontoFragmentViewModel.kt new file mode 100644 index 000000000..9aa5cada2 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/SkontoFragmentViewModel.kt @@ -0,0 +1,82 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel + +import androidx.lifecycle.ViewModel +import net.gini.android.bank.sdk.capture.skonto.SkontoFragmentListener +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenSideEffect +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.FullAmountChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.InfoBannerInteractionIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.InvoiceClickIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.KeyboardStateChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.ProceedClickedIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoActiveChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoAmountFieldChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoDueDateChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.TransactionDocDialogDecisionIntent +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import java.math.BigDecimal +import java.time.LocalDate + +internal typealias SkontoScreenContainerHost = ContainerHost + +internal class SkontoFragmentViewModel( + data: SkontoData, + skontoScreenInitialStateFactory: SkontoScreenInitialStateFactory, + + private val proceedClickedIntent: ProceedClickedIntent, + private val skontoActiveChangeIntent: SkontoActiveChangeIntent, + private val keyboardStateChangeIntent: KeyboardStateChangeIntent, + private val skontoAmountFieldChangeIntent: SkontoAmountFieldChangeIntent, + private val invoiceClickIntent: InvoiceClickIntent, + private val fullAmountChangeIntent: FullAmountChangeIntent, + private val skontoDueDateChangeIntent: SkontoDueDateChangeIntent, + private val transactionDocDialogDecisionIntent: TransactionDocDialogDecisionIntent, + private val infoBannerInteractionIntent: InfoBannerInteractionIntent, +) : ViewModel(), SkontoScreenContainerHost { + + override val container: Container = container( + skontoScreenInitialStateFactory.create(data) + ) + + private var listener: SkontoFragmentListener? = null + + fun setListener(listener: SkontoFragmentListener?) { + this.listener = listener + } + + fun onProceedClicked() = + with(proceedClickedIntent) { run(listener) } + + fun onConfirmAttachTransactionDocClicked(alwaysAttach: Boolean) = + with(transactionDocDialogDecisionIntent) { runConfirm(alwaysAttach, listener) } + + fun onCancelAttachTransactionDocClicked() = + with(transactionDocDialogDecisionIntent) { runCancel(listener) } + + fun onSkontoActiveChanged(newValue: Boolean) = + with(skontoActiveChangeIntent) { run(newValue) } + + fun onKeyboardStateChanged(isVisible: Boolean) = + with(keyboardStateChangeIntent) { run(isVisible) } + + fun onSkontoAmountFieldChanged(newValue: BigDecimal) = + with(skontoAmountFieldChangeIntent) { run(newValue) } + + fun onSkontoDueDateChanged(newDate: LocalDate) = + with(skontoDueDateChangeIntent) { run(newDate) } + + fun onFullAmountFieldChanged(newValue: BigDecimal) = + with(fullAmountChangeIntent) { run(newValue) } + + fun onInfoBannerClicked() = + with(infoBannerInteractionIntent) { runClick() } + + fun onInfoDialogDismissed() = + with(infoBannerInteractionIntent) { runDismiss() } + + fun onInvoiceClicked() = + with(invoiceClickIntent) { run() } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/SkontoScreenInitialStateFactory.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/SkontoScreenInitialStateFactory.kt new file mode 100644 index 000000000..3ff3d682e --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/SkontoScreenInitialStateFactory.kt @@ -0,0 +1,51 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDefaultSelectionStateUseCase +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoEdgeCaseUseCase +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoSavedAmountUseCase +import net.gini.android.capture.Amount + +internal class SkontoScreenInitialStateFactory( + private val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase, + private val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase, + private val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase, +) { + + fun create(data: SkontoData): SkontoScreenState.Ready { + + val discount = data.skontoPercentageDiscounted + + val paymentMethod = data.skontoPaymentMethod ?: SkontoData.SkontoPaymentMethod.Unspecified + val edgeCase = getSkontoEdgeCaseUseCase.execute(data.skontoDueDate, paymentMethod) + + val isSkontoSectionActive = getSkontoDefaultSelectionStateUseCase.execute(edgeCase) + + val totalAmount = + if (isSkontoSectionActive) data.skontoAmountToPay else data.fullAmountToPay + + val savedAmountValue = getSkontoSavedAmountUseCase.execute( + data.skontoAmountToPay.value, + data.fullAmountToPay.value + ) + val savedAmount = Amount(savedAmountValue, data.fullAmountToPay.currency) + + return SkontoScreenState.Ready( + isSkontoSectionActive = isSkontoSectionActive, + paymentInDays = data.skontoRemainingDays, + skontoPercentage = discount, + skontoAmount = data.skontoAmountToPay, + discountDueDate = data.skontoDueDate, + fullAmount = data.fullAmountToPay, + totalAmount = totalAmount, + paymentMethod = paymentMethod, + edgeCase = edgeCase, + edgeCaseInfoDialogVisible = edgeCase != null, + savedAmount = savedAmount, + transactionDialogVisible = false, + skontoAmountValidationError = null, + fullAmountValidationError = null, + ) + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/FullAmountChangeIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/FullAmountChangeIntent.kt new file mode 100644 index 000000000..046f1f86b --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/FullAmountChangeIntent.kt @@ -0,0 +1,57 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoAmountUseCase +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoSavedAmountUseCase +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoFullAmountValidator +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoFragmentViewModel +import net.gini.android.capture.Amount +import java.math.BigDecimal + +internal class FullAmountChangeIntent( + private val skontoFullAmountValidator: SkontoFullAmountValidator, + private val getSkontoAmountUseCase: GetSkontoAmountUseCase, + private val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase, +) { + + fun SkontoFragmentViewModel.run(newValue: BigDecimal) = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + + if (newValue == state.fullAmount.value) return@intent + + val validationError = skontoFullAmountValidator.execute(newValue) + + if (validationError != null) { + reduce { + state.copy( + fullAmountValidationError = validationError + ) + } + return@intent + } + + val totalAmount = + if (state.isSkontoSectionActive) state.skontoAmount.value else newValue + + val discount = state.skontoPercentage + + val skontoAmount = getSkontoAmountUseCase.execute(newValue, discount) + + val savedAmountValue = getSkontoSavedAmountUseCase.execute( + skontoAmount, + newValue + ) + + val savedAmount = Amount(savedAmountValue, state.fullAmount.currency) + + reduce { + state.copy( + fullAmountValidationError = validationError, + skontoAmount = state.skontoAmount.copy(value = skontoAmount), + fullAmount = state.fullAmount.copy(value = newValue), + totalAmount = state.totalAmount.copy(value = totalAmount), + savedAmount = savedAmount, + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/InfoBannerInteractionIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/InfoBannerInteractionIntent.kt new file mode 100644 index 000000000..f0961fbb4 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/InfoBannerInteractionIntent.kt @@ -0,0 +1,25 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost + +internal class InfoBannerInteractionIntent { + + fun SkontoScreenContainerHost.runClick() = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + reduce { + state.copy( + edgeCaseInfoDialogVisible = true, + ) + } + } + + fun SkontoScreenContainerHost.runDismiss() = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + reduce { + state.copy( + edgeCaseInfoDialogVisible = false, + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/InvoiceClickIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/InvoiceClickIntent.kt new file mode 100644 index 000000000..ac4a827a0 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/InvoiceClickIntent.kt @@ -0,0 +1,35 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenSideEffect +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.factory.lines.SkontoInvoicePreviewTextLinesFactory +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost +import net.gini.android.capture.analysis.LastAnalyzedDocumentProvider + +internal class InvoiceClickIntent( + private val lastAnalyzedDocumentProvider: LastAnalyzedDocumentProvider, + private val skontoInvoicePreviewTextLinesFactory: SkontoInvoicePreviewTextLinesFactory +) { + + fun SkontoScreenContainerHost.run() = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + + val skontoData = SkontoData( + skontoAmountToPay = state.skontoAmount, + skontoDueDate = state.discountDueDate, + skontoPercentageDiscounted = state.skontoPercentage, + skontoRemainingDays = state.paymentInDays, + fullAmountToPay = state.fullAmount, + skontoPaymentMethod = state.paymentMethod, + ) + val documentId = lastAnalyzedDocumentProvider.provide()?.giniApiDocumentId ?: return@intent + + postSideEffect( + SkontoScreenSideEffect.OpenInvoiceScreen( + documentId, + skontoInvoicePreviewTextLinesFactory.create(skontoData) + ) + ) + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/KeyboardStateChangeIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/KeyboardStateChangeIntent.kt new file mode 100644 index 000000000..9ac4f58cf --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/KeyboardStateChangeIntent.kt @@ -0,0 +1,18 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost + +internal class KeyboardStateChangeIntent { + + fun SkontoScreenContainerHost.run(visible: Boolean) = intent { + if (visible) return@intent + val state = state as? SkontoScreenState.Ready ?: return@intent + reduce { + state.copy( + fullAmountValidationError = null, + skontoAmountValidationError = null + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/ProceedClickedIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/ProceedClickedIntent.kt new file mode 100644 index 000000000..91ee56a27 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/ProceedClickedIntent.kt @@ -0,0 +1,36 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoFragmentListener +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost +import net.gini.android.bank.sdk.capture.skonto.viewmodel.subintent.OpenExtractionsScreenSubIntent +import net.gini.android.bank.sdk.transactiondocs.internal.usecase.GetTransactionDocShouldBeAutoAttachedUseCase +import net.gini.android.bank.sdk.transactiondocs.internal.usecase.GetTransactionDocsFeatureEnabledUseCase +import net.gini.android.bank.sdk.transactiondocs.internal.usecase.TransactionDocDialogConfirmAttachUseCase + +internal class ProceedClickedIntent( + private val openExtractionsScreenSubIntent: OpenExtractionsScreenSubIntent, + private val getTransactionDocShouldBeAutoAttachedUseCase: GetTransactionDocShouldBeAutoAttachedUseCase, + private val getTransactionDocsFeatureEnabledUseCase: GetTransactionDocsFeatureEnabledUseCase, + private val transactionDocDialogConfirmAttachUseCase: TransactionDocDialogConfirmAttachUseCase, +) { + + fun SkontoScreenContainerHost.run(skontoFragmentListener: SkontoFragmentListener?) = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + + if (!getTransactionDocsFeatureEnabledUseCase()) { + with(openExtractionsScreenSubIntent) { + run(skontoFragmentListener) + } + return@intent + } + if (getTransactionDocShouldBeAutoAttachedUseCase()) { + transactionDocDialogConfirmAttachUseCase(true) + with(openExtractionsScreenSubIntent) { + run(skontoFragmentListener) + } + } else { + reduce { state.copy(transactionDialogVisible = true) } + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoActiveChangeIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoActiveChangeIntent.kt new file mode 100644 index 000000000..f6d4ac80e --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoActiveChangeIntent.kt @@ -0,0 +1,27 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDiscountPercentageUseCase +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost + +internal class SkontoActiveChangeIntent( + private val getSkontoDiscountPercentageUseCase: GetSkontoDiscountPercentageUseCase, +) { + + fun SkontoScreenContainerHost.run(newValue: Boolean) = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + val totalAmount = if (newValue) state.skontoAmount else state.fullAmount + val discount = getSkontoDiscountPercentageUseCase.execute( + state.skontoAmount.value, + state.fullAmount.value + ) + + reduce { + state.copy( + isSkontoSectionActive = newValue, + totalAmount = totalAmount, + skontoPercentage = discount + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoAmountFieldChangeIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoAmountFieldChangeIntent.kt new file mode 100644 index 000000000..5b2e3dfc1 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoAmountFieldChangeIntent.kt @@ -0,0 +1,67 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDiscountPercentageUseCase +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoSavedAmountUseCase +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoAmountValidator +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost +import net.gini.android.capture.Amount +import java.math.BigDecimal + +internal class SkontoAmountFieldChangeIntent( + private val skontoAmountValidator: SkontoAmountValidator, + private val getSkontoDiscountPercentageUseCase: GetSkontoDiscountPercentageUseCase, + private val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase, +) { + + fun SkontoScreenContainerHost.run(newValue: BigDecimal) = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + + if (newValue == state.skontoAmount.value) return@intent + + val skontoAmountValidationError = skontoAmountValidator.execute( + newValue, + state.fullAmount.value + ) + + if (skontoAmountValidationError != null) { + reduce { + state.copy( + skontoAmount = state.skontoAmount, + skontoAmountValidationError = SkontoScreenState + .Ready.SkontoAmountValidationError.SkontoAmountMoreThanFullAmount + ) + } + return@intent + } + + val discount = getSkontoDiscountPercentageUseCase.execute( + newValue, + state.fullAmount.value + ) + + val totalAmount = if (state.isSkontoSectionActive) + newValue + else state.fullAmount.value + + val newSkontoAmount = state.skontoAmount.copy(value = newValue) + val newTotalAmount = state.totalAmount.copy(value = totalAmount) + + val savedAmountValue = getSkontoSavedAmountUseCase.execute( + newSkontoAmount.value, + state.fullAmount.value + ) + + val savedAmount = Amount(savedAmountValue, state.fullAmount.currency) + + reduce { + state.copy( + skontoAmountValidationError = skontoAmountValidationError, + skontoAmount = newSkontoAmount, + skontoPercentage = discount, + totalAmount = newTotalAmount, + savedAmount = savedAmount, + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoDueDateChangeIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoDueDateChangeIntent.kt new file mode 100644 index 000000000..d3de22a7a --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/SkontoDueDateChangeIntent.kt @@ -0,0 +1,28 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoEdgeCaseUseCase +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoRemainingDaysUseCase +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost +import java.time.LocalDate + +internal class SkontoDueDateChangeIntent( + private val getSkontoRemainingDaysUseCase: GetSkontoRemainingDaysUseCase, + private val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase, +) { + + fun SkontoScreenContainerHost.run(newDate: LocalDate) = intent { + val state = state as? SkontoScreenState.Ready ?: return@intent + val newPayInDays = getSkontoRemainingDaysUseCase.execute(newDate) + reduce { + state.copy( + discountDueDate = newDate, + paymentInDays = newPayInDays, + edgeCase = getSkontoEdgeCaseUseCase.execute( + dueDate = newDate, + paymentMethod = state.paymentMethod + ) + ) + } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/TransactionDocDialogDecisionIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/TransactionDocDialogDecisionIntent.kt new file mode 100644 index 000000000..24ac6cd8d --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/intent/TransactionDocDialogDecisionIntent.kt @@ -0,0 +1,27 @@ +package net.gini.android.bank.sdk.capture.skonto.viewmodel.intent + +import net.gini.android.bank.sdk.capture.skonto.SkontoFragmentListener +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost +import net.gini.android.bank.sdk.capture.skonto.viewmodel.subintent.OpenExtractionsScreenSubIntent +import net.gini.android.bank.sdk.transactiondocs.internal.usecase.TransactionDocDialogCancelAttachUseCase +import net.gini.android.bank.sdk.transactiondocs.internal.usecase.TransactionDocDialogConfirmAttachUseCase + +internal class TransactionDocDialogDecisionIntent( + private val openExtractionsScreenSubIntent: OpenExtractionsScreenSubIntent, + private val transactionDocDialogConfirmAttachUseCase: TransactionDocDialogConfirmAttachUseCase, + private val transactionDocDialogCancelAttachUseCase: TransactionDocDialogCancelAttachUseCase, +) { + + fun SkontoScreenContainerHost.runConfirm( + alwaysAttach: Boolean, + listener: SkontoFragmentListener? + ) = intent { + transactionDocDialogConfirmAttachUseCase(alwaysAttach) + with(openExtractionsScreenSubIntent) { run(listener) } + } + + fun SkontoScreenContainerHost.runCancel(listener: SkontoFragmentListener?) = intent { + transactionDocDialogCancelAttachUseCase() + with(openExtractionsScreenSubIntent) { run(listener) } + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/subintent/OpenExtractionsScreenSubIntent.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/subintent/OpenExtractionsScreenSubIntent.kt new file mode 100644 index 000000000..9f6803543 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/viewmodel/subintent/OpenExtractionsScreenSubIntent.kt @@ -0,0 +1,32 @@ +@file:OptIn(OrbitExperimental::class) + +package net.gini.android.bank.sdk.capture.skonto.viewmodel.subintent + +import net.gini.android.bank.sdk.capture.extractions.skonto.SkontoExtractionsHandler +import net.gini.android.bank.sdk.capture.skonto.SkontoFragmentListener +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenContainerHost +import net.gini.android.capture.provider.LastExtractionsProvider +import org.orbitmvi.orbit.annotation.OrbitExperimental + +internal class OpenExtractionsScreenSubIntent( + private val skontoExtractionsHandler: SkontoExtractionsHandler, + private val lastExtractionsProvider: LastExtractionsProvider, +) { + + suspend fun SkontoScreenContainerHost.run(listener: SkontoFragmentListener?) = subIntent { + val state = state as? SkontoScreenState.Ready ?: return@subIntent + skontoExtractionsHandler.updateExtractions( + totalAmount = state.totalAmount, + skontoPercentage = state.skontoPercentage, + skontoAmount = state.skontoAmount, + paymentInDays = state.paymentInDays, + discountDueDate = state.discountDueDate.toString(), + ) + lastExtractionsProvider.update(skontoExtractionsHandler.getExtractions().toMutableMap()) + listener?.onPayInvoiceWithSkonto( + skontoExtractionsHandler.getExtractions(), + skontoExtractionsHandler.getCompoundExtractions() + ) + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/util/ui/KeyboardExt.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/util/ui/KeyboardExt.kt new file mode 100644 index 000000000..91f749ddc --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/util/ui/KeyboardExt.kt @@ -0,0 +1,34 @@ +package net.gini.android.bank.sdk.util.ui + +import android.view.ViewTreeObserver +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +@Composable +fun keyboardAsState(): State { + val view = LocalView.current + var isImeVisible by remember { mutableStateOf(false) } + + DisposableEffect(LocalWindowInfo.current) { + val listener = ViewTreeObserver.OnPreDrawListener { + isImeVisible = ViewCompat.getRootWindowInsets(view) + ?.isVisible(WindowInsetsCompat.Type.ime()) == true + true + } + view.viewTreeObserver.addOnPreDrawListener(listener) + onDispose { + view.viewTreeObserver.removeOnPreDrawListener(listener) + } + } + return rememberUpdatedState(isImeVisible) +} diff --git a/bank-sdk/sdk/src/main/res/values-en/strings.xml b/bank-sdk/sdk/src/main/res/values-en/strings.xml index d22442ee4..9d98552bb 100644 --- a/bank-sdk/sdk/src/main/res/values-en/strings.xml +++ b/bank-sdk/sdk/src/main/res/values-en/strings.xml @@ -59,11 +59,14 @@ The %1$s discount has expired Final amount Expiry date + Your transfer limit has been exceeded: %1$s + Discounted value cannot exceed initial value Without Skonto discount   •  Active Full amount + Your transfer limit has been exceeded: %1$s Total diff --git a/bank-sdk/sdk/src/main/res/values/strings.xml b/bank-sdk/sdk/src/main/res/values/strings.xml index 6aa4fc862..95df80db1 100644 --- a/bank-sdk/sdk/src/main/res/values/strings.xml +++ b/bank-sdk/sdk/src/main/res/values/strings.xml @@ -59,11 +59,14 @@ Die %1$s Skonto sind abgelaufen Betrag nach Abzug Ablaufdatum Skonto + Ihr Überweisungslimit wurde überschritten: %1$s + Der Betrag mit Skonto darf nicht höher sein als ohne Ohne Skonto   •  Aktiviert Betrag ohne Abzug + Ihr Überweisungslimit wurde überschritten: %1$s Gesamtpreis diff --git a/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentViewModelTest.kt b/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentViewModelTest.kt index 616ac9bb5..d25b3099e 100644 --- a/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentViewModelTest.kt +++ b/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/SkontoFragmentViewModelTest.kt @@ -1,13 +1,11 @@ package net.gini.android.bank.sdk.capture.skonto -import app.cash.turbine.test import io.mockk.Runs import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import net.gini.android.bank.sdk.MainDispatcherRule import net.gini.android.bank.sdk.capture.skonto.factory.lines.SkontoInvoicePreviewTextLinesFactory @@ -16,11 +14,25 @@ import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoAmountUseCase import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDefaultSelectionStateUseCase import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoDiscountPercentageUseCase import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoEdgeCaseUseCase +import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoRemainingDaysUseCase import net.gini.android.bank.sdk.capture.skonto.usecase.GetSkontoSavedAmountUseCase +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoAmountValidator +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoFullAmountValidator +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoFragmentViewModel +import net.gini.android.bank.sdk.capture.skonto.viewmodel.SkontoScreenInitialStateFactory +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.FullAmountChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.InfoBannerInteractionIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.InvoiceClickIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.ProceedClickedIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoActiveChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoAmountFieldChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.intent.SkontoDueDateChangeIntent +import net.gini.android.bank.sdk.capture.skonto.viewmodel.subintent.OpenExtractionsScreenSubIntent import net.gini.android.capture.Amount import net.gini.android.capture.analysis.LastAnalyzedDocumentProvider import org.junit.Rule import org.junit.Test +import org.orbitmvi.orbit.test.test import java.math.BigDecimal import java.time.LocalDate @@ -40,26 +52,31 @@ class SkontoFragmentViewModelTest { val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = mockk(relaxed = true) - val viewModel = SkontoFragmentViewModel( - data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = mockk(), + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), ) - val flowData = viewModel.stateFlow.first() - assert(flowData is SkontoFragmentContract.State.Ready) + val viewModel = SkontoFragmentViewModel( + data = skontoData, + + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), + ) + + viewModel.test(this) { + expectInitialState() + } } @Test @@ -74,26 +91,32 @@ class SkontoFragmentViewModelTest { val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = mockk(relaxed = true) - val viewModel = SkontoFragmentViewModel( - data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = mockk(), + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), ) - val flowData = viewModel.stateFlow.first() - assert(flowData is SkontoFragmentContract.State.Ready) + + val viewModel = SkontoFragmentViewModel( + data = skontoData, + + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), + ) + + viewModel.test(this) { + expectInitialState() + } coVerify(exactly = 1) { getSkontoSavedAmountUseCase.execute(any(), any()) @@ -108,35 +131,41 @@ class SkontoFragmentViewModelTest { runTest { val skontoData: SkontoData = mockk(relaxed = true) - val viewModel = SkontoFragmentViewModel( - data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = mockk(), - getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), - getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), + val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase = + mockk(relaxed = true) + val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase = + mockk(relaxed = true) + val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = + mockk(relaxed = true) + + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, + getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, ) - with(viewModel.stateFlow.first()) { - assert(this is SkontoFragmentContract.State.Ready) - require(this is SkontoFragmentContract.State.Ready) - } + val infoBannerInteractionIntent = InfoBannerInteractionIntent() - viewModel.onInfoBannerClicked() + val viewModel = SkontoFragmentViewModel( + data = skontoData, + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = infoBannerInteractionIntent, + ) - with(viewModel.stateFlow.first()) { - assert(this is SkontoFragmentContract.State.Ready) - require(this is SkontoFragmentContract.State.Ready) - assert(this.edgeCaseInfoDialogVisible) + viewModel.test(this) { + runOnCreate() + containerHost.onInfoBannerClicked() + expectState { + (this as SkontoScreenState.Ready).copy(edgeCaseInfoDialogVisible = true) + } } } @@ -145,33 +174,42 @@ class SkontoFragmentViewModelTest { runTest { val skontoData: SkontoData = mockk(relaxed = true) - val viewModel = SkontoFragmentViewModel( - data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = mockk(), - getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), - getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), + val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase = + mockk(relaxed = true) + val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase = + mockk(relaxed = true) + val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = + mockk(relaxed = true) + + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, + getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, ) - viewModel.stateFlow.value = mockk(relaxed = true) - .copy(edgeCaseInfoDialogVisible = true) + val infoBannerInteractionIntent = InfoBannerInteractionIntent() - viewModel.onInfoDialogDismissed() + val viewModel = SkontoFragmentViewModel( + data = skontoData, + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = infoBannerInteractionIntent, + ) - with(viewModel.stateFlow.first()) { - assert(this is SkontoFragmentContract.State.Ready) - require(this is SkontoFragmentContract.State.Ready) - assert(!this.edgeCaseInfoDialogVisible) + viewModel.test(this) { + runOnCreate() + expectInitialState() + containerHost.onInfoDialogDismissed() + expectState { + (this as SkontoScreenState.Ready).copy(edgeCaseInfoDialogVisible = false) + } } } @@ -179,102 +217,112 @@ class SkontoFragmentViewModelTest { fun `when user clicks on invoice preview the VM should fire the navigation side effect`() = runTest { val skontoData: SkontoData = mockk(relaxed = true) + + val lastAnalyzedDocumentProvider = mockk { every { provide() } returns mockk(relaxed = true) } val skontoInvoicePreviewTextLinesFactory = mockk { every { create(any()) } returns mockk(relaxed = true) } - val viewModel = SkontoFragmentViewModel( - data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = mockk(), - getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), - getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), - skontoExtractionsHandler = mockk(), + + val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase = + mockk(relaxed = true) + val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase = + mockk(relaxed = true) + val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = + mockk(relaxed = true) + + val invoiceClickIntent = InvoiceClickIntent( lastAnalyzedDocumentProvider = lastAnalyzedDocumentProvider, skontoInvoicePreviewTextLinesFactory = skontoInvoicePreviewTextLinesFactory, - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), ) - viewModel.sideEffectFlow.test { - viewModel.onInvoiceClicked() - assert(awaitItem() is SkontoFragmentContract.SideEffect.OpenInvoiceScreen) - } - } - - @Test - fun `when user disables skonto the final amount should be changed to full price`() = - runTest { - val skontoData: SkontoData = mockk(relaxed = true) + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, + getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, + ) val viewModel = SkontoFragmentViewModel( data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = mockk(relaxed = true), - getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), - getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = invoiceClickIntent, + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), ) - viewModel.stateFlow.test { - skipItems(1) // skip initial state - viewModel.onSkontoActiveChanged(false) - with(awaitItem()) { - assert(this is SkontoFragmentContract.State.Ready) - require(this is SkontoFragmentContract.State.Ready) - assert(!this.isSkontoSectionActive) - assert(this.totalAmount == this.fullAmount) - } + viewModel.test(this) { + expectInitialState() + containerHost.onInvoiceClicked() + assert(awaitSideEffect() is SkontoScreenSideEffect.OpenInvoiceScreen) } } @Test - fun `when user enables skonto the final amount should be changed to skonto price`() = + fun `when user swith skonto the final amount should be changed`() = runTest { val skontoData: SkontoData = mockk(relaxed = true) + val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase = + mockk(relaxed = true) + val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase = + mockk(relaxed = true) + val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = + mockk(relaxed = true) + + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, + getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, + ) + + val getSkontoDiscountPercentageUseCase: GetSkontoDiscountPercentageUseCase = mockk() { + every { execute(any(), any()) } returns BigDecimal.ZERO + } + + val skontoActiveChangeIntent = SkontoActiveChangeIntent( + getSkontoDiscountPercentageUseCase = getSkontoDiscountPercentageUseCase, + ) + val viewModel = SkontoFragmentViewModel( data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = mockk(relaxed = true), - getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), - getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = skontoActiveChangeIntent, + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), ) - viewModel.stateFlow.test { - skipItems(1) // skip initial state + viewModel.test(this) { + expectInitialState() + runOnCreate() + viewModel.onSkontoActiveChanged(false) + expectState { + (this as SkontoScreenState.Ready).copy( + isSkontoSectionActive = false, + totalAmount = fullAmount, + skontoPercentage = BigDecimal.ZERO + ) + } viewModel.onSkontoActiveChanged(true) - with(awaitItem()) { - assert(this is SkontoFragmentContract.State.Ready) - require(this is SkontoFragmentContract.State.Ready) - assert(this.isSkontoSectionActive) - assert(this.totalAmount == this.skontoAmount) + expectState { + (this as SkontoScreenState.Ready).copy( + isSkontoSectionActive = true, + totalAmount = skontoAmount, + skontoPercentage = BigDecimal.ZERO + ) } } } @@ -284,76 +332,118 @@ class SkontoFragmentViewModelTest { runTest { val skontoData: SkontoData = mockk(relaxed = true) { every { fullAmountToPay } returns Amount.parse("100:EUR") + every { skontoAmountToPay } returns Amount.parse("90:EUR") + every { skontoPercentageDiscounted } returns BigDecimal.ZERO } - val getSkontoDiscountPercentageUseCase = mockk( - relaxed = true - ) { - every { execute(any(), any()) } returns mockk() + val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase = mockk { + every { execute(any(), any()) } returns BigDecimal.ZERO } + val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase = + mockk(relaxed = true) + + val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = + mockk { every { execute(any()) } returns true } + + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, + getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, + ) + + val getSkontoDiscountPercentageUseCase: GetSkontoDiscountPercentageUseCase = mockk { + every { execute(any(), any()) } returns BigDecimal.ZERO + } + + val skontoAmountFieldChangeIntent = SkontoAmountFieldChangeIntent( + skontoAmountValidator = SkontoAmountValidator(), + getSkontoDiscountPercentageUseCase = getSkontoDiscountPercentageUseCase, + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + ) + val viewModel = SkontoFragmentViewModel( data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = getSkontoDiscountPercentageUseCase, - getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), - getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = skontoAmountFieldChangeIntent, + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), ) - viewModel.onSkontoAmountFieldChanged(BigDecimal("95")) + viewModel.test(this) { + expectInitialState() + runOnCreate() + val newSkontoAmount = BigDecimal("95") + containerHost.onSkontoAmountFieldChanged(newSkontoAmount) + expectState { + (this as SkontoScreenState.Ready).copy( + skontoAmount = skontoAmount.copy(value = newSkontoAmount), + totalAmount = totalAmount.copy(value = newSkontoAmount), + ) + } + } coVerify(exactly = 1) { - getSkontoDiscountPercentageUseCase.execute( - any(), any() - ) + getSkontoDiscountPercentageUseCase.execute(any(), any()) } } @Test - fun `when user changes skonto amount to incorrect value no action should be performed`() = + fun `when user changes skonto amount to incorrect value error should be shown`() = runTest { val skontoData: SkontoData = mockk(relaxed = true) { every { fullAmountToPay } returns Amount.parse("100:EUR") + every { skontoAmountToPay } returns Amount.parse("90:EUR") } val getSkontoDiscountPercentageUseCase = mockk { every { execute(any(), any()) } returns mockk() } - val viewModel = SkontoFragmentViewModel( - data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = getSkontoDiscountPercentageUseCase, + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( getSkontoSavedAmountUseCase = mockk(relaxed = true), getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = mockk(), - getSkontoRemainingDaysUseCase = mockk(), getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), ) - viewModel.onSkontoAmountFieldChanged(BigDecimal("110")) + val skontoAmountFieldChangeIntent = SkontoAmountFieldChangeIntent( + skontoAmountValidator = SkontoAmountValidator(), + getSkontoDiscountPercentageUseCase = getSkontoDiscountPercentageUseCase, + getSkontoSavedAmountUseCase = mockk(), + ) + + val viewModel = SkontoFragmentViewModel( + data = skontoData, + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = skontoAmountFieldChangeIntent, + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), + ) - coVerify(exactly = 0) { - getSkontoDiscountPercentageUseCase.execute( - any(), any() - ) + viewModel.test(this) { + expectInitialState() + runOnCreate() + containerHost.onSkontoAmountFieldChanged(BigDecimal("110")) + expectState { + with(this as SkontoScreenState.Ready) { + copy( + skontoAmountValidationError = SkontoScreenState + .Ready.SkontoAmountValidationError.SkontoAmountMoreThanFullAmount + ) + } + } } } @@ -361,35 +451,63 @@ class SkontoFragmentViewModelTest { fun `when user changes full amount the skonto amount should be recalculated`() = runTest { val skontoData: SkontoData = mockk(relaxed = true) { - every { skontoAmountToPay } returns Amount.parse("100:EUR") - every { fullAmountToPay } returns Amount.parse("150:EUR") + every { fullAmountToPay } returns Amount.parse("100:EUR") + every { skontoAmountToPay } returns Amount.parse("90:EUR") + every { skontoPercentageDiscounted } returns BigDecimal.ZERO } - val getSkontoAmountUseCase = mockk { - every { execute(any(), any()) } returns mockk() + val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase = mockk { + every { execute(any(), any()) } returns BigDecimal.ZERO } + val getSkontoAmountUseCase: GetSkontoAmountUseCase = mockk { + every { execute(any(), any()) } returns BigDecimal.ONE + } + + val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase = + mockk(relaxed = true) + + val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = + mockk { every { execute(any()) } returns true } + + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, + getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, + ) + + val fullAmountChangeIntent = FullAmountChangeIntent( + skontoFullAmountValidator = SkontoFullAmountValidator(), + getSkontoAmountUseCase = getSkontoAmountUseCase, + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + ) + val viewModel = SkontoFragmentViewModel( data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk(), - getSkontoDiscountPercentageUseCase = mockk(relaxed = true), - getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = getSkontoAmountUseCase, - getSkontoRemainingDaysUseCase = mockk(), - getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true) { - every { execute(any()) } returns false - }, - skontoExtractionsHandler = mockk(), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = mockk(), + fullAmountChangeIntent = fullAmountChangeIntent, + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), ) - viewModel.onFullAmountFieldChanged(BigDecimal("200")) + viewModel.test(this) { + expectInitialState() + runOnCreate() + val newFullAmount = BigDecimal("200") + containerHost.onFullAmountFieldChanged(newFullAmount) + expectState { + (this as SkontoScreenState.Ready).copy( + fullAmount = skontoAmount.copy(value = newFullAmount), + skontoAmount = skontoAmount.copy(value = BigDecimal.ONE) + ) + } + } coVerify(exactly = 1) { getSkontoAmountUseCase.execute(any(), any()) @@ -399,39 +517,59 @@ class SkontoFragmentViewModelTest { @Test fun `when user clicks proceed the extraction screen should be opened`() = runTest { - val skontoData: SkontoData = mockk(relaxed = true) - val getSkontoAmountUseCase = mockk { - every { execute(any(), any()) } returns mockk() - } + val skontoData: SkontoData = mockk(relaxed = true) - val listener = mockk(relaxed = true){ + val listener = mockk(relaxed = true) { every { onPayInvoiceWithSkonto(any(), any()) } just Runs } - val viewModel = SkontoFragmentViewModel( - data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk { - every { this@mockk.invoke() } returns false - }, - getSkontoDiscountPercentageUseCase = mockk(relaxed = true), - getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = getSkontoAmountUseCase, - getSkontoRemainingDaysUseCase = mockk(), - getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), + val getSkontoSavedAmountUseCase: GetSkontoSavedAmountUseCase = + mockk(relaxed = true) + val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase = + mockk(relaxed = true) + val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase = + mockk(relaxed = true) + + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( + getSkontoSavedAmountUseCase = getSkontoSavedAmountUseCase, + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, + getSkontoDefaultSelectionStateUseCase = getSkontoDefaultSelectionStateUseCase, + ) + + val openExtractionsScreenSubIntent = OpenExtractionsScreenSubIntent( skontoExtractionsHandler = mockk(relaxed = true), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), lastExtractionsProvider = mockk(relaxed = true), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), + ) + + val proceedClickedIntent = ProceedClickedIntent( + openExtractionsScreenSubIntent = openExtractionsScreenSubIntent, + getTransactionDocShouldBeAutoAttachedUseCase = mockk(relaxed = true), + getTransactionDocsFeatureEnabledUseCase = mockk(relaxed = true), + transactionDocDialogConfirmAttachUseCase = mockk(relaxed = true), + ) + + val viewModel = SkontoFragmentViewModel( + data = skontoData, + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = proceedClickedIntent, + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = mockk(), + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), ) viewModel.setListener(listener) - viewModel.onProceedClicked() + viewModel.test(this) { + runOnCreate() + expectInitialState() + containerHost.onProceedClicked() + } verify(exactly = 1) { listener.onPayInvoiceWithSkonto(any(), any()) @@ -441,43 +579,57 @@ class SkontoFragmentViewModelTest { @Test fun `when user changes due date to date it should be applied`() = runTest { - val skontoData: SkontoData = mockk(relaxed = true) + val skontoData: SkontoData = mockk(relaxed = true) { + every { skontoDueDate } returns LocalDate.now() + } - val viewModel = SkontoFragmentViewModel( - data = skontoData, - getTransactionDocsFeatureEnabledUseCase = mockk { - every { this@mockk.invoke() } returns false - }, - getSkontoDiscountPercentageUseCase = mockk(relaxed = true), + val getSkontoEdgeCaseUseCase = mockk { + every { execute(any(), any()) } returns mockk(relaxed = true) + } + + val skontoScreenInitialStateFactory = SkontoScreenInitialStateFactory( getSkontoSavedAmountUseCase = mockk(relaxed = true), - getSkontoEdgeCaseUseCase = mockk(relaxed = true), - getSkontoAmountUseCase = mockk(relaxed = true), - getSkontoRemainingDaysUseCase = mockk(relaxed = true), + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, getSkontoDefaultSelectionStateUseCase = mockk(relaxed = true), - skontoExtractionsHandler = mockk(relaxed = true), - lastAnalyzedDocumentProvider = mockk(), - skontoInvoicePreviewTextLinesFactory = mockk(), - lastExtractionsProvider = mockk(relaxed = true), - transactionDocDialogConfirmAttachUseCase = mockk(), - transactionDocDialogCancelAttachUseCase = mockk(), - getTransactionDocShouldBeAutoAttachedUseCase = mockk(), ) - viewModel.stateFlow.test { - skipItems(1) // skip initial state - val futureDueDate = LocalDate.now().plusDays(5) - viewModel.onSkontoDueDateChanged(futureDueDate) - with(awaitItem()) { - assert(this is SkontoFragmentContract.State.Ready) - require(this is SkontoFragmentContract.State.Ready) - assert(this.discountDueDate == futureDueDate) + val skontoDueDateChangeIntent = SkontoDueDateChangeIntent( + getSkontoRemainingDaysUseCase = GetSkontoRemainingDaysUseCase(), + getSkontoEdgeCaseUseCase = getSkontoEdgeCaseUseCase, + ) + + val viewModel = SkontoFragmentViewModel( + data = skontoData, + skontoScreenInitialStateFactory = skontoScreenInitialStateFactory, + proceedClickedIntent = mockk(), + skontoActiveChangeIntent = mockk(), + keyboardStateChangeIntent = mockk(), + skontoAmountFieldChangeIntent = mockk(), + invoiceClickIntent = mockk(), + fullAmountChangeIntent = mockk(), + skontoDueDateChangeIntent = skontoDueDateChangeIntent, + transactionDocDialogDecisionIntent = mockk(), + infoBannerInteractionIntent = mockk(), + ) + + viewModel.test(this) { + expectInitialState() + runOnCreate() + val newDueDate = LocalDate.now().plusDays(5) + containerHost.onSkontoDueDateChanged(newDueDate) + expectState { + (this as SkontoScreenState.Ready).copy( + discountDueDate = newDueDate, + paymentInDays = 5 + ) } val pastDueDate = LocalDate.now().minusDays(5) - viewModel.onSkontoDueDateChanged(pastDueDate) - with(awaitItem()) { - assert(this is SkontoFragmentContract.State.Ready) - require(this is SkontoFragmentContract.State.Ready) - assert(this.discountDueDate == pastDueDate) + containerHost.onSkontoDueDateChanged(pastDueDate) + expectState { + (this as SkontoScreenState.Ready).copy( + discountDueDate = pastDueDate, + paymentInDays = 5 // always absolute value + ) } } } diff --git a/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/usecase/SkontoAmountValidatorTest.kt b/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/usecase/SkontoAmountValidatorTest.kt new file mode 100644 index 000000000..c583ac142 --- /dev/null +++ b/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/usecase/SkontoAmountValidatorTest.kt @@ -0,0 +1,37 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoAmountValidator +import org.junit.Test +import java.math.BigDecimal + + +class SkontoAmountValidatorTest { + + @Test + fun `skonto amount validation error should be null if skonto amount is less than or equal to full amount`() { + val useCase = SkontoAmountValidator() + val skontoAmount = BigDecimal("100.00") + val fullAmount = BigDecimal("200.00") + val result = useCase.execute(skontoAmount, fullAmount) + assert(result == null) + } + + @Test + fun `skonto amount validation error should be SKONTO_AMOUNT_MORE_THAN_FULL_AMOUNT if skonto amount is greater than full amount`() { + val useCase = SkontoAmountValidator() + val skontoAmount = BigDecimal("300.00") + val fullAmount = BigDecimal("200.00") + val result = useCase.execute(skontoAmount, fullAmount) + assert(result == SkontoScreenState.Ready.SkontoAmountValidationError.SkontoAmountMoreThanFullAmount) + } + + @Test + fun `skonto amount validation error should be SKONTO_AMOUNT_LIMIT_EXCEEDED if skonto amount is greater than MAX_AMOUNT`() { + val useCase = SkontoAmountValidator() + val skontoAmount = BigDecimal("1000000.00") + val fullAmount = BigDecimal("1000000.00") + val result = useCase.execute(skontoAmount, fullAmount) + assert(result == SkontoScreenState.Ready.SkontoAmountValidationError.SkontoAmountLimitExceeded) + } +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/usecase/SkontoFullAmountValidatorTest.kt b/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/usecase/SkontoFullAmountValidatorTest.kt new file mode 100644 index 000000000..5ebd9b5d6 --- /dev/null +++ b/bank-sdk/sdk/src/test/java/net/gini/android/bank/sdk/capture/skonto/usecase/SkontoFullAmountValidatorTest.kt @@ -0,0 +1,25 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase + +import net.gini.android.bank.sdk.capture.skonto.SkontoScreenState +import net.gini.android.bank.sdk.capture.skonto.validation.SkontoFullAmountValidator +import org.junit.Test +import java.math.BigDecimal + +class SkontoFullAmountValidatorTest { + + @Test + fun `full amount validation error should be null if full amount is less than or equal to MAX_AMOUNT`() { + val useCase = SkontoFullAmountValidator() + val fullAmount = BigDecimal("100.00") + val result = useCase.execute(fullAmount) + assert(result == null) + } + + @Test + fun `full amount validation error should be MAX_AMOUNT_EXCEEDED if full amount is greater than MAX_AMOUNT`() { + val useCase = SkontoFullAmountValidator() + val fullAmount = BigDecimal("1000000.00") + val result = useCase.execute(fullAmount) + assert(result == SkontoScreenState.Ready.FullAmountValidationError.FullAmountLimitExceeded) + } +} \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/GiniTextInput.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/GiniTextInput.kt index 23fa5d711..6f5868198 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/GiniTextInput.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/GiniTextInput.kt @@ -29,6 +29,7 @@ fun GiniTextInput( colors: GiniTextInputColors = GiniTextInputColors.colors(), visualTransformation: VisualTransformation = VisualTransformation.None, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + supportingText: @Composable (() -> Unit)? = null, ) { GiniTextInput( modifier = modifier, @@ -48,6 +49,7 @@ fun GiniTextInput( trailingContent = trailingContent, colors = colors, visualTransformation = visualTransformation, + supportingText = supportingText, ) } @@ -65,6 +67,7 @@ fun GiniTextInput( colors: GiniTextInputColors = GiniTextInputColors.colors(), visualTransformation: VisualTransformation = VisualTransformation.None, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + supportingText: @Composable (() -> Unit)? = null, ) { TextField( modifier = modifier, @@ -102,7 +105,8 @@ fun GiniTextInput( ) }, onValueChange = onValueChange, - trailingIcon = trailingContent + trailingIcon = trailingContent, + supportingText = supportingText, ) } diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/GiniTextInputColors.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/GiniTextInputColors.kt index 40e84341e..6a197ea33 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/GiniTextInputColors.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/GiniTextInputColors.kt @@ -37,7 +37,7 @@ data class GiniTextInputColors( textFocused: Color = GiniTheme.colorScheme.textField.text.focused, textUnfocused: Color = GiniTheme.colorScheme.textField.text.unfocused, textDisabled: Color = GiniTheme.colorScheme.textField.text.disabled, - textError: Color = GiniTheme.colorScheme.textField.text.focused, + textError: Color = GiniTheme.colorScheme.textField.text.error, indicatorFocused: Color = GiniTheme.colorScheme.textField.indicator.focused, indicatorUnfocused: Color = GiniTheme.colorScheme.textField.indicator.unfocused, indicatorDisabled: Color = GiniTheme.colorScheme.textField.indicator.disabled, diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/amount/DecimalFormatter.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/amount/DecimalFormatter.kt index e4d17d5bc..98e85f161 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/amount/DecimalFormatter.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/amount/DecimalFormatter.kt @@ -5,7 +5,7 @@ import java.text.DecimalFormat import java.text.NumberFormat class DecimalFormatter( - val numberFormat: NumberFormat = NumberFormat.getCurrencyInstance().apply { + private val numberFormat: NumberFormat = NumberFormat.getCurrencyInstance().apply { (this as? DecimalFormat)?.apply { decimalFormatSymbols = decimalFormatSymbols.apply { currencySymbol = "" @@ -14,14 +14,12 @@ class DecimalFormatter( } ) { - fun parseAmount(amount: BigDecimal) = numberFormat.format(amount).trim() - .filter { it != '.' && it != ',' } - .take(7) + fun parseAmount(amount: BigDecimal) = numberFormat.format(amount).trim() + .filter { NUMBER_CHARS.contains(it) } .trimStart('0') fun textToDigits(text: String): String = text.trim() - .filter { it != '.' && it != ',' } - .take(7) + .filter { NUMBER_CHARS.contains(it) } .trimStart('0') fun parseDigits(digits: String): BigDecimal = @@ -34,4 +32,8 @@ class DecimalFormatter( // Format to a currency string return numberFormat.format(decimal).trim() } + + companion object { + private val NUMBER_CHARS = "0123456789".toCharArray() + } } \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/amount/GiniAmountTextInput.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/amount/GiniAmountTextInput.kt index 9d02eda5b..b3b286d6e 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/amount/GiniAmountTextInput.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/ui/components/textinput/amount/GiniAmountTextInput.kt @@ -3,6 +3,7 @@ package net.gini.android.capture.ui.components.textinput.amount import android.content.res.Configuration import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -30,11 +31,12 @@ fun GiniAmountTextInput( isError: Boolean = false, decimalFormatter: DecimalFormatter = DecimalFormatter(), colors: GiniTextInputColors = GiniTextInputColors.colors(), + supportingText: String? = null, ) { val parsedAmount = decimalFormatter.parseAmount(amount) var text by remember { mutableStateOf(parsedAmount) } - + text = parsedAmount GiniTextInput( @@ -48,8 +50,11 @@ fun GiniAmountTextInput( ), label = label, onValueChange = { - text = decimalFormatter.textToDigits(it) // take only 7 digits - onValueChange(decimalFormatter.parseDigits(text)) + val newText = decimalFormatter.textToDigits(it) // take only 7 digits + if (newText != text) { + text = newText + onValueChange(decimalFormatter.parseDigits(text)) + } }, trailingContent = trailingContent, colors = colors, @@ -58,6 +63,15 @@ fun GiniAmountTextInput( currencyCode = currencyCode, isCurrencyCodeDisplay = !enabled, ), + supportingText = supportingText?.let { + { + Text( + text = supportingText, + color = colors.textError, + style = GiniTheme.typography.caption1, + ) + } + } ) } @@ -66,6 +80,7 @@ fun GiniAmountTextInput( @Composable private fun GiniTextInputPreviewLight() { GiniTextInputPreview() + GiniTextInputPreviewError() } @Preview( @@ -75,6 +90,7 @@ private fun GiniTextInputPreviewLight() { @Composable private fun GiniTextInputPreviewDark() { GiniTextInputPreview() + GiniTextInputPreviewError() } @Composable @@ -90,3 +106,20 @@ private fun GiniTextInputPreview() { ) } } + +@Composable +private fun GiniTextInputPreviewError() { + GiniTheme { + GiniAmountTextInput( + modifier = Modifier.padding(16.dp), + amount = BigDecimal("1234"), + label = "Label Text", + trailingContent = { }, + currencyCode = "EUR", + onValueChange = {}, + isError = true, + supportingText = "Error text" + ) + } +} + diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/compose/ImeListener.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/compose/ImeListener.kt new file mode 100644 index 000000000..434bf459f --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/compose/ImeListener.kt @@ -0,0 +1,31 @@ +package net.gini.android.capture.util.compose + +import android.view.ViewTreeObserver +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +@Composable +fun rememberImeState(): State { + val imeState = remember { mutableStateOf(false) } + + val view = LocalView.current + DisposableEffect(view) { + val listener = ViewTreeObserver.OnGlobalLayoutListener { + val isKeyboardOpen = ViewCompat.getRootWindowInsets(view) + ?.isVisible(WindowInsetsCompat.Type.ime()) ?: true + imeState.value = isKeyboardOpen + } + + view.viewTreeObserver.addOnGlobalLayoutListener(listener) + onDispose { + view.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + } + return imeState +} diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/compose/KeybaordPaddingProvider.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/compose/KeybaordPaddingProvider.kt new file mode 100644 index 000000000..59b4625b3 --- /dev/null +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/util/compose/KeybaordPaddingProvider.kt @@ -0,0 +1,44 @@ +package net.gini.android.capture.util.compose + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +/** + * Provides a padding based on keyboard state. If keyboard is opened the passed padding will be + * returned and 0 will be returned otherwise + * + * In case if scrollState is passed - it will be scrolled automatically to this padding + */ +@Composable +fun keyboardPadding( + padding: Dp, + scrollState: ScrollState? = null +): State { + val keybaordState by rememberImeState() + + val keyboardPadding = remember { mutableStateOf(padding) } + val paddingPx = with(LocalDensity.current) { padding.toPx().roundToInt() } + + LaunchedEffect(keybaordState, paddingPx) { + if (keybaordState) { + keyboardPadding.value = padding + scrollState?.let { scrollState -> + scrollState.animateScrollTo(scrollState.value + paddingPx, tween(300)) + } + } else { + keyboardPadding.value = 0.dp + } + } + + return keyboardPadding +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7aaac4672..2414e05b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ compose-bom = "2024.06.00" accompanist-themeAdapter = "1.1.1" compose-activity = "1.9.0" koin-bom = "3.5.6" +orbitMvi = "9.0.0" [libraries] android-gradle = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" } @@ -124,4 +125,7 @@ koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" } koin-core = { module = "io.insert-koin:koin-core" } koin-android-compat = { module = "io.insert-koin:koin-android-compat" } koin-android = { module = "io.insert-koin:koin-android" } -koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" } \ No newline at end of file +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" } +orbitmvi-compose = { group = "org.orbit-mvi", name = "orbit-compose", version.ref = "orbitMvi" } +orbitmvi-test = { group = "org.orbit-mvi", name = "orbit-test", version.ref = "orbitMvi" } +orbitmvi-viewmodel = { group = "org.orbit-mvi", name = "orbit-viewmodel", version.ref = "orbitMvi" } \ No newline at end of file