feat: draft finish budget early logic

This commit is contained in:
danilkinkin 2024-06-07 00:16:45 +05:00
parent 505e78f653
commit 87f2d93acf
8 changed files with 364 additions and 56 deletions

View file

@ -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()) {

View file

@ -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),

View file

@ -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
}
}

View file

@ -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()!!

View file

@ -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 = { }
)
}
}

View file

@ -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({}, {})
}
}

View file

@ -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

View file

@ -149,4 +149,8 @@
<string name="history_title">History</string>
<string name="view_analytics">View analytics</string>
<string name="analytics_title">Analytics</string>
<string name="finish_early">Finish early</string>
<string name="confirm_finish_budget_title">Finish budget</string>
<string name="confirm_finish_budget_description">Your current budget finished</string>
<string name="confirm_finish_budget">Finish budget</string>
</resources>