Compare commits

..

9 Commits

Author SHA1 Message Date
Rafal Wisniewski
79551ab69d fix: search & filter on list expense screen 2026-05-04 22:01:38 +02:00
Rafal Wisniewski
38f3760cef fix: math on add expense screen 2026-05-04 21:57:23 +02:00
Rafal Wisniewski
9225f2275f fix: adjust style of delete confirmation dialog 2026-05-04 21:42:55 +02:00
Rafal Wisniewski
aad0de1499 fix: scroll to date when clicked on stats screen 2026-05-04 15:44:32 +02:00
Rafal Wisniewski
5fb54bf18e fix: scroll to newly added item 2026-05-04 15:12:19 +02:00
Rafal Wisniewski
bf9309a155 fix: add currencies and search to them 2026-05-03 22:34:52 +02:00
Rafal Wisniewski
cc10ddabbe feat: add categories per day stats 2026-05-01 15:13:58 +02:00
Rafal Wisniewski
3286bcf87a fix: add info text when no data 2026-05-01 13:10:27 +02:00
dfe9dbd08b Merge pull request 'init' (#48) from develop into main
Reviewed-on: #48
2026-04-30 10:35:57 +02:00
17 changed files with 819 additions and 185 deletions

View File

@@ -12,7 +12,6 @@ import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -26,6 +25,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.BottomNavigation
@@ -97,7 +97,7 @@ fun NavigationDrawer() {
}
}
},
isSearchable = current == Screens.LIST_EXPENSE,
isSearchable = current != null && current.contains(Screens.LIST_EXPENSE),
onSearchChange = { newSearch -> search = newSearch },
onFilterChange = { newFilter -> filter = newFilter },
categories = categories,
@@ -111,17 +111,20 @@ fun NavigationDrawer() {
startDestination = if (currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE,
modifier = Modifier.padding(innerPadding)
) {
composable(Screens.LIST_EXPENSE) {
composable(Screens.LIST_EXPENSE+"?dateToScroll={dateToScroll}",
arguments = listOf(navArgument("dateToScroll"){defaultValue = ""})) {
backStackEntry ->
ListExpenseScreen(
filter = filter, search = search,
initialAutoOpen = shouldTriggerAutoOpen,
onAutoOpenConsumed = { hasHandledStartupOpen = true })
onAutoOpenConsumed = { hasHandledStartupOpen = true },
dateToScroll = backStackEntry.arguments?.getString("dateToScroll")?: "")
}
composable(Screens.TRIP_PICKER) {
TripPickerScreen(navController)
}
composable(Screens.STATISTICS) {
StatisticsScreen()
StatisticsScreen(navController)
}
composable(Screens.SETTINGS) {
SettingsScreen(navController)

View File

@@ -49,8 +49,6 @@ abstract class TripDatabase : RoomDatabase() {
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O)
@Provides
@Singleton

View File

@@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.Flow
interface ExpenseDao {
@Upsert
suspend fun insert(expense: Expense)
suspend fun insert(expense: Expense): Long
@Transaction

View File

@@ -2,7 +2,7 @@ package cc.n0th1ng.tripmoney.data.dto
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import java.time.LocalDate
data class SummaryPerCategory(
val category: Category,
@@ -11,12 +11,8 @@ data class SummaryPerCategory(
val currency: Currencies
)
data class SummaryPerCategoryRaw(
val categoryId: Int,
val categoryName: String,
val icon: Icons,
val color: String,
data class SummaryPerDay(
val day: LocalDate,
val amount: Double,
val currency: String
val percent: Float
)

View File

@@ -19,6 +19,10 @@ data class Trip(
@ColumnInfo("currency") val currency: String,
@ColumnInfo("budget") val budget: Double = 0.0
) {
fun isDummy(): Boolean {
return this.id == -1
}
companion object {
@RequiresApi(Build.VERSION_CODES.O)
val DUMMY = Trip(

View File

@@ -25,8 +25,8 @@ class ExpenseRepository @Inject constructor(
}
@WorkerThread
suspend fun save(expense: Expense) {
expenseDao.insert(expense)
suspend fun save(expense: Expense): Long {
return expenseDao.insert(expense)
}
@WorkerThread

View File

@@ -115,10 +115,6 @@ fun AddExpenseBottomSheet(
) {
val currentTripId = currentTrip.id
if (categories.isEmpty()) {
return
}
var amount by remember {
mutableStateOf(
"%.2f".format(expenseDtoToEdit?.expense?.amount ?: 0.00)
@@ -138,7 +134,11 @@ fun AddExpenseBottomSheet(
expenseDtoToEdit?.expense?.currency ?: currentTrip.currency
)
}
var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }
var category by remember {
mutableStateOf(
expenseDtoToEdit?.category ?: if (categories.isEmpty()) null else categories[0]
)
}
var datetime by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now()
@@ -273,14 +273,14 @@ fun AddExpenseBottomSheet(
SaveButton(
modifier = Modifier.fillMaxWidth(),
enabled = enableSave,
enabled = enableSave && category != null,
onClick = {
val expenseToSave = Expense(
amount = equationResult,
currency = currency,
note = note,
datetime = datetime,
categoryId = category.id,
categoryId = category!!.id,
tripId = currentTripId
)
onSave(
@@ -410,7 +410,7 @@ fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: Str
}
@Composable
fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
fun CategoryButton(onClick: () -> Unit, category: Category?, modifier: Modifier = Modifier) {
Button(
contentPadding = PaddingValues(0.dp),
onClick = onClick,
@@ -422,25 +422,21 @@ fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier =
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
// Row(modifier = modifier.fillMaxWidth()) {
if (category != null) {
Icon(
tint = Color(category.color.toColorInt()),
modifier = Modifier
.size(30.dp)
// .background(
// color = MaterialTheme.colorScheme.prima,
// shape = MaterialTheme.shapes.small
// )
.padding(end = 10.dp),
painter = painterResource(category.icon.resource),
contentDescription = stringResource(R.string.category),
)
}
Text(
text = category.name,
text = category?.name ?: stringResource(R.string.pick_category),
style = MaterialTheme.typography.titleMedium
)
// }
}
}
@@ -484,12 +480,20 @@ fun NumberKeyboard(
modifier = Modifier
.weight(1f),
containerColor = Color.Transparent,
onLongClick = onLongBackspaceClick
onLongClick = onLongBackspaceClick,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
"+", "÷", "", "×" -> KeyboardButton(
text = key,
onClick = { onOperatorClick(key) },
onClick = {
when (key) {
"+" -> onOperatorClick("+")
"÷" -> onOperatorClick("/")
"" -> onOperatorClick("-")
"×" -> onOperatorClick("*")
}
},
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
@@ -500,7 +504,7 @@ fun NumberKeyboard(
onClick = { onNumberClick(key) },
modifier = Modifier.weight(1f),
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSecondary
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@@ -532,7 +536,8 @@ fun KeyboardButton(
when {
text != null -> Text(
text = text,
style = MaterialTheme.typography.headlineMedium
style = MaterialTheme.typography.headlineMedium,
color = contentColor
)
icon != null -> Icon(painter = icon, contentDescription = null)

View File

@@ -25,7 +25,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
@@ -35,7 +35,7 @@ import com.composables.icons.materialsymbols.outlined.R
fun CategorySelectionDialog(
onDismiss: () -> Unit,
onCategorySelected: (Category) -> Unit,
selected: Category,
selected: Category?,
categories: List<Category>,
) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()

View File

@@ -1,20 +1,38 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.unit.dp
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import com.composables.icons.materialsymbols.outlined.R.drawable
@Composable
fun CurrencySelectionDialog(
@@ -23,29 +41,90 @@ fun CurrencySelectionDialog(
selected: String
) {
AlertDialog(
modifier = Modifier.sizeIn(maxHeight = 500.dp),
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pick_currency)) },
text = {
Column {
Currencies.names().forEach { currency ->
val scrollState = rememberLazyListState()
val currencies = Currencies.names()
var search by remember { mutableStateOf("") }
LaunchedEffect(selected) {
val index = currencies.indexOf(selected)
if (index != -1) {
scrollState.animateScrollToItem(index)
}
}
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
OutlinedTextField(
value = search,
onValueChange = { newText ->
search = newText
},
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
Icon(
painter = painterResource(drawable.materialsymbols_ic_search_outlined),
contentDescription = "search"
)
}
)
val filteredCurrencies = if (search.isBlank()) {
currencies
} else {
currencies.filter { currency ->
currency.lowercase().contains(search.lowercase())
}
}
LazyColumn(state = scrollState) {
items(
count = filteredCurrencies.size,
key = { index -> filteredCurrencies[index] }
) { index ->
val currency = filteredCurrencies[index]
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onCurrencySelected(currency)
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected == currency, onClick = {
onCurrencySelected(currency)
})
selected = selected == currency,
onClick = { onCurrencySelected(currency) }
)
Text(
text = currency, modifier = Modifier.padding(start = 8.dp)
text = currency,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
},
confirmButton = {})
confirmButton = {},
dismissButton = {
Button(
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
enabled = true,
onClick = onDismiss,
) { Text(stringResource(R.string.cancel)) }
}
)
}
@AllPreviews
@Composable
fun PreviewCurrencySelectionDialog() {
TripMoneyTheme {
CurrencySelectionDialog(
{},
{},
Currencies.names().random()
)
}
}

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -22,6 +21,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -40,6 +41,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -47,11 +49,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -88,7 +91,8 @@ fun ListExpenseScreen(
filter: Filter,
search: String,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
onAutoOpenConsumed: () -> Unit,
dateToScroll: String
) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
@@ -98,14 +102,23 @@ fun ListExpenseScreen(
val expensesFlow =
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, search, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
var idToScroll by remember { mutableIntStateOf(-1) }
ListExpenseScreen(
currentTrip = currentTrip,
expensesFlow = expensesFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
onSaveExpense = {
expenseAndCategoryViewModel.save(
it,
currentTrip!!,
onComplete = { id -> idToScroll = id })
},
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
isRecalculatingRate = isRecalculatingRate,
initialAutoOpen = initialAutoOpen,
onAutoOpenConsumed = onAutoOpenConsumed
onAutoOpenConsumed = onAutoOpenConsumed,
idToScroll = idToScroll,
dateToScroll = dateToScroll
)
}
@@ -114,11 +127,14 @@ fun ListExpenseScreen(
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen(
currentTrip: Trip?,
expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
isRecalculatingRate: Boolean,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
onAutoOpenConsumed: () -> Unit,
idToScroll: Int,
dateToScroll: String
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -137,16 +153,63 @@ fun ListExpenseScreen(
var itemToDelete by remember { mutableStateOf<Expense?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
if (currentTrip != null && !currentTrip.isDummy()) {
ExtendedFloatingActionButton(
onClick = { showBottomSheet = true },
icon = { Icon(Icons.Filled.Add, stringResource(string.add_expense)) },
text = { Text(text = stringResource(string.add_expense)) },
)
}
})
{
Box {
if (items.itemCount == 0) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
val textToShow = if (currentTrip == null || currentTrip.isDummy()) {
stringResource(string.no_trip_picked)
} else {
stringResource(string.no_expenses)
}
Text(
text = textToShow,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
LaunchedEffect(Unit) {
if (dateToScroll == "") return@LaunchedEffect
for (index in 0 until items.itemCount) {
val item = items.peek(index)
if (item is ExpenseListItemUi.Header && item.date.toString() == dateToScroll) {
listState.animateScrollToItem(index)
break
}
}
}
LaunchedEffect(idToScroll) {
if (idToScroll == -1) return@LaunchedEffect
for (index in 0 until items.itemCount) {
val item = items.peek(index)
if (item is ExpenseListItemUi.Item && item.expenseDto.expense.id == idToScroll) {
listState.animateScrollToItem(index)
break
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().semantics {
modifier = Modifier
.fillMaxSize()
.semantics {
contentDescription = "expensesList"
},
horizontalAlignment = Alignment.CenterHorizontally,
@@ -161,9 +224,7 @@ fun ListExpenseScreen(
}
}
) { index ->
when (val item = items[index]) {
is ExpenseListItemUi.Header -> {
CustomDivider(
date = item.date,
@@ -193,6 +254,8 @@ fun ListExpenseScreen(
}
}
}
if (itemToDelete != null) {
DeleteConfirmationDialog(
onConfirm = {
@@ -220,7 +283,6 @@ fun ListExpenseScreen(
state = sheetState
)
}
}
}
@@ -339,20 +401,22 @@ fun DeleteConfirmationDialog(
.fillMaxWidth()
.padding(top = 24.dp)
) {
Text(
text = stringResource(string.cancel),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.padding(end = 24.dp)
.clickable { onCancel() }
)
Text(
text = stringResource(string.delete),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { onConfirm() }
Button(
modifier = Modifier.padding(end = 20.dp),
onClick = onCancel
) {
Text(stringResource(string.cancel))
}
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text(stringResource(string.delete))
}
}
}
}
@@ -466,12 +530,69 @@ fun PreviewListExpenseScreen() {
TripMoneyTheme() {
val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
ListExpenseScreen(
currentTrip = Trip(
id = 1,
name = "Vacation",
currency = "USD",
startDate = LocalDate.parse("2026-01-01"),
endDate = LocalDate.parse("2026-01-11"),
),
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
isRecalculatingRate = true,
false,
{}
{},
0,
""
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewListExpenseScreenWithoutExpenses() {
TripMoneyTheme() {
val pagingData = PagingData.from(emptyList<ExpenseListItemUi>())
ListExpenseScreen(
currentTrip = Trip(
id = 1,
name = "Vacation",
currency = "USD",
startDate = LocalDate.parse("2026-01-01"),
endDate = LocalDate.parse("2026-01-11"),
),
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
isRecalculatingRate = true,
false,
{},
0,
""
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewListExpenseScreenWithoutTrip() {
TripMoneyTheme() {
val pagingData = PagingData.from(emptyList<ExpenseListItemUi>())
ListExpenseScreen(
currentTrip = null,
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
isRecalculatingRate = true,
false,
{},
0,
""
)
}
@@ -480,7 +601,7 @@ fun PreviewListExpenseScreen() {
@AllPreviews
@Composable
fun PreviewDeleteConfirmationDialog() {
TripMoneyTheme() {
TripMoneyTheme {
DeleteConfirmationDialog(
onConfirm = {},
onCancel = {})

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -48,7 +46,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -63,9 +60,6 @@ import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import com.composables.icons.materialsymbols.outlined.R
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.collections.emptyList
@RequiresApi(Build.VERSION_CODES.O)
@Composable
@@ -159,7 +153,7 @@ fun ManageCategoriesScreen(
if (itemToDelete != null) {
DeleteConfirmationDialog(
bodyText = stringResource(string.delete_category_info),
bodyText = stringResource(string.delete_category_info).format(itemToDelete?.name),
onConfirm = {
onDeleteCategory(itemToDelete!!)
itemToDelete = null
@@ -236,7 +230,7 @@ fun SwipeToDeleteExpenseCard(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {

View File

@@ -4,15 +4,20 @@ import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -32,12 +37,18 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.dto.SummaryPerDay
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
@@ -47,10 +58,12 @@ import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun StatisticsScreen() {
fun StatisticsScreen(navController: NavController) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
@@ -58,14 +71,20 @@ fun StatisticsScreen() {
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTripId)
.collectAsState(emptyList())
val summaryPerDayList by expenseAndCategoryViewModel.getSummaryPerDay(currentTripId)
.collectAsState(emptyList())
val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId)
.collectAsState(0.0)
val moneyLeft by expenseAndCategoryViewModel.getBudgetLeft(currentTripId).collectAsState(null)
StatisticsScreen(
summaryPerCategoryList,
summaryPerDayList,
summaryAmount,
Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name),
moneyLeft
moneyLeft,
onDayClicked = {
date -> navController.navigate(Screens.LIST_EXPENSE + "?dateToScroll=$date")
}
)
}
@@ -73,9 +92,11 @@ fun StatisticsScreen() {
@Composable
fun StatisticsScreen(
summaryPerCategoryList: List<SummaryPerCategory>,
summaryPerDayList: List<SummaryPerDay>,
summaryAmount: Double,
tripCurrency: Currencies,
moneyLeft: Double?
moneyLeft: Double?,
onDayClicked: (String) -> Unit
) {
Column(
modifier = Modifier
@@ -85,7 +106,9 @@ fun StatisticsScreen(
) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Summary(
Modifier.weight(1f), -1 * summaryAmount, tripCurrency.name,
Modifier.weight(1f),
if (summaryAmount == 0.0) 0.0 else -1 * summaryAmount,
tripCurrency.name,
stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses),
R.drawable.materialsymbols_ic_payment_arrow_down_outlined,
iconColor = MaterialTheme.colorScheme.error
@@ -97,8 +120,11 @@ fun StatisticsScreen(
iconColor = colorResource(cc.n0th1ng.tripmoney.R.color.good_green)
)
}
SummaryPerCategoryCard(summaryPerCategoryList)
SummaryPerCategoryCard(
modifier = Modifier.heightIn(max = 300.dp),
summaryPerCategoryList = summaryPerCategoryList
)
SummaryPerDayCard(modifier = Modifier.height(300.dp), summaryPerDayList = summaryPerDayList, onDayClicked = onDayClicked)
}
}
@@ -153,12 +179,31 @@ fun Summary(
}
@Composable
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
fun SummaryPerCategoryCard(
summaryPerCategoryList: List<SummaryPerCategory>,
modifier: Modifier = Modifier
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
if (summaryPerCategoryList.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(cc.n0th1ng.tripmoney.R.string.no_expenses_summary),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
Column(
modifier = Modifier
.padding(15.dp)
@@ -174,6 +219,49 @@ fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun SummaryPerDayCard(modifier: Modifier = Modifier, summaryPerDayList: List<SummaryPerDay>, onDayClicked: (String) -> Unit) {
ElevatedCard(
modifier = modifier
.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
if (summaryPerDayList.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(cc.n0th1ng.tripmoney.R.string.no_expenses_summary),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
Row(
modifier = Modifier
.padding(15.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
summaryPerDayList.forEach { it ->
DayCard(
summaryPerDay = it,
onDayClicked = {date -> onDayClicked(date)}
)
}
}
}
}
}
@Composable
@@ -234,23 +322,107 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun DayCard(modifier: Modifier = Modifier, summaryPerDay: SummaryPerDay, onDayClicked: (String) -> Unit) {
Column(
modifier = modifier.fillMaxHeight(), verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "%.2f".format(summaryPerDay.amount),
style = MaterialTheme.typography.labelSmall,
fontSize = (MaterialTheme.typography.labelSmall.fontSize.value - 2).sp,
)
val width = 45.dp
Box(
modifier = Modifier
.width(width)
.fillMaxHeight(0.2f + (0.98f - 0.2f) * summaryPerDay.percent)
.clip(RoundedCornerShape(width / 2))
.background(MaterialTheme.colorScheme.primary)
.clickable(onClick = {onDayClicked(summaryPerDay.day.toString())})
.padding(top = 5.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.size(width - 10.dp)
.background(
MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(width / 2)
)
.padding(vertical = 3.dp),
) {
Text(
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
lineHeight = 10.sp,
color = MaterialTheme.colorScheme.onTertiaryContainer,
text = summaryPerDay.day.format(DateTimeFormatter.ofPattern("dd"))
)
Text(
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Light,
fontSize = (MaterialTheme.typography.labelSmall.fontSize.value - 2).sp,
lineHeight = 10.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onTertiaryContainer,
text = summaryPerDay.day.format(DateTimeFormatter.ofPattern("E"))
)
}
}
}
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun Preview() {
fun PreviewStatisticScreen() {
TripMoneyTheme {
Scaffold {
StatisticsScreen(
summaryPerCategoryList,
summaryPerDayList,
summaryAmount = 125.24,
Currencies.entries.random(),
432.14
432.14,
{}
)
}
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewStatisticScreenWithNoData() {
TripMoneyTheme {
Scaffold {
StatisticsScreen(
emptyList(),
emptyList(),
summaryAmount = 0.0,
Currencies.entries.random(),
null,
{}
)
}
}
}
val categories = listOf(
Category(name = "Jedzenie", icon = Icons.RESTAURANT, color = colors.random()),
Category(name = "Transport", icon = Icons.FLIGHT, color = colors.random()),
@@ -269,3 +441,25 @@ val summaryPerCategoryList = listOf(
SummaryPerCategory(categories[3], 50.0, 0.1f, Currencies.PLN),
SummaryPerCategory(categories[5], 50.0, 0.0001f, Currencies.PLN),
)
@RequiresApi(Build.VERSION_CODES.O)
val summaryPerDayListRaw = listOf(
SummaryPerDay(LocalDate.now(), 50.0, 0f),
SummaryPerDay(LocalDate.now().minusDays(1), 500.23, 0f),
SummaryPerDay(LocalDate.now().minusDays(2), 1560.53, 0f),
SummaryPerDay(LocalDate.now().minusDays(3), 700.32, 0f),
SummaryPerDay(LocalDate.now().minusDays(4), 201.3, 0f),
SummaryPerDay(LocalDate.now().minusDays(5), 2020.64, 0f),
SummaryPerDay(LocalDate.now().minusDays(6), 510.43, 0f),
SummaryPerDay(LocalDate.now().minusDays(7), 3050.12, 0f),
SummaryPerDay(LocalDate.now().minusDays(8), 264.32, 0f),
SummaryPerDay(LocalDate.now().minusDays(9), 3596.64, 0f)
)
@RequiresApi(Build.VERSION_CODES.O)
val highestAmount = summaryPerDayListRaw.maxOf { it.amount }
@RequiresApi(Build.VERSION_CODES.O)
val summaryPerDayList = summaryPerDayListRaw.map {
it.copy(percent = ((it.amount / highestAmount)).toFloat())
}.sortedBy { it.day.toEpochDay() }

View File

@@ -38,14 +38,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.paging.PagingData
@@ -113,6 +112,17 @@ fun TripPickerScreen(
Icon(Icons.Filled.Add, stringResource(string.add_trip))
}
}) { paddingValues ->
if (trips.itemCount == 0) {
Box(modifier = Modifier.fillMaxSize().padding(10.dp), contentAlignment = Alignment.Center) {
Text(
text = stringResource(string.no_trip_added),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
LazyColumn(
modifier = Modifier
.padding(horizontal = 15.dp)
@@ -138,6 +148,8 @@ fun TripPickerScreen(
Spacer(Modifier.height(10.dp))
}
}
}
if (showBottomSheet) {
AddTripBottomSheet(
@@ -250,7 +262,8 @@ fun TripCard(
}
Column(
modifier = Modifier.padding(end = 20.dp),
horizontalAlignment = Alignment.End) {
horizontalAlignment = Alignment.End
) {
Text(
trip.currency.uppercase(),
style = MaterialTheme.typography.titleLarge,
@@ -308,3 +321,18 @@ fun PreviewTripPickerScreen() {
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewTripPickerScreenNoTrip() {
TripMoneyTheme {
TripPickerScreen(
tripsFlow = MutableStateFlow(PagingData.from(emptyList())),
currentTripId = 1,
onDelete = {},
onClick = {},
onSave = {}
)
}
}

View File

@@ -1,10 +1,169 @@
package cc.n0th1ng.tripmoney.utils
enum class Currencies {
AED,
AFN,
ALL,
AMD,
ANG,
AOA,
ARS,
AUD,
AWG,
AZN,
BAM,
BBD,
BDT,
BHD,
BIF,
BMD,
BND,
BOB,
BRL,
BSD,
BTN,
BWP,
BYN,
BZD,
CAD,
CDF,
CHF,
CLP,
CNH,
CNY,
COP,
CRC,
CUP,
CVE,
CZK,
DJF,
DKK,
DOP,
DZD,
EGP,
ERN,
ETB,
FJD,
FKP,
GBP,
GEL,
GGP,
GHS,
GIP,
GMD,
GNF,
GTQ,
GYD,
HKD,
HNL,
HTG,
HUF,
IDR,
ILS,
IMP,
INR,
IQD,
IRR,
ISK,
JEP,
JMD,
JOD,
JPY,
KES,
KGS,
KHR,
KMF,
KRW,
KWD,
KYD,
KZT,
LAK,
LBP,
LKR,
LRD,
LSL,
LYD,
MAD,
MDL,
MGA,
MKD,
MMK,
MNT,
MOP,
MRO,
MRU,
MUR,
MVR,
MWK,
MXN,
MYR,
MZN,
NAD,
NGN,
NIO,
NOK,
NPR,
NZD,
OMR,
PAB,
PEN,
PGK,
PHP,
PKR,
PLN,
EUR,
PYG,
QAR,
RON,
RSD,
RUB,
RWF,
SAR,
SBD,
SCR,
SDG,
SEK,
SGD,
SHP,
SLE,
SOS,
SRD,
SSP,
STN,
SVC,
SYP,
SZL,
THB,
TJS,
TMT,
TND,
TOP,
TRY,
TTD,
TWD,
TZS,
UAH,
UGX,
USD,
RON;
UYU,
UZS,
VES,
VND,
VUV,
WST,
XAF,
XAG,
XAU,
XCD,
XCG,
XDR,
XOF,
XPD,
XPF,
XPT,
YER,
ZAR,
ZMW,
ZWG;
companion object {
fun default(): Currencies {

View File

@@ -10,6 +10,7 @@ import androidx.paging.insertSeparators
import androidx.paging.map
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.dto.SummaryPerDay
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
@@ -44,7 +45,11 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
return expenseRepo.getBudgetLeft(tripId)
}
fun getExpensesDtoPaged(tripId: Int, search: String = "", filter: Filter = Filter()): Flow<PagingData<ExpenseDto>> =
fun getExpensesDtoPaged(
tripId: Int,
search: String = "",
filter: Filter = Filter()
): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesDtoPaged(tripId, search, filter).cachedIn(viewModelScope)
@RequiresApi(Build.VERSION_CODES.O)
@@ -86,18 +91,23 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}.cachedIn(viewModelScope)
}
fun getExpensesDto(tripId: Int, search: String = "", filter: Filter = Filter()): Flow<List<ExpenseDto>> =
fun getExpensesDto(
tripId: Int,
search: String = "",
filter: Filter = Filter()
): Flow<List<ExpenseDto>> =
expenseRepo.getExpensesDto(tripId, search, filter)
@RequiresApi(Build.VERSION_CODES.O)
fun save(expense: Expense, trip: Trip) {
fun save(expense: Expense, trip: Trip, onComplete: (Int) -> Unit) {
viewModelScope.launch {
val rate = exchangeRateRepository.getRate(
Currencies.valueOf(expense.currency),
Currencies.valueOf(trip.currency),
expense.datetime.toLocalDate()
)
expenseRepo.save(expense.copy(rate = rate))
val id = expenseRepo.save(expense.copy(rate = rate))
onComplete(id.toInt())
}
}
@@ -184,6 +194,32 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSummaryPerDay(tripId: Int): Flow<List<SummaryPerDay>> {
val tripFlow = tripRepo.getTrip(tripId)
val expensesFlow = getExpensesDto(tripId)
return tripFlow.combine(expensesFlow) { trip, expenses ->
val summaryPerDayRaw = expenses.groupBy { it.expense.datetime.toLocalDate() }
.map { (day, expensesForDay) ->
val total = expensesForDay.sumOf { it.expense.convertedAmount() }
SummaryPerDay(
amount = total,
day = day,
percent = 0.0f
)
}
.sortedByDescending { it.day }
val highestAmount =
if (summaryPerDayRaw.isEmpty()) 1.0 else summaryPerDayRaw.maxOf { it.amount }
summaryPerDayRaw.map {
it.copy(percent = ((it.amount / highestAmount)).toFloat())
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun clearOldRates() {
viewModelScope.launch {

View File

@@ -32,4 +32,17 @@
<string name="export_csv_subttext">Zapisz wydatki z %s do pliku</string>
<string name="add_new_category">Dodaj kategorie</string>
<string name="edit_category">Edytuj kategorie</string>
<string name="archive">Archiwizuj</string>
<string name="you_want_archive">Chcesz zarchiwizować?</string>
<string name="archive_category_info">Żadne wydatki nie będą usunięte.</string>
<string name="delete_category_info">Wszystkie wydatki z kategorii %s zostaną usunięte.</string>
<string name="budget">Budżet</string>
<string name="money_left">Pozostałe środki</string>
<string name="add_expense_settings">Pokaż dodawanie wydatku na starcie</string>
<string name="yesterday">Wczoraj</string>
<string name="clear">Wyczyść</string>
<string name="no_expenses">Zacznij budżetowanie od dodania wydatków</string>
<string name="no_trip_picked">Wybierz wycieczkę żeby zobaczyć wydatki</string>
<string name="no_trip_added">Zacznij budżetowanie od dodania wycieczki</string>
<string name="no_expenses_summary">Brak wydatków do podsumowania</string>
</resources>

View File

@@ -35,10 +35,14 @@
<string name="archive">Archive</string>
<string name="you_want_archive">Do you want to archive?</string>
<string name="archive_category_info">No expense will be deleted.</string>
<string name="delete_category_info">Your all expenses with category Hotel will be removed.</string>
<string name="delete_category_info">Your all expenses with category %s will be removed.</string>
<string name="budget">Budget</string>
<string name="money_left">Money left</string>
<string name="add_expense_settings">Open add expense form on startup</string>
<string name="yesterday">Yesterday</string>
<string name="clear">Clear</string>
<string name="no_expenses">Start budgeting by adding expenses</string>
<string name="no_trip_picked">Select trip to see expenses</string>
<string name="no_trip_added">Start budgeting by adding your trip</string>
<string name="no_expenses_summary">No expenses to summarize</string>
</resources>