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