diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index b6379bc..a4fc872 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -22,11 +22,11 @@ jobs: with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ba04ab..950774d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,11 +24,11 @@ jobs: with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b852a0f..f9d07a9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -120,6 +120,28 @@ android:resource="@xml/widget_month_heatmap_info" /> + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetWidgetUpdater.kt b/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetWidgetUpdater.kt index 2ec3170..8a0ad9f 100644 --- a/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetWidgetUpdater.kt +++ b/app/src/main/java/com/serranoie/app/minus/presentation/ui/budget/BudgetWidgetUpdater.kt @@ -4,10 +4,12 @@ import android.content.Context import com.serranoie.app.minus.domain.model.Transaction import com.serranoie.app.minus.presentation.widget.DailySpending import com.serranoie.app.minus.presentation.widget.MonthHeatmapData +import com.serranoie.app.minus.presentation.widget.updateAverageSpendWidget import com.serranoie.app.minus.presentation.widget.updateBudgetOverviewWidget import com.serranoie.app.minus.presentation.widget.updateDaysCountdownWidget import com.serranoie.app.minus.presentation.widget.updateExpenseWidget import com.serranoie.app.minus.presentation.widget.updateHeatmapWidget +import com.serranoie.app.minus.presentation.widget.updateMinMaxSpentWidget import com.serranoie.app.minus.presentation.widget.updateMonthHeatmapWidget import dagger.hilt.android.qualifiers.ApplicationContext import java.math.BigDecimal @@ -29,12 +31,39 @@ class BudgetWidgetUpdater @Inject constructor( val budgetAmount = budget.totalBudget.toInt() val (startDate, endDate) = resolveBudgetPeriodDates(baseState) val heatmapData = buildHeatmapData(baseState) + val currentPeriodTransactions = filterCurrentPeriodTransactions(baseState) updateExpenseWidget(context, totalSpent, totalBudget, currency) updateBudgetOverviewWidget(context, budgetAmount, currency, startDate, endDate, daysLeft) updateDaysCountdownWidget(context, daysLeft, budget.totalBudget.toInt(), "days left") updateHeatmapWidget(context, heatmapData.monthHeatmapData) updateMonthHeatmapWidget(context, heatmapData.currentMonthHeatmap, heatmapData.currentMonthTotalSpent) + updateMinMaxSpentWidget(context, currentPeriodTransactions, currency) + updateAverageSpendWidget(context, currentPeriodTransactions, currency, startDate, endDate) + } + + private fun filterCurrentPeriodTransactions(baseState: BudgetUiState): List { + val settings = baseState.budgetSettings ?: return emptyList() + val periodEnd = settings.getPeriodEndDate() + val currentPeriodId = baseState.currentPeriodId + val currentPeriodStartedAtMillis = baseState.currentPeriodStartedAtMillis + + return baseState.transactions.filter { transaction -> + if (currentPeriodId > 0L && transaction.periodId > 0L) { + return@filter transaction.periodId == currentPeriodId + } + + val txDate = transaction.date?.toLocalDate() ?: return@filter false + if (txDate.isBefore(settings.startDate) || txDate.isAfter(periodEnd)) { + return@filter false + } + + if (txDate.isEqual(settings.startDate) && currentPeriodStartedAtMillis > 0L) { + return@filter transaction.createdAt >= currentPeriodStartedAtMillis + } + + true + } } private fun resolveBudgetPeriodDates(baseState: BudgetUiState): Pair { diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/widget/AverageSpendWidget.kt b/app/src/main/java/com/serranoie/app/minus/presentation/widget/AverageSpendWidget.kt new file mode 100644 index 0000000..53440b8 --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/widget/AverageSpendWidget.kt @@ -0,0 +1,225 @@ +@file:OptIn(ExperimentalGlancePreviewApi::class) + +package com.serranoie.app.minus.presentation.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import com.serranoie.app.minus.R +import com.serranoie.app.minus.domain.model.Transaction +import com.serranoie.app.minus.presentation.util.formatCurrencySymbolOnly +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.Date + +private val averageSpendValueKey = stringPreferencesKey("average_spend_value") +private val averageSpendDaysKey = intPreferencesKey("average_spend_days") +private val averageSpendCountKey = intPreferencesKey("average_spend_count") +private val averageSpendHasSpendsKey = intPreferencesKey("average_spend_has_spends") + +class AverageSpendWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = AverageSpendWidget() +} + +class AverageSpendWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + WidgetContent(context) + } + } + } + + @Composable + private fun WidgetContent(context: Context) { + val prefs = currentState() + AverageSpendContent( + averageValue = prefs[averageSpendValueKey] ?: context.getString(R.string.empty), + spendsCount = prefs[averageSpendCountKey] ?: 0, + hasSpends = (prefs[averageSpendHasSpendsKey] ?: 0) == 1, + label = context.getString(R.string.daily_average), + noExpensesLabel = context.getString(R.string.no_transactions_title), + daysLabel = context.resources.getQuantityString( + R.plurals.days, + prefs[averageSpendDaysKey] ?: 1, + prefs[averageSpendDaysKey] ?: 1, + ), + ) + } + + @Composable + internal fun AverageSpendContent( + averageValue: String, + spendsCount: Int, + hasSpends: Boolean, + label: String, + noExpensesLabel: String, + daysLabel: String, + ) { + Box( + modifier = GlanceModifier + .fillMaxSize() + .background(GlanceTheme.colors.surface) + .clickable(actionRunCallback()) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = GlanceModifier + .fillMaxSize() + .cornerRadius(24.dp) + .background(Color(0x263F7DD5)), + contentAlignment = Alignment.Center, + ) { + AverageSpendBackdrop() + + Column( + modifier = GlanceModifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = if (hasSpends) averageValue else "-", + style = TextStyle( + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + color = GlanceTheme.colors.onSurface, + textAlign = TextAlign.Center, + ), + maxLines = 1, + ) + + Text( + text = label, + style = TextStyle( + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = GlanceTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + ), + maxLines = 1, + ) + + Spacer(modifier = GlanceModifier.height(8.dp)) + + Text( + text = if (hasSpends) "$spendsCount · $daysLabel" else noExpensesLabel, + style = TextStyle( + fontSize = 12.sp, + color = GlanceTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + ), + maxLines = 1, + ) + } + } + } + } +} + +@Composable +private fun AverageSpendBackdrop() { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + Column( + modifier = GlanceModifier + .fillMaxWidth() + .height(42.dp) + .background(Color(0x143F7DD5)), + ) {} + } +} + +suspend fun updateAverageSpendWidget( + context: Context, + spends: List, + currency: String, + startDate: Date, + endDate: Date, +) { + val activeSpends = spends.filterNot { it.isDeleted } + val days = calculateAverageSpendDays(startDate, endDate) + val average = calculateAverageSpend(activeSpends, days) + val averageDisplay = average?.let { + formatCurrencySymbolOnly( + value = it, + currencyCode = currency, + maximumFractionDigits = 2, + minimumFractionDigits = 0, + ) + } ?: context.getString(R.string.empty) + + val manager = GlanceAppWidgetManager(context) + val glanceIds = manager.getGlanceIds(AverageSpendWidget::class.java) + + glanceIds.forEach { glanceId -> + updateAppWidgetState(context, glanceId) { prefs -> + prefs[averageSpendValueKey] = averageDisplay + prefs[averageSpendDaysKey] = days + prefs[averageSpendCountKey] = activeSpends.size + prefs[averageSpendHasSpendsKey] = if (activeSpends.isNotEmpty()) 1 else 0 + } + AverageSpendWidget().update(context, glanceId) + } +} + +private fun calculateAverageSpendDays(startDate: Date, endDate: Date): Int { + val diff = endDate.time - startDate.time + return (diff / (1000 * 60 * 60 * 24)).toInt().coerceAtLeast(1) +} + +private fun calculateAverageSpend(spends: List, days: Int): BigDecimal? { + if (spends.isEmpty()) return null + val totalAmount = spends.sumOf { it.amount } + return totalAmount.divide(days.toBigDecimal(), 2, RoundingMode.HALF_EVEN) +} + +@Preview(widthDp = 180, heightDp = 120) +@Composable +private fun AverageSpendWidgetPreview() { + GlanceTheme { + AverageSpendWidget().AverageSpendContent( + averageValue = "$72", + spendsCount = 8, + hasSpends = true, + label = "Daily average", + noExpensesLabel = "No recorded expenses", + daysLabel = "12 days", + ) + } +} diff --git a/app/src/main/java/com/serranoie/app/minus/presentation/widget/MinMaxSpentWidget.kt b/app/src/main/java/com/serranoie/app/minus/presentation/widget/MinMaxSpentWidget.kt new file mode 100644 index 0000000..8bafe5f --- /dev/null +++ b/app/src/main/java/com/serranoie/app/minus/presentation/widget/MinMaxSpentWidget.kt @@ -0,0 +1,444 @@ +@file:OptIn(ExperimentalGlancePreviewApi::class) + +package com.serranoie.app.minus.presentation.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.serranoie.app.minus.R +import com.serranoie.app.minus.domain.model.Transaction +import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.time.ZoneId +import java.util.Date +import java.util.Locale + +private const val MIN_MAX_CHART_BAR_COUNT = 8 + +private val minAmountKey = intPreferencesKey("min_max_spent_min_amount") +private val maxAmountKey = intPreferencesKey("min_max_spent_max_amount") +private val currencyKey = stringPreferencesKey("min_max_spent_currency") +private val minDateKey = stringPreferencesKey("min_max_spent_min_date") +private val maxDateKey = stringPreferencesKey("min_max_spent_max_date") +private val minCommentKey = stringPreferencesKey("min_max_spent_min_comment") +private val maxCommentKey = stringPreferencesKey("min_max_spent_max_comment") +private val hasSpendsKey = intPreferencesKey("min_max_spent_has_spends") +private val minChartIndexKey = intPreferencesKey("min_max_spent_min_chart_index") +private val maxChartIndexKey = intPreferencesKey("min_max_spent_max_chart_index") +private fun minChartRatioKey(index: Int) = + floatPreferencesKey("min_max_spent_min_chart_ratio_$index") + +private fun maxChartRatioKey(index: Int) = + floatPreferencesKey("min_max_spent_max_chart_ratio_$index") + +class MinMaxSpentWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = MinMaxSpentWidget() +} + +class MinMaxSpentWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + WidgetContent(context) + } + } + } + + @Composable + private fun WidgetContent(context: Context) { + val prefs = currentState() + val hasSpends = (prefs[hasSpendsKey] ?: 0) == 1 + val minChartRatios = (0 until MIN_MAX_CHART_BAR_COUNT).map { index -> + prefs[minChartRatioKey(index)] ?: 0f + } + val maxChartRatios = (0 until MIN_MAX_CHART_BAR_COUNT).map { index -> + prefs[maxChartRatioKey(index)] ?: 0f + } + + MinMaxSpentContent( + hasSpends = hasSpends, + currency = prefs[currencyKey] ?: "USD", + minAmount = prefs[minAmountKey] ?: 0, + maxAmount = prefs[maxAmountKey] ?: 0, + minDate = prefs[minDateKey] ?: "-", + maxDate = prefs[maxDateKey] ?: "-", + minComment = prefs[minCommentKey] ?: "", + maxComment = prefs[maxCommentKey] ?: "", + minimumSpentLabel = context.getString(R.string.minimum_spent), + maximumSpentLabel = context.getString(R.string.maximum_spent), + noExpensesLabel = context.getString(R.string.no_transactions_title), + minChartRatios = minChartRatios, + maxChartRatios = maxChartRatios, + minChartIndex = prefs[minChartIndexKey] ?: -1, + maxChartIndex = prefs[maxChartIndexKey] ?: -1, + ) + } + + @Composable + internal fun MinMaxSpentContent( + hasSpends: Boolean, + currency: String, + minAmount: Int, + maxAmount: Int, + minDate: String, + maxDate: String, + minComment: String, + maxComment: String, + minimumSpentLabel: String, + maximumSpentLabel: String, + noExpensesLabel: String, + minChartRatios: List, + maxChartRatios: List, + minChartIndex: Int, + maxChartIndex: Int, + ) { + Box( + modifier = GlanceModifier.fillMaxSize().background(GlanceTheme.colors.surface) + .clickable(actionRunCallback()).padding(8.dp), + ) { + Row( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + MinMaxSpentStatPanel( + title = minimumSpentLabel, + amount = if (hasSpends) formatWidgetCurrency(currency, minAmount) else "-", + date = if (hasSpends) minDate else noExpensesLabel, + comment = minComment, + isMin = true, + chartRatios = minChartRatios, + selectedChartIndex = minChartIndex, + modifier = GlanceModifier.defaultWeight().fillMaxHeight(), + ) + + Spacer(modifier = GlanceModifier.width(8.dp)) + + MinMaxSpentStatPanel( + title = maximumSpentLabel, + amount = if (hasSpends) formatWidgetCurrency(currency, maxAmount) else "-", + date = if (hasSpends) maxDate else noExpensesLabel, + comment = maxComment, + isMin = false, + chartRatios = maxChartRatios, + selectedChartIndex = maxChartIndex, + modifier = GlanceModifier.defaultWeight().fillMaxHeight(), + ) + } + } + } +} + +@Composable +private fun MinMaxSpentStatPanel( + title: String, + amount: String, + date: String, + comment: String, + isMin: Boolean, + chartRatios: List, + selectedChartIndex: Int, + modifier: GlanceModifier = GlanceModifier, +) { + val containerColor = if (isMin) { + Color(0x263F7DD5) + } else { + Color(0x26DD1414) + } + + Box( + modifier = modifier.cornerRadius(24.dp).background(containerColor), + ) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { + SpendingBarsBackdrop( + ratios = chartRatios, + selectedIndex = selectedChartIndex, + isMin = isMin, + modifier = GlanceModifier.fillMaxWidth().height(48.dp).padding(horizontal = 8.dp), + ) + } + + Column( + modifier = GlanceModifier.padding(horizontal = 14.dp, vertical = 12.dp) + ) { + Text( + text = amount, + style = TextStyle( + fontSize = 26.sp, + fontWeight = FontWeight.Bold, + color = GlanceTheme.colors.onSurface, + ), + maxLines = 1, + ) + + Text( + text = title, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = GlanceTheme.colors.onSurfaceVariant, + ), + maxLines = 1, + ) + + Spacer(modifier = GlanceModifier.height(6.dp)) + + Text( + text = date, + style = TextStyle( + fontSize = 12.sp, + color = GlanceTheme.colors.onSurfaceVariant, + ), + maxLines = 1, + ) + + if (comment.isNotBlank()) { + Spacer(modifier = GlanceModifier.height(3.dp)) + Text( + text = "# $comment", + style = TextStyle( + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = GlanceTheme.colors.onSurfaceVariant, + ), + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun SpendingBarsBackdrop( + ratios: List, + selectedIndex: Int, + isMin: Boolean, + modifier: GlanceModifier = GlanceModifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Bottom, + ) { + repeat(MIN_MAX_CHART_BAR_COUNT) { index -> + val ratio = ratios.getOrNull(index)?.coerceIn(0f, 1f) ?: 0f + val hasData = ratio > 0f + val isSelected = index == selectedIndex + val barHeight = if (hasData) { + 6.dp + (34.dp * ratio) + } else { + 0.dp + } + Box( + modifier = GlanceModifier.defaultWeight().fillMaxHeight(), + contentAlignment = Alignment.BottomCenter, + ) { + Box( + modifier = GlanceModifier.fillMaxWidth().height(barHeight) + .padding(horizontal = 2.dp).cornerRadius(if (isSelected) 4.dp else 16.dp) + .background(chartBarColor(ratio, isSelected, isMin)), + ) {} + } + } + } +} + +private fun chartBarColor(ratio: Float, isSelected: Boolean, isMin: Boolean): Color { + if (ratio <= 0f) return Color.Transparent + + if (isSelected) { + return if (isMin) Color(0x993F7DD5) else Color(0x99DD1414) + } + + return when { + ratio <= 0.25f -> Color(0x333F7DD5) + ratio <= 0.6f -> Color(0x33A96776) + else -> Color(0x33DD1414) + } +} + +suspend fun updateMinMaxSpentWidget( + context: Context, + spends: List, + currency: String, +) { + val activeSpends = spends.filterNot { it.isDeleted } + val minSpent = activeSpends.minByOrNull { it.amount } + val maxSpent = activeSpends.maxByOrNull { it.amount } + val maxAmount = activeSpends.maxOfOrNull { it.amount } ?: BigDecimal.ZERO + val dateFormat = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.getDefault()) + val orderedSpends = activeSpends.sortedBy { it.date } + val minChart = buildSelectedChartWindow(orderedSpends, minSpent, maxAmount) + val maxChart = buildSelectedChartWindow(orderedSpends, maxSpent, maxAmount) + + val manager = GlanceAppWidgetManager(context) + val glanceIds = manager.getGlanceIds(MinMaxSpentWidget::class.java) + + glanceIds.forEach { glanceId -> + updateAppWidgetState(context, glanceId) { prefs -> + prefs[hasSpendsKey] = if (activeSpends.isNotEmpty()) 1 else 0 + prefs[currencyKey] = currency + prefs[minAmountKey] = minSpent?.amount?.toInt() ?: 0 + prefs[maxAmountKey] = maxSpent?.amount?.toInt() ?: 0 + prefs[minDateKey] = minSpent?.date?.let { + dateFormat.format( + Date.from( + it.atZone(ZoneId.systemDefault()).toInstant() + ) + ) + } ?: "-" + prefs[maxDateKey] = maxSpent?.date?.let { + dateFormat.format( + Date.from( + it.atZone(ZoneId.systemDefault()).toInstant() + ) + ) + } ?: "-" + prefs[minCommentKey] = minSpent?.comment.orEmpty() + prefs[maxCommentKey] = maxSpent?.comment.orEmpty() + prefs[minChartIndexKey] = minChart.selectedIndex + prefs[maxChartIndexKey] = maxChart.selectedIndex + for (index in 0 until MIN_MAX_CHART_BAR_COUNT) { + prefs[minChartRatioKey(index)] = minChart.ratios.getOrNull(index) ?: 0f + prefs[maxChartRatioKey(index)] = maxChart.ratios.getOrNull(index) ?: 0f + } + } + MinMaxSpentWidget().update(context, glanceId) + } +} + +private data class WidgetChartWindow( + val ratios: List, + val selectedIndex: Int, +) + +private fun buildSelectedChartWindow( + orderedSpends: List, + selectedSpend: Transaction?, + maxAmount: BigDecimal, +): WidgetChartWindow { + if (selectedSpend == null || orderedSpends.isEmpty()) { + return WidgetChartWindow( + ratios = List(MIN_MAX_CHART_BAR_COUNT) { 0f }, + selectedIndex = -1, + ) + } + + val selectedIndexInAll = orderedSpends.indexOfFirst { it == selectedSpend } + if (selectedIndexInAll < 0) { + return WidgetChartWindow( + ratios = List(MIN_MAX_CHART_BAR_COUNT) { 0f }, + selectedIndex = -1, + ) + } + + val itemsBeforeSelected = 4 + val maxStart = (orderedSpends.size - MIN_MAX_CHART_BAR_COUNT).coerceAtLeast(0) + val startIndex = (selectedIndexInAll - itemsBeforeSelected).coerceIn(0, maxStart) + val window = orderedSpends.drop(startIndex).take(MIN_MAX_CHART_BAR_COUNT) + val leadingEmptyCount = MIN_MAX_CHART_BAR_COUNT - window.size + + return WidgetChartWindow( + ratios = buildMinMaxChartRatios(window, maxAmount), + selectedIndex = leadingEmptyCount + (selectedIndexInAll - startIndex), + ) +} + +private fun buildMinMaxChartRatios( + spends: List, + maxAmount: BigDecimal, +): List { + if (spends.isEmpty() || maxAmount <= BigDecimal.ZERO) { + return List(MIN_MAX_CHART_BAR_COUNT) { 0f } + } + + return spends.map { spend -> + (spend.amount.toFloat() / maxAmount.toFloat()).coerceIn(0f, 1f) + }.let { ratios -> + List(MIN_MAX_CHART_BAR_COUNT - ratios.size) { 0f } + ratios + } +} + +@Preview(widthDp = 360, heightDp = 160) +@Composable +private fun MinMaxSpentWidgetPreview() { + GlanceTheme { + MinMaxSpentWidget().MinMaxSpentContent( + hasSpends = true, + currency = "MXN", + minAmount = 42, + maxAmount = 186, + minDate = "14 Jun 2026 10:20", + maxDate = "15 Jun 2026 18:45", + minComment = "Coffee", + maxComment = "Groceries", + minimumSpentLabel = "Minimum spent", + maximumSpentLabel = "Maximum spent", + noExpensesLabel = "No recorded expenses", + minChartRatios = listOf(0.24f, 0.4f, 0.31f, 0.8f, 0.56f, 1f, 0.62f, 0.22f), + maxChartRatios = listOf(0.4f, 0.31f, 0.8f, 0.56f, 1f, 0.62f, 0.22f, 0.18f), + minChartIndex = 0, + maxChartIndex = 4, + ) + } +} + +@Preview(widthDp = 360, heightDp = 160) +@Composable +private fun MinMaxSpentWidgetEmptyPreview() { + GlanceTheme { + MinMaxSpentWidget().MinMaxSpentContent( + hasSpends = false, + currency = "MXN", + minAmount = 0, + maxAmount = 0, + minDate = "-", + maxDate = "-", + minComment = "", + maxComment = "", + minimumSpentLabel = "Minimum spent", + maximumSpentLabel = "Maximum spent", + noExpensesLabel = "No recorded expenses", + minChartRatios = emptyList(), + maxChartRatios = emptyList(), + minChartIndex = -1, + maxChartIndex = -1, + ) + } +} diff --git a/app/src/main/res/layout/widget_average_spend_preview.xml b/app/src/main/res/layout/widget_average_spend_preview.xml new file mode 100644 index 0000000..3b6ed64 --- /dev/null +++ b/app/src/main/res/layout/widget_average_spend_preview.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_budget_overview_preview.xml b/app/src/main/res/layout/widget_budget_overview_preview.xml index ab2a444..9674aac 100644 --- a/app/src/main/res/layout/widget_budget_overview_preview.xml +++ b/app/src/main/res/layout/widget_budget_overview_preview.xml @@ -2,92 +2,71 @@ + android:padding="16dp"> - - - - - + android:layout_marginTop="16dp" + android:gravity="center_vertical" + android:orientation="horizontal"> - - - - - + android:background="#5F6673" /> - - - - - - - - + - + - - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/widget_heatmap_preview.xml b/app/src/main/res/layout/widget_heatmap_preview.xml index c91d37f..83b4627 100644 --- a/app/src/main/res/layout/widget_heatmap_preview.xml +++ b/app/src/main/res/layout/widget_heatmap_preview.xml @@ -2,121 +2,51 @@ + android:padding="10dp"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - \ No newline at end of file + + diff --git a/app/src/main/res/layout/widget_min_max_spent_preview.xml b/app/src/main/res/layout/widget_min_max_spent_preview.xml new file mode 100644 index 0000000..3fe3193 --- /dev/null +++ b/app/src/main/res/layout/widget_min_max_spent_preview.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_month_heatmap_preview.xml b/app/src/main/res/layout/widget_month_heatmap_preview.xml index 8cbca18..4b93d8e 100644 --- a/app/src/main/res/layout/widget_month_heatmap_preview.xml +++ b/app/src/main/res/layout/widget_month_heatmap_preview.xml @@ -2,34 +2,63 @@ + android:padding="10dp"> - + android:layout_marginTop="6dp" + android:orientation="vertical"> - - - - - - - + + + + + + + + + - + + + + + + + + + - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 06edc1b..3b3bcb9 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -15,4 +15,12 @@ #26FFFFFF #A3A6AD #8F949D + + + #263F7DD5 + #26DD1414 + #F2FFFFFF + #663F7DD5 + #66A96776 + #66DD1414 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 238695c..fb56ef7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,6 +131,22 @@ Full budget overview with top category and days left Heatmap calendar to see spending distribution Current month heatmap calendar + See your minimum and maximum expenses at a glance + See average spending per day at a glance + $72 + 8 · 12 days + $42 + $186 + 14 Jun 2026 10:20 + 15 Jun 2026 18:45 + $500 + 06 Mar + 16 days + 21 Mar + MAR + APR + 4 months + JUNE Quick shortcut to add a new expense Add new expense + diff --git a/app/src/main/res/xml/widget_average_spend_info.xml b/app/src/main/res/xml/widget_average_spend_info.xml new file mode 100644 index 0000000..a59651b --- /dev/null +++ b/app/src/main/res/xml/widget_average_spend_info.xml @@ -0,0 +1,16 @@ + + + diff --git a/app/src/main/res/xml/widget_min_max_spent_info.xml b/app/src/main/res/xml/widget_min_max_spent_info.xml new file mode 100644 index 0000000..6fc197a --- /dev/null +++ b/app/src/main/res/xml/widget_min_max_spent_info.xml @@ -0,0 +1,16 @@ + + + diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetComponentScreenshotTest_budgetDisplayHealthyBudget.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetComponentScreenshotTest_budgetDisplayHealthyBudget.png index f503c44..3c3be01 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetComponentScreenshotTest_budgetDisplayHealthyBudget.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetComponentScreenshotTest_budgetDisplayHealthyBudget.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetComponentScreenshotTest_budgetDisplayOverBudget.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetComponentScreenshotTest_budgetDisplayOverBudget.png index 5539cc3..a169d0b 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetComponentScreenshotTest_budgetDisplayOverBudget.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_BudgetComponentScreenshotTest_budgetDisplayOverBudget.png differ diff --git a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_HistoryScreenshotTest_historyCurrentAndPastExpenses.png b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_HistoryScreenshotTest_historyCurrentAndPastExpenses.png index 9b6ff13..e2e17f2 100644 Binary files a/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_HistoryScreenshotTest_historyCurrentAndPastExpenses.png and b/app/src/test/snapshots/images/com.serranoie.app.minus.presentation.ui.screenshot_HistoryScreenshotTest_historyCurrentAndPastExpenses.png differ diff --git a/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_editModeTransition.png b/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_editModeTransition.png index 706a7b8..5aa080d 100644 Binary files a/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_editModeTransition.png and b/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_editModeTransition.png differ diff --git a/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_periodSelectionAndSheetSwipe.png b/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_periodSelectionAndSheetSwipe.png index 4901f9d..dc7c8f3 100644 Binary files a/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_periodSelectionAndSheetSwipe.png and b/app/src/test/snapshots/videos/com.serranoie.app.minus.presentation.ui.screenshot_BudgetPeriodSheetInteractionScreenshotTest_periodSelectionAndSheetSwipe.png differ