diff --git a/app/src/main/java/com/danilkinkin/buckwheat/analytics/Analytics.kt b/app/src/main/java/com/danilkinkin/buckwheat/analytics/Analytics.kt index f8e5f84..a87bef2 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/analytics/Analytics.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/analytics/Analytics.kt @@ -62,6 +62,9 @@ fun Analytics( val spends by spendsViewModel.spends.observeAsState(emptyList()) val wholeBudget = spendsViewModel.budget.value!! val scrollState = rememberScrollState() + + val finishPeriodActualDate by spendsViewModel.finishPeriodActualDate.observeAsState(null) + // Need to hide calendar after migration to transactions, // because after migration can't restore some transactions like INCOME & SET_DAILY_BUDGET val afterMigrationToTransactions = @@ -98,6 +101,7 @@ fun Analytics( currency = spendsViewModel.currency.value!!, startDate = spendsViewModel.startPeriodDate.value!!, finishDate = spendsViewModel.finishPeriodDate.value!!, + actualFinishDate = finishPeriodActualDate, ) Spacer(modifier = Modifier.height(16.dp)) if (spends.isNotEmpty()) { diff --git a/app/src/main/java/com/danilkinkin/buckwheat/analytics/WholeBudgetCard.kt b/app/src/main/java/com/danilkinkin/buckwheat/analytics/WholeBudgetCard.kt index fca78cc..f989b56 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/analytics/WholeBudgetCard.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/analytics/WholeBudgetCard.kt @@ -8,9 +8,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.layout.* import androidx.compose.ui.platform.LocalContext @@ -21,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* +import androidx.compose.ui.zIndex import com.danilkinkin.buckwheat.R import com.danilkinkin.buckwheat.data.ExtendCurrency import com.danilkinkin.buckwheat.ui.BuckwheatTheme @@ -36,12 +40,13 @@ fun WholeBudgetCard( currency: ExtendCurrency, startDate: Date, finishDate: Date?, + actualFinishDate: Date? = null, colors: CardColors = CardDefaults.cardColors(), bigVariant: Boolean = true, contentPadding: PaddingValues = PaddingValues(vertical = 16.dp, horizontal = 24.dp), ) { val context = LocalContext.current - + StatCard( modifier = modifier.fillMaxWidth(), contentPadding = contentPadding, @@ -83,49 +88,89 @@ fun WholeBudgetCard( .padding(horizontal = if (bigVariant) 16.dp else 8.dp) .fillMaxHeight() ) - if (finishDate !== null && bigVariant) { - Surface( - modifier = Modifier + if (actualFinishDate !== null && bigVariant) { + CountDaysChip( + Modifier .align(Alignment.Center) - .requiredHeight(24.dp), - shape = CircleShape, - color = LocalContentColor.current, - contentColor = MaterialTheme.colorScheme.surface, + .offset(6.dp, (-12).dp) + .rotate(6f) + .zIndex(1f), + fromDate = startDate, + toDate = actualFinishDate + ) + Cross( + modifier = Modifier.align(Alignment.Center) ) { - val days = countDays(finishDate, startDate) - - Box( - contentAlignment = Alignment.Center, - ) { - Text( - modifier = Modifier.padding(12.dp, 0.dp), - text = String.format( - pluralStringResource(R.plurals.days_count, count = days), - days, - ), - style = MaterialTheme.typography.bodyMedium, - ) - } + CountDaysChip( + Modifier, + fromDate = startDate, + toDate = finishDate!! + ) } + } else if (finishDate !== null && bigVariant) { + CountDaysChip( + Modifier.align(Alignment.Center), + fromDate = startDate, + toDate = finishDate + ) } } Column(horizontalAlignment = Alignment.End) { - Text( - text = if (finishDate !== null) { - prettyDate( - finishDate, - pattern = "dd MMM", - simplifyIfToday = false, + Box { + if (actualFinishDate !== null) { + Text( + modifier = Modifier + .offset((-4).dp, (-20).dp) + .rotate(6f), + text = prettyDate( + actualFinishDate, + pattern = "dd MMM", + simplifyIfToday = false, + ), + softWrap = false, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontSize = if (bigVariant) MaterialTheme.typography.bodySmall.fontSize else MaterialTheme.typography.labelSmall.fontSize, ) + + Cross { + Box(Modifier.wrapContentSize()) { + Text( + text = if (finishDate !== null) { + prettyDate( + finishDate, + pattern = "dd MMM", + simplifyIfToday = false, + ) + } else { + "-" + }, + softWrap = false, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontSize = if (bigVariant) MaterialTheme.typography.bodySmall.fontSize else MaterialTheme.typography.labelSmall.fontSize, + ) + } + } } else { - "-" - }, - softWrap = false, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - fontSize = if (bigVariant) MaterialTheme.typography.bodySmall.fontSize else MaterialTheme.typography.labelSmall.fontSize, - ) + Text( + text = if (finishDate !== null) { + prettyDate( + finishDate, + pattern = "dd MMM", + simplifyIfToday = false, + ) + } else { + "-" + }, + softWrap = false, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontSize = if (bigVariant) MaterialTheme.typography.bodySmall.fontSize else MaterialTheme.typography.labelSmall.fontSize, + ) + } + } } }, ) @@ -133,6 +178,60 @@ fun WholeBudgetCard( ) } +@Composable +fun CountDaysChip(modifier: Modifier = Modifier, fromDate: Date, toDate: Date) { + Surface( + modifier = modifier + .requiredHeight(24.dp), + shape = CircleShape, + color = LocalContentColor.current, + contentColor = MaterialTheme.colorScheme.surface, + ) { + val days = countDays(toDate, fromDate) + + Box( + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(12.dp, 0.dp), + text = String.format( + pluralStringResource( + R.plurals.days_count, + count = days + ), + days, + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +fun Cross( + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.error, + content: @Composable () -> Unit, +) { + Box(modifier = modifier) { + content() + Canvas(modifier = Modifier.matchParentSize()) { + val width = this.size.width + val height = this.size.height + val offset = Offset(6f, 6f) + val thickness = 6f + + drawLine( + color = tint, + start = Offset(offset.x, height - offset.y), + end = Offset(width - offset.x, offset.y), + strokeWidth = thickness, + cap = StrokeCap.Round + ) + } + } +} + @Composable fun Arrow( modifier: Modifier = Modifier, @@ -231,7 +330,18 @@ private fun PreviewChart() { @Preview @Composable -private fun Preview() { +private fun PreviewCross() { + BuckwheatTheme { + Cross { + Text(text = "Hello") + } + } +} + +@Preview +@Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun PreviewEarlyFinish() { BuckwheatTheme { WholeBudgetCard( modifier = Modifier.height(IntrinsicSize.Min), @@ -239,13 +349,15 @@ private fun Preview() { currency = ExtendCurrency.none(), startDate = LocalDate.now().minusDays(28).toDate(), finishDate = Date(), + actualFinishDate = LocalDate.now().minusDays(2).toDate(), ) } } +@Preview @Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES) @Composable -private fun PreviewNightMode() { +private fun Preview() { BuckwheatTheme { WholeBudgetCard( modifier = Modifier.height(IntrinsicSize.Min), diff --git a/app/src/main/java/com/danilkinkin/buckwheat/data/SpendsViewModel.kt b/app/src/main/java/com/danilkinkin/buckwheat/data/SpendsViewModel.kt index 38a5e91..90de9f6 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/data/SpendsViewModel.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/data/SpendsViewModel.kt @@ -34,10 +34,12 @@ class SpendsViewModel @Inject constructor( var spentFromDailyBudget = spendsRepository.getSpentFromDailyBudget().asLiveData() var startPeriodDate = spendsRepository.getStartPeriodDate().asLiveData() var finishPeriodDate = spendsRepository.getFinishPeriodDate().asLiveData() + var finishPeriodActualDate = spendsRepository.getFinishPeriodActualDate().asLiveData() var lastChangeDailyBudgetDate = spendsRepository.getLastChangeDailyBudgetDate().asLiveData() var currency = spendsRepository.getCurrency().asLiveData() - var restedBudgetDistributionMethod = spendsRepository.getRestedBudgetDistributionMethod().asLiveData() + var restedBudgetDistributionMethod = + spendsRepository.getRestedBudgetDistributionMethod().asLiveData() var hideOverspendingWarn = spendsRepository.getHideOverspendingWarn().asLiveData() var requireDistributionRestedBudget = MutableLiveData(false) @@ -61,6 +63,15 @@ class SpendsViewModel @Inject constructor( } } + fun finishBudget() { + viewModelScope.launch { + spendsRepository.finishBudget(Date()) + + requireSetBudget.value = false + periodFinished.value = true + } + } + fun setDailyBudget(newDailyBudget: BigDecimal) { viewModelScope.launch { spendsRepository.setDailyBudget(newDailyBudget) @@ -130,24 +141,42 @@ class SpendsViewModel @Inject constructor( viewModelScope.launch { val lastChangeDailyBudgetDate = spendsRepository.getLastChangeDailyBudgetDate().first() val finishPeriodDate = spendsRepository.getFinishPeriodDate().first() + val finishPeriodActualDate = spendsRepository.getFinishPeriodActualDate().first() val dailyBudget = spendsRepository.getDailyBudget().first() val spentFromDailyBudget = spendsRepository.getSpentFromDailyBudget().first() - val restedBudgetDistributionMethod = spendsRepository.getRestedBudgetDistributionMethod().first() + val restedBudgetDistributionMethod = + spendsRepository.getRestedBudgetDistributionMethod().first() + + val finishDayNotReached = if (finishPeriodActualDate === null) { + finishPeriodDate !== null + && countDaysToToday(finishPeriodDate) > 0 + } else { + countDaysToToday(finishPeriodActualDate) > 0 + } + + val finishTimeReached = if (finishPeriodActualDate === null) { + finishPeriodDate !== null + && finishPeriodDate.time <= Date().time + } else { + finishPeriodActualDate.time <= Date().time + } when { lastChangeDailyBudgetDate !== null - && finishPeriodDate !== null && !isToday(lastChangeDailyBudgetDate) - && countDaysToToday(finishPeriodDate) > 0 -> { + && finishDayNotReached -> { if (dailyBudget - spentFromDailyBudget > BigDecimal.ZERO) { when (restedBudgetDistributionMethod) { RestedBudgetDistributionMethod.ASK -> { requireDistributionRestedBudget.value = true } + RestedBudgetDistributionMethod.REST -> { - val whatBudgetForDay = spendsRepository.whatBudgetForDay(applyTodaySpends = true) + val whatBudgetForDay = + spendsRepository.whatBudgetForDay(applyTodaySpends = true) setDailyBudget(whatBudgetForDay) } + RestedBudgetDistributionMethod.ADD_TODAY -> { val notSpent = spendsRepository.howMuchNotSpent( excludeSkippedPart = true, @@ -157,7 +186,8 @@ class SpendsViewModel @Inject constructor( } } } else { - val whatBudgetForDay = spendsRepository.whatBudgetForDay(applyTodaySpends = true) + val whatBudgetForDay = + spendsRepository.whatBudgetForDay(applyTodaySpends = true) setDailyBudget(whatBudgetForDay) } } @@ -166,7 +196,7 @@ class SpendsViewModel @Inject constructor( requireSetBudget.value = true } - finishPeriodDate !== null && finishPeriodDate.time <= Date().time -> { + finishTimeReached -> { periodFinished.value = true } } diff --git a/app/src/main/java/com/danilkinkin/buckwheat/di/SpendsRepository.kt b/app/src/main/java/com/danilkinkin/buckwheat/di/SpendsRepository.kt index 20565a2..7933d69 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/di/SpendsRepository.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/di/SpendsRepository.kt @@ -39,6 +39,7 @@ val spentFromDailyBudgetStoreKey = stringPreferencesKey("spentFromDailyBudget") val lastChangeDailyBudgetDateStoreKey = longPreferencesKey("lastChangeDailyBudgetDate") val startPeriodDateStoreKey = longPreferencesKey("startPeriodDate") val finishPeriodDateStoreKey = longPreferencesKey("finishPeriodDate") +val finishPeriodActualDateStoreKey = longPreferencesKey("finishPeriodActualDate") class SpendsRepository @Inject constructor( @ApplicationContext val context: Context, @@ -84,6 +85,10 @@ class SpendsRepository @Inject constructor( it[finishPeriodDateStoreKey]?.let { value -> Date(value) } } + fun getFinishPeriodActualDate() = context.budgetDataStore.data.map { + it[finishPeriodActualDateStoreKey]?.let { value -> Date(value) } + } + fun getLastChangeDailyBudgetDate() = context.budgetDataStore.data.map { it[lastChangeDailyBudgetDateStoreKey]?.let { value -> Date(value) } } @@ -157,6 +162,22 @@ class SpendsRepository @Inject constructor( hideOverspendingWarn(false) } + suspend fun finishBudget(finishDate: Date) { + context.budgetDataStore.edit { + it[finishPeriodActualDateStoreKey] = finishDate.time + + Log.d( + "SpendsRepository", + "Finish budget [" + + "budget: ${it[budgetStoreKey]} " + + "start date: ${Date(it[startPeriodDateStoreKey]!!)} " + + "actual finish date: ${Date(it[finishPeriodActualDateStoreKey]!!)}" + + "finish date: ${Date(it[finishPeriodDateStoreKey]!!)}" + + "]" + ) + } + } + suspend fun setDailyBudget(newDailyBudget: BigDecimal) { context.budgetDataStore.edit { val spent: BigDecimal = it[spentStoreKey]?.toBigDecimal()!! diff --git a/app/src/main/java/com/danilkinkin/buckwheat/wallet/CustomCurrencyEditor.kt b/app/src/main/java/com/danilkinkin/buckwheat/wallet/CustomCurrencyEditor.kt index e24f759..0f35cbd 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/wallet/CustomCurrencyEditor.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/wallet/CustomCurrencyEditor.kt @@ -174,6 +174,7 @@ fun CustomCurrencyEditor( } @Preview(name = "Default") +@Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES) @Composable private fun PreviewDefault() { BuckwheatTheme { @@ -184,15 +185,3 @@ private fun PreviewDefault() { ) } } - -@Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun PreviewNightMode() { - BuckwheatTheme { - CustomCurrencyEditorContent( - defaultCurrency = "", - onChange = { }, - onClose = { } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/wallet/FinishEarlyConfirm.kt b/app/src/main/java/com/danilkinkin/buckwheat/wallet/FinishEarlyConfirm.kt new file mode 100644 index 0000000..7d86fb5 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/wallet/FinishEarlyConfirm.kt @@ -0,0 +1,123 @@ +package com.danilkinkin.buckwheat.wallet + +import OverrideLocalize +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.foundation.layout.widthIn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +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.painterResource +import androidx.compose.ui.res.stringResource +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 androidx.compose.ui.window.DialogProperties +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.base.RenderAdaptivePane +import com.danilkinkin.buckwheat.ui.BuckwheatTheme + + +@Composable +fun ConfirmFinishEarly( + onConfirm: () -> Unit, + onClose: () -> Unit, +) { + Card( + shape = MaterialTheme.shapes.extraLarge, + modifier = Modifier + .widthIn(max = 440.dp) + .padding(36.dp) + ) { + Column { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 16.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_priority_high), + tint = LocalContentColor.current.copy(alpha = 0.7f), + contentDescription = null, + ) + } + Text( + text = stringResource(R.string.confirm_finish_budget_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.confirm_finish_budget_description), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 24.dp), + ) + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 16.dp), + ) { + Button( + onClick = { onClose() }, + colors = ButtonDefaults.textButtonColors(), + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Text(text = stringResource(R.string.cancel)) + } + Button( + onClick = { + onConfirm() + onClose() + }, + colors = ButtonDefaults.textButtonColors(), + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Text(text = stringResource(R.string.confirm_finish_budget)) + } + } + } + } +} + +@Composable +fun ConfirmFinishEarlyDialog( + onConfirm: () -> Unit, + onClose: () -> Unit, +) { + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + OverrideLocalize { + RenderAdaptivePane { + ConfirmFinishEarly( + onConfirm = onConfirm, + onClose = onClose + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewDefault() { + BuckwheatTheme { + ConfirmFinishEarly({}, {}) + } +} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt b/app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt index 210d774..b964e64 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt @@ -65,6 +65,7 @@ fun Wallet( (budgetCache - spent - spentFromDailyBudget) val openConfirmChangeBudgetDialog = remember { mutableStateOf(false) } + val openConfirmFinishBudgetDialog = remember { mutableStateOf(false) } if (spends === null) return @@ -265,6 +266,18 @@ fun Wallet( text = stringResource(R.string.export_to_csv), onClick = { exportCSVLaunch() } ) + + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.error + ) { + ButtonRow( + icon = painterResource(R.drawable.ic_close), + text = stringResource(R.string.finish_early), + onClick = { + openConfirmFinishBudgetDialog.value = true + } + ) + } } } AnimatedVisibility( @@ -344,6 +357,18 @@ fun Wallet( onClose = { openConfirmChangeBudgetDialog.value = false }, ) } + + if (openConfirmFinishBudgetDialog.value) { + ConfirmFinishEarlyDialog( + onConfirm = { + spendsViewModel.finishBudget() + + onClose() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onClose = { openConfirmFinishBudgetDialog.value = false }, + ) + } } @Preview diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84fcbf5..7381c52 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -149,4 +149,8 @@ History View analytics Analytics + Finish early + Finish budget + Your current budget finished + Finish budget