This commit is contained in:
Rafal Wisniewski
2026-03-31 15:31:01 +02:00
parent 9b19b100e9
commit c4c9868698
37 changed files with 1539 additions and 475 deletions

View File

@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
alias(libs.plugins.baselineprofile)
}
android {
@@ -22,7 +23,8 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -54,11 +56,13 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.profileinstaller)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
"baselineProfile"(project(":baselineprofile"))
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)

View File

@@ -13,13 +13,19 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.paging.compose.collectAsLazyPagingItems
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.BottomNavigation
import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer
@@ -27,6 +33,7 @@ import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.navigation.TopBar
import cc.n0th1ng.tripmoney.navigation.TopBarSettings
import cc.n0th1ng.tripmoney.screens.listexpense.ListExpenseScreen
import cc.n0th1ng.tripmoney.screens.managecategories.ManageCategoriesScreen
import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen
import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen
import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen
@@ -40,21 +47,25 @@ import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O)
@RequiresApi(Build.VERSION_CODES.S)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TripMoneyTheme {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
expenseAndCategoryViewModel.clearOldRates()
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId)
.collectAsLazyPagingItems()
NavigationDrawer()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -66,15 +77,19 @@ fun NavigationDrawer() {
val current = navBackStack?.destination?.route
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
var filter by remember { mutableStateOf("") }
CustomNavigationDrawer(navController, drawerState) {
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
topBar = {
if (current == Screens.SETTINGS) TopBarSettings(
navController
) else TopBar(
title = currentTrip?.name ?: "",
onClick = {
onDrawerClick = {
scope.launch {
if (drawerState.isClosed) {
drawerState.open()
@@ -82,17 +97,19 @@ fun NavigationDrawer() {
drawerState.close()
}
}
})
},
isSearchable = current == Screens.LIST_EXPENSE,
onFilterChange = { newFilter -> filter = newFilter})
},
bottomBar = { BottomNavigation(navController) }) { innerPadding ->
NavHost(
navController = navController,
startDestination = if(currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE,
startDestination = if (currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE,
modifier = Modifier.padding(innerPadding)
) {
composable(Screens.LIST_EXPENSE) {
ListExpenseScreen()
ListExpenseScreen(filter)
}
composable(Screens.TRIP_PICKER) {
TripPickerScreen(navController)
@@ -101,7 +118,10 @@ fun NavigationDrawer() {
StatisticsScreen()
}
composable(Screens.SETTINGS) {
SettingsScreen()
SettingsScreen(navController)
}
composable(Screens.MANAGE_CATEGORIES) {
ManageCategoriesScreen()
}
}
}

View File

@@ -7,7 +7,6 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
@@ -15,24 +14,34 @@ 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
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import dagger.Module
import dagger.Provides
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
import java.time.LocalDateTime
import javax.inject.Inject
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], version = 1)
@Database(
entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class],
version = 1
)
@TypeConverters(Converters::class)
abstract class TripDatabase : RoomDatabase() {
abstract fun tripDao(): TripDao
@@ -46,21 +55,28 @@ abstract class TripDatabase : RoomDatabase() {
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O)
@Provides
@Singleton
fun provideTripDatabase(
@ApplicationContext context: Context,
// expenseAndCategoryViewModel: ExpenseAndCategoryViewModel
@ApplicationContext context: Context
): TripDatabase {
val db: TripDatabase = Room.inMemoryDatabaseBuilder(
context, TripDatabase::class.java
).allowMainThreadQueries().build()
// val db: TripDatabase = Room.databaseBuilder(
// name = "tripmoney_db",
context = context,
klass = TripDatabase::class.java,
)
.allowMainThreadQueries() // TODO Remove in production!
.fallbackToDestructiveMigration() // TODO Handle schema changes during dev
.build()
CoroutineScope(Dispatchers.IO).launch {
DatabasePrepopulator(
tripDao = db.tripDao(), categoryDao = db.categoryDao(), expenseDao = db.expenseDao()
tripDao = db.tripDao(),
categoryDao = db.categoryDao(),
expenseDao = db.expenseDao()
).prepopulate()
}
return db
@@ -99,169 +115,92 @@ private class DatabasePrepopulator(
) {
@RequiresApi(Build.VERSION_CODES.O)
suspend fun prepopulate() {
tripDao.insert(Trip(name = "Włochy", startDate = LocalDate.parse("2026-03-01"), currency = "PLN"))
tripDao.insert(Trip(name = "Szwajcaria", startDate =LocalDate.parse("2025-03-01"), currency = "EUR"))
tripDao.insert(Trip(name = "Portugalia", startDate = LocalDate.parse("2025-03-01"), currency = "USD"))
categoryDao.insert(Category(name = "Accomodation", icon = Icons.HOTEL, color = colors.random()))
categoryDao.insert(Category(name = "Transport", icon = Icons.TRANSPORT, color = colors.random()))
categoryDao.insert(Category(name = "Flight", icon = Icons.FLIGHT, color = colors.random()))
categoryDao.insert(Category(name = "Restaurants", icon = Icons.RESTAURANT, color = colors.random()))
categoryDao.insert(Category(name = "Groceries", icon = Icons.GROCERIES, color = colors.random()))
categoryDao.insert(Category(name = "Coffee", icon = Icons.COFFEE,color = colors.random()))
categoryDao.insert(Category(name = "Entertainment", icon = Icons.ENTERTAINMENT,color = colors.random()))
categoryDao.insert(Category(name = "Laundry", icon = Icons.LAUNDRY,color = colors.random()))
tripDao.insert(
Trip(
name = "Włochy",
startDate = LocalDate.parse("2026-03-01"),
currency = "PLN"
)
)
tripDao.insert(
Trip(
name = "Szwajcaria",
startDate = LocalDate.parse("2025-03-01"),
currency = "EUR"
)
)
tripDao.insert(
Trip(
name = "Portugalia",
startDate = LocalDate.parse("2025-03-01"),
currency = "USD"
)
)
for (category in sampleCategories) {
categoryDao.insert(category)
}
for (expense in sampleExpenses) {
expenseDao.insert(expense)
}
}
val sampleCategories = listOf(
Category(
name = "Hotel",
icon = Icons.HOTEL,
color = colors.random()
),
Category(
name = "Jedzenie",
icon = Icons.RESTAURANT,
color = colors.random()
),
Category(
name = "Transport",
icon = Icons.FLIGHT,
color = colors.random()
),
Category(
name = "Rozrywka",
icon = Icons.ATTRACTION,
color = colors.random()
),
Category(
name = "Zakupy",
icon = Icons.GROCERIES,
color = colors.random()
),
)
@RequiresApi(Build.VERSION_CODES.O)
val sampleExpenses = (0..150).map { i ->
val datetime = if (i > 4) {
val now = LocalDateTime.now()
expenseDao.insert(
Expense(
amount = 120.50,
currency = "PLN",
note = "Hotel overnight",
datetime = now.minusDays(10),
categoryId = 1,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 45.75,
currency = "PLN",
note = "Dinner",
datetime = now.minusDays(9),
categoryId = 2,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 15.20,
currency = "PLN",
note = "Bus ticket",
datetime = now.minusDays(8),
categoryId = 3,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 89.99,
currency = "PLN",
note = "Concert tickets",
datetime = now.minusDays(7),
categoryId = 4,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 32.50,
currency = "PLN",
note = "Souvenirs",
datetime = now.minusDays(6),
categoryId = 5,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 180.00,
currency = "PLN",
note = "Hotel 3 nights",
datetime = now.minusDays(5),
categoryId = 1,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 67.30,
currency = "PLN",
note = "Lunch",
datetime = now.minusDays(4),
categoryId = 2,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 22.00,
currency = "PLN",
note = "Train ticket",
datetime = now.minusDays(3),
categoryId = 3,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 55.00,
currency = "PLN",
note = "Museum entry",
datetime = now.minusDays(2),
categoryId = 4,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 12.99,
currency = "PLN",
note = "Snacks",
datetime = now.minusDays(1),
categoryId = 2,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 210.00,
currency = "PLN",
note = "Hotel 5 nights",
datetime = now,
categoryId = 1,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 95.50,
currency = "EUR",
note = "Dinner for two",
datetime = now.minusHours(12),
categoryId = 2,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 30.00,
currency = "EUR",
note = "Taxi",
datetime = now.minusHours(6),
categoryId = 3,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 40.00,
currency = "USD",
note = "Gifts",
datetime = now.minusHours(3),
categoryId = 5,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 75.00,
currency = "PLN",
note = "Sightseeing tour",
datetime = now.minusHours(1),
categoryId = 4,
tripId = 1
)
val min = now.minusDays(10).toInstant(ZoneOffset.UTC).toEpochMilli()
val max = now.toInstant(ZoneOffset.UTC).toEpochMilli()
val randomMillis = Random.nextLong(min, max)
LocalDateTime.ofInstant(Instant.ofEpochMilli(randomMillis), ZoneOffset.UTC)
} else {
LocalDateTime.now()
}
val expense = Expense(
categoryId = Random.nextInt(1, 5),
tripId = 1,
amount = Random.nextDouble(0.1, 300.0),
currency = Currencies.entries.random().name,
note = if (i % 3 == 0) "Some note" else "",
datetime = datetime,
rate = if (Random.nextBoolean()) Random.nextDouble(
0.1,
5.0
) else 1.0
)
expense
}
}

View File

@@ -1,6 +1,7 @@
package cc.n0th1ng.tripmoney.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
@@ -14,12 +15,23 @@ interface CategoryDao {
suspend fun insert(category: Category)
@Delete
suspend fun delete(category: Category)
@Transaction
@Query(
"""
SELECT * FROM category
SELECT * FROM category WHERE archived is 0
"""
)
fun categories(): Flow<List<Category>>
@Transaction
@Query(
"""
SELECT * FROM category WHERE archived is 1
"""
)
fun archivedCategories(): Flow<List<Category>>
}

View File

@@ -16,22 +16,38 @@ interface ExpenseDao {
@Upsert
suspend fun insert(expense: Expense)
@Query(
"""
SELECT * FROM expense WHERE trip_id = :tripId
SELECT expense.*, category.*
FROM expense
JOIN category ON expense.category_id = category.id
WHERE expense.trip_id = :tripId
AND
(
(:filter IS NULL OR category.name LIKE '%' || :filter || '%')
OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%')
)
ORDER BY expense.datetime DESC
"""
)
fun expenseDtoPaged(tripId: Int): PagingSource<Int, ExpenseDto>
fun expenseDtoPaged(tripId: Int, filter: String): PagingSource<Int, ExpenseDto>
@Transaction
@Query(
"""
SELECT * FROM expense WHERE trip_id = :tripId
SELECT * FROM expense
JOIN category ON expense.category_id = category.id
WHERE trip_id = :tripId
AND
(
(:filter IS NULL OR category.name LIKE '%' || :filter || '%')
OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%')
)
ORDER BY expense.datetime DESC
"""
)
fun expenseDto(tripId: Int): Flow<List<ExpenseDto>>
fun expenseDto(tripId: Int, filter: String): Flow<List<ExpenseDto>>
@Delete
suspend fun delete(expense: Expense)

View File

@@ -17,7 +17,7 @@ interface TripDao {
@Query(
"""
SELECT * FROM trip
ORDER BY DATE(trip.start_date) DESC
ORDER BY trip.start_date DESC
"""
)
fun tripsPaged(): PagingSource<Int, Trip>

View File

@@ -1,14 +1,17 @@
package cc.n0th1ng.tripmoney.data.entity
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import cc.n0th1ng.tripmoney.utils.Icons
@Entity(tableName = "category")
@Immutable
data class Category(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String,
@ColumnInfo("icon") val icon: Icons,
@ColumnInfo("color") val color: String
@ColumnInfo("color") val color: String,
@ColumnInfo("archived") val archived: Boolean = false
)

View File

@@ -1,13 +1,25 @@
package cc.n0th1ng.tripmoney.data.entity
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import androidx.room.Relation
import java.time.LocalDateTime
@Entity(tableName = "expense")
@Entity(
tableName = "expense",
foreignKeys = [ForeignKey(
entity = Category::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("category_id"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)]
)
@Immutable
data class Expense(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("amount") val amount: Double,

View File

@@ -2,6 +2,7 @@ package cc.n0th1ng.tripmoney.data.entity
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -9,14 +10,16 @@ import cc.n0th1ng.tripmoney.utils.Currencies
import java.time.LocalDate
@Entity(tableName = "trip")
@Immutable
data class Trip(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String,
@ColumnInfo("start_date") val startDate: LocalDate,
@ColumnInfo("currency") val currency: String
@ColumnInfo("currency") val currency: String,
@ColumnInfo("budget") val budget: Double
){
companion object {
@RequiresApi(Build.VERSION_CODES.O)
val DUMMY = Trip(-1, "dummy", LocalDate.now(), Currencies.default().name)
val DUMMY = Trip(-1, "", LocalDate.now(), Currencies.default().name, budget = 0.0)
}
}

View File

@@ -13,7 +13,16 @@ class CategoryRepository @Inject constructor(private val categoryDao: CategoryDa
categoryDao.insert(category)
}
@WorkerThread
suspend fun delete(category: Category) {
categoryDao.delete(category)
}
fun getCategories(): Flow<List<Category>> {
return categoryDao.categories()
}
fun getArchivedCategories(): Flow<List<Category>> {
return categoryDao.archivedCategories()
}
}

View File

@@ -45,7 +45,6 @@ class ExchangeRateRepository @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun clearOldRates(daysToKeep: Int = 180) {
val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString()

View File

@@ -12,8 +12,11 @@ import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -32,15 +35,15 @@ class ExpenseRepository @Inject constructor(
expenseDao.delete(expense)
}
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> {
fun getExpensesDtoPaged(tripId: Int, filter: String): Flow<PagingData<ExpenseDto>> {
return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) }
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId, filter) }
).flow
}
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> {
return expenseDao.expenseDto(tripId)
fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> {
return expenseDao.expenseDto(tripId, filter)
}
@RequiresApi(Build.VERSION_CODES.O)

View File

@@ -9,7 +9,10 @@ import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class TripRepository @Inject constructor(
@@ -18,9 +21,7 @@ class TripRepository @Inject constructor(
) {
@RequiresApi(Build.VERSION_CODES.O)
@WorkerThread
suspend fun save(trip: Trip) {
expenseRepository.recalculateTripExpenses(trip.id)
tripDao.insert(trip)
}

View File

@@ -7,6 +7,8 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -37,7 +39,7 @@ fun BottomNavigation(navController: NavController) {
painter = painterResource(
R.drawable.materialsymbols_ic_list_outlined,
),
null
"list screen"
)
}
)

View File

@@ -100,4 +100,5 @@ object Screens {
const val TRIP_PICKER = "trip_picker"
const val STATISTICS = "statistics"
const val SETTINGS = "settings"
const val MANAGE_CATEGORIES = "manage_categories"
}

View File

@@ -1,27 +1,130 @@
package cc.n0th1ng.tripmoney.navigation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import com.composables.icons.materialsymbols.outlined.R.drawable
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopBar(onClick: () -> Unit, title: String = "") {
fun TopBar(
onDrawerClick: () -> Unit,
title: String = "",
isSearchable: Boolean = false,
onFilterChange: (String) -> Unit
) {
var isSearch by remember { mutableStateOf(false) }
var value by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
TopAppBar(
title = { Text(title) },
title = {
if (isSearch && isSearchable) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
OutlinedTextField(
textStyle = MaterialTheme.typography.bodyMedium,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(0.9f).focusRequester(focusRequester),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent
),
value = value,
onValueChange = { newText ->
value = newText
},
singleLine = true,
trailingIcon = {
Icon(
modifier = Modifier.clickable(onClick = {
isSearch = false
value = ""
onFilterChange("")
}),
imageVector = Icons.Default.Close,
contentDescription = null
)
}
)
LaunchedEffect(key1 = value) {
delay(1000)
onFilterChange(value)
}
} else {
Text(title)
}
},
navigationIcon = {
IconButton(onClick = { onClick() }) {
IconButton(onClick = { onDrawerClick() }) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
},
actions = {
if (!isSearch && isSearchable) {
Row(
modifier = Modifier.padding(end = 13.dp),
horizontalArrangement = Arrangement.spacedBy(15.dp)
) {
Icon(
tint = MaterialTheme.colorScheme.primary,
painter = painterResource(drawable.materialsymbols_ic_filter_alt_outlined),
contentDescription = null,
modifier = Modifier.clickable(onClick = {})
)
Icon(
tint = MaterialTheme.colorScheme.primary,
painter = painterResource(drawable.materialsymbols_ic_search_outlined),
contentDescription = null,
modifier = Modifier.clickable(onClick = {
isSearch = true
})
)
}
}
}
)
}
@@ -40,3 +143,15 @@ fun TopBarSettings(navController: NavHostController) {
}
)
}
@AllPreviews
@Composable
fun PreviewTopBar() {
TripMoneyTheme {
TopBar(
onDrawerClick = {},
title = "Essa",
onFilterChange = {}
)
}
}

View File

@@ -1,5 +1,8 @@
package cc.n0th1ng.tripmoney.screens
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -8,14 +11,17 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -27,44 +33,68 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
@Composable
fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
var name by remember { mutableStateOf("") }
var icon by remember { mutableStateOf(Icons.entries[0]) }
var color by remember { mutableStateOf(colors[0]) }
fun AddCategoryDialog(
onDismiss: () -> Unit,
onSave: (Category) -> Unit,
categoryToEdit: Category? = null
) {
var name by remember { mutableStateOf(categoryToEdit?.name ?: "") }
var icon by remember { mutableStateOf(categoryToEdit?.icon ?: Icons.entries[0]) }
var color by remember { mutableStateOf(categoryToEdit?.color ?: colors[0]) }
var isArchived by remember { mutableStateOf(categoryToEdit?.archived ?: false) }
AlertDialog(
onDismissRequest = onDismiss, title = { Text("Add new category") }, text = {
onDismissRequest = onDismiss,
title = { Text(stringResource(if (categoryToEdit == null) R.string.add_new_category else R.string.edit_category)) },
text = {
AlertDialogFill(
onTextChange = { newText ->
name = newText
},
onIconChange = { newIcon -> icon = newIcon },
onColorChange = { newColor -> color = newColor }
onColorChange = { newColor -> color = newColor },
onArchivedChange = { newArchived ->
isArchived = newArchived
},
name = name,
icon = icon,
color = color,
isArchived = isArchived
)
}, confirmButton = {
},
confirmButton = {
Button(
enabled = !name.isEmpty(),
onClick = {
onSave(
Category(
val categoryToSave = Category(
name = name,
icon = icon,
color = color
color = color,
archived = isArchived
)
onSave(
if (categoryToEdit != null) categoryToSave.copy(id = categoryToEdit.id) else categoryToSave
)
}) { Text("Save") }
}) { Text(stringResource(R.string.save)) }
},
dismissButton = {
Row() {
Button(
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error),
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.secondary),
onClick = onDismiss
) { Text("close") }
) { Text(stringResource(R.string.cancel)) }
}
})
}
@@ -72,24 +102,26 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
fun AlertDialogFill(
onTextChange: (String) -> Unit,
onIconChange: (Icons) -> Unit,
onColorChange: (String) -> Unit
onColorChange: (String) -> Unit,
onArchivedChange: (Boolean) -> Unit,
name: String,
icon: Icons,
color: String,
isArchived: Boolean
) {
var text by remember { mutableStateOf("") }
var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) }
var colorHex by remember { mutableStateOf(colors[0]) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
modifier = Modifier.size(30.dp),
painter = painterResource(iconId), contentDescription = null,
tint = Color(colorHex.toColorInt())
painter = painterResource(icon.resource), contentDescription = null,
tint = Color(color.toColorInt())
)
OutlinedTextField(label = { Text("Name") }, value = text, onValueChange = { newText ->
text = newText
onTextChange(text)
OutlinedTextField(label = { Text("Name") }, value = name, onValueChange = { newText ->
onTextChange(newText)
})
}
@@ -104,7 +136,6 @@ fun AlertDialogFill(
modifier = Modifier
.size(30.dp)
.clickable(onClick = {
iconId = icon.resource
onIconChange(icon)
}),
painter = painterResource(icon.resource),
@@ -123,8 +154,7 @@ fun AlertDialogFill(
Box(
modifier = Modifier
.clickable(onClick = {
colorHex = color
onColorChange(colorHex)
onColorChange(color)
})
.size(30.dp)
.aspectRatio(1f)
@@ -132,5 +162,49 @@ fun AlertDialogFill(
) {}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = isArchived,
onCheckedChange = onArchivedChange
)
Text(
text = "Archived",
style = MaterialTheme.typography.titleMedium
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewAddCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewEditCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {},
categoryToEdit = Category(
0, "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.entries.random(),
color = colors.random(),
archived = true
)
)
}
}

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -11,6 +12,7 @@ import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
@@ -224,7 +226,7 @@ fun AddExpenseBottomSheet(
) {
Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
fontSize = 17.sp
style = MaterialTheme.typography.titleMedium
)
}
CategoryButton(
@@ -265,6 +267,11 @@ fun AddExpenseBottomSheet(
equationResult = evaluate(amount)
enableSave = amount.isDoubleTwoDigitsOrEquation() && equationResult > 0
},
onLongBackspaceClick = {
amount = "0.00"
equationResult = evaluate(amount)
enableSave = false
}
)
SaveButton(
@@ -394,7 +401,13 @@ fun NoteInput(
@Composable
fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) {
Button(onClick = onClick, modifier = modifier, shape = MaterialTheme.shapes.medium) {
Button(
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors()
.copy(containerColor = MaterialTheme.colorScheme.secondary)
) {
Text(text)
}
}
@@ -402,18 +415,35 @@ fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: Str
@Composable
fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
Button(
contentPadding = PaddingValues(0.dp),
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors()
.copy(containerColor = Color(category.color.toColorInt()), contentColor = Color.Black)
.copy(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
// Row(modifier = modifier.fillMaxWidth()) {
Icon(
modifier = Modifier.padding(end = 10.dp),
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(category.name)
Text(
text = category.name,
style = MaterialTheme.typography.titleMedium
)
// }
}
}
@@ -437,7 +467,8 @@ fun NumberKeyboard(
modifier: Modifier = Modifier,
onNumberClick: (String) -> Unit,
onBackspaceClick: () -> Unit,
onOperatorClick: (String) -> Unit
onOperatorClick: (String) -> Unit,
onLongBackspaceClick: () -> Unit,
) {
Column(
modifier = modifier,
@@ -455,22 +486,23 @@ fun NumberKeyboard(
onClick = onBackspaceClick,
modifier = Modifier
.weight(1f),
containerColor = MaterialTheme.colorScheme.primary
containerColor = Color.Transparent,
onLongClick = onLongBackspaceClick
)
"+", "/", "-", "*" -> KeyboardButton(
text = key,
onClick = { onOperatorClick(key) },
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
)
else -> KeyboardButton(
text = key,
onClick = { onNumberClick(key) },
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondary,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSecondary
)
}
@@ -487,26 +519,24 @@ fun KeyboardButton(
icon: Painter? = null,
onClick: () -> Unit,
enabled: Boolean = true,
onLongClick: () -> Unit = {},
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary
) {
Button(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.padding(2.dp)
.aspectRatio(2.5f),
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor
)
.aspectRatio(2.5f)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.background(containerColor, shape = MaterialTheme.shapes.medium),
) {
when {
text != null -> Text(
text,
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.headlineMedium
)
icon != null -> Icon(painter = icon, contentDescription = null)
@@ -594,26 +624,31 @@ fun PreviewAddExpenseEnabled() {
val categoriesToPreview = listOf(
Category(
1,
name = "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
color = colors.random()
),
Category(
2,
name = "Jedzenie",
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
color = colors.random()
),
Category(
3,
name = "Transport",
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
color = colors.random()
),
Category(
4,
name = "Rozrywka",
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
color = colors.random()
),
Category(
5,
name = "Zakupy",
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
color = colors.random()

View File

@@ -28,7 +28,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import com.composables.icons.materialsymbols.outlined.R
@@ -37,7 +36,7 @@ fun CategorySelectionDialog(
onDismiss: () -> Unit,
onCategorySelected: (Category) -> Unit,
selected: Category,
categories: List<Category>
categories: List<Category>,
) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val listState = rememberLazyListState()
@@ -91,7 +90,7 @@ fun CategorySelectionDialog(
contentDescription = stringResource(string.category)
)
Text(
text = stringResource(string.add_new_category), modifier = Modifier.padding(start = 8.dp),
text = stringResource(string.add_new), modifier = Modifier.padding(start = 8.dp),
)
}
}

View File

@@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -47,6 +46,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -58,33 +58,46 @@ import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category
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.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen() {
fun ListExpenseScreen(filter: String) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val expensesFlow = expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId)
val expensesFlow =
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
ListExpenseScreen(
expensesFlow = expensesFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
isRecalculatingRate = isRecalculatingRate
)
}
@@ -94,7 +107,8 @@ fun ListExpenseScreen() {
@Composable
fun ListExpenseScreen(
expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
isRecalculatingRate: Boolean
) {
val items = expensesFlow.collectAsLazyPagingItems()
@@ -111,13 +125,7 @@ fun ListExpenseScreen(
)
})
{
if (items.loadState.refresh == LoadState.Loading) {
// Show loading indicator
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
else {
Box {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -208,7 +216,7 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
date.format(
DateTimeFormatter.ofPattern("dd EEEE")
).toString(),
modifier = Modifier.background(Color.White.copy(alpha = 0f)),
modifier = Modifier.padding(horizontal = 5.dp).background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.titleMedium
)
Row(
@@ -221,8 +229,14 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
HorizontalDivider(modifier = Modifier.weight(2f))
Text(
"%.2f %s".format(sum, currency),
modifier = Modifier.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.bodyMedium
modifier = Modifier
.background(
MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.small
)
.padding(5.dp),
color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.bodySmall
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
@@ -256,7 +270,7 @@ fun SwipeToDeleteExpenseCard(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
@@ -329,7 +343,7 @@ fun ExpenseCard(
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.secondaryContainer),
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth(0.9f)
.height(70.dp)
@@ -344,14 +358,29 @@ fun ExpenseCard(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
//TODO
// .background(
// Brush.horizontalGradient(
// colorStops = arrayOf(
// 1f to Color(expenseDto.category.color.toColorInt()),
// 4f to MaterialTheme.colorScheme.surfaceDim
// )
// )
// )
.padding(horizontal = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxHeight()
) {
Icon(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceDim,
shape = MaterialTheme.shapes.small
)
.padding(10.dp),
painter = painterResource(expenseDto.category.icon.resource),
contentDescription = "Category",
tint = Color(expenseDto.category.color.toColorInt())
@@ -367,13 +396,13 @@ fun ExpenseCard(
Text(
text = expenseDto.category.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurface
)
Text(
modifier = Modifier.padding(0.dp),
text = expenseDto.expense.note,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurface
)
}
@@ -382,7 +411,7 @@ fun ExpenseCard(
DateTimeFormatter.ofPattern("dd MMM HH:mm")
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -390,7 +419,7 @@ fun ExpenseCard(
Text(
text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurface
)
@@ -398,7 +427,7 @@ fun ExpenseCard(
Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurface
)
}
@@ -407,107 +436,125 @@ fun ExpenseCard(
}
}
//@RequiresApi(Build.VERSION_CODES.O)
//@AllPreviews
//@Composable
//fun PreviewListExpenseScreen() {
// TripMoneyTheme() {
// val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
// ListExpenseScreen(
// expensesDtoFlow = MutableStateFlow(pagingData),
// onSaveExpense = {},
// onDeleteExpense = {},
// dailySums = emptyMap()
// )
//
// }
//}
//
//@AllPreviews
//@Composable
//fun PreviewDeleteConfirmationDialog() {
// TripMoneyTheme() {
// DeleteConfirmationDialog(
// onConfirm = {},
// onCancel = {})
// }
//}
//
//
//@RequiresApi(Build.VERSION_CODES.O)
//private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseDto> {
// val sampleCategories = listOf(
// Category(
// name = "Hotel",
// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
// color = colors.random()
// ),
// Category(
// name = "Jedzenie",
// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
// color = colors.random()
// ),
// Category(
// name = "Transport",
// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
// color = colors.random()
// ),
// Category(
// name = "Rozrywka",
// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
// color = colors.random()
// ),
// Category(
// name = "Zakupy",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = colors.random()
// ),
// )
//
// val trip = Trip(
// id = 1,
// name = "Vacation",
// currency = "USD",
// startDate = LocalDate.parse("2026-01-01")
// )
//
// val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
// val endLong = LocalDateTime.now().toEpochMilli()
//
// val result: MutableList<ExpenseDto> = mutableListOf()
// for (i in 0..15) {
// val category = sampleCategories.random()
// val datetime = if (i > 4) {
// LocalDateTime.ofEpochSecond(
// Random.nextLong(startLong, endLong),
// 0,
// ZoneOffset.UTC
// )
// } else LocalDateTime.now()
//
// val expense = Expense(
// id = i,
// categoryId = category.id,
// tripId = 1,
// amount = Random.nextDouble(0.1, 300.0),
// currency = Currencies.entries.random().name,
// note = if (i % 3 == 0) "Some note" else "",
// datetime = datetime,
// rate = if (Random.nextBoolean()) Random.nextDouble(
// 0.1,
// 5.0
// ) else 1.0
// )
//
//
// val expenseDto = ExpenseDto(
// expense = expense,
// category = category,
// trip = trip
// )
// result.add(
// expenseDto
// )
// }
// return result
//}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewListExpenseScreen() {
TripMoneyTheme() {
val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
ListExpenseScreen(
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
true
)
}
}
@AllPreviews
@Composable
fun PreviewDeleteConfirmationDialog() {
TripMoneyTheme() {
DeleteConfirmationDialog(
onConfirm = {},
onCancel = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseListItemUi> {
val sampleCategories = listOf(
Category(
name = "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
color = colors.random()
),
Category(
name = "Jedzenie",
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
color = colors.random()
),
Category(
name = "Transport",
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
color = colors.random()
),
Category(
name = "Rozrywka",
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
color = colors.random()
),
Category(
name = "Zakupy",
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
color = colors.random()
),
)
val trip = Trip(
id = 1,
name = "Vacation",
currency = "USD",
startDate = LocalDate.parse("2026-01-01")
)
val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
val endLong = LocalDateTime.now().toEpochMilli()
val result: MutableList<ExpenseListItemUi> = mutableListOf()
result.add(
ExpenseListItemUi.Header(
LocalDateTime.ofEpochSecond(
Random.nextLong(startLong, endLong),
0,
ZoneOffset.UTC
).toLocalDate(), Random.nextDouble(0.1, 300.0), Currencies.entries.random().name
)
)
for (i in 0..15) {
val category = sampleCategories.random()
val datetime = if (i > 4) {
LocalDateTime.ofEpochSecond(
Random.nextLong(startLong, endLong),
0,
ZoneOffset.UTC
)
} else LocalDateTime.now()
val expense = Expense(
id = i,
categoryId = category.id,
tripId = 1,
amount = Random.nextDouble(0.1, 300.0),
currency = Currencies.entries.random().name,
note = if (i % 3 == 0) "Some note" else "",
datetime = datetime,
rate = if (Random.nextBoolean()) Random.nextDouble(
0.1,
5.0
) else 1.0
)
val expenseDto = ExpenseDto(
expense = expense,
category = category,
trip = trip
)
result.add(
ExpenseListItemUi.Item(expenseDto)
)
if (i % 5 == 0) {
result.add(
ExpenseListItemUi.Header(
datetime.toLocalDate(),
Random.nextDouble(0.1, 300.0),
Currencies.entries.random().name
)
)
}
}
return result
}

View File

@@ -0,0 +1,431 @@
package cc.n0th1ng.tripmoney.screens.managecategories
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
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.padding
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
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.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
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.screens.addexpense.categoriesToPreview
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
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
fun ManageCategoriesScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
val archivedCategories by expenseAndCategoryViewModel.getArchivedCategories()
.collectAsState(emptyList())
ManageCategoriesScreen(
categories = categories,
archivedCategories = archivedCategories,
onSaveCategory = { expenseAndCategoryViewModel.save(it) },
onDeleteCategory = {
expenseAndCategoryViewModel.delete(it)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ManageCategoriesScreen(
categories: List<Category>,
archivedCategories: List<Category>,
onSaveCategory: (Category) -> Unit,
onDeleteCategory: (Category) -> Unit,
) {
var categoryToEdit by remember { mutableStateOf<Category?>(null) }
var showAddCategoryDialog by remember { mutableStateOf(false) }
var itemToDelete by remember { mutableStateOf<Category?>(null) }
var itemToArchive by remember { mutableStateOf<Category?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showAddCategoryDialog = true },
icon = { Icon(Icons.Filled.Add, stringResource(string.add_new)) },
text = { Text(text = stringResource(string.add_new)) },
)
})
{
LazyColumn(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
items(categories, key = { it.id }) { category ->
SwipeToDeleteExpenseCard(
category = category,
onDelete = { itemToArchive = category },
onClick = {
categoryToEdit = category
showAddCategoryDialog = true
}
)
Spacer(Modifier.height(10.dp))
}
if (archivedCategories.isNotEmpty()) {
item {
CustomDivider()
Spacer(modifier = Modifier.height(10.dp))
}
}
items(archivedCategories, key = { it.id }) { archivedCategory ->
SwipeToDeleteExpenseCard(
category = archivedCategory,
onDelete = { itemToDelete = archivedCategory },
onClick = {
categoryToEdit = archivedCategory
showAddCategoryDialog = true
},
isArchived = true
)
Spacer(Modifier.height(10.dp))
}
}
if (showAddCategoryDialog) {
AddCategoryDialog(
onDismiss = {
showAddCategoryDialog = false
}, onSave = { category ->
onSaveCategory(category)
showAddCategoryDialog = false
},
categoryToEdit = categoryToEdit
)
}
}
if (itemToDelete != null) {
DeleteConfirmationDialog(
bodyText = stringResource(string.delete_category_info),
onConfirm = {
onDeleteCategory(itemToDelete!!)
itemToDelete = null
},
onCancel = {
itemToDelete = null
}
)
}
if (itemToArchive != null) {
DeleteConfirmationDialog(
title = stringResource(string.you_want_archive),
buttonText = stringResource(string.archive),
bodyText = stringResource(string.archive_category_info),
onConfirm = {
onSaveCategory(itemToArchive!!.copy(archived = true))
itemToArchive = null
},
onCancel = {
itemToArchive = null
}
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
private fun CustomDivider() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.Center,
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
"Archived",
modifier = Modifier
.padding(horizontal = 5.dp)
.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.titleMedium
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun SwipeToDeleteExpenseCard(
category: Category,
onDelete: (Category) -> Unit,
onClick: (Category) -> Unit,
isArchived: Boolean = false
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
onDelete(category)
false
} else {
false
}
}
)
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
Box(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
painter = painterResource(
if (isArchived) R.drawable.materialsymbols_ic_delete_outlined
else R.drawable.materialsymbols_ic_archive_outlined
),
contentDescription = stringResource(string.delete)
)
}
}
) {
CategoryCard(
category = category,
onClick = onClick,
isArchived = isArchived
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeleteConfirmationDialog(
title: String = stringResource(string.delete_confirmation),
buttonText: String = stringResource(string.delete),
bodyText: String = "",
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
BasicAlertDialog(
onDismissRequest = { onCancel() }
) {
Column(
Modifier
.background(
MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium
)
.padding(24.dp)
) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = bodyText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp)
) {
Button(
onClick = onCancel,
colors = ButtonDefaults.buttonColors().copy(containerColor = MaterialTheme.colorScheme.secondary),
modifier = Modifier
.padding(end = 10.dp)
){
Text(text = stringResource(string.cancel),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondary)
}
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors().copy(containerColor = MaterialTheme.colorScheme.error),
){
Text(text = buttonText,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onError)
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun CategoryCard(
category: Category,
onClick: (Category) -> Unit,
isArchived: Boolean = false
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth(0.9f)
.height(70.dp)
.combinedClickable(
enabled = true,
onClick = { onClick(category) },
onLongClick = { onClick(category) }),
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.alpha(if (isArchived) 0.6f else 1f)
.padding(horizontal = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp),
modifier = Modifier.fillMaxHeight()
) {
Icon(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceDim,
shape = MaterialTheme.shapes.small
)
.padding(10.dp),
painter = painterResource(category.icon.resource),
contentDescription = "Category",
tint = Color(category.color.toColorInt())
)
Column()
{
Text(
text = category.name,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewManageCategoriesScreen() {
TripMoneyTheme {
ManageCategoriesScreen(categories = categoriesToPreview.subList(0,2), categoriesToPreview.subList(3,5), {}, {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewAddCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewEditCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {},
categoryToEdit = Category(
0, "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.entries.random(),
color = colors.random(),
archived = false
)
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewDeleteConfirmationDialog() {
TripMoneyTheme {
DeleteConfirmationDialog(
onConfirm = {},
onCancel = {},
bodyText = "Your all expenses with category Hotel will be removed.",
title = "Do you want to delete?",
buttonText = "Delete"
)
}
}

View File

@@ -38,10 +38,16 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.screens.listexpense.CategorySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.statistics.categories
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
@@ -60,7 +66,7 @@ import java.nio.file.Files
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun SettingsScreen() {
fun SettingsScreen(navController: NavHostController) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
@@ -68,6 +74,7 @@ fun SettingsScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
val context = LocalContext.current
val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope()
@@ -90,7 +97,8 @@ fun SettingsScreen() {
e.printStackTrace()
}
}
}
},
onCategoriesClick = {navController.navigate(Screens.MANAGE_CATEGORIES)}
)
}
@@ -103,11 +111,13 @@ fun SettingsScreen(
onCurrencySave: (Currencies) -> Unit,
tripName: String,
onExportToCsv: () -> Unit,
onCategoriesClick: () -> Unit
) {
Scaffold { padding ->
var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoriesDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -142,9 +152,15 @@ fun SettingsScreen(
SettingsListItem(
onClick = onExportToCsv,
stringResource(string.export_to_csv),
supportingText = "Save expenses from %s to a file".format(tripName),
supportingText = stringResource(string.export_csv_subttext).format(tripName),
iconResource = R.drawable.materialsymbols_ic_csv_outlined
)
SettingsListItem(
onClick = onCategoriesClick,
stringResource(string.categories),
supportingText = stringResource(string.manage_categories),
iconResource = R.drawable.materialsymbols_ic_label_outlined
)
if (showThemeDialog) {
ThemeSelectionDialog(
@@ -258,7 +274,14 @@ fun ThemeSelectionDialog(
@Composable
fun PreviewSettingsScreen() {
TripMoneyTheme {
SettingsScreen(Currencies.entries.random(), AppTheme.entries.random(), {}, {}, "Włochy", {})
SettingsScreen(
Currencies.entries.random(),
AppTheme.entries.random(),
{},
{},
"Włochy",
{},
{})
}
}

View File

@@ -1,5 +1,6 @@
package cc.n0th1ng.tripmoney.screens.statistics
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
@@ -13,9 +14,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -30,6 +37,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.string
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
@@ -82,9 +90,10 @@ fun StatisticsScreen(
@Composable
fun Summary(summaryAmount: Double, currency: String) {
Card(
modifier = Modifier
.fillMaxWidth()
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Row(
modifier = Modifier
@@ -94,7 +103,10 @@ fun Summary(summaryAmount: Double, currency: String) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses), style = MaterialTheme.typography.titleSmall)
Text(
stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses),
style = MaterialTheme.typography.titleSmall
)
Text(
"%.2f %s".format(summaryAmount, currency),
style = MaterialTheme.typography.headlineLarge
@@ -118,7 +130,11 @@ fun Summary(summaryAmount: Double, currency: String) {
@Composable
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
Card(modifier = Modifier.fillMaxWidth()) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Column(
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
@@ -191,17 +207,20 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun Preview() {
TripMoneyTheme {
Scaffold {
StatisticsScreen(
summaryPerCategoryList,
summaryAmount = 125.24,
Currencies.entries.random()
)
}
}
}
val categories = listOf(

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
@@ -21,7 +20,6 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -50,16 +48,21 @@ 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
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.screens.listexpense.DeleteConfirmationDialog
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@@ -70,9 +73,38 @@ fun TripPickerScreen(
) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
var showBottomSheet by remember { mutableStateOf(false) }
val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems()
val tripsFlow = tripViewModel.getTrips()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
TripPickerScreen(
tripsFlow = tripsFlow,
currentTripId = currentTripId,
onDelete = { trip -> tripViewModel.delete(trip) },
onClick = { trip ->
settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
},
onSave = { trip ->
tripViewModel.save(trip)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
fun TripPickerScreen(
tripsFlow: Flow<PagingData<Trip>>,
currentTripId: Int,
onDelete: (Trip) -> Unit,
onClick: (Trip) -> Unit,
onSave: (Trip) -> Unit
) {
var showBottomSheet by remember { mutableStateOf(false) }
val trips: LazyPagingItems<Trip> = tripsFlow.collectAsLazyPagingItems()
var tripToEdit by remember { mutableStateOf<Trip?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
FloatingActionButton(
@@ -91,11 +123,11 @@ fun TripPickerScreen(
val trip = trips[i]
if (trip != null) {
SwipeToDeleteTripCard(
trip, onDelete = {
tripViewModel.delete(trip)
trip = trip,
onDelete = {
onDelete(trip)
}, onClick = {
settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
onClick(trip)
}, isSelected = currentTripId == trip.id,
onLongClick = { trip ->
tripToEdit = trip
@@ -113,7 +145,7 @@ fun TripPickerScreen(
tripToEdit = null
},
onSave = { trip ->
tripViewModel.save(trip)
onSave(trip)
showBottomSheet = false
tripToEdit = null
},
@@ -152,7 +184,6 @@ fun SwipeToDeleteTripCard(
}
SwipeToDismissBox(
modifier = Modifier.alpha(if (isSelected) 1.0f else 0.7f),
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
@@ -160,7 +191,7 @@ fun SwipeToDeleteTripCard(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
@@ -186,7 +217,7 @@ fun TripCard(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondary
MaterialTheme.colorScheme.surfaceContainer
}
),
modifier = Modifier
@@ -195,7 +226,7 @@ fun TripCard(
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(trip)
}, onClick = { onClick(trip) }),
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp)
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -217,3 +248,38 @@ fun TripCard(
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewTripPickerScreen() {
val tripsToPreview = listOf(
Trip(
1,
name = "Włochy",
startDate = LocalDate.parse("2026-03-01"),
currency = "PLN"
),
Trip(
2,
name = "Szwajcaria",
startDate = LocalDate.parse("2025-03-01"),
currency = "EUR"
),
Trip(
3,
name = "Portugalia",
startDate = LocalDate.parse("2025-03-01"),
currency = "USD"
)
)
TripMoneyTheme {
TripPickerScreen(
tripsFlow = MutableStateFlow(PagingData.from(tripsToPreview)),
currentTripId = 1,
onDelete = {},
onClick = {},
onSave = {}
)
}
}

View File

@@ -40,15 +40,28 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
private val tripRepo: TripRepository
) : ViewModel() {
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesDtoPaged(tripId).cachedIn(viewModelScope)
fun archiveCategory(category: Category) {
viewModelScope.launch {
categoryRepo.save(category.copy(archived = true))
}
}
fun deArchiveCategory(category: Category) {
viewModelScope.launch {
categoryRepo.save(category.copy(archived = false))
}
}
fun getExpensesDtoPaged(tripId: Int, filter: String = ""): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesDtoPaged(tripId, filter).cachedIn(viewModelScope)
@RequiresApi(Build.VERSION_CODES.O)
fun getExpensesWithHeadersPaged(
tripId: Int
tripId: Int,
filter: String = ""
): Flow<PagingData<ExpenseListItemUi>> {
val pagingFlow = getExpensesDtoPaged(tripId)
val sumsFlow = getDailySums(tripId)
val pagingFlow = getExpensesDtoPaged(tripId, filter)
val sumsFlow = getDailySums(tripId, filter)
val tripFlow = tripRepo.getTrip(tripId)
return combine(pagingFlow, sumsFlow, tripFlow) { pagingData, sums, trip ->
val currency = trip?.currency ?: ""
@@ -80,8 +93,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}.cachedIn(viewModelScope)
}
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> =
expenseRepo.getExpensesDto(tripId)
fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> =
expenseRepo.getExpensesDto(tripId, filter)
@RequiresApi(Build.VERSION_CODES.O)
fun save(expense: Expense, trip: Trip) {
@@ -96,14 +109,20 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
fun delete(expense: Expense) {
viewModelScope.launch {
expenseRepo.delete(expense)
}
}
fun delete(category: Category) {
viewModelScope.launch {
categoryRepo.delete(category)
}
}
fun getCategories(): Flow<List<Category>> = categoryRepo.getCategories()
fun getArchivedCategories(): Flow<List<Category>> = categoryRepo.getArchivedCategories()
fun save(category: Category) {
viewModelScope.launch {
@@ -132,8 +151,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
@RequiresApi(Build.VERSION_CODES.O)
fun getDailySums(tripId: Int): Flow<Map<LocalDate, Double>> {
return getExpensesDto(tripId)
fun getDailySums(tripId: Int, filter: String): Flow<Map<LocalDate, Double>> {
return getExpensesDto(tripId, filter)
.map { expenses ->
expenses.groupBy { it.expense.datetime.toLocalDate() }
.mapValues { (_, list) ->
@@ -182,7 +201,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
@RequiresApi(Build.VERSION_CODES.O)
sealed class ExpenseListItemUi {
data class Item(val expenseDto: ExpenseDto) : ExpenseListItemUi()
data class Header(val date: LocalDate, val sum: Double, val currency: String) : ExpenseListItemUi()
data class Header(val date: LocalDate, val sum: Double, val currency: String) :
ExpenseListItemUi()
}
}

View File

@@ -1,21 +1,30 @@
package cc.n0th1ng.tripmoney.viewmodel
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
import cc.n0th1ng.tripmoney.data.repository.TripRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class TripViewModel @Inject constructor(private val repository: TripRepository) : ViewModel() {
class TripViewModel @Inject constructor(
private val repository: TripRepository,
private val expenseRepository: ExpenseRepository
) : ViewModel() {
private val _isRecalculating = MutableStateFlow(false)
val isRecalculating: StateFlow<Boolean> = _isRecalculating
fun getTrips(): Flow<PagingData<Trip>> = repository.getTrips().cachedIn(viewModelScope)
fun getTrip(tripId: Int): Flow<Trip?> = repository.getTrip(tripId)
@@ -26,9 +35,15 @@ class TripViewModel @Inject constructor(private val repository: TripRepository)
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun save(trip: Trip) {
viewModelScope.launch {
repository.save(trip)
_isRecalculating.value = true
withContext(Dispatchers.IO) {
expenseRepository.recalculateTripExpenses(trip.id)
}
_isRecalculating.value = false
}
}

View File

@@ -5,7 +5,7 @@
<string name="save">Zapisz</string>
<string name="backspace">Usuń</string>
<string name="pick_category">Wybierz kategorie</string>
<string name="add_new_category">dodaj nową</string>
<string name="add_new">dodaj nową</string>
<string name="pick_currency">Wybierz kategorie</string>
<string name="cancel">Anuluj</string>
<string name="add_expense">Dodaj wydatek</string>
@@ -27,4 +27,9 @@
<string name="list_of_expenses">Lista wydatków</string>
<string name="statistics">Statystyki</string>
<string name="export_to_csv">Eksport do CSV</string>
<string name="manage_categories">Zarządzaj kategoriami</string>
<string name="categories">Kategorie</string>
<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>
</resources>

View File

@@ -5,7 +5,7 @@
<string name="save">Save</string>
<string name="backspace">Backspace</string>
<string name="pick_category">Pick a category</string>
<string name="add_new_category">Add new</string>
<string name="add_new">Add new</string>
<string name="pick_currency">Pick a currency</string>
<string name="cancel">Cancel</string>
<string name="add_expense">Add expense</string>
@@ -27,4 +27,13 @@
<string name="list_of_expenses">List of expenses</string>
<string name="statistics">Statistics</string>
<string name="export_to_csv">Export to CSV</string>
<string name="export_csv_subttext">Save expenses from %s to a file</string>
<string name="manage_categories">Manage categories</string>
<string name="categories">Categories</string>
<string name="add_new_category">Add category</string>
<string name="edit_category">Edit category</string>
<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>
</resources>

1
baselineprofile/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,52 @@
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.baselineprofile)
}
android {
namespace = "cc.n0th1ng.baselineprofile"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
defaultConfig {
minSdk = 28
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
targetProjectPath = ":app"
}
// This is the configuration block for the Baseline Profile plugin.
// You can specify to run the generators on a managed devices or connected devices.
baselineProfile {
useConnectedDevices = true
}
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.espresso.core)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}
androidComponents {
onVariants { v ->
val artifactsLoader = v.artifacts.getBuiltArtifactsLoader()
v.instrumentationRunnerArguments.put(
"targetAppId",
v.testedApks.map { artifactsLoader.load(it)?.applicationId }
)
}
}

View File

@@ -0,0 +1 @@
<manifest />

View File

@@ -0,0 +1,40 @@
package cc.n0th1ng.baselineprofile
import android.R.attr.contentDescription
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generate() {
rule.collect(
packageName = "cc.n0th1ng.tripmoney",
includeInStartupProfile = true
) {
pressHome()
startActivityAndWait()
// Give Compose time to render
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

@@ -0,0 +1,76 @@
package cc.n0th1ng.baselineprofile
import androidx.benchmark.macro.BaselineProfileMode
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* This test class benchmarks the speed of app startup.
* Run this benchmark to verify how effective a Baseline Profile is.
* It does this by comparing [CompilationMode.None], which represents the app with no Baseline
* Profiles optimizations, and [CompilationMode.Partial], which uses Baseline Profiles.
*
* Run this benchmark to see startup measurements and captured system traces for verifying
* the effectiveness of your Baseline Profiles. You can run it directly from Android
* Studio as an instrumentation test, or run all benchmarks for a variant, for example benchmarkRelease,
* with this Gradle task:
* ```
* ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest
* ```
*
* You should run the benchmarks on a physical device, not an Android emulator, because the
* emulator doesn't represent real world performance and shares system resources with its host.
*
* For more information, see the [Macrobenchmark documentation](https://d.android.com/macrobenchmark#create-macrobenchmark)
* and the [instrumentation arguments documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args).
**/
@RunWith(AndroidJUnit4::class)
@LargeTest
class StartupBenchmarks {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun startupCompilationNone() =
benchmark(CompilationMode.None())
@Test
fun startupCompilationBaselineProfiles() =
benchmark(CompilationMode.Partial(BaselineProfileMode.Require))
private fun benchmark(compilationMode: CompilationMode) {
// The application id for the running build variant is read from the instrumentation arguments.
rule.measureRepeated(
packageName = InstrumentationRegistry.getArguments().getString("targetAppId")
?: throw Exception("targetAppId not passed as instrumentation runner arg"),
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode,
startupMode = StartupMode.COLD,
iterations = 10,
setupBlock = {
pressHome()
},
measureBlock = {
startActivityAndWait()
// TODO Add interactions to wait for when your app is fully drawn.
// The app is fully drawn when Activity.reportFullyDrawn is called.
// For Jetpack Compose, you can use ReportDrawn, ReportDrawnWhen and ReportDrawnAfter
// from the AndroidX Activity library.
// Check the UiAutomator documentation for more information on how to
// interact with the app.
// https://d.android.com/training/testing/other-components/ui-automator
}
)
}
}

View File

@@ -6,4 +6,6 @@ plugins {
id("com.google.devtools.ksp") version "2.2.21-2.0.5" apply false
id("com.google.dagger.hilt.android") version "2.57.1" apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.baselineprofile) apply false
}

View File

@@ -21,3 +21,4 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
org.gradle.configuration-cache=true

View File

@@ -10,6 +10,10 @@ activityCompose = "1.8.0"
composeBom = "2024.09.00"
navigationCompose = "2.9.7"
foundationLayout = "1.10.5"
uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.2.4"
baselineprofile = "1.2.4"
profileinstaller = "1.3.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -28,9 +32,14 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
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-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-test = { id = "com.android.test", version.ref = "agp" }
baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }

View File

@@ -21,4 +21,4 @@ dependencyResolutionManagement {
rootProject.name = "tripMoney"
include(":app")
include(":baselineprofile")