Skip to content

Commit 64934bb

Browse files
committed
Implement Loan dashboard
1 parent d788bb4 commit 64934bb

15 files changed

Lines changed: 2673 additions & 2 deletions

File tree

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: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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(primarySweepAngle, primarySegmentInfo, secondarySegmentInfo, strokeWidth) {
77+
detectTapGestures { tapOffset ->
78+
val tappedSegment = detectTappedSegment(
79+
tapOffset = tapOffset,
80+
sizeCenter = Offset(size.width / 2f, size.height / 2f),
81+
sizeMinDimension = min(size.width, size.height).toFloat(),
82+
strokeWidthPx = strokeWidth.toPx(),
83+
primarySweepAngle = primarySweepAngle,
84+
primarySegmentInfo = primarySegmentInfo,
85+
secondarySegmentInfo = secondarySegmentInfo,
86+
)
87+
88+
tappedSegment?.let { segmentInfo ->
89+
activeTooltip = if (activeTooltip == segmentInfo) null else segmentInfo
90+
}
91+
}
92+
},
93+
) {
94+
drawArc(
95+
color = backgroundColor,
96+
startAngle = 0f,
97+
sweepAngle = 360f,
98+
useCenter = false,
99+
style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Butt),
100+
)
101+
102+
drawArc(
103+
color = primaryColor,
104+
startAngle = -90f,
105+
sweepAngle = primarySweepAngle,
106+
useCenter = false,
107+
style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Butt),
108+
)
109+
}
110+
111+
activeTooltip?.let { tooltipInfo ->
112+
Popup(
113+
alignment = Alignment.TopCenter,
114+
offset = IntOffset(0, popupOffsetY),
115+
properties = PopupProperties(
116+
dismissOnClickOutside = true,
117+
focusable = true,
118+
),
119+
onDismissRequest = { activeTooltip = null },
120+
) {
121+
DonutChartTooltip(segmentInfo = tooltipInfo)
122+
}
123+
}
124+
125+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
126+
Text(
127+
text = centerText,
128+
style = KptTheme.typography.titleLarge,
129+
color = primaryColor,
130+
fontWeight = FontWeight.Bold,
131+
)
132+
Text(
133+
text = centerSubText,
134+
style = MifosTypography.labelSmall,
135+
color = KptTheme.colorScheme.onSurfaceVariant,
136+
fontWeight = FontWeight.SemiBold,
137+
)
138+
}
139+
}
140+
}
141+
142+
private fun detectTappedSegment(
143+
tapOffset: Offset,
144+
sizeCenter: Offset,
145+
sizeMinDimension: Float,
146+
strokeWidthPx: Float,
147+
primarySweepAngle: Float,
148+
primarySegmentInfo: DonutSegmentInfo?,
149+
secondarySegmentInfo: DonutSegmentInfo?,
150+
): DonutSegmentInfo? {
151+
val dx = tapOffset.x - sizeCenter.x
152+
val dy = tapOffset.y - sizeCenter.y
153+
val distance = sqrt((dx * dx) + (dy * dy))
154+
val outerRadius = sizeMinDimension / 2f
155+
val innerRadius = (outerRadius - strokeWidthPx).coerceAtLeast(0f)
156+
157+
if (distance !in innerRadius..outerRadius) return null
158+
159+
val angleFromXAxis = (atan2(dy, dx) * 180f / PI.toFloat())
160+
val angleFromTopClockwise = (angleFromXAxis + 90f + 360f) % 360f
161+
val isPrimary = angleFromTopClockwise < primarySweepAngle
162+
return if (isPrimary) primarySegmentInfo else secondarySegmentInfo
163+
}
164+
165+
@Composable
166+
private fun DonutChartTooltip(segmentInfo: DonutSegmentInfo) {
167+
Surface(
168+
shape = DesignToken.shapes.large,
169+
color = AppColors.borderColor,
170+
) {
171+
Row(
172+
modifier = Modifier.padding(horizontal = DesignToken.padding.medium, vertical = DesignToken.padding.small),
173+
verticalAlignment = Alignment.CenterVertically,
174+
) {
175+
Box(
176+
modifier = Modifier
177+
.size(DesignToken.padding.dp10)
178+
.background(segmentInfo.color, DesignToken.shapes.medium),
179+
)
180+
Spacer(modifier = Modifier.width(DesignToken.padding.dp6))
181+
Text(
182+
text = "${segmentInfo.label}: ${segmentInfo.value}",
183+
style = KptTheme.typography.bodyMedium,
184+
color = Color.White,
185+
)
186+
}
187+
}
188+
}
189+
190+
data class DonutSegmentInfo(
191+
val label: String,
192+
val value: String,
193+
val color: Color,
194+
)

0 commit comments

Comments
 (0)