diff --git a/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/TransactionEditE2ETests.kt b/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/TransactionEditE2ETests.kt new file mode 100644 index 0000000..1ca0c2e --- /dev/null +++ b/app/src/androidTest/java/com/serranoie/app/minus/presentation/ui/e2e/history/TransactionEditE2ETests.kt @@ -0,0 +1,515 @@ +package com.serranoie.app.minus.presentation.ui.e2e.history + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import com.google.common.truth.Truth +import com.serranoie.app.minus.R +import com.serranoie.app.minus.domain.model.RecurrentFrequency +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.history.edit.TransactionEditScreen +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import org.junit.Rule +import org.junit.Test +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +class TransactionEditE2ETests { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val today: LocalDate = LocalDate.now() + private val periodStart: LocalDate = today.minusDays(15) + private val periodEnd: LocalDate = today.plusDays(15) + + private data class SavePayload( + val amount: BigDecimal, + val comment: String, + val dateTime: LocalDateTime, + val isRecurrent: Boolean, + val frequency: RecurrentFrequency?, + val endDate: LocalDate?, + val subscriptionDay: Int?, + ) + + private fun sampleTransaction( + amount: String = "50.00", + comment: String = "Coffee", + date: LocalDateTime = LocalDateTime.now().minusHours(2), + isRecurrent: Boolean = false, + frequency: RecurrentFrequency? = null, + subscriptionDay: Int? = null, + recurrentEndDate: LocalDateTime? = null, + ): Transaction = Transaction.create( + amount = BigDecimal(amount), + comment = comment, + date = date, + isRecurrent = isRecurrent, + recurrentFrequency = frequency, + subscriptionDay = subscriptionDay, + recurrentEndDate = recurrentEndDate, + ) + + private fun setEditContent( + transaction: Transaction, + onCancel: () -> Unit = {}, + onSave: ( + newAmount: BigDecimal, + newComment: String, + newDateTime: LocalDateTime, + newIsRecurrent: Boolean, + newFrequency: RecurrentFrequency?, + newEndDate: LocalDate?, + newSubscriptionDay: Int?, + ) -> Unit = { _, _, _, _, _, _, _ -> }, + ) { + composeTestRule.setContent { + MinusTheme { + TransactionEditScreen( + transaction = transaction, + budgetStartDate = periodStart, + budgetEndDate = periodEnd, + currencyCode = "USD", + onCancel = onCancel, + onSave = onSave, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + private fun prettyDate(date: LocalDate): String { + val deviceLocale = composeTestRule.activity.resources.configuration.locales[0] + val monthFormat = java.time.format.DateTimeFormatter.ofPattern("dd MMMM", deviceLocale) + return date.format(monthFormat) + } + + private fun prettyTime(time: LocalTime): String = + String.format("%02d:%02d", time.hour, time.minute) + + private fun cancelContentDesc(): String = + composeTestRule.activity.getString(R.string.cancel_edit_content_desc) + + private fun saveLabel(): String = composeTestRule.activity.getString(R.string.save) + + private fun acceptLabel(): String = composeTestRule.activity.getString(R.string.accept) + + private fun tapApply() { + composeTestRule.onAllNodesWithContentDescription("Editor action").onLast() + .performClick() + composeTestRule.waitForIdle() + } + + @Test + fun when_edit_existing_expense_and_save_then_save_callback_fires_with_same_values() { + val tx = sampleTransaction(amount = "45.50", comment = "Groceries") + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + val editTitle = composeTestRule.activity.getString(R.string.edit_expense_title) + composeTestRule.onNodeWithText(editTitle).assertIsDisplayed() + + composeTestRule.onAllNodesWithText("Groceries").assertCountEquals(1) + + tapApply() + + Truth.assertThat(captured).isNotNull() + Truth.assertThat(captured!!.amount).isEqualTo(BigDecimal("45.50")) + Truth.assertThat(captured!!.comment).isEqualTo("Groceries") + Truth.assertThat(captured!!.isRecurrent).isFalse() + } + + @Test + fun when_edit_then_close_then_on_cancel_callback_fires() { + val tx = sampleTransaction() + var cancelCount = 0 + + setEditContent( + transaction = tx, + onCancel = { cancelCount += 1 }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithContentDescription(cancelContentDesc()).performClick() + + composeTestRule.waitForIdle() + + Truth.assertThat(cancelCount).isEqualTo(1) + } + + @Test + fun when_edit_and_add_new_category_and_save_then_on_save_has_new_comment() { + val tx = sampleTransaction(comment = "Old") + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithText("Old").onLast().performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithText("Old").onLast().performTextReplacement("Old Lunch") + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithText("Old Lunch").onLast().performImeAction() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + tapApply() + + Truth.assertThat(captured).isNotNull() + Truth.assertThat(captured!!.comment).isEqualTo("Old Lunch") + } + + @Test + fun when_edit_and_delete_category_and_save_then_on_save_has_empty_comment() { + val tx = sampleTransaction(comment = "Coffee") + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithText("Coffee").onLast().performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(600) + composeTestRule.waitForIdle() + + composeTestRule.onAllNodesWithText("Coffee").onLast().performTextClearance() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.waitForIdle() + + composeTestRule.onAllNodes(hasSetTextAction()).onLast().performImeAction() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + tapApply() + + Truth.assertThat(captured).isNotNull() + Truth.assertThat(captured!!.comment).isEmpty() + } + + @Test + fun when_tap_recurrent_toggle_then_recurrence_config_sheet_appears() { + val tx = sampleTransaction() + + setEditContent(transaction = tx) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + val recurrentLabel = composeTestRule.activity.getString(R.string.recurrent_toggle_label) + composeTestRule.onNodeWithText(recurrentLabel).performClick() + + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + val configureTitle = composeTestRule.activity.getString(R.string.configure_recurrence) + composeTestRule.onNodeWithText(configureTitle).assertIsDisplayed() + } + + @Test + fun when_tap_recurrent_and_set_monthly_then_on_save_has_monthly_frequency() { + val tx = sampleTransaction() + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + val recurrentLabel = composeTestRule.activity.getString(R.string.recurrent_toggle_label) + composeTestRule.onNodeWithText(recurrentLabel).performClick() + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + val monthlyLabel = composeTestRule.activity.getString(R.string.recurrent_frequency_monthly) + composeTestRule.onNodeWithText(monthlyLabel).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(saveLabel()).performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + tapApply() + + Truth.assertThat(captured).isNotNull() + Truth.assertThat(captured!!.isRecurrent).isTrue() + Truth.assertThat(captured!!.frequency).isEqualTo(RecurrentFrequency.MONTHLY) + } + + @Test + fun when_edit_recurrent_then_tap_toggle_then_sheet_shows_existing_values() { + val initialDate = LocalDateTime.now().minusMonths(2) + val tx = sampleTransaction( + isRecurrent = true, + frequency = RecurrentFrequency.MONTHLY, + subscriptionDay = 15, + recurrentEndDate = initialDate.plusMonths(6), + ) + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + val recurrentTitle = + composeTestRule.activity.getString(R.string.edit_recurrent_expense_title) + composeTestRule.onNodeWithText(recurrentTitle).assertIsDisplayed() + + val recurrentLabel = composeTestRule.activity.getString(R.string.recurrent_toggle_label) + composeTestRule.onNodeWithText(recurrentLabel).performClick() + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + val monthlyLabel = composeTestRule.activity.getString(R.string.recurrent_frequency_monthly) + composeTestRule.onAllNodesWithText(monthlyLabel).onLast().assertIsDisplayed() + + val monthlyDayFormat = composeTestRule.activity.getString( + R.string.monthly_on_day_format, + 15, + ) + composeTestRule.onAllNodesWithText(monthlyDayFormat).onLast().assertIsDisplayed() + + composeTestRule.onNodeWithText(saveLabel()).performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + tapApply() + + Truth.assertThat(captured).isNotNull() + Truth.assertThat(captured!!.isRecurrent).isTrue() + Truth.assertThat(captured!!.frequency).isEqualTo(RecurrentFrequency.MONTHLY) + Truth.assertThat(captured!!.subscriptionDay).isEqualTo(15) + } + + @Test + fun when_edit_recurrent_and_change_recurrence_day_then_on_save_has_new_day() { + val tx = sampleTransaction( + isRecurrent = true, + frequency = RecurrentFrequency.MONTHLY, + subscriptionDay = 10, + ) + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + val recurrentLabel = composeTestRule.activity.getString(R.string.recurrent_toggle_label) + composeTestRule.onNodeWithText(recurrentLabel).performClick() + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + val nextDayDesc = composeTestRule.activity.getString(R.string.next_day) + composeTestRule.onNodeWithContentDescription(nextDayDesc).performClick() + composeTestRule.onNodeWithContentDescription(nextDayDesc).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(saveLabel()).performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(400) + composeTestRule.waitForIdle() + + tapApply() + + Truth.assertThat(captured).isNotNull() + Truth.assertThat(captured!!.isRecurrent).isTrue() + Truth.assertThat(captured!!.subscriptionDay).isEqualTo(12) + } + + // CHECK DATE SELECTOR HANDLER + @Test + fun when_edit_and_change_date_and_save_then_on_save_has_new_date() { + val original = LocalDateTime.now().minusDays(2) + val tx = sampleTransaction(date = original) + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + val originalFormatted = prettyDate(original.toLocalDate()) + composeTestRule.onAllNodesWithText(originalFormatted).onLast().performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(acceptLabel()).performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.waitForIdle() + + tapApply() + + Truth.assertThat(captured).isNotNull() + } + + @Test + fun when_edit_and_change_time_and_save_then_on_save_has_new_time() { + val original = LocalDateTime.of(today, LocalTime.of(10, 30)) + val tx = sampleTransaction(date = original) + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + val originalTime = prettyTime(original.toLocalTime()) + composeTestRule.onAllNodesWithText(originalTime).onLast().performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(acceptLabel()).performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.waitForIdle() + + tapApply() + + Truth.assertThat(captured).isNotNull() + } + + @Test + fun when_edit_and_change_date_and_time_and_save_then_on_save_has_both_new_values() { + val original = LocalDateTime.of(today.minusDays(3), LocalTime.of(8, 15)) + val tx = sampleTransaction(date = original) + var captured: SavePayload? = null + + setEditContent( + transaction = tx, + onSave = { amount, comment, dateTime, isRecurrent, frequency, endDate, subDay -> + captured = + SavePayload(amount, comment, dateTime, isRecurrent, frequency, endDate, subDay) + }, + ) + + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + val originalFormatted = prettyDate(original.toLocalDate()) + composeTestRule.onAllNodesWithText(originalFormatted).onLast().performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(acceptLabel()).performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.waitForIdle() + + val originalTime = prettyTime(original.toLocalTime()) + composeTestRule.onAllNodesWithText(originalTime).onLast().performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(acceptLabel()).performClick() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.waitForIdle() + + tapApply() + + Truth.assertThat(captured).isNotNull() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/editor/BudgetPeriodSheet.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/editor/BudgetPeriodSheet.kt index 43db56e..85f4eb8 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/editor/BudgetPeriodSheet.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/editor/BudgetPeriodSheet.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -75,6 +76,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.serranoie.app.minus.R @@ -178,25 +181,21 @@ fun BudgetPeriodSheet( targetState = isEditMode, transitionSpec = { if (targetState) { - ( - slideInHorizontally( - initialOffsetX = { it / 3 }, animationSpec = tween(300) - ) + fadeIn(tween(250, delayMillis = 50)) - ).togetherWith( - slideOutHorizontally( - targetOffsetX = { -it / 3 }, animationSpec = tween(300) - ) + fadeOut(tween(200)) - ) + (slideInHorizontally( + initialOffsetX = { it / 3 }, animationSpec = tween(300) + ) + fadeIn(tween(250, delayMillis = 50))).togetherWith( + slideOutHorizontally( + targetOffsetX = { -it / 3 }, animationSpec = tween(300) + ) + fadeOut(tween(200)) + ) } else { - ( - slideInHorizontally( - initialOffsetX = { -it / 3 }, animationSpec = tween(300) - ) + fadeIn(tween(250, delayMillis = 50)) - ).togetherWith( - slideOutHorizontally( - targetOffsetX = { it / 3 }, animationSpec = tween(300) - ) + fadeOut(tween(200)) - ) + (slideInHorizontally( + initialOffsetX = { -it / 3 }, animationSpec = tween(300) + ) + fadeIn(tween(250, delayMillis = 50))).togetherWith( + slideOutHorizontally( + targetOffsetX = { it / 3 }, animationSpec = tween(300) + ) + fadeOut(tween(200)) + ) } }, label = "sheetContent" @@ -352,24 +351,29 @@ private fun ViewBudgetContent( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - BudgetDisplay( - budget = totalBudget, - budgetState = budgetState, - budgetSettings = budgetSettings, - currencyCode = currencyCode, - bigVariant = false, + Box( modifier = Modifier - .weight(1.5f) - .height(120.dp), - startDate = startDateAsDate, - finishDate = endDateAsDate - ) + .weight(1.65f) + .fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + BudgetDisplay( + budget = totalBudget, + budgetState = budgetState, + budgetSettings = budgetSettings, + currencyCode = currencyCode, + bigVariant = false, + modifier = Modifier.fillMaxWidth(), + startDate = startDateAsDate, + finishDate = endDateAsDate + ) + } if (endDateAsDate != null) { Box( modifier = Modifier .weight(1f) - .height(120.dp), + .fillMaxHeight(), contentAlignment = Alignment.Center, ) { DaysLeftCard( @@ -378,18 +382,16 @@ private fun ViewBudgetContent( ) } } else { + // NOTE: this behavior shouldn't happen, sheet need to render new budget state. Card( modifier = Modifier .weight(1f) - .height(120.dp), - colors = CardDefaults.cardColors( + .height(120.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - shape = RoundedCornerShape(12.dp) + ), shape = RoundedCornerShape(12.dp) ) { Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { Text( text = stringResource(R.string.no_ending_date), @@ -461,10 +463,13 @@ private fun ViewBudgetContent( colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error, ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.25F)) ) { Text( text = stringResource(R.string.finalize_period), style = MaterialTheme.typography.labelMediumEmphasized, + textAlign = TextAlign.Center, + lineHeight = TextUnit(0.925f, TextUnitType.Em), ) } } @@ -488,9 +493,7 @@ fun EditBudgetContent( } val pendingNotificationText = resources.getQuantityString( - R.plurals.pending_expense, - pendingExpensesCount, - pendingExpensesCount + R.plurals.pending_expense, pendingExpensesCount, pendingExpensesCount ) val currentBudget = budgetSettings?.totalBudget ?: BigDecimal.ZERO @@ -607,8 +610,7 @@ fun EditBudgetContent( if (showPreviousValuesChip && currentBudget > BigDecimal.ZERO) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start ) { AssistChip( onClick = { showPreviousValues = !showPreviousValues }, @@ -679,16 +681,13 @@ fun EditBudgetContent( } Box( - modifier = Modifier.weight(0.5f), - contentAlignment = Alignment.CenterEnd + modifier = Modifier.weight(0.5f), contentAlignment = Alignment.CenterEnd ) { IconButton( onClick = { budgetText = if (currentBudget > BigDecimal.ZERO) { - ( - currentBudget.multiply(BigDecimal(100)) - .toBigInteger() - ).toString() + (currentBudget.multiply(BigDecimal(100)) + .toBigInteger()).toString() } else { "" } @@ -700,8 +699,7 @@ fun EditBudgetContent( currencyCache = currentCurrency strategyCache = currentStrategy showPreviousValues = false - }, - modifier = Modifier.size(40.dp) + }, modifier = Modifier.size(40.dp) ) { Icon( imageVector = Icons.Default.Check, @@ -738,8 +736,7 @@ fun EditBudgetContent( label = { Text( stringResource( - R.string.use_previous_period_days, - previousPeriodDays + R.string.use_previous_period_days, previousPeriodDays ) ) }, @@ -897,8 +894,7 @@ fun EditBudgetContent( onSelect = { code -> currencyCache = code showCurrencyPicker = false - } - ) + }) } if (showStrategyPicker) { @@ -908,8 +904,7 @@ fun EditBudgetContent( onSelect = { strategy -> strategyCache = strategy showStrategyPicker = false - } - ) + }) } } @@ -1159,8 +1154,7 @@ private fun CompactPeriodCard( modifier = modifier.clickable(onClick = onClick), onClick = onClick, border = BorderStroke( - width = if (isSelected) 2.dp else 1.dp, - color = borderColor + width = if (isSelected) 2.dp else 1.dp, color = borderColor ), colors = CardDefaults.outlinedCardColors(containerColor = backgroundColor), ) { @@ -1176,14 +1170,11 @@ private fun CompactPeriodCard( BudgetPeriod.WEEKLY -> stringResource(R.string.budget_period_weekly) BudgetPeriod.BIWEEKLY -> stringResource(R.string.budget_period_biweekly) BudgetPeriod.MONTHLY -> stringResource(R.string.budget_period_monthly) - }, - style = if (isSelected) { + }, style = if (isSelected) { MaterialTheme.typography.bodySmallEmphasized } else { MaterialTheme.typography.bodySmallCondensed - }, - fontWeight = FontWeight.Medium, - color = textColor + }, fontWeight = FontWeight.Medium, color = textColor ) Text( @@ -1231,15 +1222,13 @@ private fun BudgetPeriodSheetPreview() { currencyCode = "MXN", onPeriodSelected = { }, onSaveBudget = { }, - onFinishEarly = { } - ) + onFinishEarly = { }) } } } @Preview( - showBackground = true, - device = "spec:width=1080px,height=2340px,dpi=440" + showBackground = true, device = "spec:width=1080px,height=2340px,dpi=440" ) @Composable private fun EditModePreview() { diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/editor/Editor.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/editor/Editor.kt index 566aa5d..4f4a0ba 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/editor/Editor.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/editor/Editor.kt @@ -95,651 +95,685 @@ import java.time.LocalDate @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) @Composable fun Editor( - uiState: BudgetUiState, - animState: AnimState, - onInputChange: (String) -> Unit = {}, - onFocus: () -> Unit, - onOpenHistory: () -> Unit, - onOpenSettings: () -> Unit, - onOpenAnalytics: () -> Unit = {}, - onOpenWallet: () -> Unit = {}, - openWalletOnStart: Boolean = false, - showBudgetPeriodSheet: Boolean = false, - forceBudgetPeriodSheetSetup: Boolean = false, - selectedViewPeriod: BudgetPeriod = BudgetPeriod.DAILY, - onShowBudgetPeriodSheet: () -> Unit = {}, - onHideBudgetPeriodSheet: () -> Unit = {}, - onPeriodSelected: (BudgetPeriod) -> Unit = {}, - onCommentClick: () -> Unit, - onBudgetPillClickForTutorial: () -> Unit = {}, - onAnalyticsClickForTutorial: () -> Unit = {}, - onChangePeriod: (BudgetPeriod) -> Unit = {}, - onFinishBudgetEarly: () -> Unit = {}, - onSaveBudget: (BudgetSettings) -> Unit = {}, - onCommentUpdate: (String) -> Unit = {}, - onDeleteTag: (String) -> Unit = {}, - onRecurrentToggle: (Boolean) -> Unit = {}, - onCreditToggle: (Boolean) -> Unit = {}, - showCreditQuickToggleFeature: Boolean = false, - onDismissRecurrentDialog: () -> Unit = {}, - onDismissCreditCutoffDialog: () -> Unit = {}, - onRecurrentExpenseConfirm: (RecurrentFrequency, LocalDate, Int?) -> Unit = { _, _, _ -> }, - onCreditCutoffConfirm: (Int) -> Unit = {}, - showAnalyticsButton: Boolean = true, - showSettingsButton: Boolean = true, - budgetPillHintAnchorModifier: Modifier = Modifier, - analyticsHintAnchorModifier: Modifier = Modifier, - modifier: Modifier = Modifier, + uiState: BudgetUiState, + animState: AnimState, + onInputChange: (String) -> Unit = {}, + onFocus: () -> Unit, + onOpenHistory: () -> Unit, + onOpenSettings: () -> Unit, + onOpenAnalytics: () -> Unit = {}, + onOpenWallet: () -> Unit = {}, + openWalletOnStart: Boolean = false, + showBudgetPeriodSheet: Boolean = false, + forceBudgetPeriodSheetSetup: Boolean = false, + selectedViewPeriod: BudgetPeriod = BudgetPeriod.DAILY, + onShowBudgetPeriodSheet: () -> Unit = {}, + onHideBudgetPeriodSheet: () -> Unit = {}, + onPeriodSelected: (BudgetPeriod) -> Unit = {}, + onCommentClick: () -> Unit, + onBudgetPillClickForTutorial: () -> Unit = {}, + onAnalyticsClickForTutorial: () -> Unit = {}, + onChangePeriod: (BudgetPeriod) -> Unit = {}, + onFinishBudgetEarly: () -> Unit = {}, + onSaveBudget: (BudgetSettings) -> Unit = {}, + onCommentUpdate: (String) -> Unit = {}, + onDeleteTag: (String) -> Unit = {}, + onRecurrentToggle: (Boolean) -> Unit = {}, + onCreditToggle: (Boolean) -> Unit = {}, + showCreditQuickToggleFeature: Boolean = false, + onDismissRecurrentDialog: () -> Unit = {}, + onDismissCreditCutoffDialog: () -> Unit = {}, + onRecurrentExpenseConfirm: (RecurrentFrequency, LocalDate, Int?) -> Unit = { _, _, _ -> }, + onCreditCutoffConfirm: (Int) -> Unit = {}, + showAnalyticsButton: Boolean = true, + showSettingsButton: Boolean = true, + budgetPillHintAnchorModifier: Modifier = Modifier, + analyticsHintAnchorModifier: Modifier = Modifier, + modifier: Modifier = Modifier, ) { - val view = LocalView.current - val scope = rememberCoroutineScope() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - val editorFocusController = remember { FocusController() } - - if (uiState.showRecurrentDialog) { - RecurrentExpenseDialog( - budgetSettings = uiState.budgetSettings, - onDismiss = onDismissRecurrentDialog, - onConfirm = onRecurrentExpenseConfirm - ) - } - - if (uiState.showCreditCutoffDialog) { - CreditCutoffDayDialog( - onDismiss = onDismissCreditCutoffDialog, onConfirm = onCreditCutoffConfirm - ) - } - - Column( - modifier = modifier - .fillMaxSize() - .background(colorButton) - .statusBarsPadding() - .clickable( - interactionSource = remember { MutableInteractionSource() }, indication = null - ) { onFocus() }) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - BudgetPill( - budgetState = uiState.budgetState, - budgetSettings = uiState.budgetSettings, - viewPeriod = selectedViewPeriod, - currencyCode = uiState.budgetSettings?.currencyCode ?: "USD", - centerRemainingAmount = animState == AnimState.EDITING, - onOpenSettings = onOpenSettings, - onOpenBudgetSheet = { -// onBudgetPillClickForTutorial() - view.weakHapticFeedback() - onShowBudgetPeriodSheet() - }, - modifier = Modifier - .weight(1f) - .animateContentSize(animationSpec = tween(200)) - .padding(top = 8.dp, bottom = 8.dp, end = 8.dp) - .then(budgetPillHintAnchorModifier) - ) - - AnimatedContent( - targetState = animState == AnimState.EDITING, transitionSpec = { - slideInHorizontally(animationSpec = tween(200)) { it } + fadeIn(tween(200)) togetherWith slideOutHorizontally( - animationSpec = tween(200) - ) { -it } + fadeOut( - tween( - 200 - ) - ) - }, label = "topBarTrailingSwitch" - ) { isEditing -> - if (isEditing) { - Row(verticalAlignment = Alignment.CenterVertically) { - RecurrenceModeToggle( - isEnabled = uiState.isRecurrentEnabled, - onToggle = onRecurrentToggle, - icon = Icons.Rounded.EventRepeat, - contentDescription = "Recurrent payment", - ) - Spacer(modifier = Modifier.size(8.dp)) - if (showCreditQuickToggleFeature) { - RecurrenceModeToggle( - isEnabled = uiState.isCreditEnabled, - onToggle = onCreditToggle, - icon = Icons.Rounded.CreditCard, - contentDescription = "Credit card payment", - ) - } - } - } else { - Row(verticalAlignment = Alignment.CenterVertically) { - if (showAnalyticsButton) { - IconButton( - onClick = { - onAnalyticsClickForTutorial() - view.weakHapticFeedback() - onOpenAnalytics() - }, modifier = Modifier - .size(48.dp) - .then(analyticsHintAnchorModifier) - ) { - Icon( - imageVector = Icons.Rounded.BarChart, - contentDescription = "Analytics", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(28.dp), - ) - } - } - - if (showSettingsButton) { - IconButton( - onClick = { - onOpenSettings() - view.weakHapticFeedback() - }, modifier = Modifier.size(48.dp) - ) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = "Settings", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(28.dp), - ) - } - } - } - } - } - } - - AnimatedContent( - targetState = animState, transitionSpec = { - when (targetState) { - AnimState.EDITING -> fadeIn(tween(200)) togetherWith fadeOut(tween(200)) - AnimState.IDLE -> fadeIn(tween(300)) togetherWith fadeOut(tween(200)) - else -> fadeIn(tween(200)) togetherWith fadeOut(tween(200)) - } - }, label = "editorContent" - ) { state -> - when (state) { - AnimState.EDITING -> { - EditingContent( - input = uiState.numpadInput, - onInputChange = onInputChange, - currencyCode = uiState.budgetSettings?.currencyCode ?: "USD", - isCalculation = uiState.isCalculation, - tags = uiState.tags, - currentComment = uiState.currentComment, - onCommentUpdate = onCommentUpdate, - onDeleteTag = onDeleteTag, - editorFocusController = editorFocusController, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) - } - - AnimState.IDLE, AnimState.RESET -> { - IdleContent( - budgetState = uiState.budgetState, - currencyCode = uiState.budgetSettings?.currencyCode ?: "USD", - modifier = Modifier.fillMaxWidth() - ) - } - - else -> { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Text( - text = "Saving...", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - - if (showBudgetPeriodSheet) { - ModalBottomSheet( - onDismissRequest = onHideBudgetPeriodSheet, - sheetState = sheetState, - ) { - val shouldForceSetupMode = forceBudgetPeriodSheetSetup - logcat { - "Opening BudgetPeriodSheet: forceBudgetPeriodSheetSetup=$forceBudgetPeriodSheetSetup, hasBudgetSettings=${uiState.budgetSettings != null}, currentPeriodId=${uiState.currentPeriodId}, startInEditMode=$shouldForceSetupMode" - } - BudgetPeriodSheet( - budgetSettings = uiState.budgetSettings, - budgetState = uiState.budgetState, - selectedPeriod = selectedViewPeriod, - pendingExpensesCount = uiState.pendingExpensesForNextPeriod.size, - currencyCode = uiState.budgetSettings?.currencyCode ?: "USD", - startInEditMode = shouldForceSetupMode, - onPeriodSelected = { newPeriod -> - logcat { "BudgetPeriodSheet onPeriodSelected -> newPeriod=$newPeriod" } - onPeriodSelected(newPeriod) - }, - onSaveBudget = { newSettings -> - logcat { "BudgetPeriodSheet onSaveBudget -> $newSettings" } - onSaveBudget(newSettings) - scope.launch { sheetState.hide() } - onHideBudgetPeriodSheet() - }, - onEditBudget = { - // Re-enter the sheet in edit mode (force setup) - onShowBudgetPeriodSheet() - scope.launch { sheetState.hide() } - }, - onFinishEarly = { - onFinishBudgetEarly() - onOpenAnalytics() - scope.launch { sheetState.hide() } - onHideBudgetPeriodSheet() - }, - ) - } - } + val view = LocalView.current + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val editorFocusController = remember { FocusController() } + + if (uiState.showRecurrentDialog) { + RecurrentExpenseDialog( + budgetSettings = uiState.budgetSettings, + onDismiss = onDismissRecurrentDialog, + onConfirm = onRecurrentExpenseConfirm + ) + } + + if (uiState.showCreditCutoffDialog) { + CreditCutoffDayDialog( + onDismiss = onDismissCreditCutoffDialog, + onConfirm = onCreditCutoffConfirm + ) + } + + Column( + modifier = modifier + .fillMaxSize() + .background(colorButton) + .statusBarsPadding() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onFocus() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BudgetPill( + budgetState = uiState.budgetState, + budgetSettings = uiState.budgetSettings, + viewPeriod = selectedViewPeriod, + currencyCode = uiState.budgetSettings?.currencyCode ?: "USD", + centerRemainingAmount = animState == AnimState.EDITING, + onOpenSettings = onOpenSettings, + onOpenBudgetSheet = { +// onBudgetPillClickForTutorial() + view.weakHapticFeedback() + onShowBudgetPeriodSheet() + }, + modifier = Modifier + .weight(1f) + .animateContentSize(animationSpec = tween(200)) + .padding(top = 8.dp, bottom = 8.dp, end = 8.dp) + .then(budgetPillHintAnchorModifier) + ) + + AnimatedContent( + targetState = animState == AnimState.EDITING, + transitionSpec = { + slideInHorizontally(animationSpec = tween(200)) { it } + fadeIn(tween(200)) togetherWith slideOutHorizontally( + animationSpec = tween(200) + ) { -it } + fadeOut( + tween( + 200 + ) + ) + }, + label = "topBarTrailingSwitch" + ) { isEditing -> + if (isEditing) { + Row(verticalAlignment = Alignment.CenterVertically) { + RecurrenceModeToggle( + isEnabled = uiState.isRecurrentEnabled, + onToggle = onRecurrentToggle, + icon = Icons.Rounded.EventRepeat, + contentDescription = "Recurrent payment", + ) + Spacer(modifier = Modifier.size(8.dp)) + if (showCreditQuickToggleFeature) { + RecurrenceModeToggle( + isEnabled = uiState.isCreditEnabled, + onToggle = onCreditToggle, + icon = Icons.Rounded.CreditCard, + contentDescription = "Credit card payment", + ) + } + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showAnalyticsButton) { + IconButton( + onClick = { + onAnalyticsClickForTutorial() + view.weakHapticFeedback() + onOpenAnalytics() + }, + modifier = Modifier + .size(48.dp) + .then(analyticsHintAnchorModifier) + ) { + Icon( + imageVector = Icons.Rounded.BarChart, + contentDescription = "Analytics", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(28.dp), + ) + } + } + + if (showSettingsButton) { + IconButton( + onClick = { + onOpenSettings() + view.weakHapticFeedback() + }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(28.dp), + ) + } + } + } + } + } + } + + AnimatedContent( + targetState = animState, + transitionSpec = { + when (targetState) { + AnimState.EDITING -> fadeIn(tween(200)) togetherWith fadeOut(tween(200)) + AnimState.IDLE -> fadeIn(tween(300)) togetherWith fadeOut(tween(200)) + else -> fadeIn(tween(200)) togetherWith fadeOut(tween(200)) + } + }, + label = "editorContent" + ) { state -> + when (state) { + AnimState.EDITING -> { + EditingContent( + input = uiState.numpadInput, + onInputChange = onInputChange, + currencyCode = uiState.budgetSettings?.currencyCode ?: "USD", + isCalculation = uiState.isCalculation, + tags = uiState.tags, + currentComment = uiState.currentComment, + onCommentUpdate = onCommentUpdate, + onDeleteTag = onDeleteTag, + editorFocusController = editorFocusController, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + + AnimState.IDLE, AnimState.RESET -> { + IdleContent( + budgetState = uiState.budgetState, + currencyCode = uiState.budgetSettings?.currencyCode ?: "USD", + modifier = Modifier.fillMaxWidth() + ) + } + + else -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = "Saving...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + if (showBudgetPeriodSheet) { + ModalBottomSheet( + onDismissRequest = onHideBudgetPeriodSheet, + sheetState = sheetState, + ) { + val shouldForceSetupMode = forceBudgetPeriodSheetSetup + logcat { + "Opening BudgetPeriodSheet: forceBudgetPeriodSheetSetup=$forceBudgetPeriodSheetSetup, hasBudgetSettings=${uiState.budgetSettings != null}, currentPeriodId=${uiState.currentPeriodId}, startInEditMode=$shouldForceSetupMode" + } + BudgetPeriodSheet( + budgetSettings = uiState.budgetSettings, + budgetState = uiState.budgetState, + selectedPeriod = selectedViewPeriod, + pendingExpensesCount = uiState.pendingExpensesForNextPeriod.size, + currencyCode = uiState.budgetSettings?.currencyCode ?: "USD", + startInEditMode = shouldForceSetupMode, + onPeriodSelected = { newPeriod -> + logcat { "BudgetPeriodSheet onPeriodSelected -> newPeriod=$newPeriod" } + onPeriodSelected(newPeriod) + }, + onSaveBudget = { newSettings -> + logcat { "BudgetPeriodSheet onSaveBudget -> $newSettings" } + onSaveBudget(newSettings) + scope.launch { sheetState.hide() } + onHideBudgetPeriodSheet() + }, + onEditBudget = { + // Re-enter the sheet in edit mode (force setup) + onShowBudgetPeriodSheet() + scope.launch { sheetState.hide() } + }, + onFinishEarly = { + onFinishBudgetEarly() + onOpenAnalytics() + scope.launch { sheetState.hide() } + onHideBudgetPeriodSheet() + }, + ) + } + } } @Composable private fun EditingContent( - input: String, - onInputChange: (String) -> Unit, - currencyCode: String, - isCalculation: Boolean, - tags: List, - currentComment: String, - onCommentUpdate: (String) -> Unit, - onDeleteTag: (String) -> Unit, - editorFocusController: FocusController, - modifier: Modifier = Modifier + input: String, + onInputChange: (String) -> Unit, + currencyCode: String, + isCalculation: Boolean, + tags: List, + currentComment: String, + onCommentUpdate: (String) -> Unit, + onDeleteTag: (String) -> Unit, + editorFocusController: FocusController, + modifier: Modifier = Modifier ) { - val currencyFormat = symbolOnlyCurrencyFormat(currencyCode) - val currencySymbol = SupportedCurrency.findByCode(currencyCode)?.symbol ?: "$" - - val hasExpressionOperators = remember(input) { input.any { it in "+-×÷" } } - val showCalculationUi = hasExpressionOperators - - val calculationResult = remember(input, showCalculationUi) { - if (!showCalculationUi || input.isEmpty()) return@remember null - - val last = input.lastOrNull() - if (last != null && (last in "+-×÷" || last == '.')) { - null - } else { - evaluateCalculation(input) - } - } - - val displayContent = if (showCalculationUi) { - "$currencySymbol $input" - } else { - try { - val value = input.toBigDecimalOrNull() ?: BigDecimal.ZERO - currencyFormat.format(value) - } catch (e: Exception) { - input.ifEmpty { currencyFormat.format(BigDecimal.ZERO) } - } - } - - val baseTextStyle = MaterialTheme.typography.displayLargeCondensed.copy( - fontWeight = FontWeight.W500, - fontSize = 86.sp, - ) - - BoxWithConstraints( - modifier = modifier.fillMaxSize() - ) { - val density = LocalDensity.current - val availableWidth = maxWidth - 32.dp - val amountSlotHeight = 124.dp - val containerSizePx = remember(availableWidth, amountSlotHeight, density) { - with(density) { - IntSize( - width = availableWidth.toPx().toInt(), height = amountSlotHeight.toPx().toInt() - ) - } - } - - Column( - modifier = Modifier.fillMaxSize() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(amountSlotHeight) - .padding(start = 16.dp, end = 16.dp), contentAlignment = Alignment.TopEnd - ) { - AnimatedContent( - targetState = if (showCalculationUi && calculationResult != null) "result" else "input", - transitionSpec = { - (fadeIn(animationSpec = tween(200)) + slideInHorizontally( - animationSpec = tween(200) - ) { it / 4 }) togetherWith (fadeOut(animationSpec = tween(200)) + slideOutHorizontally( - animationSpec = tween(200) - ) { -it / 4 }) - }, - label = "EditorNumberTransition", - modifier = Modifier.fillMaxWidth() - ) { state -> - if (state == "result" && showCalculationUi && calculationResult != null) { - Column( - horizontalAlignment = Alignment.End, modifier = Modifier.fillMaxWidth() - ) { - AutoResizeBasicTextField( - value = displayContent, - onValueChange = {}, - readOnly = true, - modifier = Modifier.wrapContentWidth(Alignment.End), - textStyle = baseTextStyle.copy( - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.End - ), - singleLine = true, - minFontSize = 20.sp, - maxFontSize = 57.sp, - containerSize = containerSizePx - ) - AutoResizeBasicTextField( - value = "= ${currencySymbol}$calculationResult", - onValueChange = {}, - readOnly = true, - modifier = Modifier - .wrapContentWidth(Alignment.End) - .padding(top = 4.dp), - textStyle = baseTextStyle.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End - ), - singleLine = true, - minFontSize = 16.sp, - maxFontSize = 36.sp, - containerSize = containerSizePx - ) - } - } else { - AutoResizeBasicTextField( - value = displayContent, - onValueChange = {}, - readOnly = true, - modifier = Modifier.wrapContentWidth(Alignment.End), - textStyle = baseTextStyle.copy( - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.End - ), - singleLine = true, - containerSize = containerSizePx, - decorationBox = { innerTextField -> - Box { innerTextField() } - }) - } - } - } - - Spacer(modifier = Modifier.weight(1f)) - - CategoryToolbar( - tags = tags, - currentComment = currentComment, - stage = EditStage.EDIT_SPENT, - onCommentUpdate = onCommentUpdate, - onDeleteTag = onDeleteTag, - editorFocusController = editorFocusController, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 26.dp) - ) - } - } + val currencyFormat = symbolOnlyCurrencyFormat(currencyCode) + val currencySymbol = SupportedCurrency.findByCode(currencyCode)?.symbol ?: "$" + + val hasExpressionOperators = remember(input) { input.any { it in "+-×÷" } } + val showCalculationUi = hasExpressionOperators + + val calculationResult = remember(input, showCalculationUi) { + if (!showCalculationUi || input.isEmpty()) return@remember null + + val last = input.lastOrNull() + if (last != null && (last in "+-×÷" || last == '.')) { + null + } else { + evaluateCalculation(input) + } + } + + val displayContent = if (showCalculationUi) { + "$currencySymbol $input" + } else { + try { + val value = input.toBigDecimalOrNull() ?: BigDecimal.ZERO + currencyFormat.format(value) + } catch (e: Exception) { + input.ifEmpty { currencyFormat.format(BigDecimal.ZERO) } + } + } + + val baseTextStyle = MaterialTheme.typography.displayLargeCondensed.copy( + fontWeight = FontWeight.W500, + fontSize = 86.sp, + ) + + BoxWithConstraints( + modifier = modifier.fillMaxSize() + ) { + val density = LocalDensity.current + val availableWidth = maxWidth - 32.dp + val amountSlotHeight = 124.dp + val containerSizePx = remember(availableWidth, amountSlotHeight, density) { + with(density) { + IntSize( + width = availableWidth.toPx().toInt(), + height = amountSlotHeight.toPx().toInt() + ) + } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(amountSlotHeight) + .padding(start = 16.dp, end = 16.dp), + contentAlignment = Alignment.TopEnd + ) { + AnimatedContent( + targetState = if (showCalculationUi && calculationResult != null) "result" else "input", + transitionSpec = { + ( + fadeIn(animationSpec = tween(200)) + slideInHorizontally( + animationSpec = tween(200) + ) { it / 4 } + ) togetherWith ( + fadeOut(animationSpec = tween(200)) + slideOutHorizontally( + animationSpec = tween(200) + ) { -it / 4 } + ) + }, + label = "EditorNumberTransition", + modifier = Modifier.fillMaxWidth() + ) { state -> + if (state == "result" && showCalculationUi && calculationResult != null) { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.fillMaxWidth() + ) { + AutoResizeBasicTextField( + value = displayContent, + onValueChange = {}, + readOnly = true, + modifier = Modifier.wrapContentWidth(Alignment.End), + textStyle = baseTextStyle.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End + ), + singleLine = true, + minFontSize = 20.sp, + maxFontSize = 57.sp, + containerSize = containerSizePx + ) + AutoResizeBasicTextField( + value = "= ${currencySymbol}$calculationResult", + onValueChange = {}, + readOnly = true, + modifier = Modifier + .wrapContentWidth(Alignment.End) + .padding(top = 4.dp), + textStyle = baseTextStyle.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End + ), + singleLine = true, + minFontSize = 16.sp, + maxFontSize = 36.sp, + containerSize = containerSizePx + ) + } + } else { + AutoResizeBasicTextField( + value = displayContent, + onValueChange = {}, + readOnly = true, + modifier = Modifier.wrapContentWidth(Alignment.End), + textStyle = baseTextStyle.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End + ), + singleLine = true, + containerSize = containerSizePx, + decorationBox = { innerTextField -> + Box { innerTextField() } + } + ) + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + CategoryToolbar( + tags = tags, + currentComment = currentComment, + stage = EditStage.EDIT_SPENT, + onCommentUpdate = onCommentUpdate, + onDeleteTag = onDeleteTag, + editorFocusController = editorFocusController, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 26.dp) + ) + } + } } private fun evaluateCalculation(input: String): String? { - if (input.isBlank()) return null - - return try { - val normalized = input.trim().replace("×", "*").replace("÷", "/") - - normalized.lastOrNull()?.let { if (it in "+-*/") return null } - - val hasOperator = normalized.any { it in "+-*/" } - - if (!hasOperator) { - val num = normalized.toBigDecimalOrNull() ?: return null - return if (num.scale() <= 0 || num.stripTrailingZeros().scale() <= 0) { - num.toBigInteger().toString() - } else { - num.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString() - } - } - - val tokenPattern = Regex("([+\\-*/])") - val parts = tokenPattern.split(normalized).filter { it.isNotEmpty() } - val operators = tokenPattern.findAll(normalized).map { it.value }.toList() - - if (parts.isEmpty() || parts[0].isEmpty()) return null - - if (operators.size > parts.size - 1) return null - - var result = parts[0].toBigDecimalOrNull() ?: return null - - for (i in operators.indices) { - if (i + 1 >= parts.size) break - val operator = operators[i] - val nextNum = parts[i + 1].toBigDecimalOrNull() ?: return null - - result = when (operator) { - "+" -> result + nextNum - "-" -> result - nextNum - "*" -> result * nextNum - "/" -> { - if (nextNum.compareTo(BigDecimal.ZERO) == 0) return null - result.divide(nextNum, 2, java.math.RoundingMode.HALF_UP) - } - - else -> return null - } - } - - if (result.scale() <= 0 || result.stripTrailingZeros().scale() <= 0) { - result.toBigInteger().toString() - } else { - result.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString() - } - } catch (e: Exception) { - null - } + if (input.isBlank()) return null + + return try { + val normalized = input.trim().replace("×", "*").replace("÷", "/") + + normalized.lastOrNull()?.let { if (it in "+-*/") return null } + + val hasOperator = normalized.any { it in "+-*/" } + + if (!hasOperator) { + val num = normalized.toBigDecimalOrNull() ?: return null + return if (num.scale() <= 0 || num.stripTrailingZeros().scale() <= 0) { + num.toBigInteger().toString() + } else { + num.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString() + } + } + + val tokenPattern = Regex("([+\\-*/])") + val parts = tokenPattern.split(normalized).filter { it.isNotEmpty() } + val operators = tokenPattern.findAll(normalized).map { it.value }.toList() + + if (parts.isEmpty() || parts[0].isEmpty()) return null + + if (operators.size > parts.size - 1) return null + + var result = parts[0].toBigDecimalOrNull() ?: return null + + for (i in operators.indices) { + if (i + 1 >= parts.size) break + val operator = operators[i] + val nextNum = parts[i + 1].toBigDecimalOrNull() ?: return null + + result = when (operator) { + "+" -> result + nextNum + "-" -> result - nextNum + "*" -> result * nextNum + "/" -> { + if (nextNum.compareTo(BigDecimal.ZERO) == 0) return null + result.divide(nextNum, 2, java.math.RoundingMode.HALF_UP) + } + + else -> return null + } + } + + if (result.scale() <= 0 || result.stripTrailingZeros().scale() <= 0) { + result.toBigInteger().toString() + } else { + result.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString() + } + } catch (e: Exception) { + null + } } @Composable private fun RecurrenceModeToggle( - isEnabled: Boolean, - onToggle: (Boolean) -> Unit, - icon: ImageVector, - contentDescription: String, - modifier: Modifier = Modifier, + isEnabled: Boolean, + onToggle: (Boolean) -> Unit, + icon: ImageVector, + contentDescription: String, + modifier: Modifier = Modifier, ) { - val containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f) - val contentColor = MaterialTheme.colorScheme.tertiary - val selectedColor = contentColor.copy(alpha = 0.22f) - - Card( - modifier = modifier.height(50.dp), shape = CircleShape, colors = CardDefaults.cardColors( - containerColor = containerColor, - contentColor = contentColor, - ), onClick = { onToggle(!isEnabled) }) { - Box( - modifier = Modifier - .fillMaxHeight() - .padding(horizontal = 6.dp, vertical = 6.dp) - .clip(CircleShape) - .background(if (isEnabled) selectedColor else Color.Transparent) - .padding(horizontal = 14.dp, vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(24.dp) - ) - } - } + val containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f) + val contentColor = MaterialTheme.colorScheme.tertiary + val selectedColor = contentColor.copy(alpha = 0.22f) + + Card( + modifier = modifier.height(50.dp), + shape = CircleShape, + colors = CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor, + ), + onClick = { onToggle(!isEnabled) } + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 6.dp, vertical = 6.dp) + .clip(CircleShape) + .background(if (isEnabled) selectedColor else Color.Transparent) + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(24.dp) + ) + } + } } @Composable private fun CreditCutoffDayDialog( - onDismiss: () -> Unit, - onConfirm: (Int) -> Unit, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit, ) { - var cutoffDayInput by remember { mutableStateOf("15") } - val cutoffDay = cutoffDayInput.toIntOrNull() - val isValid = cutoffDay != null && cutoffDay in 1..31 - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(R.string.credit_cutoff_dialog_title), - style = MaterialTheme.typography.titleMediumEmphasized - ) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = stringResource(R.string.credit_cutoff_dialog_message)) - OutlinedTextField( - value = cutoffDayInput, - onValueChange = { value -> - cutoffDayInput = value.filter { it.isDigit() }.take(2) - }, - label = { Text(stringResource(R.string.credit_cutoff_dialog_label)) }, - singleLine = true, - isError = cutoffDayInput.isNotBlank() && !isValid, - ) - } - }, - confirmButton = { - Button(onClick = { cutoffDay?.let(onConfirm) }, enabled = isValid) { - Text( - stringResource(R.string.save), - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - stringResource(R.string.cancel), - style = MaterialTheme.typography.labelSmallEmphasized - ) - } - }) + var cutoffDayInput by remember { mutableStateOf("15") } + val cutoffDay = cutoffDayInput.toIntOrNull() + val isValid = cutoffDay != null && cutoffDay in 1..31 + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.credit_cutoff_dialog_title), + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.credit_cutoff_dialog_message)) + OutlinedTextField( + value = cutoffDayInput, + onValueChange = { value -> + cutoffDayInput = value.filter { it.isDigit() }.take(2) + }, + label = { Text(stringResource(R.string.credit_cutoff_dialog_label)) }, + singleLine = true, + isError = cutoffDayInput.isNotBlank() && !isValid, + ) + } + }, + confirmButton = { + Button(onClick = { cutoffDay?.let(onConfirm) }, enabled = isValid) { + Text( + stringResource(R.string.save), + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + stringResource(R.string.cancel), + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + ) } @Composable private fun IdleContent( - budgetState: BudgetState?, currencyCode: String, modifier: Modifier = Modifier + budgetState: BudgetState?, + currencyCode: String, + modifier: Modifier = Modifier ) { - val cursorVisible = remember { mutableStateOf(true) } - LaunchedEffect(Unit) { - while (true) { - delay(530) - cursorVisible.value = !cursorVisible.value - } - } - - Box( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - contentAlignment = Alignment.CenterEnd - ) { - Text( - text = if (cursorVisible.value) "|" else "", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - ) - } + val cursorVisible = remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + while (true) { + delay(530) + cursorVisible.value = !cursorVisible.value + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterEnd + ) { + Text( + text = if (cursorVisible.value) "|" else "", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } } @Preview(showBackground = true, device = "id:pixel_5", backgroundColor = 0xFF121212) @Composable fun EditorPreview_Idle() { - MinusTheme { - Editor( - uiState = BudgetUiState( - budgetSettings = BudgetSettings( - totalBudget = BigDecimal("500.00"), - period = BudgetPeriod.DAILY, - startDate = LocalDate.now(), - currencyCode = "USD" - ), budgetState = BudgetState( - remainingToday = BigDecimal("110.00"), - totalSpentToday = BigDecimal("12.50"), - dailyBudget = BigDecimal("122.50"), - daysRemaining = 15, - progress = 0.1f, - isOverBudget = false, - totalBudget = BigDecimal("500.00"), - totalSpentInPeriod = BigDecimal("12.50") - ), transactions = emptyList(), numpadInput = "", isNumpadValid = false - ), - animState = AnimState.IDLE, - onFocus = {}, - onOpenHistory = {}, - onOpenSettings = {}, - onCommentClick = {}, - onCommentUpdate = {}, - onDeleteTag = {}, - onRecurrentToggle = {}, - onCreditToggle = {}, - onDismissRecurrentDialog = {}, - onDismissCreditCutoffDialog = {}, - onRecurrentExpenseConfirm = { _, _, _ -> }, - onCreditCutoffConfirm = {}) - } + MinusTheme { + Editor( + uiState = BudgetUiState( + budgetSettings = BudgetSettings( + totalBudget = BigDecimal("500.00"), + period = BudgetPeriod.DAILY, + startDate = LocalDate.now(), + currencyCode = "USD" + ), + budgetState = BudgetState( + remainingToday = BigDecimal("110.00"), + totalSpentToday = BigDecimal("12.50"), + dailyBudget = BigDecimal("122.50"), + daysRemaining = 15, + progress = 0.1f, + isOverBudget = false, + totalBudget = BigDecimal("500.00"), + totalSpentInPeriod = BigDecimal("12.50") + ), + transactions = emptyList(), + numpadInput = "", + isNumpadValid = false + ), + animState = AnimState.IDLE, + onFocus = {}, + onOpenHistory = {}, + onOpenSettings = {}, + onCommentClick = {}, + onCommentUpdate = {}, + onDeleteTag = {}, + onRecurrentToggle = {}, + onCreditToggle = {}, + onDismissRecurrentDialog = {}, + onDismissCreditCutoffDialog = {}, + onRecurrentExpenseConfirm = { _, _, _ -> }, + onCreditCutoffConfirm = {} + ) + } } @Preview(showBackground = true, device = "id:Nexus One", backgroundColor = 0xFF121212) @Composable fun EditorPreview_Editing() { - MinusTheme { - Editor( - uiState = BudgetUiState( - budgetSettings = BudgetSettings( - totalBudget = BigDecimal("500.00"), - period = BudgetPeriod.DAILY, - startDate = LocalDate.now(), - currencyCode = "USD" - ), budgetState = BudgetState( - remainingToday = BigDecimal("110.00"), - totalSpentToday = BigDecimal("12.50"), - dailyBudget = BigDecimal("122.50"), - daysRemaining = 15, - progress = 0.1f, - isOverBudget = false, - totalBudget = BigDecimal("500.00"), - totalSpentInPeriod = BigDecimal("12.50") - ), transactions = emptyList(), numpadInput = "250", isNumpadValid = true - ), - animState = AnimState.EDITING, - onFocus = {}, - onOpenHistory = {}, - onOpenSettings = {}, - onCommentClick = {}, - onCommentUpdate = {}, - onDeleteTag = {}, - onRecurrentToggle = {}, - onCreditToggle = {}, - onDismissRecurrentDialog = {}, - onDismissCreditCutoffDialog = {}, - onRecurrentExpenseConfirm = { _, _, _ -> }, - onCreditCutoffConfirm = {}) - } + MinusTheme { + Editor( + uiState = BudgetUiState( + budgetSettings = BudgetSettings( + totalBudget = BigDecimal("500.00"), + period = BudgetPeriod.DAILY, + startDate = LocalDate.now(), + currencyCode = "USD" + ), + budgetState = BudgetState( + remainingToday = BigDecimal("110.00"), + totalSpentToday = BigDecimal("12.50"), + dailyBudget = BigDecimal("122.50"), + daysRemaining = 15, + progress = 0.1f, + isOverBudget = false, + totalBudget = BigDecimal("500.00"), + totalSpentInPeriod = BigDecimal("12.50") + ), + transactions = emptyList(), + numpadInput = "250", + isNumpadValid = true + ), + animState = AnimState.EDITING, + onFocus = {}, + onOpenHistory = {}, + onOpenSettings = {}, + onCommentClick = {}, + onCommentUpdate = {}, + onDeleteTag = {}, + onRecurrentToggle = {}, + onCreditToggle = {}, + onDismissRecurrentDialog = {}, + onDismissCreditCutoffDialog = {}, + onRecurrentExpenseConfirm = { _, _, _ -> }, + onCreditCutoffConfirm = {} + ) + } } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/History.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/History.kt deleted file mode 100644 index d2eccc5..0000000 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/History.kt +++ /dev/null @@ -1,247 +0,0 @@ -package com.serranoie.app.minus.presentation.ui.history - -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.unit.dp -import com.serranoie.app.minus.R -import com.serranoie.app.minus.domain.model.Transaction -import com.serranoie.app.minus.presentation.ui.theme.component.expense.NoTransactionsView -import com.serranoie.app.minus.presentation.util.symbolOnlyCurrencyFormat -import java.time.LocalDate - -enum class RecurrentPaymentsViewMode { - HORIZONTAL_LIST, VERTICAL_LIST; - - companion object { - fun fromName(value: String?): RecurrentPaymentsViewMode = runCatching { - value?.let(::valueOf) - }.getOrNull() ?: HORIZONTAL_LIST - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun History( - uiState: HistoryUiState, - modifier: Modifier = Modifier, - readOnly: Boolean = false, - onQueueDeleteWithUndo: (transaction: Transaction, message: String, onUndo: () -> Unit) -> Unit = { _, _, _ -> }, - onCancelPendingDelete: () -> Unit = {}, - onShowInfoSnackbar: (message: String) -> Unit = {}, - onProcessIntent: (HistoryUiIntent) -> Unit = {}, -) { - val resources = LocalResources.current - val scrollState = rememberLazyListState() - val currencyCode = uiState.budgetSettings?.currencyCode ?: "USD" - val currencyFormat = remember(currencyCode) { symbolOnlyCurrencyFormat(currencyCode) } - - LaunchedEffect(uiState.groupedCurrentTransactions.keys, readOnly, onProcessIntent) { - val sortedDates = uiState.groupedCurrentTransactions.keys.filterNotNull().sortedDescending() - val current = uiState.expandedDates - if (current.isEmpty()) { - onProcessIntent( - HistoryUiIntent.ToggleExpandedDate( - sortedDates.firstOrNull() ?: return@LaunchedEffect - ) - ) - } - } - - Box(modifier = modifier.fillMaxSize()) { - LazyColumn( - state = scrollState, - modifier = Modifier - .fillMaxSize() - .animateContentSize() - .statusBarsPadding() - .padding(horizontal = 16.dp), - ) { - budgetDisplaySection( - budgetState = uiState.budgetState, - budgetSettings = uiState.budgetSettings, - currencyCode = currencyCode, - ) - - currentPeriodRecurrentSection( - upcomingRecurrentInPeriod = uiState.upcomingRecurrentInPeriod, - showUpcomingRecurrentInPeriod = uiState.showUpcomingRecurrentInPeriod, - onToggleShowUpcomingRecurrentInPeriod = { - onProcessIntent( - HistoryUiIntent.ToggleUpcomingRecurrentInPeriod(!uiState.showUpcomingRecurrentInPeriod) - ) - }, - recurrentPaymentsViewMode = uiState.recurrentPaymentsViewMode, - currencyFormat = currencyFormat, - onDelete = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToDelete(tx)) }, - onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(tx)) }, - onClick = { tx -> onProcessIntent(HistoryUiIntent.SetSelectedTransaction(tx)) }, - ) - - transactionDateSections( - groupedTransactions = uiState.groupedCurrentTransactions, - expandedDates = uiState.expandedDates, - deletingTransactionIds = uiState.pendingRemovedTransactions.keys, - currencyCode = currencyCode, - currencyFormat = currencyFormat, - readOnly = readOnly, - keyPrefix = "date", - onToggleDate = { date -> onProcessIntent(HistoryUiIntent.ToggleExpandedDate(date)) }, - onDelete = { tx -> - onQueueDeleteWithUndo( - tx, - resources.getString( - R.string.expense_deleted_format, - tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } - ), - ) { - onCancelPendingDelete() - } - onProcessIntent(HistoryUiIntent.DeleteTransaction(tx)) - }, - onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetEditingTransaction(tx)) }, - onClick = { tx -> onProcessIntent(HistoryUiIntent.SetSelectedTransaction(tx)) }, - ) - - futureRecurrentSection( - futureRecurrentOutOfPeriod = uiState.futureRecurrentOutOfPeriod, - showOutOfPeriodSubscriptions = uiState.showOutOfPeriodSubscriptions, - onToggleShowOutOfPeriodSubscriptions = { - onProcessIntent( - HistoryUiIntent.ToggleOutOfPeriodSubscriptions(!uiState.showOutOfPeriodSubscriptions) - ) - }, - recurrentPaymentsViewMode = uiState.recurrentPaymentsViewMode, - currencyFormat = currencyFormat, - onDelete = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToDelete(tx)) }, - onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(tx)) }, - onClick = { tx -> onProcessIntent(HistoryUiIntent.SetSelectedTransaction(tx)) }, - ) - - pastPeriodToggleSection( - groupedPastTransactions = uiState.groupedPastTransactions, - showPastPeriod = uiState.showPastPeriod, - onToggleShowPastPeriod = { - onProcessIntent(HistoryUiIntent.TogglePastPeriod(!uiState.showPastPeriod)) - }, - ) - - pastTransactionDateSections( - showPastPeriod = uiState.showPastPeriod, - groupedPastTransactions = uiState.groupedPastTransactions, - expandedDates = uiState.expandedDates, - deletingTransactionIds = uiState.pendingRemovedTransactions.keys, - currencyCode = currencyCode, - currencyFormat = currencyFormat, - readOnly = readOnly, - onToggleDate = { date -> onProcessIntent(HistoryUiIntent.ToggleExpandedDate(date)) }, - onDelete = { tx -> - onQueueDeleteWithUndo( - tx, - resources.getString( - R.string.expense_deleted_format, - tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } - ), - ) { - onCancelPendingDelete() - } - onProcessIntent(HistoryUiIntent.DeleteTransaction(tx)) - }, - onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetEditingTransaction(tx)) }, - onClick = { tx -> onProcessIntent(HistoryUiIntent.SetSelectedTransaction(tx)) }, - ) - - item("spacer-bottom") { - Spacer(modifier = Modifier.height(32.dp)) - } - } - - if (uiState.transactions.isEmpty()) { - NoTransactionsView( - modifier = Modifier - .align(Alignment.Center) - .padding(32.dp), - ) - } - - TransactionDetailDialog( - transaction = uiState.selectedTransaction, - currencyFormat = currencyFormat, - readOnly = readOnly, - isDismissingTransactionDialog = uiState.isDismissingTransactionDialog, - onDismissStart = { onProcessIntent(HistoryUiIntent.SetDismissingDialog(true)) }, - onDismiss = { onProcessIntent(HistoryUiIntent.SetSelectedTransaction(null)) }, - onMarkAsPaid = { onProcessIntent(HistoryUiIntent.SetSelectedTransaction(null)) }, - onEdit = { tx -> - onProcessIntent(HistoryUiIntent.SetSelectedTransaction(null)) - onProcessIntent(HistoryUiIntent.SetEditingTransaction(tx)) - }, - onDelete = { tx -> - onProcessIntent(HistoryUiIntent.SetSelectedTransaction(null)) - if (tx.isRecurrent) { - onProcessIntent(HistoryUiIntent.SetRecurrentToDelete(tx)) - } else { - onQueueDeleteWithUndo( - tx, - resources.getString( - R.string.expense_deleted_format, - tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } - ), - ) { onCancelPendingDelete() } - onProcessIntent(HistoryUiIntent.DeleteTransaction(tx)) - } - }, - ) - } - - TransactionEditDialog( - transaction = uiState.editingTransaction, - budgetStartDate = uiState.budgetSettings?.startDate ?: LocalDate.now().minusDays(30), - budgetEndDate = uiState.budgetSettings?.getPeriodEndDate() ?: LocalDate.now(), - currencyCode = currencyCode, - onCancel = { onProcessIntent(HistoryUiIntent.SetEditingTransaction(null)) }, - onSave = { tx -> - onProcessIntent(HistoryUiIntent.SaveEditedTransaction(tx)) - onShowInfoSnackbar( - resources.getString( - R.string.expense_modified_format, - tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } - ) - ) - onProcessIntent(HistoryUiIntent.SetEditingTransaction(null)) - }, - ) - - DeleteRecurrentExpenseDialog( - transaction = uiState.recurrentToDelete.takeIf { uiState.showDeleteRecurrentDialog }, - onDismiss = { onProcessIntent(HistoryUiIntent.DismissDeleteRecurrentDialog) }, - onConfirm = { tx -> - onProcessIntent(HistoryUiIntent.ConfirmDeleteRecurrent(tx)) - }, - ) - - TransactionEditDialog( - transaction = uiState.recurrentToEdit, - budgetStartDate = uiState.budgetSettings?.startDate ?: LocalDate.now().minusDays(30), - budgetEndDate = uiState.budgetSettings?.getPeriodEndDate() ?: LocalDate.now(), - currencyCode = currencyCode, - onCancel = { onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(null)) }, - onSave = { tx -> - onProcessIntent(HistoryUiIntent.SaveEditedTransaction(tx)) - onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(null)) - }, - ) -} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryCalculations.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryCalculations.kt index 708bbaa..528154e 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryCalculations.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryCalculations.kt @@ -6,192 +6,195 @@ import com.serranoie.app.minus.presentation.ui.theme.component.expense.UpcomingR import java.time.LocalDate internal fun buildDisplayTransactions( - transactions: List, - pendingRemovedTransactions: Map, + transactions: List, + pendingRemovedTransactions: Map, ): List = transactions + pendingRemovedTransactions.values.filterNot { pending -> - transactions.any { it.id == pending.id } + transactions.any { it.id == pending.id } } internal fun splitPeriodTransactions( - transactions: List, - budgetStartDate: LocalDate, - budgetEndDate: LocalDate, - currentPeriodStartedAtMillis: Long, - currentPeriodId: Long, - previousPeriodId: Long, + transactions: List, + budgetStartDate: LocalDate, + budgetEndDate: LocalDate, + currentPeriodStartedAtMillis: Long, + currentPeriodId: Long, + previousPeriodId: Long, ): Pair, List> { - val sorted = transactions.sortedByDescending { it.date } - val current = sorted.filter { transaction -> - if (currentPeriodId > 0L && transaction.periodId > 0L) { - return@filter transaction.periodId == currentPeriodId - } - val txDate = transaction.date?.toLocalDate() ?: return@filter false - if (txDate.isBefore(budgetStartDate) || txDate.isAfter(budgetEndDate)) { - return@filter false - } - if (txDate.isEqual(budgetStartDate) && currentPeriodStartedAtMillis > 0L) { - return@filter transaction.createdAt >= currentPeriodStartedAtMillis - } - true - } - val past = sorted.filter { transaction -> - if (currentPeriodId > 0L && transaction.periodId > 0L) { - return@filter transaction.periodId == previousPeriodId - } - val txDate = transaction.date?.toLocalDate() ?: return@filter false - txDate.isBefore(budgetStartDate) || (txDate.isEqual(budgetStartDate) && currentPeriodStartedAtMillis > 0L && transaction.createdAt < currentPeriodStartedAtMillis) - } - return current to past + val sorted = transactions.sortedByDescending { it.date } + val current = sorted.filter { transaction -> + if (currentPeriodId > 0L && transaction.periodId > 0L) { + return@filter transaction.periodId == currentPeriodId + } + val txDate = transaction.date?.toLocalDate() ?: return@filter false + if (txDate.isBefore(budgetStartDate) || txDate.isAfter(budgetEndDate)) { + return@filter false + } + if (txDate.isEqual(budgetStartDate) && currentPeriodStartedAtMillis > 0L) { + return@filter transaction.createdAt >= currentPeriodStartedAtMillis + } + true + } + val past = sorted.filter { transaction -> + if (currentPeriodId > 0L && transaction.periodId > 0L) { + return@filter transaction.periodId == previousPeriodId + } + val txDate = transaction.date?.toLocalDate() ?: return@filter false + txDate.isBefore(budgetStartDate) || (txDate.isEqual(budgetStartDate) && currentPeriodStartedAtMillis > 0L && transaction.createdAt < currentPeriodStartedAtMillis) + } + return current to past } internal fun buildUpcomingRecurrentItems( - transactions: List, - budgetStartDate: LocalDate, - budgetEndDate: LocalDate, - today: LocalDate, + transactions: List, + budgetStartDate: LocalDate, + budgetEndDate: LocalDate, + today: LocalDate, ): Pair, List> { - val recurrentTransactions = transactions.filter { it.isRecurrent } - - val upcomingInPeriod = recurrentTransactions.mapNotNull { transaction -> - val savedDate = transaction.date?.toLocalDate() - val nextDate = calculateNextChargeDate(transaction, today) ?: savedDate?.takeIf { date -> - date.isAfter(today) - } - nextDate?.let { date -> - if (!date.isBefore(budgetStartDate) && !date.isAfter(budgetEndDate)) { - UpcomingRecurrentItem( - transaction = transaction, - nextChargeDate = date, - isInCurrentPeriod = true, - ) - } else { - null - } - } - }.distinctBy { it.transaction.id }.sortedBy { it.nextChargeDate } - - val futureOutOfPeriod = recurrentTransactions.mapNotNull { transaction -> - calculateNextChargeDate(transaction, today)?.let { nextDate -> - if (nextDate.isAfter(budgetEndDate)) { - UpcomingRecurrentItem( - transaction = transaction, - nextChargeDate = nextDate, - isInCurrentPeriod = false, - ) - } else { - null - } - } - }.sortedBy { it.nextChargeDate } - - return upcomingInPeriod to futureOutOfPeriod + val recurrentTransactions = transactions.filter { it.isRecurrent } + + val upcomingInPeriod = recurrentTransactions.mapNotNull { transaction -> + val savedDate = transaction.date?.toLocalDate() + val nextDate = calculateNextChargeDate(transaction, today) ?: savedDate?.takeIf { date -> + date.isAfter(today) + } + nextDate?.let { date -> + if (!date.isBefore(budgetStartDate) && !date.isAfter(budgetEndDate)) { + UpcomingRecurrentItem( + transaction = transaction, + nextChargeDate = date, + isInCurrentPeriod = true, + ) + } else { + null + } + } + }.distinctBy { it.transaction.id }.sortedBy { it.nextChargeDate } + + val futureOutOfPeriod = recurrentTransactions.mapNotNull { transaction -> + calculateNextChargeDate(transaction, today)?.let { nextDate -> + if (nextDate.isAfter(budgetEndDate)) { + UpcomingRecurrentItem( + transaction = transaction, + nextChargeDate = nextDate, + isInCurrentPeriod = false, + ) + } else { + null + } + } + }.sortedBy { it.nextChargeDate } + + return upcomingInPeriod to futureOutOfPeriod } internal fun buildGroupedCurrentTransactions( - currentPeriodTransactions: List, - displayTransactions: List, - budgetStartDate: LocalDate, - budgetEndDate: LocalDate, - today: LocalDate, + currentPeriodTransactions: List, + displayTransactions: List, + budgetStartDate: LocalDate, + budgetEndDate: LocalDate, + today: LocalDate, ): Map> { - val regularTransactions = currentPeriodTransactions.filterNot { it.isRecurrent } - val recurrentCharges = displayTransactions.filter { it.isRecurrent && !it.isDeleted } - .flatMap { transaction -> - getRecurringChargesInPeriod(transaction, budgetStartDate, budgetEndDate, today) - } + val regularTransactions = currentPeriodTransactions.filterNot { it.isRecurrent } + val recurrentCharges = displayTransactions.filter { it.isRecurrent && !it.isDeleted } + .flatMap { transaction -> + getRecurringChargesInPeriod(transaction, budgetStartDate, budgetEndDate, today) + } - return groupTransactionsByDate(regularTransactions + recurrentCharges) + return groupTransactionsByDate(regularTransactions + recurrentCharges) } internal fun groupTransactionsByDate( - transactions: List, + transactions: List, ): Map> = transactions.groupBy { it.date?.toLocalDate() } - .toSortedMap(compareByDescending { it }) + .toSortedMap(compareByDescending { it }) internal fun calculateNextChargeDate(transaction: Transaction, today: LocalDate): LocalDate? { - if (!transaction.isRecurrent) { - return null - } - - val frequency = transaction.recurrentFrequency ?: return null - val startDate = transaction.date?.toLocalDate() ?: return null - val endDate = transaction.recurrentEndDate?.toLocalDate() - - if (endDate != null && today.isAfter(endDate)) { - return null - } - - return when (frequency) { - RecurrentFrequency.WEEKLY -> { - var nextDate = startDate - while (!nextDate.isAfter(today)) { - nextDate = nextDate.plusWeeks(1) - } - if (endDate == null || !nextDate.isAfter(endDate)) nextDate else null - } - - RecurrentFrequency.BIWEEKLY -> { - var nextDate = startDate - while (!nextDate.isAfter(today)) { - nextDate = nextDate.plusWeeks(2) - } - if (endDate == null || !nextDate.isAfter(endDate)) nextDate else null - } - - RecurrentFrequency.MONTHLY -> { - val billingDay = transaction.subscriptionDay ?: startDate.dayOfMonth - var nextDate = today.withDayOfMonth(billingDay.coerceAtMost(today.lengthOfMonth())) - - if (today.dayOfMonth >= billingDay) { - nextDate = nextDate.plusMonths(1) - val maxDay = nextDate.lengthOfMonth() - if (billingDay > maxDay) { - nextDate = nextDate.withDayOfMonth(maxDay) - } - } - - if (endDate != null && nextDate.isAfter(endDate)) null else nextDate - } - } + if (!transaction.isRecurrent) { + return null + } + + val frequency = transaction.recurrentFrequency ?: return null + val startDate = transaction.date?.toLocalDate() ?: return null + val endDate = transaction.recurrentEndDate?.toLocalDate() + + if (endDate != null && today.isAfter(endDate)) { + return null + } + + return when (frequency) { + RecurrentFrequency.WEEKLY -> { + var nextDate = startDate + while (!nextDate.isAfter(today)) { + nextDate = nextDate.plusWeeks(1) + } + if (endDate == null || !nextDate.isAfter(endDate)) nextDate else null + } + + RecurrentFrequency.BIWEEKLY -> { + var nextDate = startDate + while (!nextDate.isAfter(today)) { + nextDate = nextDate.plusWeeks(2) + } + if (endDate == null || !nextDate.isAfter(endDate)) nextDate else null + } + + RecurrentFrequency.MONTHLY -> { + val billingDay = transaction.subscriptionDay ?: startDate.dayOfMonth + var nextDate = today.withDayOfMonth(billingDay.coerceAtMost(today.lengthOfMonth())) + + if (today.dayOfMonth >= billingDay) { + nextDate = nextDate.plusMonths(1) + val maxDay = nextDate.lengthOfMonth() + if (billingDay > maxDay) { + nextDate = nextDate.withDayOfMonth(maxDay) + } + } + + if (endDate != null && nextDate.isAfter(endDate)) null else nextDate + } + } } internal fun getRecurringChargesInPeriod( - transaction: Transaction, - periodStart: LocalDate, - periodEnd: LocalDate, - today: LocalDate, + transaction: Transaction, + periodStart: LocalDate, + periodEnd: LocalDate, + today: LocalDate, ): List { - val frequency = transaction.recurrentFrequency ?: return emptyList() - val originalDateTime = transaction.date ?: return emptyList() - val startDate = originalDateTime.toLocalDate() - val originalTime = originalDateTime.toLocalTime() - val subscriptionEnd = transaction.recurrentEndDate?.toLocalDate() ?: periodEnd.plusMonths(1) - - val virtualTransactions = mutableListOf() - var chargeDate = startDate - - while (!chargeDate.isAfter(subscriptionEnd)) { - if (!chargeDate.isBefore(periodStart) && !chargeDate.isAfter(periodEnd) && !chargeDate.isAfter(today)) { - virtualTransactions.add( - transaction.copy( - date = chargeDate.atTime(originalTime), - id = transaction.id * 1000000 + chargeDate.toEpochDay(), - sourceTransactionId = transaction.sourceTransactionId ?: transaction.id, - ) - ) - } - - chargeDate = when (frequency) { - RecurrentFrequency.WEEKLY -> chargeDate.plusWeeks(1) - RecurrentFrequency.BIWEEKLY -> chargeDate.plusWeeks(2) - RecurrentFrequency.MONTHLY -> { - val billingDay = transaction.subscriptionDay ?: startDate.dayOfMonth - val nextMonth = chargeDate.plusMonths(1) - val maxDay = nextMonth.lengthOfMonth() - nextMonth.withDayOfMonth(billingDay.coerceAtMost(maxDay)) - } - } - } - - return virtualTransactions + val frequency = transaction.recurrentFrequency ?: return emptyList() + val originalDateTime = transaction.date ?: return emptyList() + val startDate = originalDateTime.toLocalDate() + val originalTime = originalDateTime.toLocalTime() + val subscriptionEnd = transaction.recurrentEndDate?.toLocalDate() ?: periodEnd.plusMonths(1) + + val virtualTransactions = mutableListOf() + var chargeDate = startDate + + while (!chargeDate.isAfter(subscriptionEnd)) { + if (!chargeDate.isBefore(periodStart) && !chargeDate.isAfter(periodEnd) && !chargeDate.isAfter( + today + ) + ) { + virtualTransactions.add( + transaction.copy( + date = chargeDate.atTime(originalTime), + id = transaction.id * 1000000 + chargeDate.toEpochDay(), + sourceTransactionId = transaction.sourceTransactionId ?: transaction.id, + ) + ) + } + + chargeDate = when (frequency) { + RecurrentFrequency.WEEKLY -> chargeDate.plusWeeks(1) + RecurrentFrequency.BIWEEKLY -> chargeDate.plusWeeks(2) + RecurrentFrequency.MONTHLY -> { + val billingDay = transaction.subscriptionDay ?: startDate.dayOfMonth + val nextMonth = chargeDate.plusMonths(1) + val maxDay = nextMonth.lengthOfMonth() + nextMonth.withDayOfMonth(billingDay.coerceAtMost(maxDay)) + } + } + } + + return virtualTransactions } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryDialogs.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryDialogs.kt deleted file mode 100644 index 613e3ff..0000000 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryDialogs.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.serranoie.app.minus.presentation.ui.history - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.serranoie.app.minus.R -import com.serranoie.app.minus.domain.model.RecurrentFrequency -import com.serranoie.app.minus.domain.model.Transaction -import com.serranoie.app.minus.presentation.ui.theme.bodyMediumCondensed -import com.serranoie.app.minus.presentation.util.prettyDate -import java.text.NumberFormat -import java.time.LocalDate - -@Composable -internal fun TransactionDetailDialog( - transaction: Transaction?, - currencyFormat: NumberFormat, - readOnly: Boolean, - isDismissingTransactionDialog: Boolean, - onDismissStart: () -> Unit, - onDismiss: () -> Unit, - onMarkAsPaid: () -> Unit, - onEdit: (Transaction) -> Unit, - onDelete: (Transaction) -> Unit, -) { - if (transaction == null) return - - val transactionDateText = transaction.date?.let { date -> - prettyDate(date, showTime = true, forceHideDate = false, human = true) - } ?: "Sin fecha" - val recurrenceLabel = when (transaction.recurrentFrequency) { - RecurrentFrequency.WEEKLY -> "Semanal" - RecurrentFrequency.BIWEEKLY -> "Quincenal" - RecurrentFrequency.MONTHLY -> "Mensual" - null -> "" - } - - val details = buildList { - add("Descripción" to transaction.comment.ifEmpty { "Sin nombre" }) - add("Fecha" to transactionDateText) - if (transaction.isRecurrent && recurrenceLabel.isNotEmpty()) { - add("Frecuencia" to recurrenceLabel) - } - transaction.subscriptionDay?.let { day -> - if (transaction.isRecurrent) { - add("Día de cobro" to "Día $day") - } - } - transaction.recurrentEndDate?.let { endDate -> - if (transaction.isRecurrent) { - add( - "Fin recurrencia" to prettyDate( - endDate, - showTime = false, - forceHideDate = false, - human = true, - ) - ) - } - } - } - - AnimatedVisibility( - visible = true, - enter = EnterTransition.None, - exit = ExitTransition.None, - ) { - Box(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - if (!isDismissingTransactionDialog) { - onDismissStart() - onDismiss() - } - }, - ) - TransactionDetailTicketCard( - transaction = transaction, - totalAmountText = currencyFormat.format(transaction.amount), - details = details, - onMarkAsPaid = onMarkAsPaid, - onEdit = { onEdit(transaction) }, - onDelete = { onDelete(transaction) }, - readOnly = readOnly, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .align(Alignment.Center), - ) - } - } -} - -@Composable -internal fun TransactionEditDialog( - transaction: Transaction?, - budgetStartDate: LocalDate, - budgetEndDate: LocalDate, - currencyCode: String, - onCancel: () -> Unit, - onSave: (Transaction) -> Unit, -) { - if (transaction == null) return - - Dialog( - onDismissRequest = onCancel, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnBackPress = true, - dismissOnClickOutside = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - TransactionEditScreen( - transaction = transaction, - budgetStartDate = budgetStartDate, - budgetEndDate = budgetEndDate, - currencyCode = currencyCode, - onCancel = onCancel, - onSave = { newAmount, newComment, newDateTime, newIsRecurrent, newFrequency, newEndDate, newSubscriptionDay -> - val updatedTransaction = transaction.copy( - id = transaction.sourceTransactionId ?: transaction.id, - amount = newAmount, - comment = newComment, - date = newDateTime, - isRecurrent = newIsRecurrent, - recurrentFrequency = newFrequency, - recurrentEndDate = newEndDate?.atStartOfDay(), - subscriptionDay = newSubscriptionDay, - sourceTransactionId = null, - ) - onSave(updatedTransaction) - }, - ) - } - } -} - -@Composable -internal fun DeleteRecurrentExpenseDialog( - transaction: Transaction?, - onDismiss: () -> Unit, - onConfirm: (Transaction) -> Unit, -) { - if (transaction == null) return - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - stringResource(R.string.delete_recurrent_expense_title), - style = MaterialTheme.typography.titleLargeEmphasized, - ) - }, - text = { - Column { - val recurrentExpenseName = transaction.comment.ifEmpty { - stringResource(R.string.delete_recurrent_expense_fallback_name) - } - Text( - text = stringResource( - R.string.delete_recurrent_expense_message, - recurrentExpenseName, - ), - style = MaterialTheme.typography.bodyMediumCondensed, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.delete_recurrent_expense_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = { - Button( - onClick = { onConfirm(transaction) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - ) { - Text( - stringResource(R.string.delete), - style = MaterialTheme.typography.labelMediumEmphasized, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - text = stringResource(R.string.cancel), - style = MaterialTheme.typography.labelMediumEmphasized, - ) - } - }, - ) -} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryScreen.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryScreen.kt index 7725100..e9bc8a7 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryScreen.kt @@ -1,15 +1,67 @@ package com.serranoie.app.minus.presentation.ui.history +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.serranoie.app.minus.R import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.history.dialogs.DeleteRecurrentExpenseDialog +import com.serranoie.app.minus.presentation.ui.history.dialogs.TransactionDetailDialog +import com.serranoie.app.minus.presentation.ui.history.dialogs.TransactionEditDialog +import com.serranoie.app.minus.presentation.ui.history.sections.budgetDisplaySection +import com.serranoie.app.minus.presentation.ui.history.sections.currentPeriodRecurrentSection +import com.serranoie.app.minus.presentation.ui.history.sections.futureRecurrentSection +import com.serranoie.app.minus.presentation.ui.history.sections.pastPeriodToggleSection +import com.serranoie.app.minus.presentation.ui.history.sections.pastTransactionDateSections +import com.serranoie.app.minus.presentation.ui.history.sections.transactionDateSections +import com.serranoie.app.minus.presentation.ui.theme.component.expense.NoTransactionsView +import com.serranoie.app.minus.presentation.util.symbolOnlyCurrencyFormat +import java.time.LocalDate +/** + * View-mode for the recurrent payments section. Lives at the top of + * the history feature so it can be referenced by the section + * composables and by the settings dialog without an import cycle. + */ +enum class RecurrentPaymentsViewMode { + HORIZONTAL_LIST, VERTICAL_LIST; + + companion object { + fun fromName(value: String?): RecurrentPaymentsViewMode = runCatching { + value?.let(::valueOf) + }.getOrNull() ?: HORIZONTAL_LIST + } +} + +/** + * Public entry point for the History screen. Wires up [HistoryViewModel] + * (collects UI state, observes one-shot effects) and delegates the + * actual rendering to the stateless [History] composable below. + * + * E2E tests and Previews bypass this and call [History] directly with + * a hand-built [HistoryUiState]. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HistoryScreen( - modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier, + modifier: Modifier = Modifier, readOnly: Boolean = false, onQueueDeleteWithUndo: (transaction: Transaction, message: String, onUndo: () -> Unit) -> Unit = { _, _, _ -> }, onCancelPendingDelete: () -> Unit = {}, @@ -36,3 +88,225 @@ fun HistoryScreen( onProcessIntent = viewModel::processIntent, ) } + +/** + * Stateless body of the History screen — the LazyColumn with all + * sections, plus the transaction-detail / edit / delete-recurrent + * dialogs. + * + * Takes a pre-built [uiState] and an [onProcessIntent] callback so + * it can be driven by a real [HistoryViewModel] (in production) or + * by hand-crafted state in tests / previews. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun History( + uiState: HistoryUiState, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + onQueueDeleteWithUndo: (transaction: Transaction, message: String, onUndo: () -> Unit) -> Unit = { _, _, _ -> }, + onCancelPendingDelete: () -> Unit = {}, + onShowInfoSnackbar: (message: String) -> Unit = {}, + onProcessIntent: (HistoryUiIntent) -> Unit = {}, +) { + val resources = LocalResources.current + val scrollState = rememberLazyListState() + val currencyCode = uiState.budgetSettings?.currencyCode ?: "USD" + val currencyFormat = remember(currencyCode) { symbolOnlyCurrencyFormat(currencyCode) } + + LaunchedEffect(uiState.groupedCurrentTransactions.keys, readOnly, onProcessIntent) { + val sortedDates = uiState.groupedCurrentTransactions.keys.filterNotNull().sortedDescending() + val current = uiState.expandedDates + if (current.isEmpty()) { + onProcessIntent( + HistoryUiIntent.ToggleExpandedDate( + sortedDates.firstOrNull() ?: return@LaunchedEffect + ) + ) + } + } + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + state = scrollState, + modifier = Modifier + .fillMaxSize() + .animateContentSize() + .statusBarsPadding() + .padding(horizontal = 16.dp), + ) { + budgetDisplaySection( + budgetState = uiState.budgetState, + budgetSettings = uiState.budgetSettings, + currencyCode = currencyCode, + ) + + currentPeriodRecurrentSection( + upcomingRecurrentInPeriod = uiState.upcomingRecurrentInPeriod, + showUpcomingRecurrentInPeriod = uiState.showUpcomingRecurrentInPeriod, + onToggleShowUpcomingRecurrentInPeriod = { + onProcessIntent( + HistoryUiIntent.ToggleUpcomingRecurrentInPeriod(!uiState.showUpcomingRecurrentInPeriod) + ) + }, + recurrentPaymentsViewMode = uiState.recurrentPaymentsViewMode, + currencyFormat = currencyFormat, + onDelete = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToDelete(tx)) }, + onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(tx)) }, + onClick = { tx -> onProcessIntent(HistoryUiIntent.SetSelectedTransaction(tx)) }, + ) + + transactionDateSections( + groupedTransactions = uiState.groupedCurrentTransactions, + expandedDates = uiState.expandedDates, + deletingTransactionIds = uiState.pendingRemovedTransactions.keys, + currencyCode = currencyCode, + currencyFormat = currencyFormat, + readOnly = readOnly, + keyPrefix = "date", + onToggleDate = { date -> onProcessIntent(HistoryUiIntent.ToggleExpandedDate(date)) }, + onDelete = { tx -> + onQueueDeleteWithUndo( + tx, + resources.getString( + R.string.expense_deleted_format, + tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } + ), + ) { + onCancelPendingDelete() + } + onProcessIntent(HistoryUiIntent.DeleteTransaction(tx)) + }, + onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetEditingTransaction(tx)) }, + onClick = { tx -> onProcessIntent(HistoryUiIntent.SetSelectedTransaction(tx)) }, + ) + + futureRecurrentSection( + futureRecurrentOutOfPeriod = uiState.futureRecurrentOutOfPeriod, + showOutOfPeriodSubscriptions = uiState.showOutOfPeriodSubscriptions, + onToggleShowOutOfPeriodSubscriptions = { + onProcessIntent( + HistoryUiIntent.ToggleOutOfPeriodSubscriptions(!uiState.showOutOfPeriodSubscriptions) + ) + }, + recurrentPaymentsViewMode = uiState.recurrentPaymentsViewMode, + currencyFormat = currencyFormat, + onDelete = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToDelete(tx)) }, + onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(tx)) }, + onClick = { tx -> onProcessIntent(HistoryUiIntent.SetSelectedTransaction(tx)) }, + ) + + pastPeriodToggleSection( + groupedPastTransactions = uiState.groupedPastTransactions, + showPastPeriod = uiState.showPastPeriod, + onToggleShowPastPeriod = { + onProcessIntent(HistoryUiIntent.TogglePastPeriod(!uiState.showPastPeriod)) + }, + ) + + pastTransactionDateSections( + showPastPeriod = uiState.showPastPeriod, + groupedPastTransactions = uiState.groupedPastTransactions, + expandedDates = uiState.expandedDates, + deletingTransactionIds = uiState.pendingRemovedTransactions.keys, + currencyCode = currencyCode, + currencyFormat = currencyFormat, + readOnly = readOnly, + onToggleDate = { date -> onProcessIntent(HistoryUiIntent.ToggleExpandedDate(date)) }, + onDelete = { tx -> + onQueueDeleteWithUndo( + tx, + resources.getString( + R.string.expense_deleted_format, + tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } + ), + ) { + onCancelPendingDelete() + } + onProcessIntent(HistoryUiIntent.DeleteTransaction(tx)) + }, + onEdit = { tx -> onProcessIntent(HistoryUiIntent.SetEditingTransaction(tx)) }, + onClick = { tx -> onProcessIntent(HistoryUiIntent.SetSelectedTransaction(tx)) }, + ) + + item("spacer-bottom") { + Spacer(modifier = Modifier.height(32.dp)) + } + } + + if (uiState.transactions.isEmpty()) { + NoTransactionsView( + modifier = Modifier + .align(Alignment.Center) + .padding(32.dp), + ) + } + + TransactionDetailDialog( + transaction = uiState.selectedTransaction, + currencyFormat = currencyFormat, + readOnly = readOnly, + isDismissingTransactionDialog = uiState.isDismissingTransactionDialog, + onDismissStart = { onProcessIntent(HistoryUiIntent.SetDismissingDialog(true)) }, + onDismiss = { onProcessIntent(HistoryUiIntent.SetSelectedTransaction(null)) }, + onMarkAsPaid = { onProcessIntent(HistoryUiIntent.SetSelectedTransaction(null)) }, + onEdit = { tx -> + onProcessIntent(HistoryUiIntent.SetSelectedTransaction(null)) + onProcessIntent(HistoryUiIntent.SetEditingTransaction(tx)) + }, + onDelete = { tx -> + onProcessIntent(HistoryUiIntent.SetSelectedTransaction(null)) + if (tx.isRecurrent) { + onProcessIntent(HistoryUiIntent.SetRecurrentToDelete(tx)) + } else { + onQueueDeleteWithUndo( + tx, + resources.getString( + R.string.expense_deleted_format, + tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } + ), + ) { onCancelPendingDelete() } + onProcessIntent(HistoryUiIntent.DeleteTransaction(tx)) + } + }, + ) + } + + TransactionEditDialog( + transaction = uiState.editingTransaction, + budgetStartDate = uiState.budgetSettings?.startDate ?: LocalDate.now().minusDays(30), + budgetEndDate = uiState.budgetSettings?.getPeriodEndDate() ?: LocalDate.now(), + currencyCode = currencyCode, + onCancel = { onProcessIntent(HistoryUiIntent.SetEditingTransaction(null)) }, + onSave = { tx -> + onProcessIntent(HistoryUiIntent.SaveEditedTransaction(tx)) + onShowInfoSnackbar( + resources.getString( + R.string.expense_modified_format, + tx.comment.ifEmpty { resources.getString(R.string.generic_expense) } + ) + ) + onProcessIntent(HistoryUiIntent.SetEditingTransaction(null)) + }, + ) + + DeleteRecurrentExpenseDialog( + transaction = uiState.recurrentToDelete.takeIf { uiState.showDeleteRecurrentDialog }, + onDismiss = { onProcessIntent(HistoryUiIntent.DismissDeleteRecurrentDialog) }, + onConfirm = { tx -> + onProcessIntent(HistoryUiIntent.ConfirmDeleteRecurrent(tx)) + }, + ) + + TransactionEditDialog( + transaction = uiState.recurrentToEdit, + budgetStartDate = uiState.budgetSettings?.startDate ?: LocalDate.now().minusDays(30), + budgetEndDate = uiState.budgetSettings?.getPeriodEndDate() ?: LocalDate.now(), + currencyCode = currencyCode, + onCancel = { onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(null)) }, + onSave = { tx -> + onProcessIntent(HistoryUiIntent.SaveEditedTransaction(tx)) + onProcessIntent(HistoryUiIntent.SetRecurrentToEdit(null)) + }, + ) +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistorySections.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistorySections.kt deleted file mode 100644 index fa80a37..0000000 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistorySections.kt +++ /dev/null @@ -1,490 +0,0 @@ -package com.serranoie.app.minus.presentation.ui.history - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideOutHorizontally -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.PaddingValues -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.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.serranoie.app.minus.R -import com.serranoie.app.minus.domain.model.BudgetSettings -import com.serranoie.app.minus.domain.model.BudgetState -import com.serranoie.app.minus.domain.model.Transaction -import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListItemPosition -import com.serranoie.app.minus.presentation.ui.theme.component.WavyDivider -import com.serranoie.app.minus.presentation.ui.theme.component.budget.BudgetDisplay -import com.serranoie.app.minus.presentation.ui.theme.component.date.DayTotalItem -import com.serranoie.app.minus.presentation.ui.theme.component.date.HistoryDateDivider -import com.serranoie.app.minus.presentation.ui.theme.component.expense.RecurrentPaymentsDivider -import com.serranoie.app.minus.presentation.ui.theme.component.expense.SwipeableExpenseItem -import com.serranoie.app.minus.presentation.ui.theme.component.expense.SwipeableUpcomingRecurrentItem -import com.serranoie.app.minus.presentation.ui.theme.component.expense.UpcomingRecurrentItem -import com.serranoie.app.minus.presentation.ui.theme.component.ticket.RecurrentTicketCard -import com.serranoie.app.minus.presentation.util.prettyDate -import logcat.logcat -import java.math.BigDecimal -import java.text.NumberFormat -import java.time.LocalDate -import java.time.ZoneId -import java.util.Date - -internal fun LazyListScope.budgetDisplaySection( - budgetState: BudgetState?, - budgetSettings: BudgetSettings?, - currencyCode: String, -) { - item("budget-display") { - val startDate = budgetSettings?.startDate?.let { - Date.from(it.atStartOfDay(ZoneId.systemDefault()).toInstant()) - } ?: Date() - - val finishDate = budgetSettings?.getPeriodEndDate()?.let { - Date.from(it.atStartOfDay(ZoneId.systemDefault()).toInstant()) - } - - val budget = budgetState?.totalBudget ?: BigDecimal.ZERO - logcat("History") { - "BudgetDisplay input budget=$budget budgetStateTotal=${budgetState?.totalBudget} budgetSettingsTotal=${budgetSettings?.totalBudget} rollOverLimit=${budgetSettings?.rollOverLimit} rollOverCarry=${budgetSettings?.rollOverCarryForward}" - } - - BudgetDisplay( - budget = budget, - budgetState = budgetState, - budgetSettings = budgetSettings, - currencyCode = currencyCode, - bigVariant = true, - modifier = Modifier.fillMaxWidth(), - startDate = startDate, - finishDate = finishDate, - ) - } -} - -internal fun LazyListScope.currentPeriodRecurrentSection( - upcomingRecurrentInPeriod: List, - showUpcomingRecurrentInPeriod: Boolean, - onToggleShowUpcomingRecurrentInPeriod: () -> Unit, - recurrentPaymentsViewMode: RecurrentPaymentsViewMode, - currencyFormat: NumberFormat, - onDelete: (Transaction) -> Unit, - onEdit: (Transaction) -> Unit, - onClick: (Transaction) -> Unit, -) { - if (upcomingRecurrentInPeriod.isEmpty()) return - - item("upcoming-recurrent-toggle") { - RecurrentPaymentsDivider( - title = stringResource(R.string.recurrent_payments_divider_title_current_period), - isExpanded = showUpcomingRecurrentInPeriod, - onToggleClick = onToggleShowUpcomingRecurrentInPeriod, - itemCount = upcomingRecurrentInPeriod.size, - modifier = Modifier.fillMaxWidth(), - ) - } - - item("upcoming-recurrent-content") { - AnimatedVisibility( - visible = showUpcomingRecurrentInPeriod, - enter = expandVertically( - animationSpec = tween(300), - expandFrom = Alignment.Top, - ) + fadeIn(animationSpec = tween(300)), - exit = shrinkVertically( - animationSpec = tween(300), - shrinkTowards = Alignment.Top, - ) + fadeOut(animationSpec = tween(300)), - ) { - RecurrentItemsContent( - items = upcomingRecurrentInPeriod, - recurrentPaymentsViewMode = recurrentPaymentsViewMode, - currencyFormat = currencyFormat, - verticalItem = { _, item, position -> - SwipeableUpcomingRecurrentItem( - item = item, - currencyFormat = currencyFormat, - position = position, - onDelete = { onDelete(item.transaction) }, - onEdit = { onEdit(item.transaction) }, - onClick = { onClick(item.transaction) }, - ) - }, - horizontalKeyPrefix = "upcoming", - onClick = onClick, - ) - } - } -} - -internal fun LazyListScope.transactionDateSections( - groupedTransactions: Map>, - expandedDates: Set, - deletingTransactionIds: Set, - currencyCode: String, - currencyFormat: NumberFormat, - readOnly: Boolean, - keyPrefix: String, - onToggleDate: (LocalDate) -> Unit, - onDelete: (Transaction) -> Unit, - onEdit: (Transaction) -> Unit, - onClick: (Transaction) -> Unit, -) { - groupedTransactions.forEach { (date, transactions) -> - val isExpanded = date?.let { expandedDates.contains(it) } ?: false - val dayTotal = transactions.sumOf { it.amount } - - item("$keyPrefix-date-$date") { - HistoryDateDivider( - date = date, - isExpanded = isExpanded, - onToggleClick = { - date?.let(onToggleDate) - }, - totalAmount = dayTotal, - currencyCode = currencyCode, - ) - } - - item("$keyPrefix-date-content-$date") { - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically( - animationSpec = tween(300), - expandFrom = Alignment.Top, - ) + fadeIn(animationSpec = tween(300)), - exit = shrinkVertically( - animationSpec = tween(300), - shrinkTowards = Alignment.Top, - ) + fadeOut(animationSpec = tween(300)), - ) { - Column { - transactions.forEachIndexed { index, transaction -> - key(transaction.id) { - val position = paddedListItemPosition( - index, transactions.lastIndex, transactions.size - ) - val isBeingDeleted = transaction.id in deletingTransactionIds - AnimatedVisibility( - visible = !isBeingDeleted, - enter = EnterTransition.None, - exit = slideOutHorizontally( - animationSpec = tween(durationMillis = 280), - targetOffsetX = { fullWidth -> fullWidth }, - ) + fadeOut(animationSpec = tween(durationMillis = 280)), - ) { - SwipeableExpenseItem( - transaction = transaction, - currencyFormat = currencyFormat, - position = position, - isBeingDeleted = isBeingDeleted, - onDelete = { onDelete(transaction) }, - onEdit = { onEdit(transaction) }, - readOnly = readOnly, - onClick = { onClick(transaction) }, - ) - } - - if (index < transactions.size - 1 && transaction.id !in deletingTransactionIds) { - Spacer(modifier = Modifier.height(2.dp)) - } - } - } - - DayTotalItem( - total = dayTotal, - currencyFormat = currencyFormat, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - } - } - } - } -} - -internal fun LazyListScope.futureRecurrentSection( - futureRecurrentOutOfPeriod: List, - showOutOfPeriodSubscriptions: Boolean, - onToggleShowOutOfPeriodSubscriptions: () -> Unit, - recurrentPaymentsViewMode: RecurrentPaymentsViewMode, - currencyFormat: NumberFormat, - onDelete: (Transaction) -> Unit, - onEdit: (Transaction) -> Unit, - onClick: (Transaction) -> Unit, -) { - if (futureRecurrentOutOfPeriod.isEmpty()) return - - item("future-recurrent-toggle") { - val interactionSource = remember { MutableInteractionSource() } - Box( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = interactionSource, - indication = null, - ) { - onToggleShowOutOfPeriodSubscriptions() - }, - ) { - WavyDivider( - text = if (showOutOfPeriodSubscriptions) { - "Ocultar subscripciones fuera del periodo" - } else { - "Mostrar subscripciones fuera del periodo" - }, - horizontalPadding = 0.dp, - amplitude = 4f, - wavelength = 45f, - ) - } - } - - item("future-recurrent-content") { - AnimatedVisibility( - visible = showOutOfPeriodSubscriptions, - enter = expandVertically( - animationSpec = tween(300), - expandFrom = Alignment.Top, - ) + fadeIn(animationSpec = tween(300)), - exit = shrinkVertically( - animationSpec = tween(300), - shrinkTowards = Alignment.Top, - ) + fadeOut(animationSpec = tween(300)), - ) { - RecurrentItemsContent( - items = futureRecurrentOutOfPeriod, - recurrentPaymentsViewMode = recurrentPaymentsViewMode, - currencyFormat = currencyFormat, - verticalItem = { _, item, position -> - SwipeableUpcomingRecurrentItem( - item = item, - currencyFormat = currencyFormat, - position = position, - onDelete = { onDelete(item.transaction) }, - onEdit = { onEdit(item.transaction) }, - onClick = { onClick(item.transaction) }, - ) - }, - horizontalKeyPrefix = "future", - onClick = onClick, - ) - } - } -} - -internal fun LazyListScope.pastPeriodToggleSection( - groupedPastTransactions: Map>, - showPastPeriod: Boolean, - onToggleShowPastPeriod: () -> Unit, -) { - if (groupedPastTransactions.isEmpty()) return - - item("wavy-divider") { - val interactionSource = remember { MutableInteractionSource() } - Box( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = interactionSource, - indication = null, - ) { - onToggleShowPastPeriod() - }, - ) { - WavyDivider( - text = if (showPastPeriod) "Ocultar gastos del periodo pasado" else "Mostrar gastos del periodo pasado", - horizontalPadding = 0.dp, - amplitude = 4f, - wavelength = 45f, - ) - } - } -} - -internal fun LazyListScope.pastTransactionDateSections( - showPastPeriod: Boolean, - groupedPastTransactions: Map>, - expandedDates: Set, - deletingTransactionIds: Set, - currencyCode: String, - currencyFormat: NumberFormat, - readOnly: Boolean, - onToggleDate: (LocalDate) -> Unit, - onDelete: (Transaction) -> Unit, - onEdit: (Transaction) -> Unit, - onClick: (Transaction) -> Unit, -) { - if (!showPastPeriod) return - - groupedPastTransactions.forEach { (date, transactions) -> - val isExpanded = date?.let { expandedDates.contains(it) } ?: false - val dayTotal = transactions.sumOf { it.amount } - - item("past-date-$date") { - HistoryDateDivider( - date = date, - isExpanded = isExpanded, - onToggleClick = { - date?.let(onToggleDate) - }, - totalAmount = dayTotal, - currencyCode = currencyCode, - ) - } - - item("past-date-content-$date") { - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically( - animationSpec = tween(300), - expandFrom = Alignment.Top, - ) + fadeIn(animationSpec = tween(300)), - exit = shrinkVertically( - animationSpec = tween(300), - shrinkTowards = Alignment.Top, - ) + fadeOut(animationSpec = tween(300)), - ) { - Column { - transactions.forEachIndexed { index, transaction -> - key(transaction.id) { - val position = paddedListItemPosition( - index, transactions.lastIndex, transactions.size - ) - val isBeingDeleted = transaction.id in deletingTransactionIds - AnimatedVisibility( - visible = !isBeingDeleted, - enter = EnterTransition.None, - exit = slideOutHorizontally( - animationSpec = tween(durationMillis = 280), - targetOffsetX = { fullWidth -> fullWidth }, - ) + fadeOut(animationSpec = tween(durationMillis = 280)), - ) { - SwipeableExpenseItem( - transaction = transaction, - currencyFormat = currencyFormat, - position = position, - isBeingDeleted = isBeingDeleted, - onDelete = { onDelete(transaction) }, - onEdit = { onEdit(transaction) }, - readOnly = readOnly, - onClick = { onClick(transaction) }, - ) - } - - if (index < transactions.size - 1 && transaction.id !in deletingTransactionIds) { - Spacer(modifier = Modifier.height(2.dp)) - } - } - } - - val totalText = currencyFormat.format(dayTotal) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Total del día: ", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = totalText, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - } - } - } - } - } -} - -@Composable -private fun RecurrentItemsContent( - items: List, - recurrentPaymentsViewMode: RecurrentPaymentsViewMode, - currencyFormat: NumberFormat, - verticalItem: @Composable (index: Int, item: UpcomingRecurrentItem, position: PaddedListItemPosition) -> Unit, - horizontalKeyPrefix: String, - onClick: (Transaction) -> Unit, -) { - if (recurrentPaymentsViewMode == RecurrentPaymentsViewMode.VERTICAL_LIST) { - Column(modifier = Modifier.fillMaxWidth()) { - items.forEachIndexed { index, item -> - verticalItem( - index, item, paddedListItemPosition(index, items.lastIndex, items.size) - ) - if (index < items.lastIndex) { - Spacer(modifier = Modifier.height(2.dp)) - } - } - } - } else { - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - itemsIndexed( - items = items, - key = { _, item -> "$horizontalKeyPrefix-${item.transaction.id}" }, - ) { _, item -> - RecurrentTicketCard( - title = item.transaction.comment, - amountFormatted = currencyFormat.format(item.transaction.amount), - nextChargeDate = prettyDate( - item.nextChargeDate.atStartOfDay(), - showTime = false, - forceShowDate = false, - ), - frequencyLabel = item.transaction.recurrentFrequency?.name?.lowercase() - ?.replaceFirstChar { it.uppercase() }, - onClick = { onClick(item.transaction) }, - modifier = Modifier.fillParentMaxWidth(0.45f), - ) - } - } - } -} - -private fun paddedListItemPosition( - index: Int, - lastIndex: Int, - size: Int, -): PaddedListItemPosition = when { - size == 1 -> PaddedListItemPosition.Single - index == 0 -> PaddedListItemPosition.First - index == lastIndex -> PaddedListItemPosition.Last - else -> PaddedListItemPosition.Middle -} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryViewModel.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryViewModel.kt index 2feaa9f..c910843 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/HistoryViewModel.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.minus.presentation.ui.history +package com.serranoie.app.minus.presentation.ui.history import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -107,11 +107,9 @@ class HistoryViewModel @Inject constructor( _uiState.value = _uiState.value.copy( pendingRemovedTransactions = _uiState.value.pendingRemovedTransactions + (transaction.id to transaction), ) - viewModelScope.launch { - budgetTransactionHandler.deleteTransaction(transaction) - } autoDismissJob = viewModelScope.launch { - delay(330) + delay(EXIT_ANIMATION_DURATION_MS) + budgetTransactionHandler.deleteTransaction(transaction) _uiState.value = _uiState.value.copy(pendingRemovedTransactions = emptyMap()) } } @@ -212,6 +210,11 @@ class HistoryViewModel @Inject constructor( return transactions.filter { it.periodId > 0L }.maxByOrNull { it.periodId }?.periodId ?: 0L } + + companion object { + private const val EXIT_ANIMATION_DURATION_MS = 600L + } + override fun onCleared() { super.onCleared() autoDismissJob?.cancel() diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/TransactionDetailTicketCard.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/TransactionDetailTicketCard.kt deleted file mode 100644 index 67b8f5a..0000000 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/TransactionDetailTicketCard.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.serranoie.app.minus.presentation.ui.history - -import androidx.compose.foundation.background -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.TextUnitType -import androidx.compose.ui.unit.dp -import com.serranoie.app.minus.domain.model.Transaction -import com.serranoie.app.minus.presentation.ui.theme.bodyMediumCondensed -import com.serranoie.app.minus.presentation.ui.theme.component.ticket.TicketCard - -@Composable -internal fun TransactionDetailTicketCard( - transaction: Transaction, - totalAmountText: String, - details: List>, - onMarkAsPaid: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, - readOnly: Boolean, - modifier: Modifier = Modifier, -) { - TicketCard( - backgroundColor = MaterialTheme.colorScheme.background, - teethWidthDp = 20f, - teethHeightDp = 4f, - modifier = modifier, - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier - .background(Color.Black) - .padding(vertical = 8.dp, horizontal = 18.dp) - ) { - Text( - text = if (transaction.isRecurrent) "GASTO RECURRENTE" else "GASTO", - color = Color.White, - style = MaterialTheme.typography.labelLargeEmphasized, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - fontSize = TextUnit(26f, TextUnitType.Sp), - ) - } - - Text( - text = "Num. de Operación: #${transaction.id}", - style = MaterialTheme.typography.bodyMediumCondensed, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - - HorizontalDivider() - - Text( - text = "MONTO TOTAL", - style = MaterialTheme.typography.labelLargeEmphasized, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - - Text( - text = totalAmountText, - style = MaterialTheme.typography.headlineLargeEmphasized, - color = MaterialTheme.colorScheme.error, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ) - - HorizontalDivider() - - details.forEach { (label, value) -> - Box(modifier = Modifier.fillMaxWidth()) { - Text( - text = label, - style = MaterialTheme.typography.bodyMediumCondensed, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.CenterStart), - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMediumCondensed, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterEnd), - ) - } - } - - if (transaction.isRecurrent) { - Button( - onClick = onMarkAsPaid, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary, - contentColor = MaterialTheme.colorScheme.onSecondary, - ), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = "Marcar como pagado", - style = MaterialTheme.typography.labelSmallEmphasized, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.primary, - ), - onClick = onEdit, - modifier = Modifier.weight(1f), - ) { - Text("Editar", style = MaterialTheme.typography.labelSmallEmphasized) - } - - if (!readOnly) { - Button( - onClick = onDelete, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text("Eliminar", style = MaterialTheme.typography.labelSmallEmphasized) - } - } - } - } - } -} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/TransactionEditScreen.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/TransactionEditScreen.kt deleted file mode 100644 index be4108a..0000000 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/TransactionEditScreen.kt +++ /dev/null @@ -1,894 +0,0 @@ -package com.serranoie.app.minus.presentation.ui.history - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.basicMarquee -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.CalendarToday -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.SaveAlt -import androidx.compose.material.icons.rounded.Repeat -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.SelectableDates -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TimePicker -import androidx.compose.material3.ToggleButton -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberTimePickerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import com.serranoie.app.minus.R -import com.serranoie.app.minus.domain.model.RecurrentFrequency -import com.serranoie.app.minus.domain.model.Transaction -import com.serranoie.app.minus.presentation.ui.editor.category.CategoryToolbar -import com.serranoie.app.minus.presentation.ui.editor.category.FocusController -import com.serranoie.app.minus.presentation.ui.theme.MinusTheme -import com.serranoie.app.minus.presentation.ui.theme.bodySmallCondensed -import com.serranoie.app.minus.presentation.ui.theme.component.numpad.EditMode -import com.serranoie.app.minus.presentation.ui.theme.component.numpad.EditStage -import com.serranoie.app.minus.presentation.ui.theme.component.numpad.EditorState -import com.serranoie.app.minus.presentation.ui.theme.component.numpad.Numpad -import com.serranoie.app.minus.presentation.ui.theme.displayLargeCondensed -import com.serranoie.app.minus.presentation.ui.theme.labelMediumCondensed -import com.serranoie.app.minus.presentation.util.prettyDate -import com.serranoie.app.minus.presentation.util.symbolOnlyCurrencyFormat -import kotlinx.coroutines.launch -import java.math.BigDecimal -import java.math.RoundingMode -import java.time.Instant -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.LocalTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Locale -import com.serranoie.app.minus.presentation.ui.theme.component.numpad.Transaction as NumpadTransaction - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TransactionEditScreen( - transaction: Transaction, - budgetStartDate: LocalDate, - budgetEndDate: LocalDate, - currencyCode: String = "USD", - onCancel: () -> Unit = {}, - onSave: ( - newAmount: BigDecimal, newComment: String, newDateTime: LocalDateTime, newIsRecurrent: Boolean, newFrequency: RecurrentFrequency?, newEndDate: LocalDate?, newSubscriptionDay: Int? - ) -> Unit = { _, _, _, _, _, _, _ -> }, - modifier: Modifier = Modifier -) { - val currencyFormat = symbolOnlyCurrencyFormat(currencyCode) - val scope = rememberCoroutineScope() - - var editedAmount by remember { mutableStateOf(transaction.amount.toString()) } - var editedComment by remember { mutableStateOf(transaction.comment) } - var editedDate by remember { - mutableStateOf( - transaction.date?.toLocalDate() ?: LocalDate.now() - ) - } - var editedTime by remember { - mutableStateOf(transaction.date?.toLocalTime() ?: LocalTime.now()) - } - - var isRecurrent by remember { mutableStateOf(transaction.isRecurrent) } - var selectedFrequency by remember { - mutableStateOf(transaction.recurrentFrequency ?: RecurrentFrequency.MONTHLY) - } - var subscriptionDay by remember { - mutableIntStateOf(transaction.subscriptionDay ?: transaction.date?.dayOfMonth ?: 1) - } - var recurrentEndDate by remember { - mutableStateOf(transaction.recurrentEndDate?.toLocalDate() ?: budgetEndDate.plusMonths(3)) - } - - var showDatePicker by remember { mutableStateOf(false) } - var showTimePicker by remember { mutableStateOf(false) } - var showRecurrentBottomSheet by remember { mutableStateOf(false) } - - val focusController = remember { FocusController() } - - var isCalculation by remember { mutableStateOf(false) } - - // Calculate dynamic target height for numpad to take 48% of screen height - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp.dp - val targetNumpadHeight = screenHeight * 0.48f - - val baseTextStyle = MaterialTheme.typography.displayLargeCondensed.copy( - fontWeight = FontWeight.W500 - ) - - val editorState = remember(editedAmount) { - EditorState( - mode = EditMode.EDIT, - rawSpentValue = editedAmount, - stage = EditStage.EDIT_SPENT, - currentSpent = editedAmount, - currentComment = editedComment, - editedTransaction = transaction?.let { - NumpadTransaction( - id = it.id, - amount = it.amount.toPlainString(), - comment = it.comment, - date = it.date?.atZone(ZoneId.systemDefault())?.toInstant() - ?.let { instant -> java.util.Date.from(instant) } ?: java.util.Date()) - }) - } - - Column( - modifier = modifier - .fillMaxSize() - .statusBarsPadding() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = onCancel, modifier = Modifier.size(48.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cancel_edit_content_desc), - tint = MaterialTheme.colorScheme.onSurface - ) - } - - Text( - text = if (transaction.isRecurrent) stringResource(R.string.edit_recurrent_expense_title) else stringResource( - R.string.edit_expense_title - ), - style = MaterialTheme.typography.titleMediumEmphasized, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .padding(start = 8.dp) - .basicMarquee() - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = prettyDate( - editedDate.atStartOfDay(), forceShowDate = true, showTime = false, human = false - ), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { showDatePicker = true } - .padding(horizontal = 2.dp, vertical = 4.dp)) - - Text( - text = "—", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 4.dp) - ) - - Text( - text = String.format("%02d:%02d", editedTime.hour, editedTime.minute), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { showTimePicker = true } - .padding(horizontal = 2.dp, vertical = 4.dp)) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(16.dp), - contentAlignment = Alignment.TopEnd - ) { - val formattedAmount = remember(editedAmount) { - try { - val value = editedAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO - currencyFormat.format(value) - } catch (e: Exception) { - editedAmount - } - } - - Text( - text = formattedAmount, - style = baseTextStyle, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.End, - modifier = Modifier.fillMaxWidth() - ) - } - - if (transaction.isRecurrent) { - Button( - onClick = { showRecurrentBottomSheet = true }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = MaterialTheme.shapes.medium - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Rounded.Repeat, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Text( - text = if (isRecurrent) stringResource(R.string.configure_recurrence) else stringResource( - R.string.make_recurrent - ), style = MaterialTheme.typography.labelSmallEmphasized - ) - } - - if (isRecurrent) { - val freqText = when (selectedFrequency) { - RecurrentFrequency.WEEKLY -> stringResource(R.string.weekly_with_desc) - RecurrentFrequency.BIWEEKLY -> stringResource(R.string.biweekly_with_desc) - RecurrentFrequency.MONTHLY -> stringResource( - R.string.recurrent_frequency_monthly, subscriptionDay - ) - } - Text( - text = freqText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.basicMarquee() - ) - } - } - } - } - - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = MaterialTheme.shapes.medium - ) { - CategoryToolbar( - tags = emptyList(), - currentComment = editedComment, - stage = EditStage.EDIT_SPENT, - onCommentUpdate = { editedComment = it }, - editorFocusController = focusController, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Numpad( - modifier = Modifier - .fillMaxWidth() - .height(targetNumpadHeight), - editorState = editorState, - onNumberInput = { digit -> - editedAmount = if (editedAmount == "0") { - digit.toString() - } else { - editedAmount + digit.toString() - } - }, - onDotInput = { - val lastChar = editedAmount.lastOrNull() - if (editedAmount.isEmpty() || (lastChar != null && lastChar in "+-×÷")) { - editedAmount += "0." - } else { - val lastOperatorIndex = editedAmount.indexOfLast { it in "+-×÷" } - val currentSegment = editedAmount.substring(lastOperatorIndex + 1) - if (!currentSegment.contains(".")) { - editedAmount += "." - } - } - }, - onBackspace = { - editedAmount = editedAmount.dropLast(1).ifEmpty { "0" } - }, - onBackspaceLongPress = { - editedAmount = "0" - }, - onApply = { - val newAmount = editedAmount.toBigDecimalOrNull() ?: transaction.amount - val frequency = if (isRecurrent) selectedFrequency else null - val endDate = if (isRecurrent) recurrentEndDate else null - val subDay = if (isRecurrent && selectedFrequency == RecurrentFrequency.MONTHLY) { - subscriptionDay - } else null - - onSave( - newAmount, - editedComment, - editedDate.atTime(editedTime), - isRecurrent, - frequency, - endDate, - subDay - ) - }, - isCalculation = isCalculation, - onCalculationModeChanged = { isCalculation = it }, - onOperatorInput = { operator -> - val lastChar = editedAmount.lastOrNull() - if (editedAmount.isNotEmpty() && lastChar != null && lastChar !in "+-×÷" && lastChar != '.') { - editedAmount += operator.toString() - } - }, - onEqualsInput = { - val result = evaluateCalculation(editedAmount) - if (result != null) { - editedAmount = result - } - }, - onDelete = { - onCancel() - }) - } - - if (showDatePicker) { - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = editedDate.atStartOfDay(ZoneId.systemDefault()).toInstant() - .toEpochMilli() - ) - - DatePickerDialog(onDismissRequest = { showDatePicker = false }, confirmButton = { - TextButton( - onClick = { - datePickerState.selectedDateMillis?.let { millis -> - val selectedDate = - Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()) - .toLocalDate() - // Ensure date is within budget period - editedDate = when { - selectedDate.isBefore(budgetStartDate) -> budgetStartDate - selectedDate.isAfter(budgetEndDate) -> budgetEndDate - else -> selectedDate - } - } - showDatePicker = false - }) { - Text(stringResource(R.string.accept)) - } - }, dismissButton = { - TextButton(onClick = { showDatePicker = false }) { - Text(stringResource(R.string.cancel)) - } - }) { - DatePicker(state = datePickerState) - } - } - - if (showTimePicker) { - val timePickerState = rememberTimePickerState( - initialHour = editedTime.hour, initialMinute = editedTime.minute - ) - - Dialog(onDismissRequest = { showTimePicker = false }) { - Surface( - shape = MaterialTheme.shapes.large, tonalElevation = 6.dp - ) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.select_time), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 16.dp) - ) - - TimePicker(state = timePickerState) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = { showTimePicker = false }) { - Text(stringResource(R.string.cancel)) - } - - TextButton( - onClick = { - editedTime = LocalTime.of( - timePickerState.hour, timePickerState.minute - ) - showTimePicker = false - }) { - Text(stringResource(R.string.accept)) - } - } - } - } - } - } - - if (showRecurrentBottomSheet) { - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - - ModalBottomSheet( - onDismissRequest = { showRecurrentBottomSheet = false }, sheetState = sheetState - ) { - RecurrentConfigBottomSheetContent( - isRecurrent = isRecurrent, - selectedFrequency = selectedFrequency, - subscriptionDay = subscriptionDay, - recurrentEndDate = recurrentEndDate, - onSaveConfiguration = { newIsRecurrent, newFrequency, newSubscriptionDay, newEndDate -> - isRecurrent = newIsRecurrent - selectedFrequency = newFrequency - subscriptionDay = newSubscriptionDay - recurrentEndDate = newEndDate - }, - onDismiss = { - scope.launch { sheetState.hide() }.invokeOnCompletion { - showRecurrentBottomSheet = false - } - }) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun RecurrentConfigBottomSheetContent( - isRecurrent: Boolean, - selectedFrequency: RecurrentFrequency, - subscriptionDay: Int, - recurrentEndDate: LocalDate, - onSaveConfiguration: (Boolean, RecurrentFrequency, Int, LocalDate) -> Unit, - onDismiss: () -> Unit -) { - var showEndDatePicker by remember { mutableStateOf(false) } - var localIsRecurrent by remember(isRecurrent) { mutableStateOf(isRecurrent) } - var localSelectedFrequency by remember(selectedFrequency) { mutableStateOf(selectedFrequency) } - var localSubscriptionDay by remember(subscriptionDay) { mutableIntStateOf(subscriptionDay) } - var localRecurrentEndDate by remember(recurrentEndDate) { mutableStateOf(recurrentEndDate) } - val today = LocalDate.now() - val maxSelectableDate = today.plusMonths(12) - val dateFormatter = remember { DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()) } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.configure_recurrence), - style = MaterialTheme.typography.titleMediumEmphasized, - ) - - IconButton(onClick = { - onSaveConfiguration( - localIsRecurrent, - localSelectedFrequency, - localSubscriptionDay, - localRecurrentEndDate, - ) - onDismiss() - }) { - Icon( - imageVector = Icons.Default.SaveAlt, - contentDescription = stringResource(R.string.save), - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.recurrent_expense), - style = MaterialTheme.typography.bodyLarge - ) - Switch( - checked = localIsRecurrent, - onCheckedChange = { localIsRecurrent = it } - ) - } - - if (localIsRecurrent) { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - stringResource(R.string.recurrent_expense_frequency_subtitle), - style = MaterialTheme.typography.labelMediumCondensed - ) - - val options = listOf( - stringResource(R.string.recurrent_frequency_weekly), - stringResource(R.string.recurrent_frequency_biweekly), - stringResource(R.string.recurrent_frequency_monthly) - ) - val frequencies = listOf( - RecurrentFrequency.WEEKLY, - RecurrentFrequency.BIWEEKLY, - RecurrentFrequency.MONTHLY, - ) - val selectedIndex = frequencies.indexOf(localSelectedFrequency).coerceAtLeast(0) - - Row( - Modifier.padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), - ) { - val modifiers = - listOf(Modifier.weight(1f), Modifier.weight(1.5f), Modifier.weight(1f)) - - options.forEachIndexed { index, label -> - ToggleButton( - checked = selectedIndex == index, - onCheckedChange = { localSelectedFrequency = frequencies[index] }, - modifier = modifiers[index].semantics { role = Role.RadioButton }, - shapes = when (index) { - 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() - options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() - else -> ButtonGroupDefaults.connectedMiddleButtonShapes() - }, - ) { - Text(label) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - if (localSelectedFrequency == RecurrentFrequency.MONTHLY) { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceContainer - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - IconButton(onClick = { - localSubscriptionDay = (localSubscriptionDay - 1).coerceAtLeast(1) - }) { - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = stringResource(R.string.previous_day), - modifier = Modifier.size(18.dp) - ) - } - - Text( - text = stringResource(R.string.monthly_on_day_format, localSubscriptionDay), - style = MaterialTheme.typography.titleMedium - ) - - IconButton(onClick = { - localSubscriptionDay = (localSubscriptionDay + 1).coerceAtMost(31) - }) { - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = stringResource(R.string.next_day), - modifier = Modifier.size(18.dp) - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - } - - Text( - text = stringResource(R.string.limit_date), - style = MaterialTheme.typography.labelMediumCondensed, - modifier = Modifier.padding(bottom = 4.dp) - ) - - OutlinedTextField( - value = localRecurrentEndDate.format(dateFormatter), - onValueChange = {}, - readOnly = true, - placeholder = { Text(stringResource(R.string.date_placeholder)) }, - trailingIcon = { - Icon( - imageVector = Icons.Default.CalendarToday, - contentDescription = null, - tint = MaterialTheme.colorScheme.outlineVariant, - ) - }, - shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedBorderColor = MaterialTheme.colorScheme.surfaceContainer, - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - TextButton( - onClick = { showEndDatePicker = true }, - modifier = Modifier.align(Alignment.End) - ) { - Text(stringResource(R.string.change_date)) - } - - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - border = BorderStroke( - 1.dp, - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ), - colors = CardDefaults.outlinedCardColors( - containerColor = Color.Transparent - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.outline, - ) - Text( - text = buildRecurrentSummary( - frequency = localSelectedFrequency, - selectedDay = localSubscriptionDay, - selectedEndDate = localRecurrentEndDate, - formatter = dateFormatter, - ), - style = MaterialTheme.typography.bodySmallCondensed, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) - } - - if (showEndDatePicker) { - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = localRecurrentEndDate.atStartOfDay(ZoneId.systemDefault()) - .toInstant().toEpochMilli(), - selectableDates = object : SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean { - val date = Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneId.systemDefault()) - .toLocalDate() - return date.isAfter(today) && !date.isAfter(maxSelectableDate) - } - } - ) - - DatePickerDialog(onDismissRequest = { showEndDatePicker = false }, confirmButton = { - TextButton( - onClick = { - datePickerState.selectedDateMillis?.let { millis -> - localRecurrentEndDate = - Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()) - .toLocalDate() - } - showEndDatePicker = false - } - ) { - Text(stringResource(R.string.accept)) - } - }, dismissButton = { - TextButton(onClick = { showEndDatePicker = false }) { - Text(stringResource(R.string.cancel)) - } - }) { - DatePicker(state = datePickerState) - } - } -} - -@Preview(showBackground = true, device = "id:pixel_5") -@Composable -fun TransactionEditScreenPreview() { - MinusTheme { - TransactionEditScreen( - transaction = Transaction( - id = 1L, - amount = BigDecimal("50.00"), - comment = "ani", - date = LocalDateTime.now(), - isDeleted = false - ), - budgetStartDate = LocalDate.now().minusDays(15), - budgetEndDate = LocalDate.now().plusDays(15), - currencyCode = "USD", - onCancel = {}, - onSave = { _, _, _, _, _, _, _ -> }) - } -} - -@Preview(showBackground = true, device = "id:pixel_5") -@Composable -fun TransactionEditScreenRecurringPreview() { - MinusTheme { - TransactionEditScreen( - transaction = Transaction( - id = 1L, - amount = BigDecimal("99.00"), - comment = "Netflix", - date = LocalDateTime.now(), - isDeleted = false, - isRecurrent = true, - recurrentFrequency = RecurrentFrequency.MONTHLY, - subscriptionDay = 15, - recurrentEndDate = LocalDateTime.now().plusMonths(6) - ), - budgetStartDate = LocalDate.now().minusDays(15), - budgetEndDate = LocalDate.now().plusDays(15), - currencyCode = "USD", - onCancel = {}, - onSave = { _, _, _, _, _, _, _ -> }) - } -} - -@Composable -private fun buildRecurrentSummary( - frequency: RecurrentFrequency, - selectedDay: Int, - selectedEndDate: LocalDate, - formatter: DateTimeFormatter -): String { - val formattedDate = selectedEndDate.format(formatter) - return when (frequency) { - RecurrentFrequency.WEEKLY -> stringResource(R.string.summary_weekly_format, formattedDate) - RecurrentFrequency.BIWEEKLY -> stringResource( - R.string.summary_biweekly_format, formattedDate - ) - - RecurrentFrequency.MONTHLY -> stringResource( - R.string.summary_monthly_format, selectedDay, formattedDate - ) - } -} - -private fun evaluateCalculation(input: String): String? { - if (input.isBlank()) return null - - return try { - val normalized = input.trim().replace("×", "*").replace("÷", "/") - - normalized.lastOrNull()?.let { if (it in "+-*/") return null } - - val hasOperator = normalized.any { it in "+-*/" } - - if (!hasOperator) { - val num = normalized.toBigDecimalOrNull() ?: return null - return if (num.scale() <= 0 || num.stripTrailingZeros().scale() <= 0) { - num.toBigInteger().toString() - } else { - num.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString() - } - } - - val tokenPattern = Regex("([+\\-*/])") - val parts = tokenPattern.split(normalized).filter { it.isNotEmpty() } - val operators = tokenPattern.findAll(normalized).map { it.value }.toList() - - if (parts.isEmpty() || parts[0].isEmpty()) return null - - if (operators.size > parts.size - 1) return null - - var result = parts[0].toBigDecimalOrNull() ?: return null - - for (i in operators.indices) { - if (i + 1 >= parts.size) break - val operator = operators[i] - val nextNum = parts[i + 1].toBigDecimalOrNull() ?: return null - - result = when (operator) { - "+" -> result + nextNum - "-" -> result - nextNum - "*" -> result * nextNum - "/" -> { - if (nextNum.compareTo(BigDecimal.ZERO) == 0) return null // Division by zero - result.divide(nextNum, 2, RoundingMode.HALF_UP) - } - - else -> return null - } - } - - if (result.scale() <= 0 || result.stripTrailingZeros().scale() <= 0) { - result.toBigInteger().toString() - } else { - result.setScale(2, RoundingMode.HALF_UP).toPlainString() - } - } catch (e: Exception) { - null - } -} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/dialogs/HistoryDialogs.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/dialogs/HistoryDialogs.kt new file mode 100644 index 0000000..aeac15b --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/dialogs/HistoryDialogs.kt @@ -0,0 +1,233 @@ +package com.serranoie.app.minus.presentation.ui.history.dialogs + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.serranoie.app.minus.R +import com.serranoie.app.minus.domain.model.RecurrentFrequency +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.history.edit.TransactionEditScreen +import com.serranoie.app.minus.presentation.ui.theme.bodyMediumCondensed +import com.serranoie.app.minus.presentation.util.prettyDate +import java.text.NumberFormat +import java.time.LocalDate + +@Composable +internal fun TransactionDetailDialog( + transaction: Transaction?, + currencyFormat: NumberFormat, + readOnly: Boolean, + isDismissingTransactionDialog: Boolean, + onDismissStart: () -> Unit, + onDismiss: () -> Unit, + onMarkAsPaid: () -> Unit, + onEdit: (Transaction) -> Unit, + onDelete: (Transaction) -> Unit, +) { + if (transaction == null) return + + val transactionDateText = transaction.date?.let { date -> + prettyDate(date, showTime = true, forceHideDate = false, human = true) + } ?: "Sin fecha" + val recurrenceLabel = when (transaction.recurrentFrequency) { + RecurrentFrequency.WEEKLY -> "Semanal" + RecurrentFrequency.BIWEEKLY -> "Quincenal" + RecurrentFrequency.MONTHLY -> "Mensual" + null -> "" + } + + val details = buildList { + add("Descripción" to transaction.comment.ifEmpty { "Sin nombre" }) + add("Fecha" to transactionDateText) + if (transaction.isRecurrent && recurrenceLabel.isNotEmpty()) { + add("Frecuencia" to recurrenceLabel) + } + transaction.subscriptionDay?.let { day -> + if (transaction.isRecurrent) { + add("Día de cobro" to "Día $day") + } + } + transaction.recurrentEndDate?.let { endDate -> + if (transaction.isRecurrent) { + add( + "Fin recurrencia" to prettyDate( + endDate, + showTime = false, + forceHideDate = false, + human = true, + ) + ) + } + } + } + + AnimatedVisibility( + visible = true, + enter = EnterTransition.None, + exit = ExitTransition.None, + ) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (!isDismissingTransactionDialog) { + onDismissStart() + onDismiss() + } + }, + ) + TransactionDetailTicketCard( + transaction = transaction, + totalAmountText = currencyFormat.format(transaction.amount), + details = details, + onMarkAsPaid = onMarkAsPaid, + onEdit = { onEdit(transaction) }, + onDelete = { onDelete(transaction) }, + readOnly = readOnly, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .align(Alignment.Center), + ) + } + } +} + +@Composable +internal fun TransactionEditDialog( + transaction: Transaction?, + budgetStartDate: LocalDate, + budgetEndDate: LocalDate, + currencyCode: String, + onCancel: () -> Unit, + onSave: (Transaction) -> Unit, +) { + if (transaction == null) return + + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + TransactionEditScreen( + transaction = transaction, + budgetStartDate = budgetStartDate, + budgetEndDate = budgetEndDate, + currencyCode = currencyCode, + onCancel = onCancel, + onSave = { newAmount, newComment, newDateTime, newIsRecurrent, newFrequency, newEndDate, newSubscriptionDay -> + val updatedTransaction = transaction.copy( + id = transaction.sourceTransactionId ?: transaction.id, + amount = newAmount, + comment = newComment, + date = newDateTime, + isRecurrent = newIsRecurrent, + recurrentFrequency = newFrequency, + recurrentEndDate = newEndDate?.atStartOfDay(), + subscriptionDay = newSubscriptionDay, + sourceTransactionId = null, + ) + onSave(updatedTransaction) + }, + ) + } + } +} + +@Composable +internal fun DeleteRecurrentExpenseDialog( + transaction: Transaction?, + onDismiss: () -> Unit, + onConfirm: (Transaction) -> Unit, +) { + if (transaction == null) return + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(R.string.delete_recurrent_expense_title), + style = MaterialTheme.typography.titleLargeEmphasized, + ) + }, + text = { + Column { + val recurrentExpenseName = transaction.comment.ifEmpty { + stringResource(R.string.delete_recurrent_expense_fallback_name) + } + Text( + text = stringResource( + R.string.delete_recurrent_expense_message, + recurrentExpenseName, + ), + style = MaterialTheme.typography.bodyMediumCondensed, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.delete_recurrent_expense_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button( + onClick = { onConfirm(transaction) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text( + stringResource(R.string.delete), + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.labelMediumEmphasized, + ) + } + }, + ) +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/dialogs/TransactionDetailTicketCard.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/dialogs/TransactionDetailTicketCard.kt new file mode 100644 index 0000000..ac26729 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/dialogs/TransactionDetailTicketCard.kt @@ -0,0 +1,154 @@ +package com.serranoie.app.minus.presentation.ui.history.dialogs + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.theme.bodyMediumCondensed +import com.serranoie.app.minus.presentation.ui.theme.component.ticket.TicketCard + +@Composable +internal fun TransactionDetailTicketCard( + transaction: Transaction, + totalAmountText: String, + details: List>, + onMarkAsPaid: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + readOnly: Boolean, + modifier: Modifier = Modifier, +) { + TicketCard( + backgroundColor = MaterialTheme.colorScheme.background, + teethWidthDp = 20f, + teethHeightDp = 4f, + modifier = modifier, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .background(Color.Black) + .padding(vertical = 8.dp, horizontal = 18.dp) + ) { + Text( + text = if (transaction.isRecurrent) "GASTO RECURRENTE" else "GASTO", + color = Color.White, + style = MaterialTheme.typography.labelLargeEmphasized, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + fontSize = TextUnit(26f, TextUnitType.Sp), + ) + } + + Text( + text = "Num. de Operación: #${transaction.id}", + style = MaterialTheme.typography.bodyMediumCondensed, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + HorizontalDivider() + + Text( + text = "MONTO TOTAL", + style = MaterialTheme.typography.labelLargeEmphasized, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Text( + text = totalAmountText, + style = MaterialTheme.typography.headlineLargeEmphasized, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + + HorizontalDivider() + + details.forEach { (label, value) -> + Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodyMediumCondensed, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterStart), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMediumCondensed, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + + if (transaction.isRecurrent) { + Button( + onClick = onMarkAsPaid, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Marcar como pagado", + style = MaterialTheme.typography.labelSmallEmphasized, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary, + ), + onClick = onEdit, + modifier = Modifier.weight(1f), + ) { + Text("Editar", style = MaterialTheme.typography.labelSmallEmphasized) + } + + if (!readOnly) { + Button( + onClick = onDelete, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text("Eliminar", style = MaterialTheme.typography.labelSmallEmphasized) + } + } + } + } + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditAmountDisplay.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditAmountDisplay.kt new file mode 100644 index 0000000..3c559cf --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditAmountDisplay.kt @@ -0,0 +1,55 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import java.math.BigDecimal +import java.text.NumberFormat +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.tooling.preview.Preview +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.displayLargeCondensed + +@Composable +internal fun EditAmountDisplay( + rawAmount: String, + currencyFormat: NumberFormat, + style: TextStyle, + modifier: Modifier = Modifier, +) { + val formattedAmount = remember(rawAmount) { + try { + val value = rawAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO + currencyFormat.format(value) + } catch (e: Exception) { + rawAmount + } + } + + Text( + text = formattedAmount, + style = style, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + modifier = modifier.fillMaxWidth() + ) +} + + +@Preview(showBackground = true) +@Composable +private fun EditAmountDisplayPreview() { + MinusTheme { + EditAmountDisplay( + rawAmount = "1234.56", + currencyFormat = NumberFormat.getCurrencyInstance(), + style = MaterialTheme.typography.displayLargeCondensed, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditCalculations.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditCalculations.kt new file mode 100644 index 0000000..56671af --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditCalculations.kt @@ -0,0 +1,61 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +import java.math.BigDecimal +import java.math.RoundingMode + +internal fun evaluateCalculation(input: String): String? { + if (input.isBlank()) return null + + return try { + val normalized = input.trim().replace("×", "*").replace("÷", "/") + + normalized.lastOrNull()?.let { if (it in "+-*/") return null } + + val hasOperator = normalized.any { it in "+-*/" } + + if (!hasOperator) { + val num = normalized.toBigDecimalOrNull() ?: return null + return if (num.scale() <= 0 || num.stripTrailingZeros().scale() <= 0) { + num.toBigInteger().toString() + } else { + num.setScale(2, RoundingMode.HALF_UP).toPlainString() + } + } + + val tokenPattern = Regex("([+\\-*/])") + val parts = tokenPattern.split(normalized).filter { it.isNotEmpty() } + val operators = tokenPattern.findAll(normalized).map { it.value }.toList() + + if (parts.isEmpty() || parts[0].isEmpty()) return null + + if (operators.size > parts.size - 1) return null + + var result = parts[0].toBigDecimalOrNull() ?: return null + + for (i in operators.indices) { + if (i + 1 >= parts.size) break + val operator = operators[i] + val nextNum = parts[i + 1].toBigDecimalOrNull() ?: return null + + result = when (operator) { + "+" -> result + nextNum + "-" -> result - nextNum + "*" -> result * nextNum + "/" -> { + if (nextNum.compareTo(BigDecimal.ZERO) == 0) return null // Division by zero + result.divide(nextNum, 2, RoundingMode.HALF_UP) + } + + else -> return null + } + } + + if (result.scale() <= 0 || result.stripTrailingZeros().scale() <= 0) { + result.toBigInteger().toString() + } else { + result.setScale(2, RoundingMode.HALF_UP).toPlainString() + } + } catch (e: Exception) { + null + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditDatePickerDialog.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditDatePickerDialog.kt new file mode 100644 index 0000000..eaf7f3b --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditDatePickerDialog.kt @@ -0,0 +1,76 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.serranoie.app.minus.R +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import androidx.compose.ui.tooling.preview.Preview +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EditDatePickerDialog( + initialDate: LocalDate, + minDate: LocalDate?, + maxDate: LocalDate?, + onDismiss: () -> Unit, + onDateSelected: (LocalDate) -> Unit, +) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = initialDate.atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + ) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val selectedDate = + Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC) + .toLocalDate() + onDateSelected(selectedDate) + } + } + ) { + Text(stringResource(R.string.accept)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) { + DatePicker(state = datePickerState) + } + + @Suppress("UNUSED_VARIABLE") + val unused = listOf(minDate, maxDate) +} + + +@Preview(showBackground = true) +@Composable +private fun EditDatePickerDialogPreview() { + MinusTheme { + EditDatePickerDialog( + initialDate = LocalDate.now(), + minDate = LocalDate.now().minusDays(30), + maxDate = LocalDate.now().plusDays(30), + onDismiss = {}, + onDateSelected = {}, + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditTimePickerDialog.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditTimePickerDialog.kt new file mode 100644 index 0000000..b850a41 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/EditTimePickerDialog.kt @@ -0,0 +1,89 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.serranoie.app.minus.R +import androidx.compose.ui.tooling.preview.Preview +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EditTimePickerDialog( + initialHour: Int, + initialMinute: Int, + onDismiss: () -> Unit, + onTimeSelected: (hour: Int, minute: Int) -> Unit, +) { + val timePickerState = rememberTimePickerState( + initialHour = initialHour, + initialMinute = initialMinute + ) + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.large, + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.select_time), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + + TimePicker(state = timePickerState) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + + TextButton( + onClick = { + onTimeSelected(timePickerState.hour, timePickerState.minute) + } + ) { + Text(stringResource(R.string.accept)) + } + } + } + } + } +} + + +@Preview(showBackground = true) +@Composable +private fun EditTimePickerDialogPreview() { + MinusTheme { + EditTimePickerDialog( + initialHour = 14, + initialMinute = 30, + onDismiss = {}, + onTimeSelected = { _, _ -> }, + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/RecurrenceConfigSheet.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/RecurrenceConfigSheet.kt new file mode 100644 index 0000000..5c37e8e --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/RecurrenceConfigSheet.kt @@ -0,0 +1,369 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.R +import com.serranoie.app.minus.domain.model.RecurrentFrequency +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.bodySmallCondensed +import com.serranoie.app.minus.presentation.ui.theme.labelMediumCondensed +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun RecurrenceConfigSheet( + isRecurrent: Boolean, + selectedFrequency: RecurrentFrequency, + subscriptionDay: Int, + recurrentEndDate: LocalDate, + onSaveConfiguration: ( + isRecurrent: Boolean, frequency: RecurrentFrequency, subscriptionDay: Int, endDate: LocalDate + ) -> Unit, + onDismiss: () -> Unit, +) { + var showEndDatePicker by remember { mutableStateOf(false) } + var localIsRecurrent by remember(isRecurrent) { mutableStateOf(isRecurrent) } + var localSelectedFrequency by remember(selectedFrequency) { mutableStateOf(selectedFrequency) } + var localSubscriptionDay by remember(subscriptionDay) { mutableIntStateOf(subscriptionDay) } + var localRecurrentEndDate by remember(recurrentEndDate) { mutableStateOf(recurrentEndDate) } + val today = LocalDate.now() + val maxSelectableDate = today.plusMonths(12) + val dateFormatter = remember { DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.configure_recurrence), + style = MaterialTheme.typography.titleMediumEmphasized, + ) + + Switch( + checked = localIsRecurrent, onCheckedChange = { localIsRecurrent = it }) + } + + if (localIsRecurrent) { + Spacer(Modifier.height(16.dp)) + + Text( + stringResource(R.string.recurrent_expense_frequency_subtitle), + style = MaterialTheme.typography.labelMediumCondensed + ) + + val options = listOf( + stringResource(R.string.recurrent_frequency_weekly), + stringResource(R.string.recurrent_frequency_biweekly), + stringResource(R.string.recurrent_frequency_monthly) + ) + val frequencies = listOf( + RecurrentFrequency.WEEKLY, + RecurrentFrequency.BIWEEKLY, + RecurrentFrequency.MONTHLY, + ) + val selectedIndex = frequencies.indexOf(localSelectedFrequency).coerceAtLeast(0) + + Row( + Modifier.padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + ) { + val modifiers = + listOf(Modifier.weight(1f), Modifier.weight(1.5f), Modifier.weight(1f)) + + options.forEachIndexed { index, label -> + ToggleButton( + checked = selectedIndex == index, + onCheckedChange = { localSelectedFrequency = frequencies[index] }, + modifier = modifiers[index].semantics { role = Role.RadioButton }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + ) { + Text(label, style = MaterialTheme.typography.labelMediumCondensed) + } + } + } + + Spacer(Modifier.height(16.dp)) + + if (localSelectedFrequency == RecurrentFrequency.MONTHLY) { + MonthlySubscriptionDayRow( + day = localSubscriptionDay, + onDayChange = { localSubscriptionDay = it }, + ) + Spacer(Modifier.height(16.dp)) + } + + Text( + text = stringResource(R.string.limit_date), + style = MaterialTheme.typography.labelMediumCondensed, + modifier = Modifier.padding(bottom = 4.dp) + ) + + OutlinedTextField( + value = localRecurrentEndDate.format(dateFormatter), + onValueChange = {}, + readOnly = true, + placeholder = { Text(stringResource(R.string.date_placeholder)) }, + trailingIcon = { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { + showEndDatePicker = true + }) + }, + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedBorderColor = MaterialTheme.colorScheme.surfaceContainer, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + shape = MaterialTheme.shapes.large, + border = BorderStroke( + 1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + ) + Text( + text = buildRecurrentSummary( + frequency = localSelectedFrequency, + selectedDay = localSubscriptionDay, + selectedEndDate = localRecurrentEndDate, + formatter = dateFormatter, + ), + style = MaterialTheme.typography.bodySmallCondensed, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + Button( + modifier = Modifier.fillMaxWidth(), onClick = { + onSaveConfiguration( + localIsRecurrent, + localSelectedFrequency, + localSubscriptionDay, + localRecurrentEndDate, + ) + onDismiss() + }) { + Text( + text = stringResource(R.string.save), + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + + Spacer(Modifier.height(12.dp)) + } + + if (showEndDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = localRecurrentEndDate.atStartOfDay(ZoneId.systemDefault()) + .toInstant().toEpochMilli(), selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val date = Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneId.systemDefault()) + .toLocalDate() + return date.isAfter(today) && !date.isAfter(maxSelectableDate) + } + }) + + DatePickerDialog(onDismissRequest = { showEndDatePicker = false }, confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + localRecurrentEndDate = + Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()) + .toLocalDate() + } + showEndDatePicker = false + }) { + Text(stringResource(R.string.accept)) + } + }, dismissButton = { + TextButton(onClick = { showEndDatePicker = false }) { + Text(stringResource(R.string.cancel)) + } + }) { + DatePicker(state = datePickerState) + } + } +} + +@Composable +private fun MonthlySubscriptionDayRow( + day: Int, + onDayChange: (Int) -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainer + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton(onClick = { onDayChange((day - 1).coerceAtLeast(1)) }) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.previous_day), + modifier = Modifier.size(18.dp) + ) + } + + Text( + text = stringResource(R.string.monthly_on_day_format, day), + style = MaterialTheme.typography.titleSmallEmphasized + ) + + IconButton(onClick = { onDayChange((day + 1).coerceAtMost(31)) }) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.next_day), + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +@Composable +private fun buildRecurrentSummary( + frequency: RecurrentFrequency, + selectedDay: Int, + selectedEndDate: LocalDate, + formatter: DateTimeFormatter +): String { + val formattedDate = selectedEndDate.format(formatter) + return when (frequency) { + RecurrentFrequency.WEEKLY -> stringResource(R.string.summary_weekly_format, formattedDate) + RecurrentFrequency.BIWEEKLY -> stringResource( + R.string.summary_biweekly_format, formattedDate + ) + + RecurrentFrequency.MONTHLY -> stringResource( + R.string.summary_monthly_format, selectedDay, formattedDate + ) + } +} + + +@Preview(showBackground = true) +@Composable +private fun RecurrenceConfigSheetPreview() { + MinusTheme { + RecurrenceConfigSheet( + isRecurrent = true, + selectedFrequency = RecurrentFrequency.MONTHLY, + subscriptionDay = 15, + recurrentEndDate = LocalDate.now().plusMonths(6), + onSaveConfiguration = { _, _, _, _ -> }, + onDismiss = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RecurrenceConfigSheetWeeklyPreview() { + MinusTheme { + RecurrenceConfigSheet( + isRecurrent = true, + selectedFrequency = RecurrentFrequency.WEEKLY, + subscriptionDay = 1, + recurrentEndDate = LocalDate.now().plusMonths(3), + onSaveConfiguration = { _, _, _, _ -> }, + onDismiss = {}, + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/RecurrenceToggleButton.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/RecurrenceToggleButton.kt new file mode 100644 index 0000000..715099c --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/RecurrenceToggleButton.kt @@ -0,0 +1,75 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.EventRepeat +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedToggleButton +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButtonColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.serranoie.app.minus.R +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun RecurrenceToggleButton( + isRecurrent: Boolean, + onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val tertiary = MaterialTheme.colorScheme.tertiary + + ElevatedToggleButton( + checked = isRecurrent, + onCheckedChange = onToggle, + modifier = modifier, + colors = ToggleButtonColors( + // Unchecked: tertiary container tint at 50% — soft, secondary feel. + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.25f), + contentColor = tertiary, + // Checked: tertiary tint at 22% — visually selected without screaming. + checkedContainerColor = MaterialTheme.colorScheme.tertiaryContainer, + checkedContentColor = tertiary, + disabledContentColor = MaterialTheme.colorScheme.outline, + disabledContainerColor = MaterialTheme.colorScheme.outlineVariant + ), + ) { + Icon( + imageVector = Icons.Rounded.EventRepeat, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.recurrent_toggle_label), style = MaterialTheme.typography.titleSmallEmphasized) + } +} + +@Preview(showBackground = true) +@Composable +private fun RecurrenceToggleButtonOnPreview() { + MinusTheme { + RecurrenceToggleButton( + isRecurrent = true, + onToggle = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RecurrenceToggleButtonOffPreview() { + MinusTheme { + RecurrenceToggleButton( + isRecurrent = false, + onToggle = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionDateTimeRow.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionDateTimeRow.kt new file mode 100644 index 0000000..26f8a44 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionDateTimeRow.kt @@ -0,0 +1,86 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.presentation.util.prettyDate +import java.time.LocalDate +import java.time.LocalTime +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.tooling.preview.Preview +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme + +@Composable +internal fun TransactionDateTimeRow( + date: LocalDate, + time: LocalTime, + onDateClick: () -> Unit, + onTimeClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = prettyDate( + date.atStartOfDay(), + forceShowDate = true, + showTime = false, + human = false + ), + style = MaterialTheme.typography.titleSmallEmphasized, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onDateClick() } + .padding(horizontal = 2.dp, vertical = 4.dp) + ) + + Text( + text = "—", + style = MaterialTheme.typography.titleSmallEmphasized, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp) + ) + + Text( + text = String.format("%02d:%02d", time.hour, time.minute), + style = MaterialTheme.typography.titleSmallEmphasized, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onTimeClick() } + .padding(horizontal = 2.dp, vertical = 4.dp) + ) + } +} + + +@Preview(showBackground = true) +@Composable +private fun TransactionDateTimeRowPreview() { + MinusTheme { + TransactionDateTimeRow( + date = LocalDate.now(), + time = LocalTime.of(14, 30), + onDateClick = {}, + onTimeClick = {}, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionEditScreen.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionEditScreen.kt new file mode 100644 index 0000000..c3b9309 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionEditScreen.kt @@ -0,0 +1,376 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.domain.model.RecurrentFrequency +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.editor.category.CategoryToolbar +import com.serranoie.app.minus.presentation.ui.editor.category.FocusController +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.component.numpad.EditMode +import com.serranoie.app.minus.presentation.ui.theme.component.numpad.EditStage +import com.serranoie.app.minus.presentation.ui.theme.component.numpad.EditorState +import com.serranoie.app.minus.presentation.ui.theme.component.numpad.Numpad +import com.serranoie.app.minus.presentation.ui.theme.displayLargeCondensed +import com.serranoie.app.minus.presentation.util.symbolOnlyCurrencyFormat +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.util.Date +import kotlin.time.Duration.Companion.milliseconds +import com.serranoie.app.minus.presentation.ui.theme.component.numpad.Transaction as NumpadTransaction + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransactionEditScreen( + transaction: Transaction, + budgetStartDate: LocalDate, + budgetEndDate: LocalDate, + currencyCode: String = "USD", + onCancel: () -> Unit = {}, + onSave: ( + newAmount: BigDecimal, + newComment: String, + newDateTime: LocalDateTime, + newIsRecurrent: Boolean, + newFrequency: RecurrentFrequency?, + newEndDate: LocalDate?, + newSubscriptionDay: Int? + ) -> Unit = { _, _, _, _, _, _, _ -> }, + modifier: Modifier = Modifier +) { + val currencyFormat = symbolOnlyCurrencyFormat(currencyCode) + val scope = rememberCoroutineScope() + + var editedAmount by remember { mutableStateOf(transaction.amount.toString()) } + var editedComment by remember { mutableStateOf(transaction.comment) } + var editedDate by remember { + mutableStateOf(transaction.date?.toLocalDate() ?: LocalDate.now()) + } + var editedTime by remember { + mutableStateOf(transaction.date?.toLocalTime() ?: LocalTime.now()) + } + + var isRecurrent by remember { mutableStateOf(transaction.isRecurrent) } + var selectedFrequency by remember { + mutableStateOf(transaction.recurrentFrequency ?: RecurrentFrequency.MONTHLY) + } + var subscriptionDay by remember { + mutableIntStateOf(transaction.subscriptionDay ?: transaction.date?.dayOfMonth ?: 1) + } + var recurrentEndDate by remember { + mutableStateOf(transaction.recurrentEndDate?.toLocalDate() ?: budgetEndDate.plusMonths(3)) + } + + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + var showRecurrentBottomSheet by remember { mutableStateOf(false) } + + val focusController = remember { FocusController() } + + var isCalculation by remember { mutableStateOf(false) } + + // Calculate dynamic target height for numpad to take 48% of screen height + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val targetNumpadHeight = screenHeight * 0.48f + + val baseTextStyle = MaterialTheme.typography.displayLargeCondensed.copy( + fontWeight = FontWeight.W500 + ) + + val editorState = remember(editedAmount) { + EditorState( + mode = EditMode.EDIT, + rawSpentValue = editedAmount, + stage = EditStage.EDIT_SPENT, + currentSpent = editedAmount, + currentComment = editedComment, + editedTransaction = transaction.let { + NumpadTransaction( + id = it.id, + amount = it.amount.toPlainString(), + comment = it.comment, + date = it.date?.atZone(ZoneId.systemDefault())?.toInstant() + ?.let { instant -> Date.from(instant) } ?: Date() + ) + } + ) + } + + Column( + modifier = modifier + .fillMaxSize() + .statusBarsPadding() + ) { + TransactionEditTopBar( + isRecurrent = transaction.isRecurrent, + onCancel = onCancel, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RecurrenceToggleButton( + isRecurrent = isRecurrent, + onToggle = { + scope.launch { + delay(180.milliseconds) + showRecurrentBottomSheet = true + } + }, + modifier = Modifier.weight(1f), + ) + + TransactionDateTimeRow( + date = editedDate, + time = editedTime, + onDateClick = { showDatePicker = true }, + onTimeClick = { showTimePicker = true }, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(16.dp), + contentAlignment = Alignment.TopEnd + ) { + EditAmountDisplay( + rawAmount = editedAmount, + currencyFormat = currencyFormat, + style = baseTextStyle, + modifier = Modifier.fillMaxWidth(), + ) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium + ) { + CategoryToolbar( + tags = emptyList(), + currentComment = editedComment, + stage = EditStage.EDIT_SPENT, + onCommentUpdate = { editedComment = it }, + editorFocusController = focusController, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Numpad( + modifier = Modifier + .fillMaxWidth() + .height(targetNumpadHeight), + editorState = editorState, + onNumberInput = { digit -> + editedAmount = if (editedAmount == "0") { + digit.toString() + } else { + editedAmount + digit.toString() + } + }, + onDotInput = { + val lastChar = editedAmount.lastOrNull() + if (editedAmount.isEmpty() || (lastChar != null && lastChar in "+-×÷")) { + editedAmount += "0." + } else { + val lastOperatorIndex = editedAmount.indexOfLast { it in "+-×÷" } + val currentSegment = editedAmount.substring(lastOperatorIndex + 1) + if (!currentSegment.contains(".")) { + editedAmount += "." + } + } + }, + onBackspace = { + editedAmount = editedAmount.dropLast(1).ifEmpty { "0" } + }, + onBackspaceLongPress = { + editedAmount = "0" + }, + onApply = { + val newAmount = editedAmount.toBigDecimalOrNull() ?: transaction.amount + val frequency = if (isRecurrent) selectedFrequency else null + val endDate = if (isRecurrent) recurrentEndDate else null + val subDay = if (isRecurrent && selectedFrequency == RecurrentFrequency.MONTHLY) { + subscriptionDay + } else { + null + } + + onSave( + newAmount, + editedComment, + editedDate.atTime(editedTime), + isRecurrent, + frequency, + endDate, + subDay + ) + }, + isCalculation = isCalculation, + onCalculationModeChanged = { isCalculation = it }, + onOperatorInput = { operator -> + val lastChar = editedAmount.lastOrNull() + if (editedAmount.isNotEmpty() && lastChar != null && lastChar !in "+-×÷" && lastChar != '.') { + editedAmount += operator.toString() + } + }, + onEqualsInput = { + val result = evaluateCalculation(editedAmount) + if (result != null) { + editedAmount = result + } + }, + onDelete = { + onCancel() + }, + enableCalculationMode = false, + ) + } + + if (showDatePicker) { + EditDatePickerDialog( + initialDate = editedDate, + minDate = budgetStartDate, + maxDate = budgetEndDate, + onDismiss = { showDatePicker = false }, + onDateSelected = { selected -> + editedDate = when { + selected.isBefore(budgetStartDate) -> budgetStartDate + selected.isAfter(budgetEndDate) -> budgetEndDate + else -> selected + } + showDatePicker = false + }, + ) + } + + if (showTimePicker) { + EditTimePickerDialog( + initialHour = editedTime.hour, + initialMinute = editedTime.minute, + onDismiss = { showTimePicker = false }, + onTimeSelected = { hour, minute -> + editedTime = LocalTime.of(hour, minute) + showTimePicker = false + }, + ) + } + + if (showRecurrentBottomSheet) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + onDismissRequest = { showRecurrentBottomSheet = false }, + sheetState = sheetState + ) { + RecurrenceConfigSheet( + isRecurrent = true, + selectedFrequency = selectedFrequency, + subscriptionDay = subscriptionDay, + recurrentEndDate = recurrentEndDate, + onSaveConfiguration = { newIsRecurrent, newFrequency, newSubscriptionDay, newEndDate -> + isRecurrent = newIsRecurrent + selectedFrequency = newFrequency + subscriptionDay = newSubscriptionDay + recurrentEndDate = newEndDate + }, + onDismiss = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + showRecurrentBottomSheet = false + } + } + ) + } + } +} + +@Preview(showBackground = true, device = "id:pixel_5") +@Composable +fun TransactionEditScreenPreview() { + MinusTheme { + TransactionEditScreen( + transaction = Transaction( + id = 1L, + amount = BigDecimal("50.00"), + comment = "ani", + date = LocalDateTime.now(), + isDeleted = false + ), + budgetStartDate = LocalDate.now().minusDays(15), + budgetEndDate = LocalDate.now().plusDays(15), + currencyCode = "USD", + onCancel = {}, + onSave = { _, _, _, _, _, _, _ -> } + ) + } +} + +@Preview(showBackground = true, device = "id:pixel_5") +@Composable +fun TransactionEditScreenRecurringPreview() { + MinusTheme { + TransactionEditScreen( + transaction = Transaction( + id = 1L, + amount = BigDecimal("99.00"), + comment = "Netflix", + date = LocalDateTime.now(), + isDeleted = false, + isRecurrent = true, + recurrentFrequency = RecurrentFrequency.MONTHLY, + subscriptionDay = 15, + recurrentEndDate = LocalDateTime.now().plusMonths(6) + ), + budgetStartDate = LocalDate.now().minusDays(15), + budgetEndDate = LocalDate.now().plusDays(15), + currencyCode = "USD", + onCancel = {}, + onSave = { _, _, _, _, _, _, _ -> } + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionEditTopBar.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionEditTopBar.kt new file mode 100644 index 0000000..f99466a --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/edit/TransactionEditTopBar.kt @@ -0,0 +1,83 @@ +package com.serranoie.app.minus.presentation.ui.history.edit + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.R +import androidx.compose.ui.tooling.preview.Preview +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme + + +@Composable +internal fun TransactionEditTopBar( + isRecurrent: Boolean, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onCancel, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cancel_edit_content_desc), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + Text( + text = if (isRecurrent) { + stringResource(R.string.edit_recurrent_expense_title) + } else { + stringResource(R.string.edit_expense_title) + }, + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(start = 8.dp) + .basicMarquee() + ) + } +} + + +@Preview(showBackground = true) +@Composable +private fun TransactionEditTopBarPreview() { + MinusTheme { + TransactionEditTopBar( + isRecurrent = false, + onCancel = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TransactionEditTopBarRecurrentPreview() { + MinusTheme { + TransactionEditTopBar( + isRecurrent = true, + onCancel = {}, + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/BudgetDisplaySection.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/BudgetDisplaySection.kt new file mode 100644 index 0000000..b3c17b6 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/BudgetDisplaySection.kt @@ -0,0 +1,83 @@ +package com.serranoie.app.minus.presentation.ui.history.sections + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +import com.serranoie.app.minus.domain.model.BudgetSettings +import com.serranoie.app.minus.domain.model.BudgetState +import com.serranoie.app.minus.presentation.ui.theme.component.budget.BudgetDisplay +import logcat.logcat +import java.math.BigDecimal +import java.time.ZoneId +import java.util.Date +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.serranoie.app.minus.domain.model.BudgetPeriod +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import java.time.LocalDate + +internal fun LazyListScope.budgetDisplaySection( + budgetState: BudgetState?, + budgetSettings: BudgetSettings?, + currencyCode: String, +) { + item("budget-display") { + val startDate = budgetSettings?.startDate?.let { + Date.from(it.atStartOfDay(ZoneId.systemDefault()).toInstant()) + } ?: Date() + + val finishDate = budgetSettings?.getPeriodEndDate()?.let { + Date.from(it.atStartOfDay(ZoneId.systemDefault()).toInstant()) + } + + val budget = budgetState?.totalBudget ?: BigDecimal.ZERO + logcat("History") { + "BudgetDisplay input budget=$budget budgetStateTotal=${budgetState?.totalBudget} budgetSettingsTotal=${budgetSettings?.totalBudget} rollOverLimit=${budgetSettings?.rollOverLimit} rollOverCarry=${budgetSettings?.rollOverCarryForward}" + } + + BudgetDisplay( + budget = budget, + budgetState = budgetState, + budgetSettings = budgetSettings, + currencyCode = currencyCode, + bigVariant = true, + modifier = Modifier.fillMaxWidth(), + startDate = startDate, + finishDate = finishDate, + ) + } +} + +@PreviewLightDark +@Composable +private fun BudgetDisplaySectionPreview() { + val today = LocalDate.now() + MinusTheme { + LazyColumn(modifier = Modifier.fillMaxSize()) { + budgetDisplaySection( + budgetState = BudgetState( + remainingToday = BigDecimal("50.00"), + totalSpentToday = BigDecimal("10.00"), + dailyBudget = BigDecimal("50.00"), + daysRemaining = 15, + progress = 0.2f, + isOverBudget = false, + totalBudget = BigDecimal("1500.00"), + totalSpentInPeriod = BigDecimal("250.00"), + ), + budgetSettings = BudgetSettings( + totalBudget = BigDecimal("1500.00"), + period = BudgetPeriod.MONTHLY, + startDate = today.minusDays(15), + endDate = today.plusDays(15), + currencyCode = "USD", + daysInPeriod = 31, + ), + currencyCode = "USD", + ) + } + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/CurrentPeriodRecurrentSection.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/CurrentPeriodRecurrentSection.kt new file mode 100644 index 0000000..d0c1573 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/CurrentPeriodRecurrentSection.kt @@ -0,0 +1,118 @@ +package com.serranoie.app.minus.presentation.ui.history.sections + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.serranoie.app.minus.R +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.history.RecurrentPaymentsViewMode +import com.serranoie.app.minus.presentation.ui.theme.component.expense.RecurrentPaymentsDivider +import com.serranoie.app.minus.presentation.ui.theme.component.expense.SwipeableUpcomingRecurrentItem +import com.serranoie.app.minus.presentation.ui.theme.component.expense.UpcomingRecurrentItem +import java.text.NumberFormat +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.serranoie.app.minus.domain.model.RecurrentFrequency +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import java.math.BigDecimal +import java.time.LocalDate + + +internal fun LazyListScope.currentPeriodRecurrentSection( + upcomingRecurrentInPeriod: List, + showUpcomingRecurrentInPeriod: Boolean, + onToggleShowUpcomingRecurrentInPeriod: () -> Unit, + recurrentPaymentsViewMode: RecurrentPaymentsViewMode, + currencyFormat: NumberFormat, + onDelete: (Transaction) -> Unit, + onEdit: (Transaction) -> Unit, + onClick: (Transaction) -> Unit, +) { + if (upcomingRecurrentInPeriod.isEmpty()) return + + item("upcoming-recurrent-toggle") { + RecurrentPaymentsDivider( + title = stringResource(R.string.recurrent_payments_divider_title_current_period), + isExpanded = showUpcomingRecurrentInPeriod, + onToggleClick = onToggleShowUpcomingRecurrentInPeriod, + itemCount = upcomingRecurrentInPeriod.size, + modifier = Modifier.fillMaxWidth(), + ) + } + + item("upcoming-recurrent-content") { + AnimatedVisibility( + visible = showUpcomingRecurrentInPeriod, + enter = expandVertically( + animationSpec = tween(300), + expandFrom = Alignment.Top, + ) + fadeIn(animationSpec = tween(300)), + exit = shrinkVertically( + animationSpec = tween(300), + shrinkTowards = Alignment.Top, + ) + fadeOut(animationSpec = tween(300)), + ) { + RecurrentItemsContent( + items = upcomingRecurrentInPeriod, + recurrentPaymentsViewMode = recurrentPaymentsViewMode, + currencyFormat = currencyFormat, + verticalItem = { _, item, position -> + SwipeableUpcomingRecurrentItem( + item = item, + currencyFormat = currencyFormat, + position = position, + onDelete = { onDelete(item.transaction) }, + onEdit = { onEdit(item.transaction) }, + onClick = { onClick(item.transaction) }, + ) + }, + horizontalKeyPrefix = "upcoming", + onClick = onClick, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun CurrentPeriodRecurrentSectionPreview() { + val today = LocalDate.now() + val sampleItem = UpcomingRecurrentItem( + transaction = Transaction( + id = 1L, + amount = BigDecimal("15.00"), + comment = "Netflix", + date = today.atStartOfDay(), + isDeleted = false, + isRecurrent = true, + recurrentFrequency = RecurrentFrequency.MONTHLY, + ), + nextChargeDate = today.plusDays(5), + isInCurrentPeriod = true, + ) + MinusTheme { + LazyColumn(modifier = Modifier.fillMaxSize()) { + currentPeriodRecurrentSection( + upcomingRecurrentInPeriod = listOf(sampleItem), + showUpcomingRecurrentInPeriod = true, + onToggleShowUpcomingRecurrentInPeriod = {}, + recurrentPaymentsViewMode = RecurrentPaymentsViewMode.HORIZONTAL_LIST, + currencyFormat = NumberFormat.getCurrencyInstance(), + onDelete = {}, + onEdit = {}, + onClick = {}, + ) + } + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/FutureRecurrentSection.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/FutureRecurrentSection.kt new file mode 100644 index 0000000..a84afcc --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/FutureRecurrentSection.kt @@ -0,0 +1,135 @@ +package com.serranoie.app.minus.presentation.ui.history.sections + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.history.RecurrentPaymentsViewMode +import com.serranoie.app.minus.presentation.ui.theme.component.WavyDivider +import com.serranoie.app.minus.presentation.ui.theme.component.expense.SwipeableUpcomingRecurrentItem +import com.serranoie.app.minus.presentation.ui.theme.component.expense.UpcomingRecurrentItem +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.tooling.preview.Preview +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import java.time.LocalDate +import java.text.NumberFormat +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.serranoie.app.minus.domain.model.RecurrentFrequency +import java.math.BigDecimal + +internal fun LazyListScope.futureRecurrentSection( + futureRecurrentOutOfPeriod: List, + showOutOfPeriodSubscriptions: Boolean, + onToggleShowOutOfPeriodSubscriptions: () -> Unit, + recurrentPaymentsViewMode: RecurrentPaymentsViewMode, + currencyFormat: NumberFormat, + onDelete: (Transaction) -> Unit, + onEdit: (Transaction) -> Unit, + onClick: (Transaction) -> Unit, +) { + if (futureRecurrentOutOfPeriod.isEmpty()) return + + item("future-recurrent-toggle") { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = null, + ) { + onToggleShowOutOfPeriodSubscriptions() + }, + ) { + WavyDivider( + text = if (showOutOfPeriodSubscriptions) { + "Ocultar subscripciones fuera del periodo" + } else { + "Mostrar subscripciones fuera del periodo" + }, + horizontalPadding = 0.dp, + amplitude = 4f, + wavelength = 45f, + ) + } + } + + item("future-recurrent-content") { + AnimatedVisibility( + visible = showOutOfPeriodSubscriptions, + enter = expandVertically( + animationSpec = tween(300), + expandFrom = Alignment.Top, + ) + fadeIn(animationSpec = tween(300)), + exit = shrinkVertically( + animationSpec = tween(300), + shrinkTowards = Alignment.Top, + ) + fadeOut(animationSpec = tween(300)), + ) { + RecurrentItemsContent( + items = futureRecurrentOutOfPeriod, + recurrentPaymentsViewMode = recurrentPaymentsViewMode, + currencyFormat = currencyFormat, + verticalItem = { _, item, position -> + SwipeableUpcomingRecurrentItem( + item = item, + currencyFormat = currencyFormat, + position = position, + onDelete = { onDelete(item.transaction) }, + onEdit = { onEdit(item.transaction) }, + onClick = { onClick(item.transaction) }, + ) + }, + horizontalKeyPrefix = "future", + onClick = onClick, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun FutureRecurrentSectionPreview() { + val today = LocalDate.now() + val sampleItem = UpcomingRecurrentItem( + transaction = Transaction( + id = 1L, + amount = BigDecimal("99.00"), + comment = "Spotify", + date = today.atStartOfDay(), + isDeleted = false, + isRecurrent = true, + recurrentFrequency = RecurrentFrequency.MONTHLY, + ), + nextChargeDate = today.plusMonths(2), + isInCurrentPeriod = false, + ) + MinusTheme { + LazyColumn(modifier = Modifier.fillMaxSize()) { + futureRecurrentSection( + futureRecurrentOutOfPeriod = listOf(sampleItem), + showOutOfPeriodSubscriptions = true, + onToggleShowOutOfPeriodSubscriptions = {}, + recurrentPaymentsViewMode = RecurrentPaymentsViewMode.HORIZONTAL_LIST, + currencyFormat = NumberFormat.getCurrencyInstance(), + onDelete = {}, + onEdit = {}, + onClick = {}, + ) + } + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/PastPeriodToggleSection.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/PastPeriodToggleSection.kt new file mode 100644 index 0000000..5346e1e --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/PastPeriodToggleSection.kt @@ -0,0 +1,72 @@ +package com.serranoie.app.minus.presentation.ui.history.sections + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.component.WavyDivider +import java.math.BigDecimal +import java.time.LocalDate + +internal fun LazyListScope.pastPeriodToggleSection( + groupedPastTransactions: Map>, + showPastPeriod: Boolean, + onToggleShowPastPeriod: () -> Unit, +) { + if (groupedPastTransactions.isEmpty()) return + + item("wavy-divider") { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = null, + ) { + onToggleShowPastPeriod() + }, + ) { + WavyDivider( + text = if (showPastPeriod) "Ocultar gastos del periodo pasado" else "Mostrar gastos del periodo pasado", + horizontalPadding = 0.dp, + amplitude = 4f, + wavelength = 45f, + ) + } + } +} + + +@Preview(showBackground = true) +@Composable +private fun PastPeriodToggleSectionPreview() { + val today = LocalDate.now() + val pastDate = today.minusDays(20) + val tx = Transaction( + id = 1L, + amount = BigDecimal("30.00"), + comment = "Lunch", + date = pastDate.atStartOfDay(), + isDeleted = false, + ) + MinusTheme { + LazyColumn(modifier = Modifier.fillMaxSize()) { + pastPeriodToggleSection( + groupedPastTransactions = mapOf(pastDate to listOf(tx)), + showPastPeriod = false, + onToggleShowPastPeriod = {}, + ) + } + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/PastTransactionDateSections.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/PastTransactionDateSections.kt new file mode 100644 index 0000000..411ef32 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/PastTransactionDateSections.kt @@ -0,0 +1,173 @@ +package com.serranoie.app.minus.presentation.ui.history.sections + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.component.date.HistoryDateDivider +import com.serranoie.app.minus.presentation.ui.theme.component.expense.SwipeableExpenseItem +import java.math.BigDecimal +import java.text.NumberFormat +import java.time.LocalDate + +internal fun LazyListScope.pastTransactionDateSections( + showPastPeriod: Boolean, + groupedPastTransactions: Map>, + expandedDates: Set, + deletingTransactionIds: Set, + currencyCode: String, + currencyFormat: NumberFormat, + readOnly: Boolean, + onToggleDate: (LocalDate) -> Unit, + onDelete: (Transaction) -> Unit, + onEdit: (Transaction) -> Unit, + onClick: (Transaction) -> Unit, +) { + if (!showPastPeriod) return + + groupedPastTransactions.forEach { (date, transactions) -> + val isExpanded = date?.let { expandedDates.contains(it) } ?: false + val dayTotal = transactions.sumOf { it.amount } + + item("past-date-$date") { + HistoryDateDivider( + date = date, + isExpanded = isExpanded, + onToggleClick = { + date?.let(onToggleDate) + }, + totalAmount = dayTotal, + currencyCode = currencyCode, + ) + } + + item("past-date-content-$date") { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically( + animationSpec = tween(300), + expandFrom = Alignment.Top, + ) + fadeIn(animationSpec = tween(300)), + exit = shrinkVertically( + animationSpec = tween(300), + shrinkTowards = Alignment.Top, + ) + fadeOut(animationSpec = tween(300)), + ) { + Column { + transactions.forEachIndexed { index, transaction -> + key(transaction.id) { + val position = paddedListItemPosition( + index, + transactions.lastIndex, + transactions.size + ) + val isBeingDeleted = transaction.id in deletingTransactionIds + AnimatedVisibility( + visible = !isBeingDeleted, + enter = EnterTransition.None, + exit = slideOutHorizontally( + animationSpec = tween(durationMillis = 280), + targetOffsetX = { fullWidth -> fullWidth }, + ) + fadeOut(animationSpec = tween(durationMillis = 280)), + ) { + SwipeableExpenseItem( + transaction = transaction, + currencyFormat = currencyFormat, + position = position, + isBeingDeleted = isBeingDeleted, + onDelete = { onDelete(transaction) }, + onEdit = { onEdit(transaction) }, + readOnly = readOnly, + onClick = { onClick(transaction) }, + ) + } + + if (index < transactions.size - 1 && transaction.id !in deletingTransactionIds) { + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + + val totalText = currencyFormat.format(dayTotal) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Total del día: ", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = totalText, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } +} + + +@PreviewLightDark +@Composable +private fun PastTransactionDateSectionsPreview() { + val today = LocalDate.now() + val pastDate = today.minusDays(20) + val tx = Transaction( + id = 1L, + amount = BigDecimal("30.00"), + comment = "Lunch", + date = pastDate.atStartOfDay(), + isDeleted = false, + ) + MinusTheme { + LazyColumn(modifier = Modifier.fillMaxSize()) { + pastTransactionDateSections( + showPastPeriod = true, + groupedPastTransactions = mapOf(pastDate to listOf(tx)), + expandedDates = setOf(pastDate), + deletingTransactionIds = emptySet(), + currencyCode = "USD", + currencyFormat = NumberFormat.getCurrencyInstance(), + readOnly = false, + onToggleDate = {}, + onDelete = {}, + onEdit = {}, + onClick = {}, + ) + } + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/RecurrentItemsContent.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/RecurrentItemsContent.kt new file mode 100644 index 0000000..afd4ae5 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/RecurrentItemsContent.kt @@ -0,0 +1,104 @@ +package com.serranoie.app.minus.presentation.ui.history.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.history.RecurrentPaymentsViewMode +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListItemPosition +import com.serranoie.app.minus.presentation.ui.theme.component.expense.UpcomingRecurrentItem +import com.serranoie.app.minus.presentation.ui.theme.component.ticket.RecurrentTicketCard +import com.serranoie.app.minus.presentation.util.prettyDate +import java.math.BigDecimal +import java.text.NumberFormat +import java.time.LocalDate + +@Composable +internal fun RecurrentItemsContent( + items: List, + recurrentPaymentsViewMode: RecurrentPaymentsViewMode, + currencyFormat: NumberFormat, + verticalItem: @Composable (index: Int, item: UpcomingRecurrentItem, position: PaddedListItemPosition) -> Unit, + horizontalKeyPrefix: String, + onClick: (Transaction) -> Unit, +) { + if (recurrentPaymentsViewMode == RecurrentPaymentsViewMode.VERTICAL_LIST) { + Column(modifier = Modifier.fillMaxWidth()) { + items.forEachIndexed { index, item -> + verticalItem( + index, + item, + paddedListItemPosition(index, items.lastIndex, items.size) + ) + if (index < items.lastIndex) { + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + } else { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + itemsIndexed( + items = items, + key = { _, item -> "$horizontalKeyPrefix-${item.transaction.id}" }, + ) { _, item -> + RecurrentTicketCard( + title = item.transaction.comment, + amountFormatted = currencyFormat.format(item.transaction.amount), + nextChargeDate = prettyDate( + item.nextChargeDate.atStartOfDay(), + showTime = false, + forceShowDate = false, + ), + frequencyLabel = item.transaction.recurrentFrequency?.name?.lowercase() + ?.replaceFirstChar { it.uppercase() }, + onClick = { onClick(item.transaction) }, + modifier = Modifier.fillParentMaxWidth(0.45f), + ) + } + } + } +} + +@PreviewLightDark +@Composable +private fun RecurrentItemsContentPreview() { + val today = LocalDate.now() + val sampleItem = UpcomingRecurrentItem( + transaction = Transaction( + id = 1L, + amount = BigDecimal("15.00"), + comment = "Netflix", + date = today.atStartOfDay(), + isDeleted = false, + ), + nextChargeDate = today.plusDays(5), + isInCurrentPeriod = true, + ) + MinusTheme { + RecurrentItemsContent( + items = listOf( + sampleItem, + sampleItem.copy(transaction = sampleItem.transaction.copy(id = 2L)) + ), + recurrentPaymentsViewMode = RecurrentPaymentsViewMode.HORIZONTAL_LIST, + currencyFormat = NumberFormat.getCurrencyInstance(), + verticalItem = { _, _, _ -> }, + horizontalKeyPrefix = "preview", + onClick = {}, + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/SectionUtils.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/SectionUtils.kt new file mode 100644 index 0000000..b093081 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/SectionUtils.kt @@ -0,0 +1,26 @@ +package com.serranoie.app.minus.presentation.ui.history.sections + +import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListItemPosition + +/** + * Computes the [PaddedListItemPosition] for an item in a list of + * [size] elements, given the item's [index] and the last [lastIndex]. + * + * Returns [PaddedListItemPosition.Single] when there is exactly one + * element; otherwise uses the index relative to the bounds of the + * list to pick [PaddedListItemPosition.First], [PaddedListItemPosition.Middle], + * or [PaddedListItemPosition.Last]. + * + * Used by [transactionDateSections] and [pastTransactionDateSections] + * to give each swipeable row the correct rounded-corner treatment. + */ +internal fun paddedListItemPosition( + index: Int, + lastIndex: Int, + size: Int, +): PaddedListItemPosition = when { + size == 1 -> PaddedListItemPosition.Single + index == 0 -> PaddedListItemPosition.First + index == lastIndex -> PaddedListItemPosition.Last + else -> PaddedListItemPosition.Middle +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/TransactionDateSections.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/TransactionDateSections.kt new file mode 100644 index 0000000..a8967a0 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/history/sections/TransactionDateSections.kt @@ -0,0 +1,152 @@ +package com.serranoie.app.minus.presentation.ui.history.sections + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Column +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.lazy.LazyListScope +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.theme.component.date.DayTotalItem +import com.serranoie.app.minus.presentation.ui.theme.component.date.HistoryDateDivider +import com.serranoie.app.minus.presentation.ui.theme.component.expense.SwipeableExpenseItem +import java.text.NumberFormat +import java.time.LocalDate +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.tooling.preview.Preview +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import java.math.BigDecimal + +internal fun LazyListScope.transactionDateSections( + groupedTransactions: Map>, + expandedDates: Set, + deletingTransactionIds: Set, + currencyCode: String, + currencyFormat: NumberFormat, + readOnly: Boolean, + keyPrefix: String, + onToggleDate: (LocalDate) -> Unit, + onDelete: (Transaction) -> Unit, + onEdit: (Transaction) -> Unit, + onClick: (Transaction) -> Unit, +) { + groupedTransactions.forEach { (date, transactions) -> + val isExpanded = date?.let { expandedDates.contains(it) } ?: false + val dayTotal = transactions.sumOf { it.amount } + + item("$keyPrefix-date-$date") { + HistoryDateDivider( + date = date, + isExpanded = isExpanded, + onToggleClick = { + date?.let(onToggleDate) + }, + totalAmount = dayTotal, + currencyCode = currencyCode, + ) + } + + item("$keyPrefix-date-content-$date") { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically( + animationSpec = tween(300), + expandFrom = Alignment.Top, + ) + fadeIn(animationSpec = tween(300)), + exit = shrinkVertically( + animationSpec = tween(300), + shrinkTowards = Alignment.Top, + ) + fadeOut(animationSpec = tween(300)), + ) { + Column { + transactions.forEachIndexed { index, transaction -> + key(transaction.id) { + val position = paddedListItemPosition( + index, + transactions.lastIndex, + transactions.size + ) + val isBeingDeleted = transaction.id in deletingTransactionIds + AnimatedVisibility( + visible = !isBeingDeleted, + enter = EnterTransition.None, + exit = slideOutHorizontally( + animationSpec = tween(durationMillis = 280), + targetOffsetX = { fullWidth -> fullWidth }, + ) + fadeOut(animationSpec = tween(durationMillis = 280)), + ) { + SwipeableExpenseItem( + transaction = transaction, + currencyFormat = currencyFormat, + position = position, + isBeingDeleted = isBeingDeleted, + onDelete = { onDelete(transaction) }, + onEdit = { onEdit(transaction) }, + readOnly = readOnly, + onClick = { onClick(transaction) }, + ) + } + + if (index < transactions.size - 1 && transaction.id !in deletingTransactionIds) { + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + + DayTotalItem( + total = dayTotal, + currencyFormat = currencyFormat, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + } + } +} + +@PreviewLightDark +@Composable +private fun TransactionDateSectionsPreview() { + val today = LocalDate.now() + val tx = Transaction( + id = 1L, + amount = BigDecimal("45.50"), + comment = "Groceries", + date = today.atStartOfDay(), + isDeleted = false, + ) + MinusTheme { + LazyColumn(modifier = Modifier.fillMaxSize()) { + transactionDateSections( + groupedTransactions = mapOf(today to listOf(tx)), + expandedDates = setOf(today), + deletingTransactionIds = emptySet(), + currencyCode = "USD", + currencyFormat = NumberFormat.getCurrencyInstance(), + readOnly = false, + keyPrefix = "preview", + onToggleDate = {}, + onDelete = {}, + onEdit = {}, + onClick = {}, + ) + } + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreen.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreen.kt index 87fd16d..d4eb49b 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreen.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreen.kt @@ -61,17 +61,17 @@ fun MainScreen( mainScreenViewModel.effects.collect { effect -> when (effect) { is MainScreenUiEffect.RequestUndo -> { - mainScreenState.pendingDeleteTransaction?.let { tx -> - budgetViewModel.processIntent( - BudgetTransactionIntent.RestoreTransactionTapped(tx) - ) - } + budgetViewModel.processIntent( + BudgetTransactionIntent.RestoreTransactionTapped(effect.transaction) + ) } + is MainScreenUiEffect.UpdateDragProgress -> { budgetViewModel.processIntent( BudgetNumpadIntent.SetDragProgress(effect.progress) ) } + is MainScreenUiEffect.OpenWallet -> onNavigateToWallet() is MainScreenUiEffect.OpenAnalytics -> onNavigateToAnalytics() is MainScreenUiEffect.ShowUndoSnackbar -> {} @@ -89,18 +89,15 @@ fun MainScreen( when (intent) { is MainScreenUiIntent.ProcessBudgetTransactionIntent -> budgetViewModel.processIntent(intent.intent) + is MainScreenUiIntent.ProcessBudgetEditorIntent -> budgetViewModel.processIntent(intent.intent) + is MainScreenUiIntent.ProcessBudgetNumpadIntent -> budgetViewModel.processIntent(intent.intent) + else -> mainScreenViewModel.processIntent(intent, tutorialStage) } - if (intent is MainScreenUiIntent.QueueDeleteWithUndo) { - mainScreenViewModel.onTransactionDeleteQueued(intent.transaction, intent.message) - } - if (intent is MainScreenUiIntent.CancelPendingDelete) { - mainScreenViewModel.onPendingDeleteCanceled() - } }, onNavigateToAnalytics = onNavigateToAnalytics, onNavigateToSettings = onNavigateToSettings, diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenContent.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenContent.kt index ddbd952..2164767 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenContent.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenContent.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.minus.presentation.ui.home +package com.serranoie.app.minus.presentation.ui.home import android.util.Log import androidx.compose.animation.core.AnimationSpec @@ -52,7 +52,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow + import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -142,18 +142,20 @@ fun MainScreenContent( val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { - snapshotFlow { mainScreenState.isSnackbarVisible }.collect { visible -> - if (visible) { - val result = snackbarHostState.showSnackbar( - message = mainScreenState.snackbarMessage, - actionLabel = mainScreenState.snackbarActionLabel, - duration = SnackbarDuration.Short, - ) - when (result) { - SnackbarResult.ActionPerformed -> onProcessIntent(MainScreenUiIntent.CancelPendingDelete) - SnackbarResult.Dismissed -> onProcessIntent(MainScreenUiIntent.DismissSnackbar) - } + LaunchedEffect( + mainScreenState.isSnackbarVisible, + mainScreenState.snackbarMessage, + mainScreenState.snackbarActionLabel, + ) { + if (mainScreenState.isSnackbarVisible) { + val result = snackbarHostState.showSnackbar( + message = mainScreenState.snackbarMessage, + actionLabel = mainScreenState.snackbarActionLabel, + duration = SnackbarDuration.Short, + ) + when (result) { + SnackbarResult.ActionPerformed -> onProcessIntent(MainScreenUiIntent.CancelPendingDelete) + SnackbarResult.Dismissed -> onProcessIntent(MainScreenUiIntent.DismissSnackbar) } } } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenMviContract.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenMviContract.kt index 7b04cd8..641e52f 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenMviContract.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenMviContract.kt @@ -40,7 +40,7 @@ sealed interface MainScreenUiEffect { val actionLabel: String, ) : MainScreenUiEffect - data object RequestUndo : MainScreenUiEffect + data class RequestUndo(val transaction: Transaction) : MainScreenUiEffect data class UpdateDragProgress(val progress: Float) : MainScreenUiEffect diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenViewModel.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenViewModel.kt index 98005aa..50d8147 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenViewModel.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/home/MainScreenViewModel.kt @@ -1,4 +1,4 @@ -package com.serranoie.app.minus.presentation.ui.home +package com.serranoie.app.minus.presentation.ui.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class MainScreenViewModel @Inject constructor() : ViewModel() { @@ -57,7 +58,7 @@ class MainScreenViewModel @Inject constructor() : ViewModel() { pendingDeleteTransaction = transaction, isSnackbarVisible = true, snackbarMessage = message, - snackbarActionLabel = "Undo", + snackbarActionLabel = "UNDO", snackbarHasUndo = true, ) } @@ -66,14 +67,14 @@ class MainScreenViewModel @Inject constructor() : ViewModel() { _effects.emit( MainScreenUiEffect.ShowUndoSnackbar( message = message, - actionLabel = "Undo", + actionLabel = "UNDO", ) ) } autoDismissJob = viewModelScope.launch { - delay(3_500L) - cancelPendingDelete() + delay(3_500L.milliseconds) + dismissSnackbar() } } @@ -97,9 +98,12 @@ class MainScreenViewModel @Inject constructor() : ViewModel() { } private fun cancelPendingDelete() { + val transaction = _uiState.value.pendingDeleteTransaction onPendingDeleteCanceled() - viewModelScope.launch { - _effects.emit(MainScreenUiEffect.RequestUndo) + if (transaction != null) { + viewModelScope.launch { + _effects.emit(MainScreenUiEffect.RequestUndo(transaction)) + } } } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/SwipeActions.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/SwipeActions.kt index a805cef..f86d1f1 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/SwipeActions.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/SwipeActions.kt @@ -92,10 +92,8 @@ fun SwipeActions( } ) - // Reset swipe state when disabled (e.g., when item is being deleted) LaunchedEffect(enabled) { if (!enabled) { - state.snapTo(SwipeToDismissBoxValue.Settled) hasTriggeredHaptic = false willDismiss = false } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/budget/BudgetDisplay.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/budget/BudgetDisplay.kt index 1af3619..ae041ea 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/budget/BudgetDisplay.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/budget/BudgetDisplay.kt @@ -70,7 +70,7 @@ fun BudgetDisplay( actualFinishDate: Date? = null, extraDaysFromRemaining: Int = 0, showRolloverStyle: Boolean = true, - contentPadding: PaddingValues = PaddingValues(vertical = 8.dp, horizontal = 18.dp), + contentPadding: PaddingValues = PaddingValues(vertical = 16.dp, horizontal = 18.dp), ) { val currencyFormat = symbolOnlyCurrencyFormat(currencyCode) diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/budget/SpendBudgetCard.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/budget/SpendBudgetCard.kt index 2fb9a92..eb27b13 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/budget/SpendBudgetCard.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/budget/SpendBudgetCard.kt @@ -194,7 +194,8 @@ fun SpendBudgetCard( ) { Text( text = numberFormat(context, spend, "MXN"), - style = MaterialTheme.typography.titleLargeCondensed.copy(fontWeight = FontWeight.Light), + style = MaterialTheme.typography.displaySmallEmphasized.copy(fontWeight = FontWeight.Light), + fontSize = MaterialTheme.typography.titleLargeEmphasized.fontSize, overflow = TextOverflow.Ellipsis, softWrap = false, lineHeight = TextUnit(0.2f, TextUnitType.Em), diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/expense/SwipeableExpenseItem.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/expense/SwipeableExpenseItem.kt index 3d78db0..704b5a7 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/expense/SwipeableExpenseItem.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/expense/SwipeableExpenseItem.kt @@ -12,10 +12,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.ui.theme.MinusTheme import com.serranoie.app.minus.presentation.ui.theme.component.PaddedListItemPosition import com.serranoie.app.minus.presentation.ui.theme.component.SwipeActions import com.serranoie.app.minus.presentation.ui.theme.component.SwipeActionsConfig -import com.serranoie.app.minus.presentation.ui.theme.MinusTheme import java.text.NumberFormat import java.time.LocalDate import java.time.LocalDateTime @@ -25,188 +25,205 @@ private const val SWIPE_ACTION_THRESHOLD = 0.25f @Composable fun SwipeableExpenseItem( - transaction: Transaction, - currencyFormat: NumberFormat, - position: PaddedListItemPosition, - onDelete: () -> Unit, - onEdit: () -> Unit, - readOnly: Boolean, - isBeingDeleted: Boolean = false, - onClick: () -> Unit = {} + transaction: Transaction, + currencyFormat: NumberFormat, + position: PaddedListItemPosition, + onDelete: () -> Unit, + onEdit: () -> Unit, + readOnly: Boolean, + isBeingDeleted: Boolean = false, + onClick: () -> Unit = {} ) { - val shape = when (position) { - PaddedListItemPosition.First -> RoundedCornerShape( - topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 4.dp - ) + val shape = when (position) { + PaddedListItemPosition.First -> RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) - PaddedListItemPosition.Last -> RoundedCornerShape( - bottomStart = 16.dp, bottomEnd = 16.dp, topStart = 4.dp, topEnd = 4.dp - ) + PaddedListItemPosition.Last -> RoundedCornerShape( + bottomStart = 16.dp, + bottomEnd = 16.dp, + topStart = 4.dp, + topEnd = 4.dp + ) - PaddedListItemPosition.Single -> RoundedCornerShape(16.dp) - PaddedListItemPosition.Middle -> RoundedCornerShape(4.dp) - } + PaddedListItemPosition.Single -> RoundedCornerShape(16.dp) + PaddedListItemPosition.Middle -> RoundedCornerShape(4.dp) + } - if (readOnly) { - Surface( - shape = shape, - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.fillMaxWidth() - ) { - ExpenseItem( - transaction = transaction, - currencyFormat = currencyFormat, - position = position, - onClick = onClick - ) - } - } else { - Surface( - shape = shape, - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = Modifier.fillMaxWidth() - ) { - SwipeActions( - modifier = Modifier.fillMaxWidth(), - shape = shape, - enabled = !isBeingDeleted, - startActionsConfig = SwipeActionsConfig( - threshold = SWIPE_ACTION_THRESHOLD, - icon = Icons.Default.Edit, - iconTint = MaterialTheme.colorScheme.onPrimary, - background = MaterialTheme.colorScheme.primary, - backgroundActive = MaterialTheme.colorScheme.primary, - stayDismissed = false, - onDismiss = onEdit - ), - endActionsConfig = SwipeActionsConfig( - threshold = SWIPE_ACTION_THRESHOLD, - icon = Icons.Default.Delete, - iconTint = MaterialTheme.colorScheme.onError, - background = MaterialTheme.colorScheme.error, - backgroundActive = MaterialTheme.colorScheme.error, - stayDismissed = true, - onDismiss = onDelete - ) - ) { - ExpenseItem( - transaction = transaction, - currencyFormat = currencyFormat, - position = position, - onClick = onClick - ) - } - } - } + if (readOnly) { + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + ExpenseItem( + transaction = transaction, + currencyFormat = currencyFormat, + position = position, + onClick = onClick + ) + } + } else { + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + SwipeActions( + modifier = Modifier.fillMaxWidth(), + shape = shape, + enabled = !isBeingDeleted, + startActionsConfig = SwipeActionsConfig( + threshold = SWIPE_ACTION_THRESHOLD, + icon = Icons.Default.Edit, + iconTint = MaterialTheme.colorScheme.onPrimary, + background = MaterialTheme.colorScheme.primary, + backgroundActive = MaterialTheme.colorScheme.primary, + stayDismissed = false, + onDismiss = onEdit + ), + endActionsConfig = SwipeActionsConfig( + threshold = SWIPE_ACTION_THRESHOLD, + icon = Icons.Default.Delete, + iconTint = MaterialTheme.colorScheme.onError, + background = MaterialTheme.colorScheme.error, + backgroundActive = MaterialTheme.colorScheme.error, + stayDismissed = true, + onDismiss = onDelete + ) + ) { + ExpenseItem( + transaction = transaction, + currencyFormat = currencyFormat, + position = position, + onClick = onClick + ) + } + } + } } @Composable fun SwipeableUpcomingRecurrentItem( - item: UpcomingRecurrentItem, - currencyFormat: NumberFormat, - position: PaddedListItemPosition, - isOutOfPeriod: Boolean = false, - onDelete: () -> Unit, - onEdit: () -> Unit, - onClick: () -> Unit = {} + item: UpcomingRecurrentItem, + currencyFormat: NumberFormat, + position: PaddedListItemPosition, + isOutOfPeriod: Boolean = false, + onDelete: () -> Unit, + onEdit: () -> Unit, + onClick: () -> Unit = {} ) { - val shape = when (position) { - PaddedListItemPosition.First -> RoundedCornerShape( - topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 4.dp - ) + val shape = when (position) { + PaddedListItemPosition.First -> RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) - PaddedListItemPosition.Last -> RoundedCornerShape( - bottomStart = 16.dp, bottomEnd = 16.dp, topStart = 4.dp, topEnd = 4.dp - ) + PaddedListItemPosition.Last -> RoundedCornerShape( + bottomStart = 16.dp, + bottomEnd = 16.dp, + topStart = 4.dp, + topEnd = 4.dp + ) - PaddedListItemPosition.Single -> RoundedCornerShape(16.dp) - PaddedListItemPosition.Middle -> RoundedCornerShape(4.dp) - } + PaddedListItemPosition.Single -> RoundedCornerShape(16.dp) + PaddedListItemPosition.Middle -> RoundedCornerShape(4.dp) + } - Surface( - shape = shape, color = if (isOutOfPeriod) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier.fillMaxWidth() - ) { - SwipeActions( - modifier = Modifier.fillMaxWidth(), - shape = shape, - startActionsConfig = SwipeActionsConfig( - threshold = SWIPE_ACTION_THRESHOLD, - icon = Icons.Default.Edit, - iconTint = MaterialTheme.colorScheme.onPrimary, - background = MaterialTheme.colorScheme.primary, - backgroundActive = MaterialTheme.colorScheme.primary, - stayDismissed = false, - onDismiss = onEdit - ), - endActionsConfig = SwipeActionsConfig( - threshold = SWIPE_ACTION_THRESHOLD, - icon = Icons.Default.Delete, - iconTint = MaterialTheme.colorScheme.onError, - background = MaterialTheme.colorScheme.error, - backgroundActive = MaterialTheme.colorScheme.error, - stayDismissed = true, - onDismiss = onDelete - ) - ) { - UpcomingRecurrentItemRow( - item = item, - currencyFormat = currencyFormat, - position = position, - isOutOfPeriod = isOutOfPeriod, - onClick = onClick - ) - } - } + Surface( + shape = shape, + color = if (isOutOfPeriod) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + modifier = Modifier.fillMaxWidth() + ) { + SwipeActions( + modifier = Modifier.fillMaxWidth(), + shape = shape, + startActionsConfig = SwipeActionsConfig( + threshold = SWIPE_ACTION_THRESHOLD, + icon = Icons.Default.Edit, + iconTint = MaterialTheme.colorScheme.onPrimary, + background = MaterialTheme.colorScheme.primary, + backgroundActive = MaterialTheme.colorScheme.primary, + stayDismissed = false, + onDismiss = onEdit + ), + endActionsConfig = SwipeActionsConfig( + threshold = SWIPE_ACTION_THRESHOLD, + icon = Icons.Default.Delete, + iconTint = MaterialTheme.colorScheme.onError, + background = MaterialTheme.colorScheme.error, + backgroundActive = MaterialTheme.colorScheme.error, + stayDismissed = true, + onDismiss = onDelete + ) + ) { + UpcomingRecurrentItemRow( + item = item, + currencyFormat = currencyFormat, + position = position, + isOutOfPeriod = isOutOfPeriod, + onClick = onClick + ) + } + } } @Preview @Composable private fun SwipeableExpenseItemPreview() { - MinusTheme { - SwipeableExpenseItem( - transaction = Transaction( - id = 1L, - amount = java.math.BigDecimal("150.50"), - comment = "Compra en supermercado", - date = LocalDateTime.now(), - isDeleted = false, - isRecurrent = false - ), - currencyFormat = NumberFormat.getCurrencyInstance(Locale.US), - position = PaddedListItemPosition.Single, - onDelete = {}, - onEdit = {}, - readOnly = false, - isBeingDeleted = false, - onClick = {} - ) - } + MinusTheme { + SwipeableExpenseItem( + transaction = Transaction( + id = 1L, + amount = java.math.BigDecimal("150.50"), + comment = "Compra en supermercado", + date = LocalDateTime.now(), + isDeleted = false, + isRecurrent = false + ), + currencyFormat = NumberFormat.getCurrencyInstance(Locale.US), + position = PaddedListItemPosition.Single, + onDelete = {}, + onEdit = {}, + readOnly = false, + isBeingDeleted = false, + onClick = {} + ) + } } @Preview @Composable private fun SwipeableUpcomingRecurrentItemPreview() { - MinusTheme { - SwipeableUpcomingRecurrentItem( - item = UpcomingRecurrentItem( - transaction = Transaction( - id = 1L, - amount = java.math.BigDecimal("200.00"), - comment = "Netflix Subscription", - date = LocalDateTime.now(), - isDeleted = false, - isRecurrent = true - ), - nextChargeDate = LocalDate.now().plusDays(3), - isInCurrentPeriod = true - ), - currencyFormat = NumberFormat.getCurrencyInstance(Locale.US), - position = PaddedListItemPosition.Single, - isOutOfPeriod = false, - onDelete = {}, - onEdit = {}, - onClick = {} - ) - } + MinusTheme { + SwipeableUpcomingRecurrentItem( + item = UpcomingRecurrentItem( + transaction = Transaction( + id = 1L, + amount = java.math.BigDecimal("200.00"), + comment = "Netflix Subscription", + date = LocalDateTime.now(), + isDeleted = false, + isRecurrent = true + ), + nextChargeDate = LocalDate.now().plusDays(3), + isInCurrentPeriod = true + ), + currencyFormat = NumberFormat.getCurrencyInstance(Locale.US), + position = PaddedListItemPosition.Single, + isOutOfPeriod = false, + onDelete = {}, + onEdit = {}, + onClick = {} + ) + } } diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/numpad/Numpad.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/numpad/Numpad.kt index 482a32a..3982f19 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/numpad/Numpad.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/theme/component/numpad/Numpad.kt @@ -95,6 +95,7 @@ fun Numpad( onDragProgressChanged: (Float) -> Unit = {}, dragProgress: Float = 0f, rowHeight: Dp = 55.dp, // Still kept for backward compatibility but internal logic now uses weights + enableCalculationMode: Boolean = true, ) { val view = LocalView.current val haptic = LocalHapticFeedback.current @@ -125,11 +126,12 @@ fun Numpad( .fillMaxWidth() .windowInsetsPadding(WindowInsets.navigationBars) .padding(horizontal = 14.dp) - .pointerInput(isCalculation, hasOperators) { - var accumulatedDrag = 0f - var lastReportedProgress = 0f - var lastTickProgress = 0f - var hasTriggered = false +.pointerInput(isCalculation, hasOperators, enableCalculationMode) { + if (!enableCalculationMode) return@pointerInput + var accumulatedDrag = 0f + var lastReportedProgress = 0f + var lastTickProgress = 0f + var hasTriggered = false detectVerticalDragGestures( onDragStart = { diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2a99ad6..1aa65d9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -54,7 +54,7 @@ Editar gasto recurrente Editar gasto Cancelar edición - Configurar la recurrencia + Configurar recurrencia Hacer Recurrente Seleccionar hora Día de pago @@ -166,6 +166,7 @@ Gasta con cabeza Con el tiempo, aprende cuánto puedes ahorrar y cuánto puedes gastar. Continuar + Recurrente Selecciona el periodo %1$d días · %2$s - %3$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4c519af..d932d9a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -169,6 +169,7 @@ Dépensez malin Avec le temps, apprenez combien vous pouvez économiser et dépenser. Continuer + Récurrent Sélectionnez la période diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b15df1..bed7db4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -189,6 +189,7 @@ Spend wisely Over time, learn how much you can save and how much you can spend. Continue + Recurrent Select the period diff --git a/app/src/test/java/com/serranoie/app/minus/presentation/ui/home/MainScreenViewModelTest.kt b/app/src/test/java/com/serranoie/app/minus/presentation/ui/home/MainScreenViewModelTest.kt index 7d292ec..caec2c1 100644 --- a/app/src/test/java/com/serranoie/app/minus/presentation/ui/home/MainScreenViewModelTest.kt +++ b/app/src/test/java/com/serranoie/app/minus/presentation/ui/home/MainScreenViewModelTest.kt @@ -87,14 +87,14 @@ class MainScreenViewModelTest { assertThat(state.pendingDeleteTransaction).isEqualTo(transaction) assertThat(state.isSnackbarVisible).isTrue() assertThat(state.snackbarMessage).isEqualTo("Deleted") - assertThat(state.snackbarActionLabel).isEqualTo("Undo") + assertThat(state.snackbarActionLabel).isEqualTo("UNDO") assertThat(state.snackbarHasUndo).isTrue() val effect = awaitItem() assertThat(effect).isInstanceOf(MainScreenUiEffect.ShowUndoSnackbar::class.java) effect as MainScreenUiEffect.ShowUndoSnackbar assertThat(effect.message).isEqualTo("Deleted") - assertThat(effect.actionLabel).isEqualTo("Undo") + assertThat(effect.actionLabel).isEqualTo("UNDO") cancelAndIgnoreRemainingEvents() } } @@ -128,7 +128,8 @@ class MainScreenViewModelTest { assertThat(state.snackbarHasUndo).isFalse() val effect = awaitItem() - assertThat(effect).isEqualTo(MainScreenUiEffect.RequestUndo) + assertThat(effect).isInstanceOf(MainScreenUiEffect.RequestUndo::class.java) + assertThat((effect as MainScreenUiEffect.RequestUndo).transaction.id).isEqualTo(1L) cancelAndIgnoreRemainingEvents() } } @@ -436,7 +437,7 @@ class MainScreenViewModelTest { } @Test - fun when_3500ms_pass_without_user_action_then_snackbar_is_auto_dismissed_and_request_undo_is_emitted() = + fun when_3500ms_pass_without_user_action_then_snackbar_is_auto_dismissed_and_no_effect_is_emitted() = runTest { // Given val viewModel = newViewModel() @@ -460,8 +461,7 @@ class MainScreenViewModelTest { assertThat(state.snackbarActionLabel).isEqualTo("") assertThat(state.snackbarHasUndo).isFalse() - val effect = awaitItem() - assertThat(effect).isEqualTo(MainScreenUiEffect.RequestUndo) + expectNoEvents() cancelAndIgnoreRemainingEvents() } }