NTP-71871 | Fund details SIP simulator (#16837)

This commit is contained in:
Varun Jain
2025-07-04 19:14:39 +05:30
committed by GitHub
parent 288dc076ad
commit 6a29241c74
7 changed files with 542 additions and 0 deletions

View File

@@ -0,0 +1,474 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.amc.fundbuy.composables.widgets
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.navi.amc.fundbuy.models.ReturnsCalculatorData
import com.navi.amc.utils.AmcColor
import com.navi.amc.utils.AmcColor.rippleColor
import com.navi.amc.utils.Constant.DEFAULT
import com.navi.amc.utils.Constant.SELECTED
import com.navi.amc.utils.calculateReturns
import com.navi.amc.utils.convertToTextFieldData
import com.navi.design.font.getFontWeight
import com.navi.design.font.naviFontFamily
import com.navi.design.textview.model.NaviSpan
import com.navi.design.textview.model.TextWithStyle
import com.navi.design.utils.NoRippleIndicationSource
import com.navi.design.utils.decimalFormatForAmount
import com.navi.naviwidgets.composewidget.reusable.colorCTAPrimary
import com.navi.naviwidgets.composewidget.reusable.colorOffWhite
import com.navi.naviwidgets.composewidget.reusable.colorShadow
import com.navi.naviwidgets.composewidget.reusable.colorTextPrimary
import com.navi.naviwidgets.extensions.NaviImage
import com.navi.naviwidgets.extensions.NaviText
import com.navi.naviwidgets.extensions.NaviTextWidgetized
import com.navi.naviwidgets.models.response.ImageFieldData
import com.navi.uitron.utils.hexToComposeColor
import kotlin.math.roundToLong
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReturnsCalculatorComposable(calculatorData: ReturnsCalculatorData? = null) {
calculatorData?.let { data ->
var isExpanded by remember { mutableStateOf(data.isExpanded == true) }
var selectedDuration by remember { mutableStateOf(data.duration?.defaultSelected) }
var sliderValue by remember { mutableLongStateOf(data.sliderData?.selectedValue ?: 20000) }
val (cagr, durationInMonths, returnsPercentage) =
remember(selectedDuration) {
val item = data.duration?.items?.find { it.key == selectedDuration }
Triple(item?.cagrValue ?: 0.0, item?.durationInMonths ?: 1, item?.returns)
}
val totalInvestment =
remember(sliderValue, durationInMonths) { sliderValue.toLong() * durationInMonths }
val projectedAmount =
remember(sliderValue, cagr, durationInMonths) {
calculateReturns(
monthlyAmount = sliderValue.toDouble(),
cagrInPercentage = cagr,
durationInMonths = durationInMonths,
)
}
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier =
Modifier.fillMaxWidth().clickable(
indication = null,
interactionSource = remember { NoRippleIndicationSource() },
) {
isExpanded = !isExpanded
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
data.title?.let { title ->
NaviTextWidgetized(textFieldData = convertToTextFieldData(title))
}
val rotationState by
animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
)
NaviImage(
imageFieldData = ImageFieldData(url = data.rightChevronDown),
modifier = Modifier.size(24.dp).rotate(rotationState),
)
}
AnimatedVisibility(
visible = isExpanded,
enter =
expandVertically(
animationSpec = tween(durationMillis = 400),
expandFrom = Alignment.Top,
),
exit =
shrinkVertically(
animationSpec = tween(durationMillis = 400),
shrinkTowards = Alignment.Top,
),
) {
Column {
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
data.duration?.title?.let { title ->
NaviTextWidgetized(textFieldData = convertToTextFieldData(title))
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
data.duration?.items?.forEach { item ->
val isSelected = item.key == selectedDuration
val spanList =
if (isSelected) {
item.title?.styleVariations?.get(SELECTED)
} else {
item.title?.styleVariations?.get(DEFAULT)
}
val span = spanList?.firstOrNull()
DurationChip(
text = item.title?.text.orEmpty(),
isSelected = isSelected,
styleSpan = span,
onClick = { selectedDuration = item.key ?: "1Y" },
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
SliderSection(
data = data,
sliderValue = sliderValue,
onValueChange = { newValue -> sliderValue = newValue },
)
Spacer(modifier = Modifier.height(10.dp))
NoteSection(
data = data,
totalInvestment = totalInvestment,
projectedAmount = projectedAmount,
returns = returnsPercentage,
)
Spacer(modifier = Modifier.height(4.dp))
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SliderSection(
data: ReturnsCalculatorData,
sliderValue: Long,
onValueChange: (Long) -> Unit = {},
) {
val layoutWidthPx = remember { mutableStateOf<Float?>(null) }
val minSliderValue = data.sliderData?.minValue?.toFloat() ?: 100f
val maxSliderValue = data.sliderData?.maxValue?.toFloat() ?: 100000f
val sliderStepValue = data.sliderData?.stepValue?.toInt() ?: 100
val interactionSource = remember { NoRippleIndicationSource() }
val thumbPosition =
((sliderValue.toFloat() - minSliderValue) / (maxSliderValue - minSliderValue)).coerceIn(
0f,
1f,
)
Box(
modifier =
Modifier.fillMaxWidth().onGloballyPositioned { coordinates ->
layoutWidthPx.value = coordinates.size.width.toFloat()
}
) {
Slider(
value = sliderValue.toFloat(),
onValueChange = {
val normalizedValue = (it - minSliderValue) / (maxSliderValue - minSliderValue)
val customValue =
when {
normalizedValue < 0.01f -> minSliderValue.toLong()
else -> {
val stepIndex = ((normalizedValue - 0.01f) / 0.99f * 99).roundToLong()
1000L + stepIndex * 1000L
}
}
onValueChange(customValue)
},
valueRange = minSliderValue..maxSliderValue,
steps = ((maxSliderValue - minSliderValue) / sliderStepValue).toInt() - 1,
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
colors =
SliderDefaults.colors(
thumbColor = AmcColor.purplePrimary,
activeTrackColor =
data.sliderData?.minTrackTintColor?.hexToComposeColor
?: AmcColor.purplePrimary,
inactiveTrackColor =
data.sliderData?.maxTrackTintColor?.hexToComposeColor
?: AmcColor.borderDefault,
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
),
thumb = {
Box(
modifier =
Modifier.shadow(
elevation = 4.dp,
shape = CircleShape,
spotColor = colorTextPrimary,
ambientColor = colorTextPrimary,
)
.size(24.dp)
.background(Color.White, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Box(
modifier =
Modifier.size(20.dp)
.background(AmcColor.purplePrimary, shape = CircleShape)
)
}
},
track = { sliderPositions ->
val fraction by remember {
derivedStateOf {
(sliderPositions.value - sliderPositions.valueRange.start) /
(sliderPositions.valueRange.endInclusive -
sliderPositions.valueRange.start)
}
}
Box(
modifier =
Modifier.shadow(
elevation = 4.dp,
spotColor = colorShadow,
ambientColor = colorShadow,
)
.fillMaxWidth()
.height(4.dp)
.background(
data.sliderData?.maxTrackTintColor?.hexToComposeColor
?: AmcColor.borderDefault,
shape = RoundedCornerShape(2.dp),
)
)
Box(
modifier =
Modifier.shadow(
elevation = 4.dp,
spotColor = colorShadow,
ambientColor = colorShadow,
)
.fillMaxWidth(fraction)
.height(4.dp)
.background(
data.sliderData?.minTrackTintColor?.hexToComposeColor
?: AmcColor.purplePrimary,
shape = RoundedCornerShape(2.dp),
)
)
},
interactionSource = interactionSource,
)
if (layoutWidthPx.value != null) {
val density = LocalDensity.current
val layoutWidth = layoutWidthPx.value!!
val thumbRadiusPx = with(density) { 12.dp.toPx() }
val toolTipData = convertToTextFieldData(data.sliderData?.toolTipData)
val formattedSliderValue = decimalFormatForAmount.format(sliderValue)
toolTipData?.text = "${formattedSliderValue}/month"
val tooltipWidth =
when {
sliderValue >= 100000 -> 140.dp
sliderValue >= 1000 -> 130.dp
else -> 110.dp
}
val tooltipWidthPx = with(density) { tooltipWidth.toPx() }
val adjustedTrackWidth = layoutWidth - 2 * thumbRadiusPx
val thumbCenterX = thumbRadiusPx + thumbPosition * adjustedTrackWidth
val tooltipHalfWidth = tooltipWidthPx / 2
val tooltipCenterX =
thumbCenterX.coerceIn(tooltipHalfWidth, layoutWidth - tooltipHalfWidth)
val tooltipLeft = tooltipCenterX - tooltipHalfWidth
val triangleWidthPx = with(density) { 10.dp.toPx() }
val triangleOffsetX =
(thumbCenterX - tooltipLeft - triangleWidthPx / 2).coerceIn(
0f,
tooltipWidthPx - triangleWidthPx,
)
Box(
modifier =
Modifier.offset(x = with(density) { tooltipLeft.toDp() }, y = (-4).dp)
.width(with(density) { tooltipWidthPx.toDp() })
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier =
Modifier.align(Alignment.CenterHorizontally)
.background(colorCTAPrimary, shape = RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 6.dp)
.fillMaxWidth()
) {
NaviTextWidgetized(toolTipData, modifier = Modifier.align(Alignment.Center))
}
Box(modifier = Modifier.fillMaxWidth()) {
Canvas(
modifier =
Modifier.size(10.dp, 5.dp)
.offset(x = with(density) { triangleOffsetX.toDp() })
) {
val path =
Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width / 2f, size.height)
close()
}
drawPath(path, color = colorCTAPrimary)
}
}
}
}
}
}
}
@Composable
fun NoteSection(
data: ReturnsCalculatorData? = null,
totalInvestment: Long = 0,
projectedAmount: Double = 0.0,
returns: TextWithStyle? = null,
) {
val formattedInvestment by
remember(totalInvestment) { mutableStateOf(decimalFormatForAmount.format(totalInvestment)) }
val formattedProjectedAmount by
remember(projectedAmount) {
mutableStateOf(decimalFormatForAmount.format(projectedAmount.roundToLong()))
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = colorOffWhite),
shape = RoundedCornerShape(4.dp),
elevation = CardDefaults.cardElevation(0.dp),
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
val noteData = data?.investmentNote
Row(verticalAlignment = Alignment.CenterVertically) {
NaviTextWidgetized(textFieldData = convertToTextFieldData(noteData?.startText))
val investedAmountData =
convertToTextFieldData(noteData?.investedAmount)?.apply {
text = "$formattedInvestment"
}
NaviTextWidgetized(textFieldData = investedAmountData)
Spacer(modifier = Modifier.width(2.dp))
NaviTextWidgetized(textFieldData = convertToTextFieldData(noteData?.endText))
}
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
val projectedAmountData =
convertToTextFieldData(noteData?.projectedAmount)?.apply {
text = "$formattedProjectedAmount"
}
NaviTextWidgetized(textFieldData = projectedAmountData)
Spacer(modifier = Modifier.width(3.dp))
NaviTextWidgetized(textFieldData = convertToTextFieldData(returns))
}
}
}
}
@Composable
fun DurationChip(
text: String,
styleSpan: NaviSpan? = null,
isSelected: Boolean,
onClick: () -> Unit,
) {
Box(
modifier =
Modifier.clip(RoundedCornerShape(20.dp))
.border(
width = 1.dp,
color = if (isSelected) colorCTAPrimary else Color.Transparent,
shape = RoundedCornerShape(20.dp),
)
.background(Color.Transparent)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = rippleColor),
) {
onClick()
}
.padding(horizontal = 12.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
NaviText(
text = text,
fontFamily = naviFontFamily,
color =
styleSpan?.spanColor?.hexToComposeColor
?: if (isSelected) colorCTAPrimary else Color.Transparent,
fontSize = (styleSpan?.fontSize?.toFloat() ?: 12f).sp,
fontWeight = getFontWeight(styleSpan?.fontName),
)
}
}

View File

@@ -14,6 +14,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewStub
import android.widget.ImageView
import androidx.compose.ui.platform.ComposeView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
@@ -35,6 +36,7 @@ import com.navi.amc.compose.feature.ftue.model.FundSelectionData
import com.navi.amc.databinding.DownloadDocumentLayoutBinding
import com.navi.amc.databinding.FundDetailScreenLayoutBinding
import com.navi.amc.databinding.InvestUspLayoutBinding
import com.navi.amc.fundbuy.composables.widgets.ReturnsCalculatorComposable
import com.navi.amc.fundbuy.models.AmcHeaderData
import com.navi.amc.fundbuy.models.FundReturn
import com.navi.amc.fundbuy.viewmodel.FundBuyFlowViewModel
@@ -367,6 +369,14 @@ class FundDetailsFragment : AmcBaseFragment(), FooterInteractionListener {
}
}
fundDetailScreenData?.content?.returnsCalculatorDetails?.let { calculatorData ->
val calculatorView =
ComposeView(context).apply {
setContent { ReturnsCalculatorComposable(calculatorData) }
}
addView(calculatorView)
}
fundDetailScreenData?.content?.usp?.let {
val childBinding =
DataBindingUtil.inflate<InvestUspLayoutBinding>(

View File

@@ -12,9 +12,11 @@ import com.google.gson.annotations.SerializedName
import com.navi.amc.common.model.Footer
import com.navi.amc.common.model.FundInvestmentDetailData
import com.navi.amc.common.model.InformationCardData
import com.navi.amc.fundbuy.models.cards.SliderData
import com.navi.common.model.Header
import com.navi.design.textview.model.NaviSpan
import com.navi.design.textview.model.TextWithStyle
import com.navi.design.textview.model.TextWithStyleVariation
import com.navi.naviwidgets.models.FundDetailData
import com.navi.naviwidgets.models.response.amc.TagData
import java.io.Serializable
@@ -44,6 +46,41 @@ data class FundDetailScreenData(
@SerializedName("fundInvestmentDetails")
val fundInvestmentDetails: FundInvestmentDetailData? = null,
@SerializedName("regulatoryInfo") val regulatoryInfo: RegulatoryInfoData? = null,
@SerializedName("returnsCalculatorDetails")
val returnsCalculatorDetails: ReturnsCalculatorData? = null,
)
data class ReturnsCalculatorData(
@SerializedName("title") val title: TextWithStyle? = null,
@SerializedName("isExpanded") val isExpanded: Boolean? = false,
@SerializedName("rightChevronDown") val rightChevronDown: String? = null,
@SerializedName("rightChevronUp") val rightChevronUp: String? = null,
@SerializedName("sliderData") val sliderData: SliderData? = null,
@SerializedName("duration") val duration: DurationChipData? = null,
@SerializedName("investmentNote") val investmentNote: NoteData? = null,
)
data class NoteData(
@SerializedName("startText") val startText: TextWithStyle? = null,
@SerializedName("endText") val endText: TextWithStyle? = null,
@SerializedName("investedAmount") val investedAmount: TextWithStyle? = null,
@SerializedName("projectedAmount") val projectedAmount: TextWithStyle? = null,
@SerializedName("returns") val returns: TextWithStyle? = null,
)
data class DurationChipData(
@SerializedName("title") val title: TextWithStyle? = null,
@SerializedName("defaultSelected") val defaultSelected: String? = null,
@SerializedName("items") val items: List<ChipItemData>? = null,
)
data class ChipItemData(
@SerializedName("title") val title: TextWithStyleVariation? = null,
@SerializedName("key") val key: String? = null,
@SerializedName("isSelected") val isSelected: Boolean? = null,
@SerializedName("cagrValue") val cagrValue: Double? = null,
@SerializedName("durationInMonths") val durationInMonths: Int? = null,
@SerializedName("returns") val returns: TextWithStyle? = null,
)
@Parcelize

View File

@@ -11,6 +11,7 @@ import com.google.gson.annotations.SerializedName
import com.navi.amc.fundbuy.models.widgets.TrackerContent
import com.navi.base.model.ActionData
import com.navi.common.model.InvestmentBaseProperty
import com.navi.design.textview.model.TextWithStyle
import com.navi.naviwidgets.models.response.ImageFieldData
import com.navi.naviwidgets.models.response.TextFieldData
@@ -70,4 +71,5 @@ data class SliderData(
@SerializedName("minTrackTintColor") val minTrackTintColor: String? = null,
@SerializedName("maxTrackTintColor") val maxTrackTintColor: String? = null,
@SerializedName("title") val title: TextFieldData? = null,
@SerializedName("toolTipData") val toolTipData: TextWithStyle? = null,
)

View File

@@ -21,4 +21,5 @@ object AmcColor {
val textInputNonEditable = Color(0xFF6B6B6B)
val textInputPrimary = Color(0xFF191919)
val purplePrimary = Color(0xFF3C0050)
val rippleColor = Color.Black.copy(alpha = 0.3f)
}

View File

@@ -55,6 +55,7 @@ import java.text.SimpleDateFormat
import java.util.TimeZone
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.pow
fun calculateInvestmentReturnsAmount(
investmentType: String,
@@ -237,3 +238,18 @@ fun shouldCallUpdateSip(url: String?): Boolean {
url?.contains(RESUMED_INSTALLMENT).orFalse() ||
url?.contains(GOAL_SIP_DELETED).orFalse()
}
fun calculateReturns(
monthlyAmount: Double,
cagrInPercentage: Double,
durationInMonths: Int,
): Double {
if (cagrInPercentage == 0.0) {
return monthlyAmount * durationInMonths
}
val annualRate = cagrInPercentage / 100
val monthlyRateOfInterest = (1 + annualRate).pow(1.0 / 12) - 1
return monthlyAmount *
((((1 + monthlyRateOfInterest).pow(durationInMonths)) - 1) / monthlyRateOfInterest) *
(1 + monthlyRateOfInterest)
}

View File

@@ -265,4 +265,6 @@ object Constant {
const val VALUE = "value"
const val SIP_TYPE_CAMEL_CASE = "sipType"
const val SHOW_TOAST_MESSAGE = "showToastMessage"
const val SELECTED = "selected"
const val DEFAULT = "default"
}