diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/CaptureFlowFragment.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/CaptureFlowFragment.kt index 386c97e68f..c1f94a257d 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/CaptureFlowFragment.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/CaptureFlowFragment.kt @@ -16,12 +16,16 @@ import net.gini.android.bank.sdk.capture.digitalinvoice.DigitalInvoiceException import net.gini.android.bank.sdk.capture.digitalinvoice.DigitalInvoiceFragment import net.gini.android.bank.sdk.capture.digitalinvoice.DigitalInvoiceFragmentListener import net.gini.android.bank.sdk.capture.digitalinvoice.LineItemsValidator +import net.gini.android.bank.sdk.capture.digitalinvoice.args.DigitalInvoiceSpecificExtractionsArg +import net.gini.android.bank.sdk.capture.digitalinvoice.args.ExtractionsResultData import net.gini.android.bank.sdk.capture.extractions.skonto.SkontoInvoiceHighlightsExtractor import net.gini.android.bank.sdk.capture.skonto.SkontoDataExtractor import net.gini.android.bank.sdk.capture.skonto.SkontoFragment import net.gini.android.bank.sdk.capture.skonto.SkontoFragmentListener +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData import net.gini.android.bank.sdk.di.getGiniBankKoin import net.gini.android.bank.sdk.util.disallowScreenshots +import net.gini.android.capture.Amount import net.gini.android.capture.CaptureSDKResult import net.gini.android.capture.Document @@ -38,6 +42,8 @@ import net.gini.android.capture.tracking.useranalytics.UserAnalyticsEvent import net.gini.android.capture.tracking.useranalytics.UserAnalyticsScreen import net.gini.android.capture.tracking.useranalytics.properties.UserAnalyticsEventProperty import net.gini.android.capture.tracking.useranalytics.properties.UserAnalyticsUserProperty +import java.math.BigDecimal +import java.time.LocalDate class CaptureFlowFragment(private val openWithDocument: Document? = null) : Fragment(), @@ -178,12 +184,40 @@ class CaptureFlowFragment(private val openWithDocument: Document? = null) : private fun tryShowingReturnAssistant(result: CaptureSDKResult.Success) { LineItemsValidator.validate(result.compoundExtractions) + val skontoData = kotlin.runCatching { + val data = SkontoDataExtractor.extractSkontoData( + result.specificExtractions, + result.compoundExtractions + ) + SkontoData( + skontoPercentageDiscounted = data.skontoPercentageDiscounted, + skontoPaymentMethod = when (data.skontoPaymentMethod) { + SkontoData.SkontoPaymentMethod.Cash -> SkontoData.SkontoPaymentMethod.Cash + SkontoData.SkontoPaymentMethod.PayPal -> SkontoData.SkontoPaymentMethod.PayPal + else -> SkontoData.SkontoPaymentMethod.Unspecified + }, + skontoAmountToPay = data.skontoAmountToPay, + fullAmountToPay = data.fullAmountToPay, + skontoRemainingDays = data.skontoRemainingDays, + skontoDueDate = data.skontoDueDate + ) + }.getOrNull() + + val highlightBoxes = kotlin.runCatching { + skontoInvoiceHighlightsExtractor.extract( + result.compoundExtractions + ) + }.getOrNull() ?: emptyList() + navController.navigate( GiniCaptureFragmentDirections.toDigitalInvoiceFragment( - DigitalInvoiceFragment.getExtractionsBundle(result.specificExtractions), - DigitalInvoiceFragment.getCompoundExtractionsBundle(result.compoundExtractions), - result.returnReasons.toTypedArray(), - DigitalInvoiceFragment.getAmountsAreConsistentExtraction(result.specificExtractions) + ExtractionsResultData( + specificExtractions = result.specificExtractions, + compoundExtractions = result.compoundExtractions, + returnReasons = result.returnReasons + ), + skontoData = skontoData, + skontoInvoiceHighlights = highlightBoxes.toTypedArray(), ) ) } diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoice.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoice.kt index fcd9613949..5f171cf595 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoice.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoice.kt @@ -1,6 +1,7 @@ package net.gini.android.bank.sdk.capture.digitalinvoice import androidx.annotation.VisibleForTesting +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData import net.gini.android.capture.AmountCurrency import java.math.BigDecimal import java.math.RoundingMode @@ -30,7 +31,7 @@ internal val FRACTION_FORMAT = DecimalFormat(".00").apply { roundingMode = Round internal class DigitalInvoice( extractions: Map, compoundExtractions: Map, - savedSelectableItems: List? = null + savedSelectableItems: List? = null, ) { private var _extractions: Map = extractions @@ -53,6 +54,7 @@ internal class DigitalInvoice( private val amountToPay: BigDecimal + init { _selectableLineItems = when (savedSelectableItems) { null -> { diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceFragment.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceFragment.kt index 1d70508cce..9df2d5b525 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceFragment.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceFragment.kt @@ -14,11 +14,15 @@ import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import kotlinx.coroutines.CoroutineScope import net.gini.android.bank.sdk.GiniBank import net.gini.android.bank.sdk.R +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.DigitalInvoiceSkontoFragment +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.digitalinvoice.view.DigitalInvoiceNavigationBarBottomAdapter import net.gini.android.bank.sdk.capture.util.autoCleared import net.gini.android.bank.sdk.capture.util.parentFragmentManagerOrNull @@ -28,7 +32,6 @@ import net.gini.android.bank.sdk.util.getLayoutInflaterWithGiniCaptureTheme import net.gini.android.capture.GiniCapture import net.gini.android.capture.internal.ui.IntervalToolbarMenuItemIntervalClickListener import net.gini.android.capture.internal.util.ActivityHelper.forcePortraitOrientationOnPhones -import net.gini.android.capture.network.model.GiniCaptureCompoundExtraction import net.gini.android.capture.network.model.GiniCaptureReturnReason import net.gini.android.capture.network.model.GiniCaptureSpecificExtraction import net.gini.android.capture.tracking.useranalytics.UserAnalytics @@ -46,13 +49,8 @@ import net.gini.android.capture.view.NavButtonType * Copyright (c) 2019 Gini GmbH. */ -private const val ARGS_EXTRACTIONS = "GBS_ARGS_EXTRACTIONS" -private const val ARGS_COMPOUND_EXTRACTIONS = "GBS_ARGS_COMPOUND_EXTRACTIONS" -private const val ARGS_RETURN_REASONS = "GBS_ARGS_RETURN_REASONS" -private const val ARGS_INACCURATE_EXTRACTION = "GBS_ARGS_INACCURATE_EXTRACTION" private const val TAG_RETURN_REASON_DIALOG = "TAG_RETURN_REASON_DIALOG" -private const val TAG_WHAT_IS_THIS_DIALOG = "TAG_WHAT_IS_THIS_DIALOG" /** * Internal use only. @@ -60,6 +58,8 @@ private const val TAG_WHAT_IS_THIS_DIALOG = "TAG_WHAT_IS_THIS_DIALOG" open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.View, LineItemsAdapterListener { + private val args: DigitalInvoiceFragmentArgs by navArgs() + private var binding by autoCleared() private var lineItemsAdapter by autoCleared() private val screenName: UserAnalyticsScreen = UserAnalyticsScreen.ReturnAssistant @@ -77,30 +77,30 @@ open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.Vie private var presenter: DigitalInvoiceScreenContract.Presenter? = null - private var extractions: Map = emptyMap() - private var compoundExtractions: Map = emptyMap() - private var returnReasons: List = emptyList() - private var isInaccurateExtraction: Boolean = false private var footerDetails: DigitalInvoiceScreenContract.FooterDetails? = null private val userAnalyticsEventTracker by lazy { UserAnalytics.getAnalyticsEventTracker() } private var onBackPressedCallback: OnBackPressedCallback? = null - companion object { - internal fun getExtractionsBundle(extractions: Map): Bundle = - Bundle().apply { - extractions.forEach { putParcelable(it.key, it.value) } - } + private val skontoAdapterListener = object : SkontoListItemAdapterListener { - internal fun getCompoundExtractionsBundle(compoundExtractions: Map): Bundle = - Bundle().apply { - compoundExtractions.forEach { putParcelable(it.key, it.value) } - } + override fun onSkontoEditClicked(listItem: DigitalInvoiceSkontoListItem) { + findNavController().navigate( + DigitalInvoiceFragmentDirections.toDigitalInvoiceSkontoFragment( + DigitalInvoiceSkontoArgs( + listItem.data, + args.skontoInvoiceHighlights.toList(), + listItem.enabled + ) + ) + ) + } + + override fun onSkontoEnabled(listItem: DigitalInvoiceSkontoListItem) { + showSkonto(listItem.copy(enabled = true)) + } - internal fun getAmountsAreConsistentExtraction(extractions: Map): Boolean { - val isInaccurateExtraction = extractions["amountsAreConsistent"]?.let { - it.value == "false" - } ?: true - return isInaccurateExtraction + override fun onSkontoDisabled(listItem: DigitalInvoiceSkontoListItem) { + showSkonto(listItem.copy(enabled = false)) } } @@ -119,7 +119,6 @@ open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.Vie requireActivity().window.disallowScreenshots() } forcePortraitOrientationOnPhones(activity) - readArguments() initListener() createPresenter(activity, savedInstanceState) @@ -128,38 +127,15 @@ open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.Vie } } - private fun readArguments() { - arguments?.run { - getBundle(ARGS_EXTRACTIONS)?.run { - extractions = - keySet().map { it to getParcelable(it)!! } - .toMap() - } - getBundle(ARGS_COMPOUND_EXTRACTIONS)?.run { - compoundExtractions = - keySet().map { it to getParcelable(it)!! } - .toMap() - } - returnReasons = - (BundleCompat.getParcelableArray( - this, - ARGS_RETURN_REASONS, - GiniCaptureReturnReason::class.java - ) - ?.toList() as? List) ?: emptyList() - - isInaccurateExtraction = getBoolean(ARGS_INACCURATE_EXTRACTION, false) - } - } - private fun createPresenter(activity: Activity, savedInstanceState: Bundle?) = DigitalInvoiceScreenPresenter( activity, this, - extractions, - compoundExtractions, - returnReasons, - isInaccurateExtraction, + args.extractionsResult.specificExtractions, + args.extractionsResult.compoundExtractions, + args.extractionsResult.returnReasons, + args.skontoData, + getAmountsAreConsistentExtraction(args.extractionsResult.specificExtractions), savedInstanceState, ).apply { listener = this@DigitalInvoiceFragment.listener @@ -291,7 +267,7 @@ open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.Vie } private fun initRecyclerView() { - lineItemsAdapter = LineItemsAdapter(this, requireContext()) + lineItemsAdapter = LineItemsAdapter(this, skontoAdapterListener, requireContext()) activity?.let { binding.lineItems.apply { layoutManager = LinearLayoutManager(it) @@ -425,6 +401,9 @@ open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.Vie lineItemsAdapter.addons = addons } + override fun showSkonto(data: DigitalInvoiceSkontoListItem) { + lineItemsAdapter.skontoDiscount = listOf(data) + } /** * Internal use only. @@ -461,6 +440,7 @@ open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.Vie super.onStart() presenter?.start() setBottomSheetResultListener() + setSkontoResultListener() } private fun setBottomSheetResultListener() { @@ -480,6 +460,27 @@ open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.Vie } } + private fun setSkontoResultListener() { + parentFragmentManager + .setFragmentResultListener( + DigitalInvoiceSkontoFragment.REQUEST_KEY, + viewLifecycleOwner + ) { _: String?, result: Bundle -> + BundleCompat.getParcelable( + result, + DigitalInvoiceSkontoFragment.RESULT_KEY, + DigitalInvoiceSkontoResultArgs::class.java + )?.let { skontoResult -> + showSkonto( + DigitalInvoiceSkontoListItem( + skontoResult.skontoData, + skontoResult.isSkontoEnabled + ) + ) + } + } + } + /** * Internal use only. * @@ -569,6 +570,13 @@ open class DigitalInvoiceFragment : Fragment(), DigitalInvoiceScreenContract.Vie ) ) } + + private fun getAmountsAreConsistentExtraction(extractions: Map): Boolean { + val isInaccurateExtraction = extractions["amountsAreConsistent"]?.let { + it.value == "false" + } ?: true + return isInaccurateExtraction + } } // endregion diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceScreenContract.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceScreenContract.kt index 2c43beb2f5..17b64159f2 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceScreenContract.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceScreenContract.kt @@ -3,6 +3,8 @@ package net.gini.android.bank.sdk.capture.digitalinvoice import android.app.Activity import android.os.Bundle import kotlinx.coroutines.CoroutineScope +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.model.SkontoInvoiceHighlightBoxes import net.gini.android.capture.GiniCaptureBasePresenter import net.gini.android.capture.GiniCaptureBaseView import net.gini.android.capture.network.model.GiniCaptureReturnReason @@ -29,12 +31,16 @@ interface DigitalInvoiceScreenContract { val viewLifecycleScope: CoroutineScope fun showLineItems(lineItems: List, isInaccurateExtraction: Boolean) fun showAddons(addons: List) + fun showSkonto(data: DigitalInvoiceSkontoListItem) fun updateFooterDetails(data: FooterDetails) - fun showReturnReasonDialog(reasons: List, - resultCallback: ReturnReasonDialogResultCallback + fun showReturnReasonDialog( + reasons: List, + resultCallback: ReturnReasonDialogResultCallback ) + fun animateListScroll() fun onEditLineItem(selectableLineItem: SelectableLineItem) + fun showOnboarding() } @@ -44,7 +50,7 @@ interface DigitalInvoiceScreenContract { * @suppress */ abstract class Presenter(activity: Activity, view: View) : - GiniCaptureBasePresenter(activity, view) { + GiniCaptureBasePresenter(activity, view) { var listener: DigitalInvoiceFragmentListener? = null @@ -55,6 +61,8 @@ interface DigitalInvoiceScreenContract { abstract fun onViewCreated() abstract fun saveState(outState: Bundle) abstract fun updateLineItem(selectableLineItem: SelectableLineItem) + abstract fun enableSkonto() + abstract fun disableSkonto() } data class FooterDetails( diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceScreenPresenter.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceScreenPresenter.kt index f10d76c15e..fb24e2e394 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceScreenPresenter.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceScreenPresenter.kt @@ -6,10 +6,14 @@ import androidx.annotation.VisibleForTesting import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import net.gini.android.bank.sdk.GiniBank +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.util.BusEvent import net.gini.android.bank.sdk.capture.util.OncePerInstallEvent import net.gini.android.bank.sdk.capture.util.OncePerInstallEventStore import net.gini.android.bank.sdk.capture.util.SimpleBusEventStore +import net.gini.android.bank.sdk.di.getGiniBankKoin import net.gini.android.capture.GiniCapture import net.gini.android.capture.network.model.GiniCaptureCompoundExtraction import net.gini.android.capture.network.model.GiniCaptureReturnReason @@ -34,6 +38,7 @@ internal class DigitalInvoiceScreenPresenter( val extractions: Map = emptyMap(), val compoundExtractions: Map = emptyMap(), val returnReasons: List = emptyList(), + val skontoData: SkontoData? = null, private val isInaccurateExtraction: Boolean = false, savedInstanceBundle: Bundle?, private val oncePerInstallEventStore: OncePerInstallEventStore = OncePerInstallEventStore( @@ -55,6 +60,11 @@ internal class DigitalInvoiceScreenPresenter( private val userAnalyticsEventTracker by lazy { UserAnalytics.getAnalyticsEventTracker() } private val screenName: UserAnalyticsScreen = UserAnalyticsScreen.ReturnAssistant + private val getSkontoDefaultSelectionStateUseCase: GetSkontoDefaultSelectionStateUseCase by getGiniBankKoin().inject() + private val getSkontoEdgeCaseUseCase: GetSkontoEdgeCaseUseCase by getGiniBankKoin().inject() + + private var isSkontoSectionActive: Boolean = false + init { view.setPresenter(this) digitalInvoice = DigitalInvoice( @@ -62,6 +72,11 @@ internal class DigitalInvoiceScreenPresenter( KEY_SELECTABLE_ITEMS )?.filterIsInstance()?.toList() ) + + isSkontoSectionActive = skontoData?.let { data -> + val edgeCase = getSkontoEdgeCaseUseCase.execute(data.skontoDueDate, data.skontoPaymentMethod) + getSkontoDefaultSelectionStateUseCase.execute(edgeCase) + } ?: false } override fun saveState(outState: Bundle) { @@ -76,6 +91,16 @@ internal class DigitalInvoiceScreenPresenter( updateView() } + override fun enableSkonto() { + isSkontoSectionActive = true + updateView() + } + + override fun disableSkonto() { + isSkontoSectionActive = false + updateView() + } + override fun deselectLineItem(lineItem: SelectableLineItem) { if (canShowReturnReasonsDialog()) { view.showReturnReasonDialog(returnReasons) { selectedReason -> @@ -102,7 +127,8 @@ internal class DigitalInvoiceScreenPresenter( digitalInvoice.selectableLineItems.forEach { deselectLineItem(it) } } - private fun canShowReturnReasonsDialog() = GiniBank.enableReturnReasons && returnReasons.isNotEmpty() + private fun canShowReturnReasonsDialog() = + GiniBank.enableReturnReasons && returnReasons.isNotEmpty() override fun editLineItem(lineItem: SelectableLineItem) { view.onEditLineItem(lineItem) @@ -157,6 +183,11 @@ internal class DigitalInvoiceScreenPresenter( view.apply { showLineItems(digitalInvoice.selectableLineItems, isInaccurateExtraction) showAddons(digitalInvoice.addons) + skontoData?.let { skontoData -> + showSkonto( + DigitalInvoiceSkontoListItem(skontoData, isSkontoSectionActive) + ) + } digitalInvoice.selectedAndTotalLineItemsCount().let { (selected, total) -> footerDetails = footerDetails .copy( diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceSkontoListItem.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceSkontoListItem.kt new file mode 100644 index 0000000000..d0df27290c --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/DigitalInvoiceSkontoListItem.kt @@ -0,0 +1,8 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice + +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData + +data class DigitalInvoiceSkontoListItem( + val data: SkontoData, + val enabled: Boolean, +) \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/LineItemsAdapter.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/LineItemsAdapter.kt index d48c890559..7a379654c8 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/LineItemsAdapter.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/LineItemsAdapter.kt @@ -15,6 +15,7 @@ import net.gini.android.bank.sdk.capture.digitalinvoice.ViewType.* import net.gini.android.bank.sdk.capture.digitalinvoice.ViewType.LineItem import net.gini.android.bank.sdk.databinding.GbsItemDigitalInvoiceAddonBinding import net.gini.android.bank.sdk.databinding.GbsItemDigitalInvoiceLineItemBinding +import net.gini.android.bank.sdk.databinding.GbsItemDigitalInvoiceSkontoBinding import net.gini.android.capture.internal.ui.IntervalClickListener /** @@ -40,7 +41,22 @@ internal interface LineItemsAdapterListener { * * @suppress */ -internal class LineItemsAdapter(private val listener: LineItemsAdapterListener, private val context: Context) : +internal interface SkontoListItemAdapterListener { + fun onSkontoEditClicked(listItem: DigitalInvoiceSkontoListItem) + fun onSkontoEnabled(listItem: DigitalInvoiceSkontoListItem) + fun onSkontoDisabled(listItem: DigitalInvoiceSkontoListItem) +} + +/** + * Internal use only. + * + * @suppress + */ +internal class LineItemsAdapter( + private val listener: LineItemsAdapterListener, + private val skontoListener: SkontoListItemAdapterListener, + private val context: Context +) : RecyclerView.Adapter>() { @@ -54,6 +70,13 @@ internal class LineItemsAdapter(private val listener: LineItemsAdapterListener, field = value notifyDataSetChanged() } + + var skontoDiscount: List = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + var isInaccurateExtraction: Boolean = false private var footerDetails: DigitalInvoiceScreenContract.FooterDetails? = null @@ -69,10 +92,19 @@ internal class LineItemsAdapter(private val listener: LineItemsAdapterListener, override fun getItemCount(): Int = - lineItems.size + addons.size + lineItems.size + addons.size + skontoDiscount.size override fun getItemViewType(position: Int): Int { - return if (position < lineItems.size) LineItem.id else Addon.id + val lineItemRange = lineItems.indices + val addonRange = lineItemRange.last + 1..lineItemRange.last + addons.size + val skontoRange = addonRange.last + 1..addonRange.last + skontoDiscount.size + + return when (position) { + in lineItemRange -> LineItem.id + in addonRange -> Addon.id + in skontoRange -> SkontoInfo.id + else -> error("Unknown view type at position $position") + } } override fun onBindViewHolder(viewHolder: ViewHolder<*>, position: Int) { @@ -83,6 +115,7 @@ internal class LineItemsAdapter(private val listener: LineItemsAdapterListener, viewHolder.bind(it, lineItems, position) } } + is ViewHolder.AddonViewHolder -> { val index = position - lineItems.size val enabled = footerDetails?.buttonEnabled ?: true @@ -91,9 +124,21 @@ internal class LineItemsAdapter(private val listener: LineItemsAdapterListener, } // Adding padding for the last addon item, so the item looks full height without modifying the layout file - val bottomPadding = if (position == (itemCount - 1)) context.resources.getDimension( - net.gini.android.capture.R.dimen.gc_large).toInt() else 0 - viewHolder.itemView.setPadding(context.resources.getDimension(net.gini.android.capture.R.dimen.gc_large).toInt(), 0, 0, bottomPadding) + val bottomPadding = if (position == (itemCount - 1)) context.resources.getDimension( + net.gini.android.capture.R.dimen.gc_large + ).toInt() else 0 + viewHolder.itemView.setPadding( + context.resources.getDimension(net.gini.android.capture.R.dimen.gc_large) + .toInt(), 0, 0, bottomPadding + ) + } + + is ViewHolder.SkontoViewHolder -> { + skontoDiscount.getOrNull(viewHolder.bindingAdapterPosition - lineItems.size - skontoDiscount.size) + ?.let { + viewHolder.bind(it, null) + viewHolder.listener = skontoListener + } } } } @@ -101,7 +146,7 @@ internal class LineItemsAdapter(private val listener: LineItemsAdapterListener, override fun onViewRecycled(viewHolder: ViewHolder<*>) { viewHolder.unbind() } - } +} /** * Internal use only. @@ -129,10 +174,15 @@ internal sealed class ViewType { override val id: Int = 2 } + internal object SkontoInfo : ViewType() { + override val id: Int = 3 + } + internal companion object { fun from(viewTypeId: Int): ViewType = when (viewTypeId) { 1 -> LineItem 2 -> Addon + 3 -> SkontoInfo else -> throw IllegalStateException("Unknow adapter view type id: $viewTypeId") } } @@ -182,12 +232,13 @@ internal sealed class ViewHolder(itemView: View, val viewType: ViewType) : binding.gbsGrossPriceFractionalPart.text = fractional } - DigitalInvoice.lineItemUnitPriceIntegralAndFractionalParts(li).let {(integral, fractional) -> - binding.gbsPerUnit.text = binding.gbsPerUnit.resources.getString( - R.string.gbs_digital_invoice_line_item_quantity, - "$integral$fractional" - ) - } + DigitalInvoice.lineItemUnitPriceIntegralAndFractionalParts(li) + .let { (integral, fractional) -> + binding.gbsPerUnit.text = binding.gbsPerUnit.resources.getString( + R.string.gbs_digital_invoice_line_item_quantity, + "$integral$fractional" + ) + } } itemView.setOnClickListener(IntervalClickListener { allData?.getOrNull(dataIndex ?: -1)?.let { @@ -258,7 +309,7 @@ internal sealed class ViewHolder(itemView: View, val viewType: ViewType) : override fun bind( data: Pair, - allData: List< Pair>?, + allData: List>?, dataIndex: Int? ) { @SuppressLint("SetTextI18n") @@ -274,6 +325,35 @@ internal sealed class ViewHolder(itemView: View, val viewType: ViewType) : } } + internal class SkontoViewHolder(private val binding: GbsItemDigitalInvoiceSkontoBinding) : + ViewHolder(binding.root, SkontoInfo) { + + internal var listener: SkontoListItemAdapterListener? = null + + override fun bind( + data: DigitalInvoiceSkontoListItem, + allData: List?, + dataIndex: Int? + ) = with(binding) { + gbsSkontoAmount.text = data.data.skontoAmountToPay.value.toEngineeringString() + gbsEnableSwitch.isChecked = data.enabled + gbsEditButton.setOnClickListener { + listener?.onSkontoEditClicked(data) + } + gbsEnableSwitch.setOnClickListener { + if (gbsEnableSwitch.isChecked) { + listener?.onSkontoEnabled(data) + } else { + listener?.onSkontoDisabled(data) + } + } + } + + override fun unbind() { + + } + } + companion object { fun forViewTypeId( viewTypeId: Int, layoutInflater: LayoutInflater, parent: ViewGroup @@ -286,6 +366,7 @@ internal sealed class ViewHolder(itemView: View, val viewType: ViewType) : false ) ) + Addon -> AddonViewHolder( GbsItemDigitalInvoiceAddonBinding.inflate( layoutInflater, @@ -293,6 +374,14 @@ internal sealed class ViewHolder(itemView: View, val viewType: ViewType) : false ) ) + + SkontoInfo -> SkontoViewHolder( + GbsItemDigitalInvoiceSkontoBinding.inflate( + layoutInflater, + parent, + false + ) + ) } } } diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/args/DigitalInvoiceSpecificExtractionsArg.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/args/DigitalInvoiceSpecificExtractionsArg.kt new file mode 100644 index 0000000000..5e186e31f8 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/args/DigitalInvoiceSpecificExtractionsArg.kt @@ -0,0 +1,11 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.args + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.gini.android.capture.network.model.GiniCaptureSpecificExtraction + +@JvmInline +@Parcelize +value class DigitalInvoiceSpecificExtractionsArg( + val value: Map +) : Parcelable \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/args/ExtractionsResultData.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/args/ExtractionsResultData.kt new file mode 100644 index 0000000000..8d9c94b04c --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/args/ExtractionsResultData.kt @@ -0,0 +1,14 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.args + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.gini.android.capture.network.model.GiniCaptureCompoundExtraction +import net.gini.android.capture.network.model.GiniCaptureReturnReason +import net.gini.android.capture.network.model.GiniCaptureSpecificExtraction + +@Parcelize +class ExtractionsResultData( + val specificExtractions: Map, + val compoundExtractions: Map, + val returnReasons: List, +) : Parcelable \ No newline at end of file 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 new file mode 100644 index 0000000000..959c0fa6f5 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoFragment.kt @@ -0,0 +1,751 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +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 +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +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.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.ComposeView +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.window.Dialog +import androidx.compose.ui.window.DialogProperties +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.R +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.DigitalInvoiceSkontoScreenColors +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.section.DigitalInvoiceSkontoSectionColors +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.getGiniBankKoin +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 +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.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 org.koin.core.parameter.parametersOf +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class DigitalInvoiceSkontoFragment : Fragment() { + + companion object { + const val REQUEST_KEY = "GBS_DIGITAL_INVOICE_SKONTO_REQUEST_KEY" + const val RESULT_KEY = "GBS_DIGITAL_INVOICE_SKONTO_RESULT_KEY" + } + + private val args: DigitalInvoiceSkontoFragmentArgs by navArgs() + + private val viewModel: DigitalInvoiceSkontoFragmentViewModel by getGiniBankKoin().inject { + parametersOf(args.data) + } + + private val isBottomNavigationBarEnabled = + GiniCapture.getInstance().isBottomNavigationBarEnabled + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (GiniCapture.hasInstance() && !GiniCapture.getInstance().allowScreenshots) { + requireActivity().window.disallowScreenshots() + } + ActivityHelper.forcePortraitOrientationOnPhones(activity) + + if (resources.getBoolean(net.gini.android.capture.R.bool.gc_is_tablet)) { + requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + GiniTheme { + ScreenContent( + viewModel = viewModel, + isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, + navigateBack = { + setFragmentResult(REQUEST_KEY, Bundle().apply { + putParcelable( + RESULT_KEY, + viewModel.provideFragmentResult() + ) + } + ) + findNavController().popBackStack() + }, + navigateToInvoiceScreen = { + findNavController() + .navigate( + DigitalInvoiceSkontoFragmentDirections.toSkontoInvoiceFragment( + invoiceHighlights = args.data.invoiceHighlights.toTypedArray(), + ) + ) + } + ) + } + } + } + } +} + +@Composable +@SuppressLint("ComposableNaming") +private fun DigitalInvoiceSkontoFragmentViewModel.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( + navigateBack: () -> Unit, + viewModel: DigitalInvoiceSkontoFragmentViewModel, + modifier: Modifier = Modifier, + screenColorScheme: DigitalInvoiceSkontoScreenColors = DigitalInvoiceSkontoScreenColors.colors(), + isBottomNavigationBarEnabled: Boolean, + navigateToInvoiceScreen: () -> Unit, +) { + + BackHandler { navigateBack() } + + val state by viewModel.stateFlow.collectAsState() + + viewModel.collectSideEffect { + when (it) { + DigitalInvoiceSkontoSideEffect.OpenInvoiceScreen -> navigateToInvoiceScreen() + } + } + + ScreenStateContent( + modifier = modifier, + state = state, + screenColorScheme = screenColorScheme, + onDiscountSectionActiveChange = viewModel::onSkontoActiveChanged, + onSkontoAmountChange = viewModel::onSkontoAmountFieldChanged, + onDueDateChanged = viewModel::onSkontoDueDateChanged, + isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, + onBackClicked = navigateBack, + onInfoBannerClicked = viewModel::onInfoBannerClicked, + onInfoDialogDismissed = viewModel::onInfoDialogDismissed, + onInvoiceClicked = viewModel::onInvoiceClicked + ) +} + +@Composable +private fun ScreenStateContent( + state: DigitalInvoiceSkontoScreenState, + onDiscountSectionActiveChange: (Boolean) -> Unit, + onSkontoAmountChange: (BigDecimal) -> Unit, + onDueDateChanged: (LocalDate) -> Unit, + onBackClicked: () -> Unit, + isBottomNavigationBarEnabled: Boolean, + onInfoBannerClicked: () -> Unit, + onInfoDialogDismissed: () -> Unit, + onInvoiceClicked: () -> Unit, + modifier: Modifier = Modifier, + screenColorScheme: DigitalInvoiceSkontoScreenColors = DigitalInvoiceSkontoScreenColors.colors() +) { + when (state) { + is DigitalInvoiceSkontoScreenState.Ready -> ScreenReadyState( + modifier = modifier, + state = state, + screenColorScheme = screenColorScheme, + onDiscountSectionActiveChange = onDiscountSectionActiveChange, + onDiscountAmountChange = onSkontoAmountChange, + onDueDateChanged = onDueDateChanged, + onBackClicked = onBackClicked, + isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, + onInfoBannerClicked = onInfoBannerClicked, + onInfoDialogDismissed = onInfoDialogDismissed, + onInvoiceClicked = onInvoiceClicked, + ) + } + +} + +@Composable +private fun ScreenReadyState( + onBackClicked: () -> Unit, + onInvoiceClicked: () -> Unit, + state: DigitalInvoiceSkontoScreenState.Ready, + onDiscountSectionActiveChange: (Boolean) -> Unit, + onDiscountAmountChange: (BigDecimal) -> Unit, + onDueDateChanged: (LocalDate) -> Unit, + isBottomNavigationBarEnabled: Boolean, + modifier: Modifier = Modifier, + screenColorScheme: DigitalInvoiceSkontoScreenColors = DigitalInvoiceSkontoScreenColors.colors(), + onInfoBannerClicked: () -> Unit, + onInfoDialogDismissed: () -> Unit, +) { + + val scrollState = rememberScrollState() + Scaffold( + modifier = modifier, + containerColor = screenColorScheme.backgroundColor, + topBar = { + TopAppBar( + isBottomNavigationBarEnabled = isBottomNavigationBarEnabled, + colors = screenColorScheme.topAppBarColors, + onBackClicked = onBackClicked, + ) + }, + ) { + Column( + modifier = Modifier + .padding(it) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + YourInvoiceScanSection( + modifier = Modifier + .padding(top = 8.dp) + .tabletMaxWidth(), + colorScheme = screenColorScheme.invoiceScanSectionColors, + onClick = onInvoiceClicked, + ) + SkontoSection( + modifier = Modifier + .tabletMaxWidth(), + colors = screenColorScheme.scontoSectionColors, + 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, + ) + } + } + + if (state.edgeCaseInfoDialogVisible) { + val text = when (state.edgeCase) { + 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, + state.skontoPercentage.toFloat().formatAsDiscountPercentage() + ) + + SkontoEdgeCase.SkontoLastDay -> + stringResource( + id = R.string.gbs_skonto_section_info_dialog_pay_today_message, + ) + + null -> "" + } + InfoDialog( + text = text, + colors = screenColorScheme.infoDialogColors, + onDismissRequest = onInfoDialogDismissed + ) + } + } +} + +@Composable +private fun TopAppBar( + onBackClicked: () -> 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(onClick = onBackClicked) + } + }) +} + +@Composable +private fun NavigationActionBack( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + modifier = modifier, + onClick = onClick + ) { + Icon( + painter = rememberVectorPainter(image = Icons.AutoMirrored.Default.ArrowBack), + contentDescription = null, + ) + } +} + +@Composable +private fun YourInvoiceScanSection( + modifier: Modifier = Modifier, + colorScheme: DigitalInvoiceSkontoInvoicePreviewSectionColors, + 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 + .padding(8.dp) + .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 = rememberVectorPainter(image = Icons.AutoMirrored.Default.KeyboardArrowRight), + contentDescription = null, + tint = colorScheme.arrowTint.copy(alpha = 0.3f) + ) + } + + } +} + +@Composable +private fun SkontoSection( + colors: DigitalInvoiceSkontoSectionColors, + 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, +) { + 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(modifier = modifier.weight(0.1f)) { + 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, + ) + } + } + 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, + animatedDiscountAmount.formatAsDiscountPercentage(), + remainingDaysText + ) + + SkontoEdgeCase.SkontoExpired -> + stringResource( + id = R.string.gbs_skonto_section_discount_info_banner_date_expired_message, + animatedDiscountAmount.formatAsDiscountPercentage() + ) + + SkontoEdgeCase.SkontoLastDay -> + stringResource( + id = R.string.gbs_skonto_section_discount_info_banner_pay_today_message, + animatedDiscountAmount.formatAsDiscountPercentage() + ) + + else -> stringResource( + id = R.string.gbs_skonto_section_discount_info_banner_normal_message, + remainingDaysText, + animatedDiscountAmount.formatAsDiscountPercentage() + ) + } + + InfoBanner( + text = infoBannerText, + modifier = Modifier.fillMaxWidth(), + colors = when (edgeCase) { + SkontoEdgeCase.SkontoLastDay, + 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: DigitalInvoiceSkontoSectionColors.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(vertical = 8.dp), + text = text, + style = GiniTheme.typography.subtitle2, + color = colors.textColor, + ) + } +} + +@Composable +private fun InfoDialog( + text: String, + colors: DigitalInvoiceSkontoInfoDialogColors, + 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 +@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 = {}, + onBackClicked = {}, + isBottomNavigationBarEnabled = true, + onInfoDialogDismissed = {}, + onInfoBannerClicked = {}, + onInvoiceClicked = {} + ) + } +} + +private fun Float.formatAsDiscountPercentage(): String { + val value = BigDecimal(this.toString()).setScale(2, RoundingMode.HALF_UP) + return "${value.toString().trimEnd('0').trimEnd('.')} %" +} + +private val previewState = DigitalInvoiceSkontoScreenState.Ready( + isSkontoSectionActive = true, + paymentInDays = 14, + skontoPercentage = BigDecimal("3"), + skontoAmount = Amount.parse("97:EUR"), + discountDueDate = LocalDate.now(), + fullAmount = Amount.parse("100:EUR"), + paymentMethod = SkontoData.SkontoPaymentMethod.PayPal, + edgeCase = SkontoEdgeCase.PayByCashOnly, + edgeCaseInfoDialogVisible = false, +) \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoFragmentViewModel.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoFragmentViewModel.kt new file mode 100644 index 0000000000..d7c17ae0ee --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoFragmentViewModel.kt @@ -0,0 +1,173 @@ +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.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.model.SkontoEdgeCase +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import kotlin.math.absoluteValue + +internal class DigitalInvoiceSkontoFragmentViewModel( + args: DigitalInvoiceSkontoArgs, +) : 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 + ?: throw IllegalStateException("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 = extractSkontoEdgeCase(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 onSkontoActiveChanged(newValue: Boolean) = viewModelScope.launch { + val currentState = + stateFlow.value as? DigitalInvoiceSkontoScreenState.Ready ?: return@launch + val discount = + calculateDiscount(currentState.skontoAmount.value, currentState.fullAmount.value) + + stateFlow.emit( + currentState.copy( + isSkontoSectionActive = newValue, + skontoPercentage = discount + ) + ) + } + + 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 = calculateDiscount(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 = ChronoUnit.DAYS.between(newDate, LocalDate.now()).absoluteValue.toInt() + stateFlow.emit( + currentState.copy( + discountDueDate = newDate, + paymentInDays = newPayInDays, + edgeCase = extractSkontoEdgeCase( + 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 { + sideEffectFlow.emit(DigitalInvoiceSkontoSideEffect.OpenInvoiceScreen) + } + + private fun calculateDiscount(skontoAmount: BigDecimal, fullAmount: BigDecimal): BigDecimal { + if (fullAmount == BigDecimal.ZERO) return BigDecimal("100") + return BigDecimal.ONE + .minus(skontoAmount.divide(fullAmount, 4, RoundingMode.HALF_UP)) + .multiply(BigDecimal("100")) + .coerceAtLeast(BigDecimal.ZERO) + } + + private fun extractSkontoEdgeCase( + dueDate: LocalDate, + paymentMethod: SkontoData.SkontoPaymentMethod, + ): SkontoEdgeCase? { + val today = LocalDate.now() + return when { + dueDate.isBefore(today) -> + SkontoEdgeCase.SkontoExpired + + paymentMethod == SkontoData.SkontoPaymentMethod.Cash -> + SkontoEdgeCase.PayByCashOnly + + dueDate == today -> + SkontoEdgeCase.SkontoLastDay + + else -> 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 new file mode 100644 index 0000000000..88456dfe0e --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenModule.kt @@ -0,0 +1,11 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto + +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args.DigitalInvoiceSkontoArgs +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val digitalInvoiceSkontoScreenModule = module { + viewModel { (data: DigitalInvoiceSkontoArgs) -> + DigitalInvoiceSkontoFragmentViewModel(data) + } +} \ No newline at end of file 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/DigitalInvoiceSkontoScreenState.kt new file mode 100644 index 0000000000..213e1384e3 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/DigitalInvoiceSkontoScreenState.kt @@ -0,0 +1,27 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.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 class DigitalInvoiceSkontoScreenState { + + data class Ready( + val isSkontoSectionActive: Boolean, + val paymentInDays: Int, + val skontoPercentage: BigDecimal, + val skontoAmount: Amount, + val fullAmount: Amount, + val discountDueDate: LocalDate, + val paymentMethod: SkontoData.SkontoPaymentMethod, + val edgeCase: SkontoEdgeCase?, + val edgeCaseInfoDialogVisible: Boolean, + ) : DigitalInvoiceSkontoScreenState() +} + +internal sealed interface DigitalInvoiceSkontoSideEffect { + object OpenInvoiceScreen : DigitalInvoiceSkontoSideEffect +} + diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/args/DigitalInvoiceSkontoArgs.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/args/DigitalInvoiceSkontoArgs.kt new file mode 100644 index 0000000000..aa5c35ad72 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/args/DigitalInvoiceSkontoArgs.kt @@ -0,0 +1,13 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.model.SkontoInvoiceHighlightBoxes + +@Parcelize +data class DigitalInvoiceSkontoArgs( + val data: SkontoData, + val invoiceHighlights: List, + val isSkontoSectionActive: Boolean, +) : Parcelable \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/args/DigitalInvoiceSkontoResultArgs.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/args/DigitalInvoiceSkontoResultArgs.kt new file mode 100644 index 0000000000..b4925a0812 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/args/DigitalInvoiceSkontoResultArgs.kt @@ -0,0 +1,11 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.args + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData + +@Parcelize +internal data class DigitalInvoiceSkontoResultArgs( + val isSkontoEnabled: Boolean, + val skontoData: SkontoData, +) : Parcelable \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/DigitalInvoiceSkontoScreenColors.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/DigitalInvoiceSkontoScreenColors.kt new file mode 100644 index 0000000000..cedef7ae35 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/DigitalInvoiceSkontoScreenColors.kt @@ -0,0 +1,46 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +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.capture.ui.components.picker.date.GiniDatePickerDialogColors +import net.gini.android.capture.ui.components.topbar.GiniTopBarColors +import net.gini.android.capture.ui.theme.GiniTheme + +@Immutable +data class DigitalInvoiceSkontoScreenColors( + val backgroundColor: Color, + val topAppBarColors: GiniTopBarColors, + val invoiceScanSectionColors: DigitalInvoiceSkontoInvoicePreviewSectionColors, + val scontoSectionColors: DigitalInvoiceSkontoSectionColors, + val datePickerColor: GiniDatePickerDialogColors, + val infoDialogColors: DigitalInvoiceSkontoInfoDialogColors, +) { + + companion object { + @Composable + fun colors( + backgroundColor: Color = GiniTheme.colorScheme.background.primary, + topAppBarColors: GiniTopBarColors = + GiniTopBarColors.colors(), + skontoInvoiceScanSectionColors: DigitalInvoiceSkontoInvoicePreviewSectionColors = + DigitalInvoiceSkontoInvoicePreviewSectionColors.colors(), + discountSectionColors: DigitalInvoiceSkontoSectionColors = + DigitalInvoiceSkontoSectionColors.colors(), + datePickerColor: GiniDatePickerDialogColors = + GiniDatePickerDialogColors.colors(), + infoDialogColors: DigitalInvoiceSkontoInfoDialogColors = + DigitalInvoiceSkontoInfoDialogColors.colors(), + ) = DigitalInvoiceSkontoScreenColors( + backgroundColor = backgroundColor, + topAppBarColors = topAppBarColors, + invoiceScanSectionColors = skontoInvoiceScanSectionColors, + scontoSectionColors = discountSectionColors, + datePickerColor = datePickerColor, + infoDialogColors = infoDialogColors, + ) + } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoInfoDialogColors.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoInfoDialogColors.kt new file mode 100644 index 0000000000..97e7948e1f --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoInfoDialogColors.kt @@ -0,0 +1,29 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.section + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import net.gini.android.capture.ui.theme.GiniTheme + +@Immutable +data class DigitalInvoiceSkontoInfoDialogColors( + val cardBackgroundColor: Color, + val textColor: Color, + val buttonTextColor: Color, +) { + + companion object { + + @Composable + fun colors( + cardBackgroundColor: Color = GiniTheme.colorScheme.dialogs.container, + textColor: Color = GiniTheme.colorScheme.dialogs.text, + buttonTextColor: Color = GiniTheme.colorScheme.dialogs.labelText, + ) = DigitalInvoiceSkontoInfoDialogColors( + cardBackgroundColor = cardBackgroundColor, + textColor = textColor, + buttonTextColor = buttonTextColor, + ) + } + +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoInvoicePreviewSectionColors.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoInvoicePreviewSectionColors.kt new file mode 100644 index 0000000000..3e03ebe191 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoInvoicePreviewSectionColors.kt @@ -0,0 +1,36 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.section + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import net.gini.android.capture.ui.theme.GiniTheme + +@Immutable +data class DigitalInvoiceSkontoInvoicePreviewSectionColors( + val cardBackgroundColor: Color, + val titleTextColor: Color, + val subtitleTextColor: Color, + val iconBackgroundColor: Color, + val iconTint: Color, + val arrowTint: Color, +) { + companion object { + + @Composable + fun colors( + cardBackgroundColor: Color = GiniTheme.colorScheme.card.container, + titleTextColor: Color = GiniTheme.colorScheme.text.primary, + subtitleTextColor: Color = GiniTheme.colorScheme.text.secondary, + iconBackgroundColor: Color = GiniTheme.colorScheme.placeholder.background, + iconTint: Color = GiniTheme.colorScheme.placeholder.tint, + arrowTint: Color = GiniTheme.colorScheme.icons.secondary, + ) = DigitalInvoiceSkontoInvoicePreviewSectionColors( + cardBackgroundColor = cardBackgroundColor, + titleTextColor = titleTextColor, + subtitleTextColor = subtitleTextColor, + iconBackgroundColor = iconBackgroundColor, + iconTint = iconTint, + arrowTint = arrowTint, + ) + } +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoSectionColors.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoSectionColors.kt new file mode 100644 index 0000000000..ee7f48a117 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/digitalinvoice/skonto/colors/section/DigitalInvoiceSkontoSectionColors.kt @@ -0,0 +1,91 @@ +package net.gini.android.bank.sdk.capture.digitalinvoice.skonto.colors.section + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import net.gini.android.capture.ui.components.switcher.GiniSwitchColors +import net.gini.android.capture.ui.components.textinput.GiniTextInputColors +import net.gini.android.capture.ui.theme.GiniTheme + +@Immutable +data class DigitalInvoiceSkontoSectionColors( + val titleTextColor: Color, + val switchColors: GiniSwitchColors, + val cardBackgroundColor: Color, + val enabledHintTextColor: Color, + val successInfoBannerColors: InfoBannerColors, + val warningInfoBannerColors: InfoBannerColors, + val errorInfoBannerColors: InfoBannerColors, + val amountFieldColors: GiniTextInputColors, + val dueDateTextFieldColor: GiniTextInputColors, +) { + + companion object { + + @Composable + fun colors( + titleTextColor: Color = GiniTheme.colorScheme.text.primary, + switchColors: GiniSwitchColors = GiniSwitchColors.colors(), + cardBackgroundColor: Color = GiniTheme.colorScheme.card.container, + enabledHintTextColor: Color = GiniTheme.colorScheme.text.success, + successInfoBannerColors: InfoBannerColors = InfoBannerColors.success(), + warningInfoBannerColors: InfoBannerColors = InfoBannerColors.warning(), + errorInfoBannerColors: InfoBannerColors = InfoBannerColors.error(), + amountFieldColors: GiniTextInputColors = GiniTextInputColors.colors(), + dueDateTextFieldColor: GiniTextInputColors = GiniTextInputColors.colors(), + ) = DigitalInvoiceSkontoSectionColors( + titleTextColor = titleTextColor, + switchColors = switchColors, + cardBackgroundColor = cardBackgroundColor, + enabledHintTextColor = enabledHintTextColor, + successInfoBannerColors = successInfoBannerColors, + warningInfoBannerColors = warningInfoBannerColors, + errorInfoBannerColors = errorInfoBannerColors, + amountFieldColors = amountFieldColors, + dueDateTextFieldColor = dueDateTextFieldColor + ) + } + + @Immutable + data class InfoBannerColors( + val backgroundColor: Color, + val textColor: Color, + val iconTint: Color, + ) { + companion object { + + @Composable + fun success( + backgroundColor: Color = GiniTheme.colorScheme.card.containerSuccess, + textColor: Color = GiniTheme.colorScheme.card.contentSuccess, + iconTint: Color = GiniTheme.colorScheme.card.contentSuccess, + ) = InfoBannerColors( + backgroundColor = backgroundColor, + textColor = textColor, + iconTint = iconTint, + ) + + @Composable + fun warning( + backgroundColor: Color = GiniTheme.colorScheme.card.containerWarning, + textColor: Color = GiniTheme.colorScheme.card.contentWarning, + iconTint: Color = GiniTheme.colorScheme.card.contentWarning, + ) = InfoBannerColors( + backgroundColor = backgroundColor, + textColor = textColor, + iconTint = iconTint, + ) + + @Composable + fun error( + backgroundColor: Color = GiniTheme.colorScheme.card.containerError, + textColor: Color = GiniTheme.colorScheme.card.contentError, + iconTint: Color = GiniTheme.colorScheme.card.contentError, + ) = InfoBannerColors( + backgroundColor = backgroundColor, + textColor = textColor, + iconTint = iconTint, + ) + } + } +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoDataExtractor.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoDataExtractor.kt index c0eb3d0558..8920e00795 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoDataExtractor.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/SkontoDataExtractor.kt @@ -1,8 +1,8 @@ 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.SkontoData.Amount import net.gini.android.bank.sdk.capture.skonto.model.SkontoData.SkontoPaymentMethod +import net.gini.android.capture.Amount import net.gini.android.capture.network.model.GiniCaptureCompoundExtraction import net.gini.android.capture.network.model.GiniCaptureSpecificExtraction import java.math.BigDecimal @@ -25,7 +25,7 @@ internal class SkontoDataExtractor { fun updateGiniExtractions(updatedData: SkontoFragmentContract.State.Ready) { - _extractions["amountToPay"]?.value = updatedData.totalAmount.amount.toString() + _extractions["amountToPay"]?.value = updatedData.totalAmount.value.toString() val skontoDiscountMaps = compoundExtractions["skontoDiscounts"]?.specificExtractionMaps skontoDiscountMaps?.map { skontoDiscountData -> @@ -36,7 +36,7 @@ internal class SkontoDataExtractor { ) ?: throw NoSuchElementException("Data for `PercentageDiscounted` is missing") skontoDiscountData.putDataByKeys( - updatedData.skontoAmount.amount.toString(), + updatedData.skontoAmount.value.toString(), "skontoAmountToPay", "skontoAmountToPayCalculated" ) 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 35516c0d43..aa9c8e39ba 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 @@ -82,9 +82,11 @@ import net.gini.android.bank.sdk.capture.skonto.colors.section.SkontoInvoicePrev 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.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.di.getGiniBankKoin 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 @@ -357,16 +359,16 @@ private fun ScreenReadyState( if (state.edgeCaseInfoDialogVisible) { val text = when (state.skontoEdgeCase) { - SkontoFragmentContract.SkontoEdgeCase.PayByCashOnly -> + SkontoEdgeCase.PayByCashOnly -> stringResource(id = R.string.gbs_skonto_section_info_dialog_pay_cash_message) - SkontoFragmentContract.SkontoEdgeCase.SkontoExpired -> + SkontoEdgeCase.SkontoExpired -> stringResource( id = R.string.gbs_skonto_section_info_dialog_date_expired_message, state.skontoPercentage.toFloat().formatAsDiscountPercentage() ) - SkontoFragmentContract.SkontoEdgeCase.SkontoLastDay -> + SkontoEdgeCase.SkontoLastDay -> stringResource( id = R.string.gbs_skonto_section_info_dialog_pay_today_message, ) @@ -479,7 +481,7 @@ private fun InvoicePreviewSection( @Composable private fun SkontoSection( colors: SkontoSectionColors, - amount: SkontoData.Amount, + amount: Amount, dueDate: LocalDate, infoPaymentInDays: Int, infoDiscountValue: BigDecimal, @@ -487,7 +489,7 @@ private fun SkontoSection( onSkontoAmountChange: (BigDecimal) -> Unit, onDueDateChanged: (LocalDate) -> Unit, onInfoBannerClicked: () -> Unit, - edgeCase: SkontoFragmentContract.SkontoEdgeCase?, + edgeCase: SkontoEdgeCase?, modifier: Modifier = Modifier, isActive: Boolean, ) { @@ -544,20 +546,20 @@ private fun SkontoSection( } val infoBannerText = when (edgeCase) { - SkontoFragmentContract.SkontoEdgeCase.PayByCashOnly -> + SkontoEdgeCase.PayByCashOnly -> stringResource( id = R.string.gbs_skonto_section_discount_info_banner_pay_cash_message, animatedDiscountAmount.formatAsDiscountPercentage(), remainingDaysText ) - SkontoFragmentContract.SkontoEdgeCase.SkontoExpired -> + SkontoEdgeCase.SkontoExpired -> stringResource( id = R.string.gbs_skonto_section_discount_info_banner_date_expired_message, animatedDiscountAmount.formatAsDiscountPercentage() ) - SkontoFragmentContract.SkontoEdgeCase.SkontoLastDay -> + SkontoEdgeCase.SkontoLastDay -> stringResource( id = R.string.gbs_skonto_section_discount_info_banner_pay_today_message, animatedDiscountAmount.formatAsDiscountPercentage() @@ -574,18 +576,18 @@ private fun SkontoSection( text = infoBannerText, modifier = Modifier.fillMaxWidth(), colors = when (edgeCase) { - SkontoFragmentContract.SkontoEdgeCase.SkontoLastDay, - SkontoFragmentContract.SkontoEdgeCase.PayByCashOnly -> colors.warningInfoBannerColors + SkontoEdgeCase.SkontoLastDay, + SkontoEdgeCase.PayByCashOnly -> colors.warningInfoBannerColors - SkontoFragmentContract.SkontoEdgeCase.SkontoExpired -> colors.errorInfoBannerColors + SkontoEdgeCase.SkontoExpired -> colors.errorInfoBannerColors else -> colors.successInfoBannerColors }, onClicked = onInfoBannerClicked, clickable = edgeCase != null, ) GiniAmountTextInput( - amount = amount.amount, - currencyCode = amount.currencyCode, + amount = amount.value, + currencyCode = amount.currency.name, modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), @@ -596,7 +598,7 @@ private fun SkontoSection( trailingContent = { AnimatedVisibility(visible = isActive) { Text( - text = amount.currencyCode, + text = amount.currency.name, style = GiniTheme.typography.subtitle1, ) } @@ -751,7 +753,7 @@ private fun InfoDialog( @Composable private fun WithoutSkontoSection( colors: WithoutSkontoSectionColors, - amount: SkontoData.Amount, + amount: Amount, modifier: Modifier = Modifier, onFullAmountChange: (BigDecimal) -> Unit, isActive: Boolean, @@ -789,14 +791,14 @@ private fun WithoutSkontoSection( .padding(top = 16.dp), enabled = isActive, colors = colors.amountFieldColors, - amount = amount.amount, - currencyCode = amount.currencyCode, + 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.currencyCode, + text = amount.currency.name, style = GiniTheme.typography.subtitle1, ) } @@ -808,8 +810,8 @@ private fun WithoutSkontoSection( @Composable private fun FooterSection( - totalAmount: SkontoData.Amount, - savedAmount: SkontoData.Amount, + totalAmount: Amount, + savedAmount: Amount, discountValue: BigDecimal, colors: SkontoFooterSectionColors, isBottomNavigationBarEnabled: Boolean, @@ -820,10 +822,10 @@ private fun FooterSection( customBottomNavBarAdapter: InjectedViewAdapterInstance?, ) { val animatedTotalAmount by animateFloatAsState( - targetValue = totalAmount.amount.toFloat(), label = "totalAmount" + targetValue = totalAmount.value.toFloat(), label = "totalAmount" ) val animatedSavedAmount by animateFloatAsState( - targetValue = savedAmount.amount.toFloat(), label = "savedAmount" + targetValue = savedAmount.value.toFloat(), label = "savedAmount" ) val animatedDiscountAmount by animateFloatAsState( targetValue = discountValue.toFloat(), label = "discountAmount" @@ -831,14 +833,14 @@ private fun FooterSection( val totalPriceText = "${ currencyFormatterWithoutSymbol().format(animatedTotalAmount).trim() - } ${totalAmount.currencyCode}" + } ${totalAmount.currency.name}" val savedAmountText = stringResource( id = R.string.gbs_skonto_section_footer_label_save, "${ currencyFormatterWithoutSymbol().format(animatedSavedAmount).trim() - } ${savedAmount.currencyCode}" + } ${savedAmount.currency.name}" ) val discountLabelText = stringResource( @@ -999,12 +1001,12 @@ private val previewState = SkontoFragmentContract.State.Ready( isSkontoSectionActive = true, paymentInDays = 14, skontoPercentage = BigDecimal("3"), - skontoAmount = SkontoData.Amount(BigDecimal("97"), "EUR"), + skontoAmount = Amount.parse("97:EUR"), discountDueDate = LocalDate.now(), - fullAmount = SkontoData.Amount(BigDecimal("100"), "EUR"), - totalAmount = SkontoData.Amount(BigDecimal("97"), "EUR"), + fullAmount = Amount.parse("100:EUR"), + totalAmount = Amount.parse("97:EUR"), paymentMethod = SkontoData.SkontoPaymentMethod.PayPal, - skontoEdgeCase = SkontoFragmentContract.SkontoEdgeCase.PayByCashOnly, + skontoEdgeCase = SkontoEdgeCase.PayByCashOnly, edgeCaseInfoDialogVisible = false, - savedAmount = SkontoData.Amount(BigDecimal("3"), "EUR") + savedAmount = Amount.parse("3:EUR") ) \ 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 index cb2fdce689..81b8700599 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -11,11 +13,11 @@ internal object SkontoFragmentContract { val isSkontoSectionActive: Boolean, val paymentInDays: Int, val skontoPercentage: BigDecimal, - val skontoAmount: SkontoData.Amount, + val skontoAmount: Amount, val discountDueDate: LocalDate, - val fullAmount: SkontoData.Amount, - val totalAmount: SkontoData.Amount, - val savedAmount: SkontoData.Amount, + val fullAmount: Amount, + val totalAmount: Amount, + val savedAmount: Amount, val paymentMethod: SkontoData.SkontoPaymentMethod, val skontoEdgeCase: SkontoEdgeCase?, val edgeCaseInfoDialogVisible: Boolean, @@ -25,10 +27,4 @@ internal object SkontoFragmentContract { sealed interface SideEffect { object OpenInvoiceScreen : SideEffect } - - sealed class SkontoEdgeCase { - object SkontoLastDay : SkontoEdgeCase() - object PayByCashOnly : SkontoEdgeCase() - object SkontoExpired : SkontoEdgeCase() - } } \ 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 index 8734003a6c..d20af8a665 100644 --- 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 @@ -6,14 +6,24 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch 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.capture.Amount import java.math.BigDecimal -import java.math.RoundingMode import java.time.LocalDate -import java.time.temporal.ChronoUnit -import kotlin.math.absoluteValue 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, ) : ViewModel() { val stateFlow: MutableStateFlow = @@ -40,21 +50,21 @@ internal class SkontoFragmentViewModel( data: SkontoData, ): SkontoFragmentContract.State.Ready { - val discount = data.skontoPercentageDiscounted val paymentMethod = data.skontoPaymentMethod ?: SkontoData.SkontoPaymentMethod.Unspecified - val edgeCase = extractSkontoEdgeCase(data.skontoDueDate, paymentMethod) + val edgeCase = getSkontoEdgeCaseUseCase.execute(data.skontoDueDate, paymentMethod) - val isSkontoSectionActive = edgeCase != SkontoFragmentContract.SkontoEdgeCase.PayByCashOnly - && edgeCase != SkontoFragmentContract.SkontoEdgeCase.SkontoExpired + val isSkontoSectionActive = getSkontoDefaultSelectionStateUseCase.execute(edgeCase) val totalAmount = if (isSkontoSectionActive) data.skontoAmountToPay else data.fullAmountToPay - val savedAmountValue = - calculateSavedAmount(data.skontoAmountToPay.amount, data.fullAmountToPay.amount) - val savedAmount = SkontoData.Amount(savedAmountValue, data.fullAmountToPay.currencyCode) + val savedAmountValue = getSkontoSavedAmountUseCase.execute( + data.skontoAmountToPay.value, + data.fullAmountToPay.value + ) + val savedAmount = Amount(savedAmountValue, data.fullAmountToPay.currency) return SkontoFragmentContract.State.Ready( isSkontoSectionActive = isSkontoSectionActive, @@ -74,8 +84,10 @@ internal class SkontoFragmentViewModel( 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 = - calculateDiscount(currentState.skontoAmount.amount, currentState.fullAmount.amount) + val discount = getSkontoDiscountPercentageUseCase.execute( + currentState.skontoAmount.value, + currentState.fullAmount.value + ) stateFlow.emit( currentState.copy( @@ -89,25 +101,31 @@ internal class SkontoFragmentViewModel( fun onSkontoAmountFieldChanged(newValue: BigDecimal) = viewModelScope.launch { val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - if (newValue > currentState.fullAmount.amount) { + if (newValue > currentState.fullAmount.value) { stateFlow.emit( currentState.copy(skontoAmount = currentState.skontoAmount) ) return@launch } - val discount = calculateDiscount(newValue, currentState.fullAmount.amount) + val discount = getSkontoDiscountPercentageUseCase.execute( + newValue, + currentState.fullAmount.value + ) + val totalAmount = if (currentState.isSkontoSectionActive) newValue - else - currentState.fullAmount.amount + else currentState.fullAmount.value - val newSkontoAmount = currentState.skontoAmount.copy(amount = newValue) - val newTotalAmount = currentState.totalAmount.copy(amount = totalAmount) + val newSkontoAmount = currentState.skontoAmount.copy(value = newValue) + val newTotalAmount = currentState.totalAmount.copy(value = totalAmount) - val savedAmountValue = - calculateSavedAmount(newSkontoAmount.amount, currentState.fullAmount.amount) - val savedAmount = SkontoData.Amount(savedAmountValue, currentState.fullAmount.currencyCode) + val savedAmountValue = getSkontoSavedAmountUseCase.execute( + newSkontoAmount.value, + currentState.fullAmount.value + ) + + val savedAmount = Amount(savedAmountValue, currentState.fullAmount.currency) stateFlow.emit( currentState.copy( @@ -121,12 +139,12 @@ internal class SkontoFragmentViewModel( fun onSkontoDueDateChanged(newDate: LocalDate) = viewModelScope.launch { val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch - val newPayInDays = ChronoUnit.DAYS.between(newDate, LocalDate.now()).absoluteValue.toInt() + val newPayInDays = getSkontoRemainingDaysUseCase.execute(newDate) stateFlow.emit( currentState.copy( discountDueDate = newDate, paymentInDays = newPayInDays, - skontoEdgeCase = extractSkontoEdgeCase( + skontoEdgeCase = getSkontoEdgeCaseUseCase.execute( dueDate = newDate, paymentMethod = currentState.paymentMethod ) @@ -137,24 +155,24 @@ internal class SkontoFragmentViewModel( fun onFullAmountFieldChanged(newValue: BigDecimal) = viewModelScope.launch { val currentState = stateFlow.value as? SkontoFragmentContract.State.Ready ?: return@launch val totalAmount = - if (currentState.isSkontoSectionActive) currentState.skontoAmount.amount else newValue + if (currentState.isSkontoSectionActive) currentState.skontoAmount.value else newValue val discount = currentState.skontoPercentage - val skontoAmount = newValue.minus( - newValue.multiply( // full_amount - (full_amount * (discount / 100)) - discount.divide(BigDecimal("100"), 2, RoundingMode.HALF_UP) - ).setScale(2, RoundingMode.HALF_UP) + val skontoAmount = getSkontoAmountUseCase.execute(newValue, discount) + + val savedAmountValue = getSkontoSavedAmountUseCase.execute( + skontoAmount, + newValue ) - val savedAmountValue = calculateSavedAmount(skontoAmount, newValue) - val savedAmount = SkontoData.Amount(savedAmountValue, currentState.fullAmount.currencyCode) + val savedAmount = Amount(savedAmountValue, currentState.fullAmount.currency) stateFlow.emit( currentState.copy( - skontoAmount = currentState.skontoAmount.copy(amount = skontoAmount), - fullAmount = currentState.fullAmount.copy(amount = newValue), - totalAmount = currentState.totalAmount.copy(amount = totalAmount), + skontoAmount = currentState.skontoAmount.copy(value = skontoAmount), + fullAmount = currentState.fullAmount.copy(value = newValue), + totalAmount = currentState.totalAmount.copy(value = totalAmount), savedAmount = savedAmount, ) ) @@ -181,35 +199,4 @@ internal class SkontoFragmentViewModel( fun onInvoiceClicked() = viewModelScope.launch { sideEffectFlow.emit(SkontoFragmentContract.SideEffect.OpenInvoiceScreen) } - - private fun calculateDiscount(skontoAmount: BigDecimal, fullAmount: BigDecimal): BigDecimal { - if (fullAmount == BigDecimal.ZERO) return BigDecimal("100") - return BigDecimal.ONE - .minus(skontoAmount.divide(fullAmount, 4, RoundingMode.HALF_UP)) - .multiply(BigDecimal("100")) - .coerceAtLeast(BigDecimal.ZERO) - } - - private fun calculateSavedAmount(skontoAmount: BigDecimal, fullAmount: BigDecimal) = - fullAmount.minus(skontoAmount).coerceAtLeast(BigDecimal.ZERO) - - private fun extractSkontoEdgeCase( - dueDate: LocalDate, - paymentMethod: SkontoData.SkontoPaymentMethod, - ): SkontoFragmentContract.SkontoEdgeCase? { - val today = LocalDate.now() - return when { - dueDate.isBefore(today) -> - SkontoFragmentContract.SkontoEdgeCase.SkontoExpired - - - paymentMethod == SkontoData.SkontoPaymentMethod.Cash -> - SkontoFragmentContract.SkontoEdgeCase.PayByCashOnly - - dueDate == today -> - SkontoFragmentContract.SkontoEdgeCase.SkontoLastDay - - else -> 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 8ae2621cdb..72fea41a75 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 @@ -6,6 +6,14 @@ import org.koin.dsl.module val skontoScreenModule = module { viewModel { (data: SkontoData) -> - SkontoFragmentViewModel(data) + SkontoFragmentViewModel( + data = data, + getSkontoAmountUseCase = get(), + getSkontoDiscountPercentageUseCase = get(), + getSkontoEdgeCaseUseCase = get(), + getSkontoSavedAmountUseCase = get(), + getSkontoRemainingDaysUseCase = get(), + getSkontoDefaultSelectionStateUseCase = get(), + ) } } \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/model/SkontoData.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/model/SkontoData.kt index 79cebf63da..3503c3c3b2 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/model/SkontoData.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/model/SkontoData.kt @@ -1,9 +1,13 @@ package net.gini.android.bank.sdk.capture.skonto.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import net.gini.android.capture.Amount import java.io.Serializable import java.math.BigDecimal import java.time.LocalDate +@Parcelize data class SkontoData( val skontoPercentageDiscounted: BigDecimal, val skontoPaymentMethod: SkontoPaymentMethod?, @@ -11,41 +15,10 @@ data class SkontoData( val fullAmountToPay: Amount, val skontoRemainingDays: Int, val skontoDueDate: LocalDate, -) : Serializable { +) : Parcelable { - enum class SkontoPaymentMethod : Serializable { + @Parcelize + enum class SkontoPaymentMethod : Parcelable { Unspecified, Cash, PayPal } - - data class Amount(val amount: BigDecimal, val currencyCode: String) : Serializable { - - companion object { - - /** - * Creates [Amount] from string in format `value:currency_code` or throws an Exception - */ - @Throws(IllegalArgumentException::class) - fun parse(amountStr: String): Amount { - val amountParts = amountStr.split(":").also { - if (it.size != 2) { - throw IllegalArgumentException( - "Invalid amount format for value: $amountStr. " + - "Should be `value:currency_code`" - ) - } - } - - val amount = runCatching { BigDecimal(amountParts.first()) }.getOrElse { - throw IllegalArgumentException( - "Invalid amount format for value: $amountStr. " + - "Can't convert `${amountParts.first()} to BigDecimal`" - ) - } - - val currencyCode = amountParts.last() - - return Amount(amount, currencyCode) - } - } - } } diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/model/SkontoEdgeCase.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/model/SkontoEdgeCase.kt new file mode 100644 index 0000000000..dd8c4ce8f7 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/model/SkontoEdgeCase.kt @@ -0,0 +1,7 @@ +package net.gini.android.bank.sdk.capture.skonto.model + +internal sealed class SkontoEdgeCase { + object SkontoLastDay : SkontoEdgeCase() + object PayByCashOnly : SkontoEdgeCase() + object SkontoExpired : SkontoEdgeCase() +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoAmountUseCase.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoAmountUseCase.kt new file mode 100644 index 0000000000..67be050b86 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoAmountUseCase.kt @@ -0,0 +1,34 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase + +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Use case for calculating the Skonto amount based on the full amount and the discount percentage. + */ +internal class GetSkontoAmountUseCase { + + /** + * Calculates the Skonto amount based on the full amount and the discount percentage. + * + * The math: `FullAmount - (FullAmount * (DiscountPercentage / 100))` + * + * @param fullAmount The full amount. + * @param discount The Skonto discount (percentage). + * + * @return The calculated Skonto amount. + */ + fun execute(fullAmount: BigDecimal, discount: BigDecimal): BigDecimal = fullAmount.minus( + fullAmount.multiply( + discount.divide( + BigDecimal.ONE.movePointRight(CALCULATIONS_SCALE), + CALCULATIONS_SCALE, + RoundingMode.HALF_UP + ) + ).setScale(CALCULATIONS_SCALE, RoundingMode.HALF_UP) + ) + + companion object { + private const val CALCULATIONS_SCALE = 2 + } +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoDefaultSelectionStateUseCase.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoDefaultSelectionStateUseCase.kt new file mode 100644 index 0000000000..2726f6b205 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoDefaultSelectionStateUseCase.kt @@ -0,0 +1,23 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase + +import net.gini.android.bank.sdk.capture.skonto.model.SkontoEdgeCase +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Determines whether the Skonto default selection state should be enabled. + */ +internal class GetSkontoDefaultSelectionStateUseCase { + + /** + * Determines whether the Skonto default selection state should be enabled. + * + * @param skontoEdgeCase The edge case of the Skonto. + * + * @return True if the Skonto default selection state should be enabled, false otherwise. + */ + fun execute(skontoEdgeCase: SkontoEdgeCase?): Boolean = + skontoEdgeCase != SkontoEdgeCase.PayByCashOnly + && skontoEdgeCase != SkontoEdgeCase.SkontoExpired + +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoDiscountPercentageUseCase.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoDiscountPercentageUseCase.kt new file mode 100644 index 0000000000..f354f77be0 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoDiscountPercentageUseCase.kt @@ -0,0 +1,37 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase + +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Use case for calculating the discount percentage based on the skonto amount and the full amount. + */ +internal class GetSkontoDiscountPercentageUseCase { + + /** + * Calculates the discount percentage based on the skonto amount and the full amount. + * + * @param skontoAmount The amount of the Skonto. + * @param fullAmount The full amount. + * + * @return The discount percentage as a [BigDecimal]. + */ + fun execute(skontoAmount: BigDecimal, fullAmount: BigDecimal): BigDecimal { + if (fullAmount == BigDecimal.ZERO) return BigDecimal.ONE.movePointRight(CALCULATIONS_SCALE) + return BigDecimal.ONE + .minus( + skontoAmount.divide( + fullAmount, + PERCENTAGE_CALCULATIONS_SCALE, + RoundingMode.HALF_UP + ) + ) + .multiply(BigDecimal.ONE.movePointRight(CALCULATIONS_SCALE)) + .coerceAtLeast(BigDecimal.ZERO) + } + + companion object { + private const val PERCENTAGE_CALCULATIONS_SCALE = 2 + private const val CALCULATIONS_SCALE = 2 + } +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoEdgeCaseUseCase.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoEdgeCaseUseCase.kt new file mode 100644 index 0000000000..a0e8719588 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoEdgeCaseUseCase.kt @@ -0,0 +1,31 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase + +import net.gini.android.bank.sdk.capture.skonto.model.SkontoData +import net.gini.android.bank.sdk.capture.skonto.model.SkontoEdgeCase +import java.time.LocalDate + +/** + * Detects the edge case of Skonto. + */ +internal class GetSkontoEdgeCaseUseCase { + + /** + * Detects the edge case of Skonto. + * + * @param dueDate The due date of the Skonto. + * @param paymentMethod The payment method of the Skonto. + * + * @return The edge case of the Skonto, or null if there is no edge case. + */ + fun execute( + dueDate: LocalDate, paymentMethod: SkontoData.SkontoPaymentMethod? + ): SkontoEdgeCase? { + val today = LocalDate.now() + return when { + dueDate.isBefore(today) -> SkontoEdgeCase.SkontoExpired + paymentMethod == SkontoData.SkontoPaymentMethod.Cash -> SkontoEdgeCase.PayByCashOnly + dueDate == today -> SkontoEdgeCase.SkontoLastDay + else -> null + } + } +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoRemainingDaysUseCase.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoRemainingDaysUseCase.kt new file mode 100644 index 0000000000..73741c9df8 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoRemainingDaysUseCase.kt @@ -0,0 +1,18 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase + +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import kotlin.math.absoluteValue + +internal class GetSkontoRemainingDaysUseCase { + + /** + * Calculates the Skonto remaining days based on the due date. + * + * @return The number of days until the Skonto due date. + */ + fun execute(dueDate: LocalDate): Int = + ChronoUnit.DAYS.between(dueDate, LocalDate.now()).absoluteValue.toInt() +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoSavedAmountUseCase.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoSavedAmountUseCase.kt new file mode 100644 index 0000000000..ea5727a80f --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/GetSkontoSavedAmountUseCase.kt @@ -0,0 +1,22 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase + +import java.math.BigDecimal + +/** + * Use case for calculating the saved amount based on the skonto amount and the full amount. + */ +internal class GetSkontoSavedAmountUseCase { + + /** + * Calculates the saved amount based on the skonto amount and the full amount. + * + * @param skontoAmount The amount of the Skonto. + * @param fullAmount The full amount. + * + * @return The saved amount as a [BigDecimal]. Minimum possible value is 0 + */ + fun execute(skontoAmount: BigDecimal, fullAmount: BigDecimal): BigDecimal = + fullAmount + .minus(skontoAmount) + .coerceAtLeast(BigDecimal.ZERO) +} \ No newline at end of file diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/di/SkontoUseCaseModule.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/di/SkontoUseCaseModule.kt new file mode 100644 index 0000000000..c40887d473 --- /dev/null +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/capture/skonto/usecase/di/SkontoUseCaseModule.kt @@ -0,0 +1,18 @@ +package net.gini.android.bank.sdk.capture.skonto.usecase.di + +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 org.koin.dsl.module + +val skontoUseCaseModule = module { + factory { GetSkontoAmountUseCase() } + factory { GetSkontoDiscountPercentageUseCase() } + factory { GetSkontoEdgeCaseUseCase() } + factory { GetSkontoSavedAmountUseCase() } + factory { GetSkontoDefaultSelectionStateUseCase() } + factory { GetSkontoRemainingDaysUseCase() } +} diff --git a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/di/BankSdkIsolatedKoinContext.kt b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/di/BankSdkIsolatedKoinContext.kt index 6317e19a56..d4cc988523 100644 --- a/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/di/BankSdkIsolatedKoinContext.kt +++ b/bank-sdk/sdk/src/main/java/net/gini/android/bank/sdk/di/BankSdkIsolatedKoinContext.kt @@ -1,8 +1,10 @@ package net.gini.android.bank.sdk.di import net.gini.android.bank.sdk.capture.captureFlowFragmentModule +import net.gini.android.bank.sdk.capture.digitalinvoice.skonto.digitalInvoiceSkontoScreenModule import net.gini.android.bank.sdk.capture.skonto.invoice.skontoInvoiceScreenModule import net.gini.android.bank.sdk.capture.skonto.skontoScreenModule +import net.gini.android.bank.sdk.capture.skonto.usecase.di.skontoUseCaseModule import org.koin.dsl.koinApplication object BankSdkIsolatedKoinContext { @@ -10,16 +12,22 @@ object BankSdkIsolatedKoinContext { private val koinApp = koinApplication { modules( screenModules + .plus(useCaseModules) ) } val koin = koinApp.koin } +private val useCaseModules = listOf( + skontoUseCaseModule, +) + private val screenModules = listOf( skontoScreenModule, skontoInvoiceScreenModule, - captureFlowFragmentModule + captureFlowFragmentModule, + digitalInvoiceSkontoScreenModule ) fun getGiniBankKoin() = BankSdkIsolatedKoinContext.koin diff --git a/bank-sdk/sdk/src/main/res/layout/gbs_item_digital_invoice_skonto.xml b/bank-sdk/sdk/src/main/res/layout/gbs_item_digital_invoice_skonto.xml new file mode 100644 index 0000000000..2624787b58 --- /dev/null +++ b/bank-sdk/sdk/src/main/res/layout/gbs_item_digital_invoice_skonto.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + diff --git a/bank-sdk/sdk/src/main/res/navigation/gbs_nav_graph.xml b/bank-sdk/sdk/src/main/res/navigation/gbs_nav_graph.xml index 7c3112c039..616d4e3b2b 100644 --- a/bank-sdk/sdk/src/main/res/navigation/gbs_nav_graph.xml +++ b/bank-sdk/sdk/src/main/res/navigation/gbs_nav_graph.xml @@ -19,20 +19,7 @@ app:popEnterAnim="@anim/gc_nav_pop_enter_anim" app:popExitAnim="@anim/gc_nav_pop_exit_anim" app:popUpTo="@id/gbs_destination_capture_fragment" - app:popUpToInclusive="true"> - - - - - + app:popUpToInclusive="true" /> + + + + + + - + - + - + - + + + + + + + + \ No newline at end of file diff --git a/capture-sdk/sdk/src/main/java/net/gini/android/capture/Amount.kt b/capture-sdk/sdk/src/main/java/net/gini/android/capture/Amount.kt index e69a971153..e7b0354cec 100644 --- a/capture-sdk/sdk/src/main/java/net/gini/android/capture/Amount.kt +++ b/capture-sdk/sdk/src/main/java/net/gini/android/capture/Amount.kt @@ -1,5 +1,7 @@ package net.gini.android.capture +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import java.math.BigDecimal import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -12,10 +14,11 @@ import java.util.* * @param currency the amount currency. * @constructor Creates an instance of Amount class. */ -class Amount( - private val value: BigDecimal, - private val currency: AmountCurrency -) { +@Parcelize +data class Amount( + val value: BigDecimal, + val currency: AmountCurrency +) : Parcelable { /** * For internal use only. @@ -34,5 +37,35 @@ class Amount( */ @JvmField val EMPTY = Amount(BigDecimal.valueOf(0), AmountCurrency.EUR) + + /** + * Creates [Amount] from string in format `value:currency_code` or throws an Exception + */ + @Throws(IllegalArgumentException::class) + fun parse(amountStr: String): Amount { + val amountParts = amountStr.split(":").also { + if (it.size != 2) { + throw IllegalArgumentException( + "Invalid amount format for value: $amountStr. " + + "Should be `value:currency_code`" + ) + } + } + + val amount = runCatching { BigDecimal(amountParts.first()) }.getOrElse { + throw IllegalArgumentException( + "Invalid amount format for value: $amountStr. " + + "Can't convert `${amountParts.first()} to BigDecimal`" + ) + } + + val currencyCode = kotlin.runCatching { AmountCurrency.valueOf(amountParts.last()) } + .getOrNull() ?: throw IllegalArgumentException( + "Invalid currency code format for value: $amountStr. " + + "Can't convert `${amountParts.last()} to AmountCurrency`" + ) + + return Amount(amount, currencyCode) + } } } \ No newline at end of file