Skip to content

Commit 45e5d11

Browse files
committed
Implement Loan dashboard
1 parent fcaaf1c commit 45e5d11

16 files changed

Lines changed: 2993 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: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
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.animation.core.Animatable
13+
import androidx.compose.animation.core.tween
14+
import androidx.compose.foundation.Canvas
15+
import androidx.compose.foundation.background
16+
import androidx.compose.foundation.clickable
17+
import androidx.compose.foundation.gestures.detectTapGestures
18+
import androidx.compose.foundation.layout.Box
19+
import androidx.compose.foundation.layout.Column
20+
import androidx.compose.foundation.layout.Row
21+
import androidx.compose.foundation.layout.Spacer
22+
import androidx.compose.foundation.layout.height
23+
import androidx.compose.foundation.layout.padding
24+
import androidx.compose.foundation.layout.size
25+
import androidx.compose.foundation.layout.width
26+
import androidx.compose.material3.Surface
27+
import androidx.compose.material3.Text
28+
import androidx.compose.runtime.Composable
29+
import androidx.compose.runtime.LaunchedEffect
30+
import androidx.compose.runtime.getValue
31+
import androidx.compose.runtime.mutableStateOf
32+
import androidx.compose.runtime.remember
33+
import androidx.compose.runtime.rememberCoroutineScope
34+
import androidx.compose.runtime.setValue
35+
import androidx.compose.runtime.snapshotFlow
36+
import androidx.compose.ui.Alignment
37+
import androidx.compose.ui.Modifier
38+
import androidx.compose.ui.geometry.Offset
39+
import androidx.compose.ui.graphics.Color
40+
import androidx.compose.ui.graphics.StrokeCap
41+
import androidx.compose.ui.graphics.drawscope.Stroke
42+
import androidx.compose.ui.input.pointer.pointerInput
43+
import androidx.compose.ui.platform.LocalDensity
44+
import androidx.compose.ui.text.style.TextDecoration
45+
import androidx.compose.ui.unit.Dp
46+
import androidx.compose.ui.unit.IntOffset
47+
import androidx.compose.ui.window.Popup
48+
import androidx.compose.ui.window.PopupProperties
49+
import com.mifos.core.designsystem.theme.AppColors
50+
import com.mifos.core.designsystem.theme.DesignToken
51+
import kotlinx.coroutines.launch
52+
import template.core.base.designsystem.theme.KptTheme
53+
import kotlin.math.PI
54+
import kotlin.math.atan2
55+
import kotlin.math.min
56+
import kotlin.math.sqrt
57+
58+
@Composable
59+
fun MifosDonutGraph(
60+
segments: List<DonutSegment>,
61+
backgroundColor: Color,
62+
modifier: Modifier = Modifier,
63+
strokeWidth: Dp = DesignToken.strokes.dp10,
64+
showLegends: Boolean = true,
65+
) {
66+
var visibleByLabel by remember(segments) {
67+
mutableStateOf(segments.associate { it.label to true })
68+
}
69+
70+
val onToggleSegment: (String) -> Unit = { label ->
71+
visibleByLabel = visibleByLabel.toMutableMap().also { map ->
72+
map[label] = !(map[label] ?: true)
73+
}
74+
}
75+
76+
var activeTooltip by remember { mutableStateOf<DonutSegment?>(null) }
77+
val density = LocalDensity.current
78+
val popupOffsetY = with(density) { DesignToken.spacing.negativeDp20.roundToPx() }
79+
80+
val scope = rememberCoroutineScope()
81+
82+
val animatedVisibleValues = remember { LinkedHashMap<String, Animatable<Float, *>>() }
83+
84+
val segmentLabels = segments.map { it.label }
85+
86+
LaunchedEffect(segmentLabels) {
87+
visibleByLabel = buildMap {
88+
segmentLabels.forEach { label ->
89+
put(label, visibleByLabel[label] ?: true)
90+
}
91+
}
92+
}
93+
94+
LaunchedEffect(segmentLabels) {
95+
val keys = segmentLabels.toSet()
96+
animatedVisibleValues.keys.toList().forEach { key ->
97+
if (key !in keys) animatedVisibleValues.remove(key)
98+
}
99+
segments.forEach { seg ->
100+
if (animatedVisibleValues[seg.label] == null) {
101+
val isVisible = visibleByLabel[seg.label] ?: true
102+
animatedVisibleValues[seg.label] = Animatable(if (isVisible) seg.value else 0f)
103+
}
104+
}
105+
}
106+
107+
LaunchedEffect(Unit) {
108+
snapshotFlow { visibleByLabel to segments }
109+
.collect { latest ->
110+
val (visibility, segs) = latest
111+
segs.forEach { seg ->
112+
val anim = animatedVisibleValues[seg.label] ?: return@forEach
113+
val target = if (visibility[seg.label] != false) seg.value else 0f
114+
scope.launch {
115+
anim.animateTo(
116+
targetValue = target,
117+
animationSpec = tween(durationMillis = 450),
118+
)
119+
}
120+
}
121+
}
122+
}
123+
124+
val segmentsForAngles = segments.map { seg ->
125+
val isVisible = visibleByLabel[seg.label] != false
126+
val v = animatedVisibleValues[seg.label]?.value ?: if (isVisible) seg.value else 0f
127+
seg.copy(value = v)
128+
}
129+
130+
val visibleSegments = segmentsForAngles.filter { it.value > 0f }
131+
val total = visibleSegments.sumOf { it.value.toDouble() }.toFloat()
132+
val hasAnyVisibleSegment = visibleSegments.isNotEmpty() && total > 0f
133+
134+
val segmentAngles = remember(visibleSegments, total) {
135+
computeSegmentAngles(
136+
segments = visibleSegments,
137+
total = total,
138+
)
139+
}
140+
141+
Column(modifier = modifier) {
142+
Box(
143+
modifier = Modifier.size(DesignToken.sizes.boxDp100),
144+
contentAlignment = Alignment.Center,
145+
) {
146+
Canvas(
147+
modifier = Modifier
148+
.size(DesignToken.sizes.boxDp100)
149+
.pointerInput(segmentAngles, strokeWidth) {
150+
detectTapGestures { tapOffset ->
151+
val tappedSegment = detectTappedSegment(
152+
tapOffset = tapOffset,
153+
sizeCenter = Offset(size.width / 2f, size.height / 2f),
154+
sizeMinDimension = min(size.width, size.height).toFloat(),
155+
strokeWidthPx = strokeWidth.toPx(),
156+
segmentAngles = segmentAngles,
157+
)
158+
159+
activeTooltip = when {
160+
tappedSegment == null -> null
161+
activeTooltip?.label == tappedSegment.label -> null
162+
else -> tappedSegment
163+
}
164+
}
165+
},
166+
) {
167+
drawArc(
168+
color = if (hasAnyVisibleSegment) backgroundColor else AppColors.borderColor,
169+
startAngle = 0f,
170+
sweepAngle = 360f,
171+
useCenter = false,
172+
style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Butt),
173+
)
174+
175+
segmentAngles.forEach { segmentAngle ->
176+
drawArc(
177+
color = segmentAngle.segment.color,
178+
startAngle = segmentAngle.startAngle,
179+
sweepAngle = segmentAngle.sweepAngle,
180+
useCenter = false,
181+
style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Butt),
182+
)
183+
}
184+
}
185+
186+
activeTooltip?.let { tooltipInfo ->
187+
Popup(
188+
alignment = Alignment.TopCenter,
189+
offset = IntOffset(0, popupOffsetY),
190+
properties = PopupProperties(
191+
dismissOnClickOutside = true,
192+
focusable = true,
193+
),
194+
onDismissRequest = { activeTooltip = null },
195+
) {
196+
DonutChartTooltip(segmentInfo = tooltipInfo)
197+
}
198+
}
199+
}
200+
201+
if (showLegends) {
202+
Spacer(modifier = Modifier.height(DesignToken.padding.medium))
203+
Row(verticalAlignment = Alignment.CenterVertically) {
204+
segments.forEach { seg ->
205+
DonutLegendToggleItem(
206+
label = seg.label,
207+
amount = seg.valueLabel,
208+
color = seg.color,
209+
isVisible = visibleByLabel[seg.label] != false,
210+
onToggle = { onToggleSegment(seg.label) },
211+
)
212+
}
213+
}
214+
}
215+
}
216+
}
217+
218+
@Composable
219+
fun DonutLegendToggleItem(
220+
label: String,
221+
amount: String,
222+
color: Color,
223+
isVisible: Boolean,
224+
onToggle: () -> Unit,
225+
modifier: Modifier = Modifier,
226+
) {
227+
Row(
228+
verticalAlignment = Alignment.Top,
229+
modifier = modifier
230+
.clickable { onToggle() }
231+
.padding(
232+
horizontal = DesignToken.padding.small,
233+
vertical = DesignToken.padding.extraSmall,
234+
),
235+
) {
236+
Box(
237+
modifier = Modifier
238+
.padding(top = DesignToken.padding.extraSmall)
239+
.size(DesignToken.sizes.boxDp12)
240+
.background(
241+
color = if (isVisible) color else Color.LightGray,
242+
shape = DesignToken.shapes.circle,
243+
),
244+
)
245+
Spacer(modifier = Modifier.width(DesignToken.padding.small))
246+
Column {
247+
Text(
248+
text = label,
249+
style = KptTheme.typography.labelMedium,
250+
color = KptTheme.colorScheme.onSurfaceVariant,
251+
textDecoration = if (isVisible) TextDecoration.None else TextDecoration.LineThrough,
252+
)
253+
Spacer(modifier = Modifier.height(KptTheme.spacing.xs))
254+
Text(
255+
text = amount,
256+
style = KptTheme.typography.labelLarge,
257+
color = KptTheme.colorScheme.onSurface,
258+
textDecoration = if (isVisible) TextDecoration.None else TextDecoration.LineThrough,
259+
)
260+
}
261+
}
262+
}
263+
264+
private fun detectTappedSegment(
265+
tapOffset: Offset,
266+
sizeCenter: Offset,
267+
sizeMinDimension: Float,
268+
strokeWidthPx: Float,
269+
segmentAngles: List<SegmentAngle>,
270+
): DonutSegment? {
271+
val dx = tapOffset.x - sizeCenter.x
272+
val dy = tapOffset.y - sizeCenter.y
273+
val distance = sqrt((dx * dx) + (dy * dy))
274+
val outerRadius = sizeMinDimension / 2f
275+
val innerRadius = (outerRadius - strokeWidthPx).coerceAtLeast(0f)
276+
277+
if (distance !in innerRadius..outerRadius) return null
278+
279+
val angleFromXAxis = (atan2(dy, dx) * 180f / PI.toFloat())
280+
val angleFromTopClockwise = (angleFromXAxis + 90f + 360f) % 360f
281+
282+
return segmentAngles
283+
.firstOrNull { it.contains(angleFromTopClockwise) }
284+
?.segment
285+
}
286+
287+
private fun computeSegmentAngles(
288+
segments: List<DonutSegment>,
289+
total: Float,
290+
): List<SegmentAngle> {
291+
if (segments.isEmpty() || total <= 0f) return emptyList()
292+
293+
var currentStart = -90f
294+
return segments.mapNotNull { segment ->
295+
val ratio = (segment.value / total).coerceIn(0f, 1f)
296+
val sweep = ratio * 360f
297+
if (sweep <= 0f) {
298+
null
299+
} else {
300+
val start = currentStart
301+
currentStart += sweep
302+
SegmentAngle(
303+
segment = segment,
304+
startAngle = start,
305+
sweepAngle = sweep,
306+
)
307+
}
308+
}
309+
}
310+
311+
private data class SegmentAngle(
312+
val segment: DonutSegment,
313+
val startAngle: Float,
314+
val sweepAngle: Float,
315+
) {
316+
val startAngleNormalized: Float
317+
get() = ((startAngle + 90f) % 360f + 360f) % 360f
318+
319+
val endAngleNormalized: Float
320+
get() = ((startAngle + sweepAngle + 90f) % 360f + 360f) % 360f
321+
322+
fun contains(angleFromTopClockwise: Float): Boolean {
323+
val start = startAngleNormalized
324+
val end = endAngleNormalized
325+
return if (end >= start) {
326+
angleFromTopClockwise in start..end
327+
} else {
328+
angleFromTopClockwise >= start || angleFromTopClockwise <= end
329+
}
330+
}
331+
}
332+
333+
@Composable
334+
private fun DonutChartTooltip(
335+
segmentInfo: DonutSegment,
336+
modifier: Modifier = Modifier,
337+
) {
338+
Surface(
339+
shape = DesignToken.shapes.large,
340+
color = AppColors.borderColor,
341+
) {
342+
Row(
343+
verticalAlignment = Alignment.CenterVertically,
344+
modifier = modifier.padding(
345+
horizontal = DesignToken.padding.medium,
346+
vertical = DesignToken.padding.small,
347+
),
348+
) {
349+
Box(
350+
modifier = Modifier
351+
.size(DesignToken.padding.dp10)
352+
.background(segmentInfo.color, DesignToken.shapes.medium),
353+
)
354+
Spacer(modifier = Modifier.width(DesignToken.padding.dp6))
355+
Text(
356+
text = "${segmentInfo.label}: ${segmentInfo.valueLabel}",
357+
style = KptTheme.typography.bodyMedium,
358+
color = Color.White,
359+
)
360+
}
361+
}
362+
}
363+
364+
data class DonutSegment(
365+
val label: String,
366+
val valueLabel: String,
367+
val value: Float,
368+
val color: Color,
369+
)

0 commit comments

Comments
 (0)