init #48

Merged
admin merged 18 commits from develop into main 2026-04-30 10:35:58 +02:00
11 changed files with 308 additions and 85 deletions
Showing only changes of commit 3847e311a5 - Show all commits

View File

@@ -30,6 +30,19 @@ android {
"proguard-rules.pro" "proguard-rules.pro"
) )
} }
create("benchmark") {
initWith(getByName("release"))
// 🔑 Critical settings for Macrobenchmark
isDebuggable = false
isMinifyEnabled = false
isShrinkResources = false
// Use release signing if needed (optional for local)
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release")
}
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
@@ -95,7 +108,7 @@ dependencies {
implementation("androidx.paging:paging-runtime:3.4.2") implementation("androidx.paging:paging-runtime:3.4.2")
implementation("androidx.paging:paging-compose:3.4.2") implementation("androidx.paging:paging-compose:3.4.2")
implementation("androidx.datastore:datastore-preferences:1.2.1") implementation(libs.androidx.datastore.preferences)
implementation(libs.icons.material.symbols.outlined.android) implementation(libs.icons.material.symbols.outlined.android)

View File

@@ -13,6 +13,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.TripMoney"> android:theme="@style/Theme.TripMoney">
<activity <activity
android:screenOrientation="portrait"
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.TripMoney"> android:theme="@style/Theme.TripMoney">

View File

@@ -19,16 +19,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import cc.n0th1ng.tripmoney.data.entity.Category
import androidx.paging.compose.collectAsLazyPagingItems
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.BottomNavigation import cc.n0th1ng.tripmoney.navigation.BottomNavigation
import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer
@@ -45,9 +41,7 @@ import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -69,6 +63,8 @@ class MainActivity : ComponentActivity() {
fun NavigationDrawer() { fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY) val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val navController = rememberNavController() val navController = rememberNavController()
@@ -76,7 +72,8 @@ fun NavigationDrawer() {
val current = navBackStack?.destination?.route val current = navBackStack?.destination?.route
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var filter by remember { mutableStateOf("") } var search by remember { mutableStateOf("") }
var filter by remember { mutableStateOf(Filter()) }
val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState() val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState()
var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) } var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) }
val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen
@@ -98,7 +95,11 @@ fun NavigationDrawer() {
} }
}, },
isSearchable = current == Screens.LIST_EXPENSE, isSearchable = current == Screens.LIST_EXPENSE,
onFilterChange = { newFilter -> filter = newFilter }) onSearchChange = { newSearch -> search = newSearch },
onFilterChange = { newFilter -> filter = newFilter },
categories = categories,
filter = filter
)
}, },
bottomBar = { BottomNavigation(navController) }) { innerPadding -> bottomBar = { BottomNavigation(navController) }) { innerPadding ->
@@ -109,7 +110,7 @@ fun NavigationDrawer() {
) { ) {
composable(Screens.LIST_EXPENSE) { composable(Screens.LIST_EXPENSE) {
ListExpenseScreen( ListExpenseScreen(
filter, filter = filter, search = search,
initialAutoOpen = shouldTriggerAutoOpen, initialAutoOpen = shouldTriggerAutoOpen,
onAutoOpenConsumed = { hasHandledStartupOpen = true }) onAutoOpenConsumed = { hasHandledStartupOpen = true })
} }
@@ -128,5 +129,25 @@ fun NavigationDrawer() {
} }
} }
} }
}
data class Filter(
val categories: List<Category> = emptyList(), val startAmount: Double = 0.0,
val endAmount: Double = Double.MAX_VALUE
) {
fun with(category: Category): Filter {
return this.copy(categories = categories + category)
}
fun withStartAmount(amount: Double): Filter {
return this.copy(startAmount = amount)
}
fun withEndAmount(amount: Double): Filter {
return this.copy(endAmount = amount)
}
fun without(category: Category): Filter {
return this.copy(categories = categories - category)
}
} }

View File

@@ -6,55 +6,93 @@ import androidx.room.Delete
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ExpenseDao { interface ExpenseDao {
@Upsert @Upsert
suspend fun insert(expense: Expense) suspend fun insert(expense: Expense)
@Query( @Query(
""" """
SELECT expense.*, category.* SELECT * FROM expense
FROM expense JOIN category ON expense.category_id = category.id
JOIN category ON expense.category_id = category.id WHERE trip_id = :tripId
WHERE expense.trip_id = :tripId AND (
AND :search IS NULL
( OR category.name LIKE '%' || :search || '%'
(:filter IS NULL OR category.name LIKE '%' || :filter || '%') OR expense.note LIKE '%' || :search || '%'
OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%')
) )
ORDER BY expense.datetime DESC AND (
:categoriesEmpty = 1
OR expense.category_id IN (:categoryIds)
)
AND (
:startAmount IS NULL OR expense.amount >= :startAmount
)
AND (
:endAmount IS NULL OR expense.amount <= :endAmount
)
ORDER BY expense.datetime DESC
""" """
) )
fun expenseDtoPaged(tripId: Int, filter: String): PagingSource<Int, ExpenseDto> fun expenseDtoPaged(
tripId: Int,
search: String?,
categoryIds: List<Int>,
categoriesEmpty: Boolean,
startAmount: Double?,
endAmount: Double?
): PagingSource<Int, ExpenseDto>
@Transaction @Transaction
@Query( @Query(
""" """
SELECT * FROM expense SELECT * FROM expense
JOIN category ON expense.category_id = category.id JOIN category ON expense.category_id = category.id
WHERE trip_id = :tripId WHERE trip_id = :tripId
AND AND (
( :search IS NULL
(:filter IS NULL OR category.name LIKE '%' || :filter || '%') OR category.name LIKE '%' || :search || '%'
OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%') OR expense.note LIKE '%' || :search || '%'
)
AND (
:categoriesEmpty = 1
OR expense.category_id IN (:categoryIds)
)
AND (
:startAmount IS NULL OR expense.amount >= :startAmount
)
AND (
:endAmount IS NULL OR expense.amount <= :endAmount
) )
ORDER BY expense.datetime DESC
"""
)
fun expenseDto(tripId: Int, filter: String): Flow<List<ExpenseDto>>
@Query(""" ORDER BY expense.datetime DESC
"""
)
fun expenseDto(
tripId: Int,
search: String?,
categoryIds: List<Int>,
categoriesEmpty: Boolean,
startAmount: Double?,
endAmount: Double?
): Flow<List<ExpenseDto>>
@Query(
"""
SELECT trip.budget - IFNULL(SUM(expense.amount * expense.rate), 0) SELECT trip.budget - IFNULL(SUM(expense.amount * expense.rate), 0)
FROM trip FROM trip
LEFT JOIN expense ON expense.trip_id = trip.id LEFT JOIN expense ON expense.trip_id = trip.id
WHERE trip.id = :tripId WHERE trip.id = :tripId
""") """
)
fun budgetLeft(tripId: Int): Double fun budgetLeft(tripId: Int): Double
@Delete @Delete

View File

@@ -3,21 +3,16 @@ package cc.n0th1ng.tripmoney.data.repository
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ExpenseRepository @Inject constructor( class ExpenseRepository @Inject constructor(
@@ -39,15 +34,43 @@ class ExpenseRepository @Inject constructor(
expenseDao.delete(expense) expenseDao.delete(expense)
} }
fun getExpensesDtoPaged(tripId: Int, filter: String): Flow<PagingData<ExpenseDto>> { fun getExpensesDtoPaged(
tripId: Int,
search: String,
filter: Filter,
): Flow<PagingData<ExpenseDto>> {
return Pager( return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false), config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId, filter) } pagingSourceFactory = {
val categoryIds = filter.categories.map { it.id }
expenseDao.expenseDtoPaged(
tripId = tripId,
search = search.takeIf { it.isNotBlank() },
categoryIds = categoryIds,
categoriesEmpty = categoryIds.isEmpty(),
startAmount = filter.startAmount,
endAmount = filter.endAmount
)
}
).flow ).flow
} }
fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> { fun getExpensesDto(
return expenseDao.expenseDto(tripId, filter) tripId: Int,
search: String = "",
filter: Filter = Filter()
): Flow<List<ExpenseDto>> {
val categoryIds = filter.categories.map { it.id }
return expenseDao.expenseDto(
tripId = tripId,
search = search.takeIf { it.isNotBlank() },
categoryIds = categoryIds,
categoriesEmpty = categoryIds.isEmpty(),
startAmount = filter.startAmount,
endAmount = filter.endAmount
)
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)

View File

@@ -2,25 +2,26 @@ package cc.n0th1ng.tripmoney.navigation
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,12 +34,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.R import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.addexpense.categoriesToPreview
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews import cc.n0th1ng.tripmoney.utils.AllPreviews
import com.composables.icons.materialsymbols.outlined.R.drawable import com.composables.icons.materialsymbols.outlined.R.drawable
@@ -50,21 +56,27 @@ fun TopBar(
onDrawerClick: () -> Unit, onDrawerClick: () -> Unit,
title: String = "", title: String = "",
isSearchable: Boolean = false, isSearchable: Boolean = false,
onFilterChange: (String) -> Unit onSearchChange: (String) -> Unit,
filter: Filter,
onFilterChange: (Filter) -> Unit,
categories: List<Category>
) { ) {
var isSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
var showFilter by remember { mutableStateOf(false) }
var value by remember { mutableStateOf("") } var value by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
TopAppBar( TopAppBar(
title = { title = {
if (isSearch && isSearchable) { if (showSearch && isSearchable) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
OutlinedTextField( OutlinedTextField(
textStyle = MaterialTheme.typography.bodyMedium, textStyle = MaterialTheme.typography.bodyMedium,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(0.9f).focusRequester(focusRequester), modifier = Modifier
.fillMaxWidth(0.9f)
.focusRequester(focusRequester),
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
@@ -79,9 +91,9 @@ fun TopBar(
trailingIcon = { trailingIcon = {
Icon( Icon(
modifier = Modifier.clickable(onClick = { modifier = Modifier.clickable(onClick = {
isSearch = false showSearch = false
value = "" value = ""
onFilterChange("") onSearchChange("")
}), }),
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = null contentDescription = null
@@ -90,7 +102,7 @@ fun TopBar(
) )
LaunchedEffect(key1 = value) { LaunchedEffect(key1 = value) {
delay(1000) delay(1000)
onFilterChange(value) onSearchChange(value)
} }
} else { } else {
Text(title) Text(title)
@@ -102,7 +114,7 @@ fun TopBar(
} }
}, },
actions = { actions = {
if (!isSearch && isSearchable) { if (!showSearch && isSearchable) {
Row( Row(
modifier = Modifier.padding(end = 13.dp), modifier = Modifier.padding(end = 13.dp),
horizontalArrangement = Arrangement.spacedBy(15.dp) horizontalArrangement = Arrangement.spacedBy(15.dp)
@@ -111,14 +123,16 @@ fun TopBar(
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
painter = painterResource(drawable.materialsymbols_ic_filter_alt_outlined), painter = painterResource(drawable.materialsymbols_ic_filter_alt_outlined),
contentDescription = null, contentDescription = null,
modifier = Modifier.clickable(onClick = {}) modifier = Modifier.clickable(onClick = {
showFilter = true
})
) )
Icon( Icon(
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
painter = painterResource(drawable.materialsymbols_ic_search_outlined), painter = painterResource(drawable.materialsymbols_ic_search_outlined),
contentDescription = null, contentDescription = null,
modifier = Modifier.clickable(onClick = { modifier = Modifier.clickable(onClick = {
isSearch = true showSearch = true
}) })
) )
@@ -127,13 +141,96 @@ fun TopBar(
} }
) )
if (showFilter) {
FilterDialog(
onDismiss = { showFilter = false },
onSave = { newFilter ->
onFilterChange(newFilter)
showFilter = false
},
categories = categories,
filter = filter
)
}
} }
@Composable
fun FilterDialog(
onDismiss: () -> Unit,
onSave: (Filter) -> Unit,
categories: List<Category>,
filter: Filter
) {
var filter by remember { mutableStateOf(filter) }
var fromAmountString by remember { mutableStateOf(filter.startAmount.toString()) }
var toAmountString by remember { mutableStateOf(filter.endAmount.toString()) }
AlertDialog(
onDismiss, {
Button(
enabled = true,
onClick = {
onSave(
filter.withStartAmount(fromAmountString.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)) {
categories.forEach {
FilterChip(selected = filter.categories.contains(it), onClick = {
filter = if (filter.categories.contains(it)) {
filter.without(it)
} else {
filter.with(it)
}
}, label = { Text(text = it.name) })
}
}
AmountTextField(label = "from", onValueChange = { newText ->
fromAmountString = newText
}, value = fromAmountString)
AmountTextField(label = "to", onValueChange = { newText ->
toAmountString = newText
}, value = toAmountString)
}
})
}
@Composable
fun AmountTextField(label: String, onValueChange: (String) -> Unit, value: String) {
var value by remember { mutableStateOf(value) }
OutlinedTextField(
label = { Text(label) },
value = if (value == Double.MAX_VALUE.toString()) "" else value,
onValueChange = { newText ->
if (newText == Double.MAX_VALUE.toString()) {
value = ""
return@OutlinedTextField
}
val regex = Regex("^\\d*\\.?\\d{0,2}$")
if (regex.matches(newText)) {
value = newText
onValueChange(value)
}
},
placeholder = { Text("0.00") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
)
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TopBarSettings(navController: NavHostController) { fun TopBarSettings(navController: NavHostController) {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.settings)) }, title = { Text(stringResource(R.string.settings)) },
navigationIcon = { navigationIcon = {
@@ -151,7 +248,30 @@ fun PreviewTopBar() {
TopBar( TopBar(
onDrawerClick = {}, onDrawerClick = {},
title = "Essa", title = "Essa",
onFilterChange = {} onSearchChange = {},
onFilterChange = {},
isSearchable = true,
categories = categoriesToPreview,
filter = Filter()
) )
} }
} }
@AllPreviews
@Composable
fun PreviewFilterDialog() {
TripMoneyTheme {
FilterDialog(
onDismiss = {},
onSave = {},
categories = categoriesToPreview,
filter = Filter()
)
}
}
private fun String.safeToDouble(): Double {
if(this == "") return Double.MAX_VALUE
if(this.isEmpty()) return 0.0
return this.toDouble()
}

View File

@@ -55,6 +55,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.R.string import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
@@ -80,16 +81,19 @@ import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ListExpenseScreen(filter: String, fun ListExpenseScreen(
initialAutoOpen: Boolean, filter: Filter,
onAutoOpenConsumed: () -> Unit ) { search: String,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
) {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY) val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val expensesFlow = val expensesFlow =
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, filter) expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, search, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState() val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
ListExpenseScreen( ListExpenseScreen(

View File

@@ -8,6 +8,7 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.insertSeparators import androidx.paging.insertSeparators
import androidx.paging.map import androidx.paging.map
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
@@ -44,16 +45,17 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
return expenseRepo.getBudgetLeft(tripId) return expenseRepo.getBudgetLeft(tripId)
} }
fun getExpensesDtoPaged(tripId: Int, filter: String = ""): Flow<PagingData<ExpenseDto>> = fun getExpensesDtoPaged(tripId: Int, search: String = "", filter: Filter = Filter()): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesDtoPaged(tripId, filter).cachedIn(viewModelScope) expenseRepo.getExpensesDtoPaged(tripId, search, filter).cachedIn(viewModelScope)
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun getExpensesWithHeadersPaged( fun getExpensesWithHeadersPaged(
tripId: Int, tripId: Int,
filter: String = "" search: String = "",
filter: Filter
): Flow<PagingData<ExpenseListItemUi>> { ): Flow<PagingData<ExpenseListItemUi>> {
val pagingFlow = getExpensesDtoPaged(tripId, filter) val pagingFlow = getExpensesDtoPaged(tripId, search, filter)
val sumsFlow = getDailySums(tripId, filter) val sumsFlow = getDailySums(tripId, search, filter)
val tripFlow = tripRepo.getTrip(tripId) val tripFlow = tripRepo.getTrip(tripId)
return combine(pagingFlow, sumsFlow, tripFlow) { pagingData, sums, trip -> return combine(pagingFlow, sumsFlow, tripFlow) { pagingData, sums, trip ->
val currency = trip?.currency ?: "" val currency = trip?.currency ?: ""
@@ -85,8 +87,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
} }
fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> = fun getExpensesDto(tripId: Int, search: String = "", filter: Filter = Filter()): Flow<List<ExpenseDto>> =
expenseRepo.getExpensesDto(tripId, filter) expenseRepo.getExpensesDto(tripId, search, filter)
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun save(expense: Expense, trip: Trip) { fun save(expense: Expense, trip: Trip) {
@@ -143,8 +145,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun getDailySums(tripId: Int, filter: String): Flow<Map<LocalDate, Double>> { fun getDailySums(tripId: Int, search: String, filter: Filter): Flow<Map<LocalDate, Double>> {
return getExpensesDto(tripId, filter) return getExpensesDto(tripId, search, filter)
.map { expenses -> .map { expenses ->
expenses.groupBy { it.expense.datetime.toLocalDate() } expenses.groupBy { it.expense.datetime.toLocalDate() }
.mapValues { (_, list) -> .mapValues { (_, list) ->

View File

@@ -39,6 +39,7 @@ dependencies {
implementation(libs.androidx.espresso.core) implementation(libs.androidx.espresso.core)
implementation(libs.androidx.uiautomator) implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4) implementation(libs.androidx.benchmark.macro.junit4)
implementation(libs.androidx.ui.test.junit4)
} }
androidComponents { androidComponents {

View File

@@ -3,6 +3,7 @@ package cc.n0th1ng.baselineprofile
import android.R.attr.contentDescription import android.R.attr.contentDescription
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
@@ -16,7 +17,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class BaselineProfileGenerator { class BaselineProfileGenerator {
@get:Rule @get:Rule
val rule = BaselineProfileRule() val rule = BaselineProfileRule()
@@ -29,12 +29,8 @@ class BaselineProfileGenerator {
pressHome() pressHome()
startActivityAndWait() startActivityAndWait()
device.waitForIdle()
// Give Compose time to render device.wait(Until.hasObject(By.desc("list screen")), 10_000)
Thread.sleep(500)
val listNav = device.wait(Until.findObject(By.desc("listExpenseScreen")), 10000)
listNav?.click() ?: throw RuntimeException("listExpenseScreen not found or not clickable")
} }
} }
} }

View File

@@ -1,5 +1,6 @@
[versions] [versions]
agp = "8.13.2" agp = "8.13.2"
datastorePreferences = "1.2.1"
iconsMaterialSymbolsOutlinedAndroid = "2.2.1" iconsMaterialSymbolsOutlinedAndroid = "2.2.1"
kotlin = "2.2.21" kotlin = "2.2.21"
coreKtx = "1.10.1" coreKtx = "1.10.1"
@@ -15,9 +16,11 @@ uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.2.4" benchmarkMacroJunit4 = "1.2.4"
baselineprofile = "1.2.4" baselineprofile = "1.2.4"
profileinstaller = "1.3.1" profileinstaller = "1.3.1"
uiTestJunit4 = "1.10.6"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
icons-material-symbols-outlined-android = { module = "com.composables:icons-material-symbols-outlined-android", version.ref = "iconsMaterialSymbolsOutlinedAndroid" } icons-material-symbols-outlined-android = { module = "com.composables:icons-material-symbols-outlined-android", version.ref = "iconsMaterialSymbolsOutlinedAndroid" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
@@ -37,6 +40,7 @@ androidx-compose-foundation-layout = { group = "androidx.compose.foundation", na
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" } androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "uiTestJunit4" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }