init
This commit is contained in:
@@ -150,4 +150,8 @@ data class Filter(
|
||||
fun without(category: Category): Filter {
|
||||
return this.copy(categories = categories - category)
|
||||
}
|
||||
|
||||
fun isDefault(): Boolean {
|
||||
return this.categories.isEmpty() && startAmount == 0.0 && endAmount == Double.MAX_VALUE
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,7 @@ import cc.n0th1ng.tripmoney.data.dao.TripDao
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
|
||||
import cc.n0th1ng.tripmoney.data.entity.Expense
|
||||
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
|
||||
import cc.n0th1ng.tripmoney.data.entity.Trip
|
||||
import cc.n0th1ng.tripmoney.screens.listexpense.toEpochMilli
|
||||
import cc.n0th1ng.tripmoney.utils.Currencies
|
||||
import cc.n0th1ng.tripmoney.utils.Icons
|
||||
import cc.n0th1ng.tripmoney.utils.colors
|
||||
@@ -26,9 +24,7 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@@ -36,7 +32,6 @@ import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import javax.inject.Singleton
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
|
||||
@Database(
|
||||
entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class],
|
||||
@@ -176,6 +171,51 @@ private class DatabasePrepopulator(
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy1",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy2",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy3",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy4",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy5",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy6",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy7",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy8",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy9 ",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@@ -193,7 +233,7 @@ private class DatabasePrepopulator(
|
||||
|
||||
|
||||
val expense = Expense(
|
||||
categoryId = Random.nextInt(1, 5),
|
||||
categoryId = Random.nextInt(1, sampleCategories.size),
|
||||
tripId = 1,
|
||||
amount = Random.nextDouble(0.1, 300.0),
|
||||
currency = Currencies.entries.random().name,
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
@@ -18,6 +19,8 @@ interface ExpenseDao {
|
||||
suspend fun insert(expense: Expense)
|
||||
|
||||
|
||||
@Transaction
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM expense
|
||||
@@ -87,13 +90,17 @@ interface ExpenseDao {
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT trip.budget - IFNULL(SUM(expense.amount * expense.rate), 0)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN trip.budget = 0 THEN NULL
|
||||
ELSE trip.budget - IFNULL(SUM(expense.amount * expense.rate), 0)
|
||||
END
|
||||
FROM trip
|
||||
LEFT JOIN expense ON expense.trip_id = trip.id
|
||||
WHERE trip.id = :tripId
|
||||
"""
|
||||
)
|
||||
fun budgetLeft(tripId: Int): Double
|
||||
fun budgetLeft(tripId: Int): Double?
|
||||
|
||||
@Delete
|
||||
suspend fun delete(expense: Expense)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
import java.time.LocalDateTime
|
||||
@@ -17,7 +18,8 @@ import java.time.LocalDateTime
|
||||
childColumns = arrayOf("category_id"),
|
||||
onUpdate = ForeignKey.CASCADE,
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)]
|
||||
)],
|
||||
indices = [Index(value = ["category_id"], unique = true)]
|
||||
)
|
||||
@Immutable
|
||||
data class Expense(
|
||||
|
||||
@@ -13,6 +13,7 @@ import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
|
||||
import cc.n0th1ng.tripmoney.utils.Currencies
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.OptionalDouble
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExpenseRepository @Inject constructor(
|
||||
@@ -20,7 +21,7 @@ class ExpenseRepository @Inject constructor(
|
||||
private val exchangeRateRepository: ExchangeRateRepository
|
||||
) {
|
||||
|
||||
fun getBudgetLeft(tripId: Int): Double {
|
||||
fun getBudgetLeft(tripId: Int): Double? {
|
||||
return expenseDao.budgetLeft(tripId)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -21,7 +22,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -38,7 +38,6 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import cc.n0th1ng.tripmoney.Filter
|
||||
@@ -121,7 +120,11 @@ fun TopBar(
|
||||
) {
|
||||
Icon(
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
painter = painterResource(drawable.materialsymbols_ic_filter_alt_outlined),
|
||||
painter = painterResource(
|
||||
if (filter.isDefault())
|
||||
drawable.materialsymbols_ic_filter_alt_outlined
|
||||
else com.composables.icons.materialsymbols.outlinedfilled.R.drawable.materialsymbols_ic_filter_alt_outlined_filled
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
showFilter = true
|
||||
@@ -150,7 +153,11 @@ fun TopBar(
|
||||
showFilter = false
|
||||
},
|
||||
categories = categories,
|
||||
filter = filter
|
||||
filter = filter,
|
||||
onClear = {
|
||||
onFilterChange(Filter())
|
||||
showFilter = false
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -161,6 +168,7 @@ fun TopBar(
|
||||
fun FilterDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Filter) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
categories: List<Category>,
|
||||
filter: Filter
|
||||
) {
|
||||
@@ -168,20 +176,28 @@ fun FilterDialog(
|
||||
var fromAmountString by remember { mutableStateOf(filter.startAmount.toString()) }
|
||||
var toAmountString by remember { mutableStateOf(filter.endAmount.toString()) }
|
||||
AlertDialog(
|
||||
onDismiss, {
|
||||
onDismissRequest = onDismiss,
|
||||
dismissButton = {
|
||||
Button(
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
|
||||
enabled = true,
|
||||
onClick = onClear
|
||||
) { Text(stringResource(R.string.clear)) }
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
enabled = true,
|
||||
onClick = {
|
||||
onSave(
|
||||
filter.withStartAmount(fromAmountString.safeToDouble())
|
||||
.withEndAmount( toAmountString.safeToDouble())
|
||||
.withEndAmount(toAmountString.safeToDouble())
|
||||
)
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
}, title = { Text("Filter") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Categories")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
categories.forEach {
|
||||
FilterChip(selected = filter.categories.contains(it), onClick = {
|
||||
filter = if (filter.categories.contains(it)) {
|
||||
@@ -189,7 +205,12 @@ fun FilterDialog(
|
||||
} else {
|
||||
filter.with(it)
|
||||
}
|
||||
}, label = { Text(text = it.name) })
|
||||
}, label = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Icon(painterResource(it.icon.resource), contentDescription = null)
|
||||
Text(text = it.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
AmountTextField(label = "from", onValueChange = { newText ->
|
||||
@@ -265,13 +286,14 @@ fun PreviewFilterDialog() {
|
||||
onDismiss = {},
|
||||
onSave = {},
|
||||
categories = categoriesToPreview,
|
||||
filter = Filter()
|
||||
filter = Filter(),
|
||||
onClear = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.safeToDouble(): Double {
|
||||
if(this == "∞") return Double.MAX_VALUE
|
||||
if(this.isEmpty()) return 0.0
|
||||
if (this == "∞") return Double.MAX_VALUE
|
||||
if (this.isEmpty()) return 0.0
|
||||
return this.toDouble()
|
||||
}
|
||||
@@ -487,7 +487,7 @@ fun NumberKeyboard(
|
||||
onLongClick = onLongBackspaceClick
|
||||
)
|
||||
|
||||
"+", "÷", "-", "×" -> KeyboardButton(
|
||||
"+", "÷", "−", "×" -> KeyboardButton(
|
||||
text = key,
|
||||
onClick = { onOperatorClick(key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -541,7 +541,7 @@ fun KeyboardButton(
|
||||
}
|
||||
|
||||
val keyboard = listOf(
|
||||
listOf("+", "-", "×", "÷"),
|
||||
listOf("+", "−", "×", "÷"),
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
|
||||
@@ -363,7 +363,7 @@ fun ExpenseCard(
|
||||
colors = CardDefaults.elevatedCardColors()
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.fillMaxWidth(0.95f)
|
||||
.height(70.dp)
|
||||
.combinedClickable(
|
||||
enabled = true,
|
||||
|
||||
@@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -32,7 +34,6 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.core.graphics.toColorLong
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
@@ -73,7 +74,7 @@ fun StatisticsScreen(
|
||||
summaryPerCategoryList: List<SummaryPerCategory>,
|
||||
summaryAmount: Double,
|
||||
tripCurrency: Currencies,
|
||||
moneyLeft: Double
|
||||
moneyLeft: Double?
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -104,7 +105,7 @@ fun StatisticsScreen(
|
||||
@Composable
|
||||
fun Summary(
|
||||
modifier: Modifier = Modifier,
|
||||
amount: Double,
|
||||
amount: Double?,
|
||||
currency: String,
|
||||
text: String,
|
||||
icon: Int,
|
||||
@@ -117,7 +118,8 @@ fun Summary(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -141,7 +143,8 @@ fun Summary(
|
||||
|
||||
}
|
||||
Text(
|
||||
"%.2f %s".format(amount, currency),
|
||||
if (amount == null) "∞" else
|
||||
"%.2f %s".format(amount, currency),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
@@ -156,7 +159,10 @@ fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(15.dp),
|
||||
modifier = Modifier
|
||||
.padding(15.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
summaryPerCategoryList.forEach {
|
||||
@@ -204,7 +210,7 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(40.dp)
|
||||
.height(30.dp)
|
||||
.fillMaxWidth(0.12f + (0.90f - 0.12f) * summaryPerCategory.percent)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
@@ -213,7 +219,7 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(11.dp)
|
||||
.padding(vertical = 5.dp, horizontal = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
"%d%%".format((summaryPerCategory.percent * 100).toInt()),
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.apache.commons.csv.CSVFormat
|
||||
import org.apache.commons.csv.CSVPrinter
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
import java.util.OptionalDouble
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.mapValues
|
||||
|
||||
@@ -41,7 +42,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
|
||||
private val tripRepo: TripRepository
|
||||
) : ViewModel() {
|
||||
|
||||
fun getBudgetLeft(tripId: Int): Double {
|
||||
fun getBudgetLeft(tripId: Int): Double? {
|
||||
return expenseRepo.getBudgetLeft(tripId)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user