Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -293,5 +293,12 @@
<!-- Handle Error-->
<string name="field_empty">This field cannot be empty</string>

<!-- Export Loan Transactions -->
<string name="feature_loan_export">Export</string>
<string name="feature_loan_export_transactions">Export</string>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why two different keys with same values ? you meant the second one to be Export Transactions did't you

<string name="feature_loan_from_date">From Date*</string>
<string name="feature_loan_to_date">To Date*</string>
<string name="feature_loan_generate_report">Generate Report</string>
<string name="feature_loan_invalid_date_range">To Date cannot be earlier than From Date</string>

</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
/*
* Copyright 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/android-client/blob/master/LICENSE.md
*/
package com.mifos.feature.loan.loanTransaction

import androidclient.feature.loan.generated.resources.Res
import androidclient.feature.loan.generated.resources.feature_loan_cancel
import androidclient.feature.loan.generated.resources.feature_loan_export_transactions
import androidclient.feature.loan.generated.resources.feature_loan_from_date
import androidclient.feature.loan.generated.resources.feature_loan_generate_report
import androidclient.feature.loan.generated.resources.feature_loan_invalid_date_range
import androidclient.feature.loan.generated.resources.feature_loan_select
import androidclient.feature.loan.generated.resources.feature_loan_to_date
import androidx.compose.foundation.clickable
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DatePickerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.mifos.core.common.utils.DateHelper.format
import com.mifos.core.designsystem.component.MifosButton
import com.mifos.core.designsystem.component.MifosCustomDialog
import com.mifos.core.designsystem.component.MifosDatePickerTextField
import com.mifos.core.designsystem.component.MifosOutlinedButton
import com.mifos.core.designsystem.icon.MifosIcons
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import kotlin.time.Clock
import kotlin.time.ExperimentalTime

@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
@Composable
internal fun ExportTransactionsDialog(
onDismiss: () -> Unit,
onGenerateReport: (fromDate: Long, toDate: Long) -> Unit,
) {
var showFromDatePicker by rememberSaveable { mutableStateOf(false) }
var showToDatePicker by rememberSaveable { mutableStateOf(false) }
var fromDate: Long? by rememberSaveable { mutableStateOf(null) }
var toDate: Long? by rememberSaveable { mutableStateOf(null) }
var showInvalidDateRangeError by remember { mutableStateOf(false) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be lost on config change, should not it be rememberSaveable also ?


val isValidDateRange = fromDate != null && toDate != null && toDate!! >= fromDate!!

val fromDatePickerState = rememberDatePickerState(
initialSelectedDateMillis = Clock.System.now().toEpochMilliseconds(),
selectableDates = createSelectableDatesFrom(LocalDate.parse("2000-01-01")),
)

val toDatePickerState = rememberDatePickerState(
initialSelectedDateMillis = Clock.System.now().toEpochMilliseconds(),
selectableDates = createSelectableDatesFrom(LocalDate.parse("2000-01-01")),
)

MifosDatePickerDialog(
show = showFromDatePicker,
state = fromDatePickerState,
onDismiss = { showFromDatePicker = false },
onConfirm = { selectedMillis ->
selectedMillis?.let {
fromDate = it
showInvalidDateRangeError = false
}
},
)

MifosDatePickerDialog(
show = showToDatePicker,
state = toDatePickerState,
onDismiss = { showToDatePicker = false },
onConfirm = { selectedMillis ->
selectedMillis?.let {
toDate = it
showInvalidDateRangeError = false
}
},
)

MifosCustomDialog(
onDismiss = onDismiss,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(0.95f),
) {
Box(
contentAlignment = Alignment.Center,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.feature_loan_export_transactions),
style = MaterialTheme.typography.titleLarge,
)
Icon(
imageVector = MifosIcons.Cancel,
contentDescription = stringResource(Res.string.feature_loan_cancel),
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier
.width(30.dp)
.height(30.dp)
.clickable { onDismiss() },
)
}

Spacer(modifier = Modifier.height(8.dp))

MifosDatePickerTextField(
value = formatDateFromMillis(fromDate),
label = stringResource(Res.string.feature_loan_from_date),
openDatePicker = {
initializeDatePicker(fromDate, fromDatePickerState)
showFromDatePicker = true
},
)

Spacer(modifier = Modifier.height(8.dp))

MifosDatePickerTextField(
value = formatDateFromMillis(toDate),
label = stringResource(Res.string.feature_loan_to_date),
openDatePicker = {
initializeDatePicker(toDate, toDatePickerState)
showToDatePicker = true
},
)

if (showInvalidDateRangeError) {
Text(
text = stringResource(Res.string.feature_loan_invalid_date_range),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
modifier = Modifier
.padding(top = 8.dp, start = 16.dp),
)
}

Spacer(modifier = Modifier.height(16.dp))

Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
MifosOutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(Res.string.feature_loan_cancel),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}

Spacer(modifier = Modifier.width(12.dp))

MifosButton(
onClick = {
if (!isValidDateRange) {
showInvalidDateRangeError = true
return@MifosButton
}
showInvalidDateRangeError = false
onGenerateReport(fromDate!!, toDate!!)
},
enabled = isValidDateRange,
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(Res.string.feature_loan_generate_report),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
)
}
}
}
}
}
}
}

@OptIn(ExperimentalTime::class)
@Composable

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this function should not be annotated with Composable it does not emit UI, call remember or other compose APIs or take compose parameter, marking it with compose it triggers unnecessary recomposition tracking overhead.

private fun formatDateFromMillis(millis: Long?): String {
if (millis == null) return ""
val localDate = Instant.fromEpochMilliseconds(millis)
.toLocalDateTime(TimeZone.currentSystemDefault())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a time zone mismatch with fun createSelectableDatesFrom(minDate: LocalDate) = object : SelectableDates I suggest using TimeZone.UTC for consistency

.date
return localDate.format("dd-MM-yyyy")
}

@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
private fun initializeDatePicker(
currentDate: Long?,
datePickerState: DatePickerState,
) {
datePickerState.selectedDateMillis = currentDate
?: Clock.System.now().toEpochMilliseconds()
}

@OptIn(ExperimentalTime::class, ExperimentalMaterial3Api::class)
private fun createSelectableDatesFrom(minDate: LocalDate) = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val selectedDate = Instant.fromEpochMilliseconds(utcTimeMillis)
.toLocalDateTime(TimeZone.UTC)
.date
return selectedDate >= minDate
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MifosDatePickerDialog(
show: Boolean,
state: DatePickerState,
onDismiss: () -> Unit,
onConfirm: (Long?) -> Unit,
) {
if (show) {
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
onConfirm(state.selectedDateMillis)
onDismiss()
},
) {
Text(stringResource(Res.string.feature_loan_select))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.feature_loan_cancel))
}
},
) {
DatePicker(state = state)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package com.mifos.feature.loan.loanTransaction

import androidclient.feature.loan.generated.resources.Res
import androidclient.feature.loan.generated.resources.feature_loan_break_down
import androidclient.feature.loan.generated.resources.feature_loan_export
import androidclient.feature.loan.generated.resources.feature_loan_id
import androidclient.feature.loan.generated.resources.feature_loan_loan_fees
import androidclient.feature.loan.generated.resources.feature_loan_loan_interest
Expand Down Expand Up @@ -43,6 +44,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
Expand Down Expand Up @@ -110,10 +112,31 @@ internal fun LoanTransactionsScreen(
val snackbarHostState = remember {
SnackbarHostState()
}
var showExportDialog by rememberSaveable { mutableStateOf(false) }

if (showExportDialog) {
ExportTransactionsDialog(
onDismiss = { showExportDialog = false },
onGenerateReport = { fromDate, toDate ->
// Placeholder: No API call as per requirements
// TODO: Implement report generation when backend API is ready
showExportDialog = false
},
)
}

MifosScaffold(
snackbarHostState = snackbarHostState,
title = stringResource(Res.string.feature_loan_loan_transactions),
onBackPressed = navigateBack,
actions = {
IconButton(onClick = { showExportDialog = true }) {
Icon(
imageVector = MifosIcons.FileUpload,
contentDescription = stringResource(Res.string.feature_loan_export),
)
}
},
) {
Box(
modifier = Modifier
Expand Down