diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ExpenseFilters.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ExpenseFilters.kt index 89f88c8..3abf97c 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ExpenseFilters.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ExpenseFilters.kt @@ -3,7 +3,11 @@ package com.example.budgeting.android.data.model data class ExpenseFilters( val search: String = "", val category: String = "All", - val sortOption: SortOption = SortOption.NEWEST + val sortOption: SortOption = SortOption.NEWEST, + val minAmount: Float? = null, + val maxAmount: Float? = null, + val startDate: String? = null, + val endDate: String? = null ) enum class SortOption( diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt index 83e1e6d..77ac4bd 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt @@ -18,6 +18,10 @@ interface ExpenseApiService { @Query("category") category: String? = null, @Query("sort_by") sortBy: String? = "created_at", @Query("order") order: String? = "desc", + @Query("offset") offset: Int? = 0, + @Query("limit") limit: Int? = 100, + @Query("min_price") minPrice: Float? = null, + @Query("max_price") maxPrice: Float? = null, @Query("date_from") dateFrom: String? = null, @Query("date_to") dateTo: String? = null ): Response>> @@ -30,6 +34,10 @@ interface ExpenseApiService { @Query("category") category: String? = null, @Query("sort_by") sortBy: String? = "created_at", @Query("order") order: String? = "desc", + @Query("offset") offset: Int? = 0, + @Query("limit") limit: Int? = 100, + @Query("min_price") minPrice: Float? = null, + @Query("max_price") maxPrice: Float? = null, @Query("date_from") dateFrom: String? = null, @Query("date_to") dateTo: String? = null ): Response>> @@ -43,6 +51,10 @@ interface ExpenseApiService { @Query("category") category: String? = null, @Query("sort_by") sortBy: String? = "created_at", @Query("order") order: String? = "desc", + @Query("offset") offset: Int? = 0, + @Query("limit") limit: Int? = 100, + @Query("min_price") minPrice: Float? = null, + @Query("max_price") maxPrice: Float? = null, @Query("date_from") dateFrom: String? = null, @Query("date_to") dateTo: String? = null ): Response>> diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt index 8227650..4eb3da6 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt @@ -17,6 +17,10 @@ class ExpenseRepository( category: String?, sortBy: String?, order: String?, + offset: Int?, + limit: Int?, + minPrice: Float?, + maxPrice: Float?, dateFrom: String?, dateTo: String? ): List { @@ -25,6 +29,10 @@ class ExpenseRepository( category = category, sortBy = sortBy, order = order, + offset = offset, + limit = limit, + minPrice = minPrice, + maxPrice = maxPrice, dateFrom = dateFrom, dateTo = dateTo ).body()?.data ?: throw Exception("Failed to fetch expenses") @@ -37,6 +45,10 @@ class ExpenseRepository( category: String?, sortBy: String?, order: String?, + offset: Int?, + limit: Int?, + minPrice: Float?, + maxPrice: Float?, dateFrom: String?, dateTo: String? ): List { @@ -44,6 +56,10 @@ class ExpenseRepository( category = category, sortBy = sortBy, order = order, + offset = offset, + limit = limit, + minPrice = minPrice, + maxPrice = maxPrice, dateFrom = dateFrom, dateTo = dateTo ).body()?.data ?: throw Exception("Failed to fetch expenses") @@ -57,6 +73,10 @@ class ExpenseRepository( category: String?, sortBy: String?, order: String?, + offset: Int?, + limit: Int?, + minPrice: Float?, + maxPrice: Float?, dateFrom: String?, dateTo: String? ): List { @@ -65,6 +85,10 @@ class ExpenseRepository( category = category, sortBy = sortBy, order = order, + offset = offset, + limit = limit, + minPrice = minPrice, + maxPrice = maxPrice, dateFrom = dateFrom, dateTo = dateTo ).body()?.data ?: throw Exception("Failed to fetch expenses") diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/AnalyticsScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/AnalyticsScreen.kt index d53a5e8..18d603e 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/AnalyticsScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/AnalyticsScreen.kt @@ -211,11 +211,11 @@ fun AnalyticsContent( CategoryCountBarChart(categoryCounts) } - AnalyticsCard("Total amount per category") { + AnalyticsCard("Spending by category") { CategoryAmountBarChart(categoryAmounts) } - AnalyticsCard("Monthly trend") { + AnalyticsCard("Spending over time") { MonthlyLineChart(monthlyTotals) } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt index f5bc2ac..a5911dd 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -24,6 +25,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.example.budgeting.android.data.model.* import com.example.budgeting.android.ui.component.ExpenseItem import com.example.budgeting.android.ui.viewmodels.* +import androidx.compose.foundation.clickable +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.filled.CalendarToday +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.Date +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -45,6 +55,7 @@ fun ExpensesScreen( var showDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } + var showAdvancedFilterDialog by remember { mutableStateOf(false) } var selectedExpense by remember { mutableStateOf(null) } LaunchedEffect(Unit) { @@ -105,10 +116,29 @@ fun ExpensesScreen( Spacer(Modifier.height(12.dp)) - ModeSelector( - selected = mode, - onSelected = { expenseViewModel.setMode(it) } - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ModeSelector( + selected = mode, + onSelected = { expenseViewModel.setMode(it) } + ) + + Spacer(Modifier.weight(1f)) + + AssistChip( + onClick = { showAdvancedFilterDialog = true }, + label = { Text("Filters") }, + leadingIcon = { + Icon( + Icons.Default.FilterList, + contentDescription = "Advanced filters" + ) + } + ) + } Spacer(Modifier.height(12.dp)) @@ -175,6 +205,21 @@ fun ExpensesScreen( } ) } + + if (showAdvancedFilterDialog) { + AdvancedFilterDialog( + initialMin = filters.minAmount, + initialMax = filters.maxAmount, + initialStartDate = filters.startDate, + initialEndDate = filters.endDate, + onDismiss = { showAdvancedFilterDialog = false }, + onApply = { min, max, start, end -> + expenseViewModel.setAmountRange(min, max) + expenseViewModel.setDateRange(start, end) + } + ) + } + } } @@ -264,7 +309,6 @@ fun ModeSelector( onSelected: (ExpenseMode) -> Unit ) { Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { ExpenseMode.entries.forEach { mode -> @@ -463,7 +507,19 @@ fun ExpensesList( onClick: (Expense) -> Unit, onLongClick: (Expense) -> Unit ) { - LazyColumn { + val listState = rememberLazyListState() + + LaunchedEffect(listState) { + snapshotFlow { + listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + }.collect { lastVisible -> + if (lastVisible != null && lastVisible >= expenses.size - 5) { + vm.loadExpenses(append = true) + } + } + } + + LazyColumn(state = listState) { items(expenses) { expense -> ExpenseItem( expense = expense, @@ -481,6 +537,7 @@ fun ExpensesList( } } + @Composable fun DeleteExpenseDialog( expense: Expense, @@ -503,3 +560,202 @@ fun DeleteExpenseDialog( } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdvancedFilterDialog( + initialMin: Float?, + initialMax: Float?, + initialStartDate: String?, + initialEndDate: String?, + onDismiss: () -> Unit, + onApply: ( + minAmount: Float?, + maxAmount: Float?, + startDate: String?, + endDate: String? + ) -> Unit +) { + var minAmount by remember { mutableStateOf(initialMin?.toString() ?: "") } + var maxAmount by remember { mutableStateOf(initialMax?.toString() ?: "") } + + var startDate by remember { mutableStateOf(initialStartDate) } + var endDate by remember { mutableStateOf(initialEndDate) } + + var showStartDatePicker by remember { mutableStateOf(false) } + var showEndDatePicker by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Advanced filters") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ){ + Text("Price range", style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = { + minAmount = "" + maxAmount = "" + } + ) { + Text("Clear") + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = minAmount, + onValueChange = { minAmount = it }, + label = { Text("Min") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = maxAmount, + onValueChange = { maxAmount = it }, + label = { Text("Max") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f) + ) + } + + Divider() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ){ + Text("Date range", style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = { + startDate = null + endDate = null + } + ) { + Text("Clear") + } + } + + // DATE RANGE + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = { showStartDatePicker = true }) { + Icon(Icons.Default.CalendarToday, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text(startDate?.toString() ?: "From") + } + + TextButton(onClick = { showEndDatePicker = true }) { + Icon(Icons.Default.CalendarToday, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text(endDate?.toString() ?: "To") + } + } + + if (showStartDatePicker) { + val state = rememberDatePickerState( + initialSelectedDateMillis = startDate + ?.let { + LocalDate.parse(it.substring(0, 10)) // handles ISO with or without time + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } + ) + + DatePickerDialog( + onDismissRequest = { showStartDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + startDate = state.selectedDateMillis + ?.let { + Instant.ofEpochMilli(it) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() // ISO-8601 yyyy-MM-dd + } + + showStartDatePicker = false + } + ) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { showStartDatePicker = false }) { + Text("Cancel") + } + } + ) { + DatePicker(state = state) + } + } + + if (showEndDatePicker) { + val state = rememberDatePickerState( + initialSelectedDateMillis = endDate + ?.let { + LocalDate.parse(it.substring(0, 10)) // handles ISO with or without time + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } + ) + + DatePickerDialog( + onDismissRequest = { showEndDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + endDate = state.selectedDateMillis + ?.let { + Instant.ofEpochMilli(it) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() // ISO-8601 yyyy-MM-dd + } + + showEndDatePicker = false + } + ) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { showEndDatePicker = false }) { + Text("Cancel") + } + } + ) { + DatePicker(state = state) + } + } + + } + }, + confirmButton = { + TextButton( + onClick = { + onApply( + minAmount.toFloatOrNull(), + maxAmount.toFloatOrNull(), + startDate?.toString(), + endDate?.toString() + ) + onDismiss() + } + ) { + Text("Apply") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt index bdbb918..109c6a2 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt @@ -117,6 +117,10 @@ class AnalyticsViewModel(context: Context) : ViewModel() { category = if (_selectedCategory.value == "All") null else _selectedCategory.value, sortBy = null, order = null, + offset = null, + limit = null, + minPrice = null, + maxPrice = null, dateFrom = _dateFrom.value?.toString(), dateTo = _dateTo.value?.toString() ) @@ -125,6 +129,10 @@ class AnalyticsViewModel(context: Context) : ViewModel() { category = if (_selectedCategory.value == "All") null else _selectedCategory.value, sortBy = null, order = null, + offset = null, + limit = null, + minPrice = null, + maxPrice = null, dateFrom = _dateFrom.value?.toString(), dateTo = _dateTo.value?.toString() ) @@ -137,6 +145,10 @@ class AnalyticsViewModel(context: Context) : ViewModel() { category = if (_selectedCategory.value == "All") null else _selectedCategory.value, sortBy = null, order = null, + offset = null, + limit = null, + minPrice = null, + maxPrice = null, dateFrom = _dateFrom.value?.toString(), dateTo = _dateTo.value?.toString() )) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt index 51dcbd3..fb07ad6 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt @@ -59,6 +59,10 @@ class ExpenseViewModel(context: Context) : ViewModel() { private val _currentUserId = MutableStateFlow(null) val currentUserId: StateFlow = _currentUserId.asStateFlow() + private var offset = 0 + private var limit = 20 + private var endReached = false + init { loadCurrentUserId() loadUserGroups() @@ -73,62 +77,81 @@ class ExpenseViewModel(context: Context) : ViewModel() { } } - fun loadExpenses() { + fun loadExpenses(append: Boolean = false) { + if (_isLoading.value || endReached && append) return + viewModelScope.launch { _isLoading.value = true _error.value = null try { + if (!append) { + offset = 0 + endReached = false + _expenses.value = emptyList() + } + _categories.value = listOf(Category(0, 0, "All", emptyList())) val f = _filters.value + val data = when (_mode.value) { - ExpenseMode.PERSONAL -> { - val response = repository.getPersonalExpenses( + ExpenseMode.PERSONAL -> + repository.getPersonalExpenses( search = f.search, category = if (f.category == "All" || f.category.isBlank()) null else f.category, sortBy = f.sortOption.sortBy, order = f.sortOption.order, - dateFrom = null, - dateTo = null + offset = offset, + limit = limit, + minPrice = f.minAmount, + maxPrice = f.maxAmount, + dateFrom = f.startDate, + dateTo = f.endDate ) - val filtered = response.filter { it.title.contains(f.search, ignoreCase = true) } - filtered - } - ExpenseMode.ALL -> { - val response = repository.getAllExpenses( + ExpenseMode.ALL -> + repository.getAllExpenses( category = if (f.category == "All" || f.category.isBlank()) null else f.category, sortBy = f.sortOption.sortBy, order = f.sortOption.order, - dateFrom = null, - dateTo = null + offset = offset, + limit = limit, + minPrice = f.minAmount, + maxPrice = f.maxAmount, + dateFrom = f.startDate, + dateTo = f.endDate ) - val filtered = response.filter { it.title.contains(f.search, ignoreCase = true) } - filtered - } ExpenseMode.GROUP -> { - // fetch expenses from all groups the user is part of - val allExpenses = mutableListOf() + val all = mutableListOf() _groupIds.value.forEach { groupId -> - val response = repository.getGroupExpenses( + all += repository.getGroupExpenses( groupId = groupId, category = if (f.category == "All" || f.category.isBlank()) null else f.category, sortBy = f.sortOption.sortBy, order = f.sortOption.order, - dateFrom = null, - dateTo = null + offset = offset, + limit = limit, + minPrice = f.minAmount, + maxPrice = f.maxAmount, + dateFrom = f.startDate, + dateTo = f.endDate ) - val filtered = response.filter { it.title.contains(f.search, ignoreCase = true) } - allExpenses.addAll(filtered) } - allExpenses + all } } - _expenses.value = data - updateCategories(data) + if (data.size < limit) endReached = true + offset += data.size + + _expenses.value = + if (append) _expenses.value + data + else data + + updateCategories(_expenses.value) + } catch (e: Exception) { _error.value = e.localizedMessage } finally { @@ -137,6 +160,7 @@ class ExpenseViewModel(context: Context) : ViewModel() { } } + private fun updateCategories(expenses: List) { viewModelScope.launch { val usedCategoryIds = expenses @@ -210,6 +234,16 @@ class ExpenseViewModel(context: Context) : ViewModel() { loadExpenses() } + fun setAmountRange(min: Float?, max: Float?) { + _filters.value = _filters.value.copy(minAmount = min, maxAmount = max) + loadExpenses() + } + + fun setDateRange(start: String?, end: String?) { + _filters.value = _filters.value.copy(startDate = start, endDate = end) + loadExpenses() + } + /** ---------------------------------------------------------- * CRUD * ---------------------------------------------------------- */