Skip to content

Commit d72f7bd

Browse files
committed
Implement Loan dashboard
1 parent d788bb4 commit d72f7bd

16 files changed

Lines changed: 2739 additions & 2 deletions

File tree

core/database/src/commonMain/kotlin/com/mifos/room/entities/accounts/loans/LoanTimelineEntity.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,6 @@ data class LoanTimelineEntity(
8181
val closedOnDate: List<Int>? = null,
8282

8383
val expectedMaturityDate: List<Int>? = null,
84+
85+
val withdrawnOnDate: List<Int>? = null,
8486
) : Parcelable

core/database/src/commonMain/kotlin/com/mifos/room/entities/accounts/loans/LoanWithAssociationsEntity.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,6 @@ data class LoanWithAssociationsEntity(
184184
val inArrears: Boolean = false,
185185

186186
val isNPA: Boolean = false,
187+
188+
val overpaidOnDate: List<Int>? = null,
187189
) : Parcelable
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
* Copyright 2026 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* See https://github.com/openMF/android-client/blob/master/LICENSE.md
9+
*/
10+
package com.mifos.core.designsystem.component
11+
12+
import androidx.compose.foundation.Canvas
13+
import androidx.compose.foundation.background
14+
import androidx.compose.foundation.gestures.detectTapGestures
15+
import androidx.compose.foundation.layout.Box
16+
import androidx.compose.foundation.layout.Column
17+
import androidx.compose.foundation.layout.Row
18+
import androidx.compose.foundation.layout.Spacer
19+
import androidx.compose.foundation.layout.padding
20+
import androidx.compose.foundation.layout.size
21+
import androidx.compose.foundation.layout.width
22+
import androidx.compose.material3.Surface
23+
import androidx.compose.material3.Text
24+
import androidx.compose.runtime.Composable
25+
import androidx.compose.runtime.getValue
26+
import androidx.compose.runtime.mutableStateOf
27+
import androidx.compose.runtime.remember
28+
import androidx.compose.runtime.setValue
29+
import androidx.compose.ui.Alignment
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.geometry.Offset
32+
import androidx.compose.ui.graphics.Color
33+
import androidx.compose.ui.graphics.StrokeCap
34+
import androidx.compose.ui.graphics.drawscope.Stroke
35+
import androidx.compose.ui.input.pointer.pointerInput
36+
import androidx.compose.ui.platform.LocalDensity
37+
import androidx.compose.ui.text.font.FontWeight
38+
import androidx.compose.ui.unit.Dp
39+
import androidx.compose.ui.unit.IntOffset
40+
import androidx.compose.ui.window.Popup
41+
import androidx.compose.ui.window.PopupProperties
42+
import com.mifos.core.designsystem.theme.AppColors
43+
import com.mifos.core.designsystem.theme.DesignToken
44+
import com.mifos.core.designsystem.theme.MifosTypography
45+
import template.core.base.designsystem.theme.KptTheme
46+
import kotlin.math.PI
47+
import kotlin.math.atan2
48+
import kotlin.math.min
49+
import kotlin.math.sqrt
50+
51+
@Composable
52+
fun MifosDonutGraph(
53+
progress: Float,
54+
primaryColor: Color,
55+
backgroundColor: Color,
56+
centerText: String,
57+
centerSubText: String,
58+
modifier: Modifier = Modifier,
59+
strokeWidth: Dp = DesignToken.strokes.dp10,
60+
primarySegmentInfo: DonutSegmentInfo? = null,
61+
secondarySegmentInfo: DonutSegmentInfo? = null,
62+
) {
63+
val clampedProgress = progress.coerceIn(0f, 1f)
64+
val primarySweepAngle = clampedProgress * 360f
65+
var activeTooltip by remember { mutableStateOf<DonutSegmentInfo?>(null) }
66+
val density = LocalDensity.current
67+
val popupOffsetY = with(density) { DesignToken.spacing.negativeDp20.roundToPx() }
68+
69+
Box(
70+
modifier = modifier.size(DesignToken.sizes.boxDp100),
71+
contentAlignment = Alignment.Center,
72+
) {
73+
Canvas(
74+
modifier = Modifier
75+
.size(DesignToken.sizes.boxDp100)
76+
.pointerInput(
77+
primarySweepAngle,
78+
primarySegmentInfo,
79+
secondarySegmentInfo,
80+
strokeWidth,
81+
) {
82+
detectTapGestures { tapOffset ->
83+
val tappedSegment = detectTappedSegment(
84+
tapOffset = tapOffset,
85+
sizeCenter = Offset(size.width / 2f, size.height / 2f),
86+
sizeMinDimension = min(size.width, size.height).toFloat(),
87+
strokeWidthPx = strokeWidth.toPx(),
88+
primarySweepAngle = primarySweepAngle,
89+
primarySegmentInfo = primarySegmentInfo,
90+
secondarySegmentInfo = secondarySegmentInfo,
91+
)
92+
activeTooltip = when {
93+
tappedSegment == null -> null
94+
activeTooltip == tappedSegment -> null
95+
else -> tappedSegment
96+
}
97+
}
98+
},
99+
) {
100+
drawArc(
101+
color = backgroundColor,
102+
startAngle = 0f,
103+
sweepAngle = 360f,
104+
useCenter = false,
105+
style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Butt),
106+
)
107+
108+
drawArc(
109+
color = primaryColor,
110+
startAngle = -90f,
111+
sweepAngle = primarySweepAngle,
112+
useCenter = false,
113+
style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Butt),
114+
)
115+
}
116+
117+
activeTooltip?.let { tooltipInfo ->
118+
Popup(
119+
alignment = Alignment.TopCenter,
120+
offset = IntOffset(0, popupOffsetY),
121+
properties = PopupProperties(
122+
dismissOnClickOutside = true,
123+
focusable = true,
124+
),
125+
onDismissRequest = { activeTooltip = null },
126+
) {
127+
DonutChartTooltip(segmentInfo = tooltipInfo)
128+
}
129+
}
130+
131+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
132+
Text(
133+
text = centerText,
134+
style = KptTheme.typography.titleLarge,
135+
color = primaryColor,
136+
fontWeight = FontWeight.Bold,
137+
)
138+
Text(
139+
text = centerSubText,
140+
style = MifosTypography.labelSmall,
141+
color = KptTheme.colorScheme.onSurfaceVariant,
142+
fontWeight = FontWeight.SemiBold,
143+
)
144+
}
145+
}
146+
}
147+
148+
private fun detectTappedSegment(
149+
tapOffset: Offset,
150+
sizeCenter: Offset,
151+
sizeMinDimension: Float,
152+
strokeWidthPx: Float,
153+
primarySweepAngle: Float,
154+
primarySegmentInfo: DonutSegmentInfo?,
155+
secondarySegmentInfo: DonutSegmentInfo?,
156+
): DonutSegmentInfo? {
157+
val dx = tapOffset.x - sizeCenter.x
158+
val dy = tapOffset.y - sizeCenter.y
159+
val distance = sqrt((dx * dx) + (dy * dy))
160+
val outerRadius = sizeMinDimension / 2f
161+
val innerRadius = (outerRadius - strokeWidthPx).coerceAtLeast(0f)
162+
163+
if (distance !in innerRadius..outerRadius) return null
164+
165+
val angleFromXAxis = (atan2(dy, dx) * 180f / PI.toFloat())
166+
val angleFromTopClockwise = (angleFromXAxis + 90f + 360f) % 360f
167+
val isPrimary = angleFromTopClockwise < primarySweepAngle
168+
return if (isPrimary) primarySegmentInfo else secondarySegmentInfo
169+
}
170+
171+
@Composable
172+
private fun DonutChartTooltip(segmentInfo: DonutSegmentInfo) {
173+
Surface(
174+
shape = DesignToken.shapes.large,
175+
color = AppColors.borderColor,
176+
) {
177+
Row(
178+
modifier = Modifier.padding(
179+
horizontal = DesignToken.padding.medium,
180+
vertical = DesignToken.padding.small,
181+
),
182+
verticalAlignment = Alignment.CenterVertically,
183+
) {
184+
Box(
185+
modifier = Modifier
186+
.size(DesignToken.padding.dp10)
187+
.background(segmentInfo.color, DesignToken.shapes.medium),
188+
)
189+
Spacer(modifier = Modifier.width(DesignToken.padding.dp6))
190+
Text(
191+
text = "${segmentInfo.label}: ${segmentInfo.value}",
192+
style = KptTheme.typography.bodyMedium,
193+
color = Color.White,
194+
)
195+
}
196+
}
197+
}
198+
199+
data class DonutSegmentInfo(
200+
val label: String,
201+
val value: String,
202+
val color: Color,
203+
)

0 commit comments

Comments
 (0)