init #48

Merged
admin merged 18 commits from develop into main 2026-04-30 10:35:58 +02:00
37 changed files with 1539 additions and 475 deletions
Showing only changes of commit c4c9868698 - Show all commits

View File

@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
alias(libs.plugins.baselineprofile)
} }
android { android {
@@ -22,7 +23,8 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@@ -54,11 +56,13 @@ dependencies {
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.profileinstaller)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
"baselineProfile"(project(":baselineprofile"))
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.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.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
@@ -27,6 +33,7 @@ import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.navigation.TopBar import cc.n0th1ng.tripmoney.navigation.TopBar
import cc.n0th1ng.tripmoney.navigation.TopBarSettings import cc.n0th1ng.tripmoney.navigation.TopBarSettings
import cc.n0th1ng.tripmoney.screens.listexpense.ListExpenseScreen 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.settings.SettingsScreen
import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen
import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen
@@ -40,21 +47,25 @@ import kotlinx.coroutines.launch
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.S)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
TripMoneyTheme { TripMoneyTheme {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
expenseAndCategoryViewModel.clearOldRates() expenseAndCategoryViewModel.clearOldRates()
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId)
.collectAsLazyPagingItems()
NavigationDrawer() NavigationDrawer()
} }
} }
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.S)
@Composable @Composable
fun NavigationDrawer() { fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -66,33 +77,39 @@ 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("") }
CustomNavigationDrawer(navController, drawerState) { CustomNavigationDrawer(navController, drawerState) {
Scaffold( Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
topBar = { topBar = {
if (current == Screens.SETTINGS) TopBarSettings( if (current == Screens.SETTINGS) TopBarSettings(
navController navController
) else TopBar( ) else TopBar(
title = currentTrip?.name ?: "", title = currentTrip?.name ?: "",
onClick = { onDrawerClick = {
scope.launch { scope.launch {
if (drawerState.isClosed) { if (drawerState.isClosed) {
drawerState.open() drawerState.open()
} else { } else {
drawerState.close() drawerState.close()
}
} }
} },
}) isSearchable = current == Screens.LIST_EXPENSE,
onFilterChange = { newFilter -> filter = newFilter})
}, },
bottomBar = { BottomNavigation(navController) }) { innerPadding -> bottomBar = { BottomNavigation(navController) }) { innerPadding ->
NavHost( NavHost(
navController = navController, 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) modifier = Modifier.padding(innerPadding)
) { ) {
composable(Screens.LIST_EXPENSE) { composable(Screens.LIST_EXPENSE) {
ListExpenseScreen() ListExpenseScreen(filter)
} }
composable(Screens.TRIP_PICKER) { composable(Screens.TRIP_PICKER) {
TripPickerScreen(navController) TripPickerScreen(navController)
@@ -101,7 +118,10 @@ fun NavigationDrawer() {
StatisticsScreen() StatisticsScreen()
} }
composable(Screens.SETTINGS) { 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.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import cc.n0th1ng.tripmoney.data.dao.CategoryDao import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao 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.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
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.Trip 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.Icons
import cc.n0th1ng.tripmoney.utils.colors import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Delay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.inject.Inject import java.time.ZoneOffset
import javax.inject.Singleton 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) @TypeConverters(Converters::class)
abstract class TripDatabase : RoomDatabase() { abstract class TripDatabase : RoomDatabase() {
abstract fun tripDao(): TripDao abstract fun tripDao(): TripDao
@@ -46,21 +55,28 @@ abstract class TripDatabase : RoomDatabase() {
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DatabaseModule { object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Provides @Provides
@Singleton @Singleton
fun provideTripDatabase( fun provideTripDatabase(
@ApplicationContext context: Context, @ApplicationContext context: Context
// expenseAndCategoryViewModel: ExpenseAndCategoryViewModel
): TripDatabase { ): TripDatabase {
val db: TripDatabase = Room.inMemoryDatabaseBuilder( val db: TripDatabase = Room.inMemoryDatabaseBuilder(
context, TripDatabase::class.java // val db: TripDatabase = Room.databaseBuilder(
).allowMainThreadQueries().build() // 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 { CoroutineScope(Dispatchers.IO).launch {
DatabasePrepopulator( DatabasePrepopulator(
tripDao = db.tripDao(), categoryDao = db.categoryDao(), expenseDao = db.expenseDao() tripDao = db.tripDao(),
categoryDao = db.categoryDao(),
expenseDao = db.expenseDao()
).prepopulate() ).prepopulate()
} }
return db return db
@@ -99,169 +115,92 @@ private class DatabasePrepopulator(
) { ) {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
suspend fun prepopulate() { 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(
tripDao.insert(Trip(name = "Portugalia", startDate = LocalDate.parse("2025-03-01"), currency = "USD")) Trip(
categoryDao.insert(Category(name = "Accomodation", icon = Icons.HOTEL, color = colors.random())) name = "Włochy",
categoryDao.insert(Category(name = "Transport", icon = Icons.TRANSPORT, color = colors.random())) startDate = LocalDate.parse("2026-03-01"),
categoryDao.insert(Category(name = "Flight", icon = Icons.FLIGHT, color = colors.random())) currency = "PLN"
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())) tripDao.insert(
categoryDao.insert(Category(name = "Entertainment", icon = Icons.ENTERTAINMENT,color = colors.random())) Trip(
categoryDao.insert(Category(name = "Laundry", icon = Icons.LAUNDRY,color = colors.random())) 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 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 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()
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 package cc.n0th1ng.tripmoney.data.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
@@ -14,12 +15,23 @@ interface CategoryDao {
suspend fun insert(category: Category) suspend fun insert(category: Category)
@Delete
suspend fun delete(category: Category)
@Transaction @Transaction
@Query( @Query(
""" """
SELECT * FROM category SELECT * FROM category WHERE archived is 0
""" """
) )
fun categories(): Flow<List<Category>> 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 @Upsert
suspend fun insert(expense: Expense) suspend fun insert(expense: Expense)
@Query( @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 ORDER BY expense.datetime DESC
""" """
) )
fun expenseDtoPaged(tripId: Int): PagingSource<Int, ExpenseDto> fun expenseDtoPaged(tripId: Int, filter: String): PagingSource<Int, ExpenseDto>
@Transaction @Transaction
@Query( @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 ORDER BY expense.datetime DESC
""" """
) )
fun expenseDto(tripId: Int): Flow<List<ExpenseDto>> fun expenseDto(tripId: Int, filter: String): Flow<List<ExpenseDto>>
@Delete @Delete
suspend fun delete(expense: Expense) suspend fun delete(expense: Expense)

View File

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

View File

@@ -1,14 +1,17 @@
package cc.n0th1ng.tripmoney.data.entity package cc.n0th1ng.tripmoney.data.entity
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.Icons
@Entity(tableName = "category") @Entity(tableName = "category")
@Immutable
data class Category( data class Category(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String, @ColumnInfo("name") val name: String,
@ColumnInfo("icon") val icon: Icons, @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 package cc.n0th1ng.tripmoney.data.entity
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Relation import androidx.room.Relation
import java.time.LocalDateTime 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( data class Expense(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("amount") val amount: Double, @ColumnInfo("amount") val amount: Double,

View File

@@ -2,6 +2,7 @@ package cc.n0th1ng.tripmoney.data.entity
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@@ -9,14 +10,16 @@ import cc.n0th1ng.tripmoney.utils.Currencies
import java.time.LocalDate import java.time.LocalDate
@Entity(tableName = "trip") @Entity(tableName = "trip")
@Immutable
data class Trip( data class Trip(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String, @ColumnInfo("name") val name: String,
@ColumnInfo("start_date") val startDate: LocalDate, @ColumnInfo("start_date") val startDate: LocalDate,
@ColumnInfo("currency") val currency: String @ColumnInfo("currency") val currency: String,
@ColumnInfo("budget") val budget: Double
){ ){
companion object { companion object {
@RequiresApi(Build.VERSION_CODES.O) @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) categoryDao.insert(category)
} }
@WorkerThread
suspend fun delete(category: Category) {
categoryDao.delete(category)
}
fun getCategories(): Flow<List<Category>> { fun getCategories(): Flow<List<Category>> {
return categoryDao.categories() 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) @RequiresApi(Build.VERSION_CODES.O)
suspend fun clearOldRates(daysToKeep: Int = 180) { suspend fun clearOldRates(daysToKeep: Int = 180) {
val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString() 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.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 kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -32,15 +35,15 @@ class ExpenseRepository @Inject constructor(
expenseDao.delete(expense) expenseDao.delete(expense)
} }
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> { fun getExpensesDtoPaged(tripId: Int, filter: String): Flow<PagingData<ExpenseDto>> {
return Pager( return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false), config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) } pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId, filter) }
).flow ).flow
} }
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> { fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> {
return expenseDao.expenseDto(tripId) return expenseDao.expenseDto(tripId, filter)
} }
@RequiresApi(Build.VERSION_CODES.O) @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.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class TripRepository @Inject constructor( class TripRepository @Inject constructor(
@@ -18,9 +21,7 @@ class TripRepository @Inject constructor(
) { ) {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@WorkerThread
suspend fun save(trip: Trip) { suspend fun save(trip: Trip) {
expenseRepository.recalculateTripExpenses(trip.id)
tripDao.insert(trip) tripDao.insert(trip)
} }

View File

@@ -7,6 +7,8 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -37,7 +39,7 @@ fun BottomNavigation(navController: NavController) {
painter = painterResource( painter = painterResource(
R.drawable.materialsymbols_ic_list_outlined, 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 TRIP_PICKER = "trip_picker"
const val STATISTICS = "statistics" const val STATISTICS = "statistics"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val MANAGE_CATEGORIES = "manage_categories"
} }

View File

@@ -1,27 +1,130 @@
package cc.n0th1ng.tripmoney.navigation 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.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.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text 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.material3.TopAppBar
import androidx.compose.runtime.Composable 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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import cc.n0th1ng.tripmoney.R 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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( 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 = { navigationIcon = {
IconButton(onClick = { onClick() }) { IconButton(onClick = { onDrawerClick() }) {
Icon(Icons.Default.Menu, contentDescription = "Menu") 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
})
)
}
}
} }
) )
} }
@@ -39,4 +142,16 @@ 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 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -27,44 +33,68 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category 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.Icons
import cc.n0th1ng.tripmoney.utils.colors import cc.n0th1ng.tripmoney.utils.colors
@Composable @Composable
fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) { fun AddCategoryDialog(
var name by remember { mutableStateOf("") } onDismiss: () -> Unit,
var icon by remember { mutableStateOf(Icons.entries[0]) } onSave: (Category) -> Unit,
var color by remember { mutableStateOf(colors[0]) } 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( 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( AlertDialogFill(
onTextChange = { newText -> onTextChange = { newText ->
name = newText name = newText
}, },
onIconChange = { newIcon -> icon = newIcon }, 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( Button(
enabled = !name.isEmpty(), enabled = !name.isEmpty(),
onClick = { onClick = {
onSave( val categoryToSave = Category(
Category( name = name,
name = name, icon = icon,
icon = icon, color = color,
color = color archived = isArchived
)
) )
}) { Text("Save") } onSave(
if (categoryToEdit != null) categoryToSave.copy(id = categoryToEdit.id) else categoryToSave
)
}) { Text(stringResource(R.string.save)) }
}, },
dismissButton = { dismissButton = {
Button( Row() {
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error), Button(
onClick = onDismiss colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.secondary),
) { Text("close") } onClick = onDismiss
) { Text(stringResource(R.string.cancel)) }
}
}) })
} }
@@ -72,24 +102,26 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
fun AlertDialogFill( fun AlertDialogFill(
onTextChange: (String) -> Unit, onTextChange: (String) -> Unit,
onIconChange: (Icons) -> 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)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Icon( Icon(
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
painter = painterResource(iconId), contentDescription = null, painter = painterResource(icon.resource), contentDescription = null,
tint = Color(colorHex.toColorInt()) tint = Color(color.toColorInt())
) )
OutlinedTextField(label = { Text("Name") }, value = text, onValueChange = { newText -> OutlinedTextField(label = { Text("Name") }, value = name, onValueChange = { newText ->
text = newText onTextChange(newText)
onTextChange(text)
}) })
} }
@@ -104,7 +136,6 @@ fun AlertDialogFill(
modifier = Modifier modifier = Modifier
.size(30.dp) .size(30.dp)
.clickable(onClick = { .clickable(onClick = {
iconId = icon.resource
onIconChange(icon) onIconChange(icon)
}), }),
painter = painterResource(icon.resource), painter = painterResource(icon.resource),
@@ -123,8 +154,7 @@ fun AlertDialogFill(
Box( Box(
modifier = Modifier modifier = Modifier
.clickable(onClick = { .clickable(onClick = {
colorHex = color onColorChange(color)
onColorChange(colorHex)
}) })
.size(30.dp) .size(30.dp)
.aspectRatio(1f) .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.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -224,7 +226,7 @@ fun AddExpenseBottomSheet(
) { ) {
Text( Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")), text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
fontSize = 17.sp style = MaterialTheme.typography.titleMedium
) )
} }
CategoryButton( CategoryButton(
@@ -265,6 +267,11 @@ fun AddExpenseBottomSheet(
equationResult = evaluate(amount) equationResult = evaluate(amount)
enableSave = amount.isDoubleTwoDigitsOrEquation() && equationResult > 0 enableSave = amount.isDoubleTwoDigitsOrEquation() && equationResult > 0
}, },
onLongBackspaceClick = {
amount = "0.00"
equationResult = evaluate(amount)
enableSave = false
}
) )
SaveButton( SaveButton(
@@ -394,7 +401,13 @@ fun NoteInput(
@Composable @Composable
fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) { 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) Text(text)
} }
} }
@@ -402,18 +415,35 @@ fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: Str
@Composable @Composable
fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) { fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
Button( Button(
contentPadding = PaddingValues(0.dp),
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors() 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( 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), painter = painterResource(category.icon.resource),
contentDescription = stringResource(R.string.category), 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, modifier: Modifier = Modifier,
onNumberClick: (String) -> Unit, onNumberClick: (String) -> Unit,
onBackspaceClick: () -> Unit, onBackspaceClick: () -> Unit,
onOperatorClick: (String) -> Unit onOperatorClick: (String) -> Unit,
onLongBackspaceClick: () -> Unit,
) { ) {
Column( Column(
modifier = modifier, modifier = modifier,
@@ -455,22 +486,23 @@ fun NumberKeyboard(
onClick = onBackspaceClick, onClick = onBackspaceClick,
modifier = Modifier modifier = Modifier
.weight(1f), .weight(1f),
containerColor = MaterialTheme.colorScheme.primary containerColor = Color.Transparent,
onLongClick = onLongBackspaceClick
) )
"+", "/", "-", "*" -> KeyboardButton( "+", "/", "-", "*" -> KeyboardButton(
text = key, text = key,
onClick = { onOperatorClick(key) }, onClick = { onOperatorClick(key) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer contentColor = MaterialTheme.colorScheme.onTertiaryContainer
) )
else -> KeyboardButton( else -> KeyboardButton(
text = key, text = key,
onClick = { onNumberClick(key) }, onClick = { onNumberClick(key) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondary, containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSecondary contentColor = MaterialTheme.colorScheme.onSecondary
) )
} }
@@ -487,26 +519,24 @@ fun KeyboardButton(
icon: Painter? = null, icon: Painter? = null,
onClick: () -> Unit, onClick: () -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
onLongClick: () -> Unit = {},
containerColor: Color = MaterialTheme.colorScheme.primary, containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary contentColor: Color = MaterialTheme.colorScheme.onPrimary
) { ) {
Button( Box(
onClick = onClick, contentAlignment = Alignment.Center,
shape = MaterialTheme.shapes.medium,
modifier = modifier modifier = modifier
.padding(2.dp) .padding(2.dp)
.aspectRatio(2.5f), .aspectRatio(2.5f)
enabled = enabled, .combinedClickable(onClick = onClick, onLongClick = onLongClick)
colors = ButtonDefaults.buttonColors( .background(containerColor, shape = MaterialTheme.shapes.medium),
containerColor = containerColor,
contentColor = contentColor ) {
)
) {
when { when {
text != null -> Text( text != null -> Text(
text, text,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.headlineMedium
) )
icon != null -> Icon(painter = icon, contentDescription = null) icon != null -> Icon(painter = icon, contentDescription = null)
@@ -594,26 +624,31 @@ fun PreviewAddExpenseEnabled() {
val categoriesToPreview = listOf( val categoriesToPreview = listOf(
Category( Category(
1,
name = "Hotel", name = "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
color = colors.random() color = colors.random()
), ),
Category( Category(
2,
name = "Jedzenie", name = "Jedzenie",
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
color = colors.random() color = colors.random()
), ),
Category( Category(
3,
name = "Transport", name = "Transport",
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
color = colors.random() color = colors.random()
), ),
Category( Category(
4,
name = "Rozrywka", name = "Rozrywka",
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
color = colors.random() color = colors.random()
), ),
Category( Category(
5,
name = "Zakupy", name = "Zakupy",
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
color = colors.random() 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.R.*
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import com.composables.icons.materialsymbols.outlined.R import com.composables.icons.materialsymbols.outlined.R
@@ -37,7 +36,7 @@ fun CategorySelectionDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCategorySelected: (Category) -> Unit, onCategorySelected: (Category) -> Unit,
selected: Category, selected: Category,
categories: List<Category> categories: List<Category>,
) { ) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -91,7 +90,7 @@ fun CategorySelectionDialog(
contentDescription = stringResource(string.category) contentDescription = stringResource(string.category)
) )
Text( 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.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@@ -47,6 +46,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -58,33 +58,46 @@ 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.R.string 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.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet 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
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi
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 kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ListExpenseScreen() { fun ListExpenseScreen(filter: String) {
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 = expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId) val expensesFlow =
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
ListExpenseScreen( ListExpenseScreen(
expensesFlow = expensesFlow, expensesFlow = expensesFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) }, onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) }, onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
isRecalculatingRate = isRecalculatingRate
) )
} }
@@ -94,7 +107,8 @@ fun ListExpenseScreen() {
@Composable @Composable
fun ListExpenseScreen( fun ListExpenseScreen(
expensesFlow: Flow<PagingData<ExpenseListItemUi>>, expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
isRecalculatingRate: Boolean
) { ) {
val items = expensesFlow.collectAsLazyPagingItems() val items = expensesFlow.collectAsLazyPagingItems()
@@ -111,58 +125,52 @@ fun ListExpenseScreen(
) )
}) })
{ {
if (items.loadState.refresh == LoadState.Loading) { Box {
// Show loading indicator LazyColumn(
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { modifier = Modifier.fillMaxSize(),
CircularProgressIndicator() horizontalAlignment = Alignment.CenterHorizontally,
} state = listState
} ) {
else { items(
LazyColumn( count = items.itemCount,
modifier = Modifier.fillMaxSize(), key = items.itemKey { item ->
horizontalAlignment = Alignment.CenterHorizontally, when (item) {
state = listState is ExpenseListItemUi.Item -> item.expenseDto.expense.id
) { is ExpenseListItemUi.Header -> "header_${item.date}"
items(
count = items.itemCount,
key = items.itemKey { item ->
when (item) {
is ExpenseListItemUi.Item -> item.expenseDto.expense.id
is ExpenseListItemUi.Header -> "header_${item.date}"
}
} }
) { index -> }
) { index ->
when (val item = items[index]) { when (val item = items[index]) {
is ExpenseListItemUi.Header -> {
CustomDivider(
date = item.date,
sum = item.sum,
currency = item.currency
)
}
is ExpenseListItemUi.Item -> {
SwipeToDeleteExpenseCard(
expenseDto = item.expenseDto,
onDelete = { expense -> itemToDelete = expense },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
}
)
}
null -> {}
is ExpenseListItemUi.Header -> {
CustomDivider(
date = item.date,
sum = item.sum,
currency = item.currency
)
} }
Spacer(Modifier.height(10.dp))
is ExpenseListItemUi.Item -> {
SwipeToDeleteExpenseCard(
expenseDto = item.expenseDto,
onDelete = { expense -> itemToDelete = expense },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
}
)
}
null -> {}
} }
Spacer(Modifier.height(10.dp))
} }
} }
}
if (itemToDelete != null) { if (itemToDelete != null) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
@@ -208,7 +216,7 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
date.format( date.format(
DateTimeFormatter.ofPattern("dd EEEE") DateTimeFormatter.ofPattern("dd EEEE")
).toString(), ).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 style = MaterialTheme.typography.titleMedium
) )
Row( Row(
@@ -221,8 +229,14 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
HorizontalDivider(modifier = Modifier.weight(2f)) HorizontalDivider(modifier = Modifier.weight(2f))
Text( Text(
"%.2f %s".format(sum, currency), "%.2f %s".format(sum, currency),
modifier = Modifier.background(Color.White.copy(alpha = 0f)), modifier = Modifier
style = MaterialTheme.typography.bodyMedium .background(
MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.small
)
.padding(5.dp),
color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.bodySmall
) )
HorizontalDivider(modifier = Modifier.weight(1f)) HorizontalDivider(modifier = Modifier.weight(1f))
} }
@@ -256,7 +270,7 @@ fun SwipeToDeleteExpenseCard(
Modifier Modifier
.clip(CardDefaults.elevatedShape) .clip(CardDefaults.elevatedShape)
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.onError) .background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp), .padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd contentAlignment = Alignment.CenterEnd
) { ) {
@@ -329,7 +343,7 @@ fun ExpenseCard(
) { ) {
ElevatedCard( ElevatedCard(
colors = CardDefaults.elevatedCardColors() colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.secondaryContainer), .copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.height(70.dp) .height(70.dp)
@@ -344,14 +358,29 @@ fun ExpenseCard(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
//TODO
// .background(
// Brush.horizontalGradient(
// colorStops = arrayOf(
// 1f to Color(expenseDto.category.color.toColorInt()),
// 4f to MaterialTheme.colorScheme.surfaceDim
// )
// )
// )
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxHeight() modifier = Modifier.fillMaxHeight()
) { ) {
Icon( Icon(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceDim,
shape = MaterialTheme.shapes.small
)
.padding(10.dp),
painter = painterResource(expenseDto.category.icon.resource), painter = painterResource(expenseDto.category.icon.resource),
contentDescription = "Category", contentDescription = "Category",
tint = Color(expenseDto.category.color.toColorInt()) tint = Color(expenseDto.category.color.toColorInt())
@@ -367,13 +396,13 @@ fun ExpenseCard(
Text( Text(
text = expenseDto.category.name, text = expenseDto.category.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
modifier = Modifier.padding(0.dp), modifier = Modifier.padding(0.dp),
text = expenseDto.expense.note, text = expenseDto.expense.note,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSurface
) )
} }
@@ -382,7 +411,7 @@ fun ExpenseCard(
DateTimeFormatter.ofPattern("dd MMM HH:mm") DateTimeFormatter.ofPattern("dd MMM HH:mm")
), ),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@@ -390,7 +419,7 @@ fun ExpenseCard(
Text( Text(
text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount), text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSurface
) )
@@ -398,7 +427,7 @@ fun ExpenseCard(
Text( Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()), text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSurface
) )
} }
@@ -407,107 +436,125 @@ fun ExpenseCard(
} }
} }
//@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
//@AllPreviews @AllPreviews
//@Composable @Composable
//fun PreviewListExpenseScreen() { fun PreviewListExpenseScreen() {
// TripMoneyTheme() { TripMoneyTheme() {
// val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList()) val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
// ListExpenseScreen( ListExpenseScreen(
// expensesDtoFlow = MutableStateFlow(pagingData), expensesFlow = MutableStateFlow(pagingData),
// onSaveExpense = {}, onSaveExpense = {},
// onDeleteExpense = {}, onDeleteExpense = {},
// dailySums = emptyMap() true
// ) )
//
// } }
//} }
//
//@AllPreviews @AllPreviews
//@Composable @Composable
//fun PreviewDeleteConfirmationDialog() { fun PreviewDeleteConfirmationDialog() {
// TripMoneyTheme() { TripMoneyTheme() {
// DeleteConfirmationDialog( DeleteConfirmationDialog(
// onConfirm = {}, onConfirm = {},
// onCancel = {}) onCancel = {})
// } }
//} }
//
//
//@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
//private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseDto> { private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseListItemUi> {
// val sampleCategories = listOf( val sampleCategories = listOf(
// Category( Category(
// name = "Hotel", name = "Hotel",
// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
// color = colors.random() color = colors.random()
// ), ),
// Category( Category(
// name = "Jedzenie", name = "Jedzenie",
// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
// color = colors.random() color = colors.random()
// ), ),
// Category( Category(
// name = "Transport", name = "Transport",
// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
// color = colors.random() color = colors.random()
// ), ),
// Category( Category(
// name = "Rozrywka", name = "Rozrywka",
// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
// color = colors.random() color = colors.random()
// ), ),
// Category( Category(
// name = "Zakupy", name = "Zakupy",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = colors.random() color = colors.random()
// ), ),
// ) )
//
// val trip = Trip( val trip = Trip(
// id = 1, id = 1,
// name = "Vacation", name = "Vacation",
// currency = "USD", currency = "USD",
// startDate = LocalDate.parse("2026-01-01") startDate = LocalDate.parse("2026-01-01")
// ) )
//
// val startLong = LocalDateTime.now().minusDays(10).toEpochMilli() val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
// val endLong = LocalDateTime.now().toEpochMilli() val endLong = LocalDateTime.now().toEpochMilli()
//
// val result: MutableList<ExpenseDto> = mutableListOf() val result: MutableList<ExpenseListItemUi> = mutableListOf()
// for (i in 0..15) { result.add(
// val category = sampleCategories.random() ExpenseListItemUi.Header(
// val datetime = if (i > 4) { LocalDateTime.ofEpochSecond(
// LocalDateTime.ofEpochSecond( Random.nextLong(startLong, endLong),
// Random.nextLong(startLong, endLong), 0,
// 0, ZoneOffset.UTC
// ZoneOffset.UTC ).toLocalDate(), Random.nextDouble(0.1, 300.0), Currencies.entries.random().name
// ) )
// } else LocalDateTime.now() )
// for (i in 0..15) {
// val expense = Expense( val category = sampleCategories.random()
// id = i, val datetime = if (i > 4) {
// categoryId = category.id, LocalDateTime.ofEpochSecond(
// tripId = 1, Random.nextLong(startLong, endLong),
// amount = Random.nextDouble(0.1, 300.0), 0,
// currency = Currencies.entries.random().name, ZoneOffset.UTC
// note = if (i % 3 == 0) "Some note" else "", )
// datetime = datetime, } else LocalDateTime.now()
// rate = if (Random.nextBoolean()) Random.nextDouble(
// 0.1, val expense = Expense(
// 5.0 id = i,
// ) else 1.0 categoryId = category.id,
// ) tripId = 1,
// amount = Random.nextDouble(0.1, 300.0),
// currency = Currencies.entries.random().name,
// val expenseDto = ExpenseDto( note = if (i % 3 == 0) "Some note" else "",
// expense = expense, datetime = datetime,
// category = category, rate = if (Random.nextBoolean()) Random.nextDouble(
// trip = trip 0.1,
// ) 5.0
// result.add( ) else 1.0
// expenseDto )
// )
// }
// return result 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.compose.ui.unit.sp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.R.*
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.AppTheme 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.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.statistics.categories
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 cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
@@ -60,7 +66,7 @@ import java.nio.file.Files
@RequiresApi(Build.VERSION_CODES.S) @RequiresApi(Build.VERSION_CODES.S)
@Composable @Composable
fun SettingsScreen() { fun SettingsScreen(navController: NavHostController) {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState() val currentTheme by settingsViewModel.theme.collectAsState()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState() val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
@@ -68,6 +74,7 @@ fun SettingsScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY) val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
val context = LocalContext.current val context = LocalContext.current
val tripName = currentTrip?.name ?: "" val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -90,7 +97,8 @@ fun SettingsScreen() {
e.printStackTrace() e.printStackTrace()
} }
} }
} },
onCategoriesClick = {navController.navigate(Screens.MANAGE_CATEGORIES)}
) )
} }
@@ -103,11 +111,13 @@ fun SettingsScreen(
onCurrencySave: (Currencies) -> Unit, onCurrencySave: (Currencies) -> Unit,
tripName: String, tripName: String,
onExportToCsv: () -> Unit, onExportToCsv: () -> Unit,
onCategoriesClick: () -> Unit
) { ) {
Scaffold { padding -> Scaffold { padding ->
var showThemeDialog by remember { mutableStateOf(false) } var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) } var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoriesDialog by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -142,9 +152,15 @@ fun SettingsScreen(
SettingsListItem( SettingsListItem(
onClick = onExportToCsv, onClick = onExportToCsv,
stringResource(string.export_to_csv), 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 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) { if (showThemeDialog) {
ThemeSelectionDialog( ThemeSelectionDialog(
@@ -258,7 +274,14 @@ fun ThemeSelectionDialog(
@Composable @Composable
fun PreviewSettingsScreen() { fun PreviewSettingsScreen() {
TripMoneyTheme { 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 package cc.n0th1ng.tripmoney.screens.statistics
import android.annotation.SuppressLint
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.background 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Card 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -30,6 +37,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
@@ -82,9 +90,10 @@ fun StatisticsScreen(
@Composable @Composable
fun Summary(summaryAmount: Double, currency: String) { fun Summary(summaryAmount: Double, currency: String) {
Card( ElevatedCard(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -94,7 +103,10 @@ fun Summary(summaryAmount: Double, currency: String) {
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column { 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( Text(
"%.2f %s".format(summaryAmount, currency), "%.2f %s".format(summaryAmount, currency),
style = MaterialTheme.typography.headlineLarge style = MaterialTheme.typography.headlineLarge
@@ -118,7 +130,11 @@ fun Summary(summaryAmount: Double, currency: String) {
@Composable @Composable
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) { fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
Card(modifier = Modifier.fillMaxWidth()) { ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Column( Column(
modifier = Modifier.padding(15.dp), modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(5.dp) verticalArrangement = Arrangement.spacedBy(5.dp)
@@ -191,16 +207,19 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
} }
} }
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@AllPreviews @AllPreviews
@Composable @Composable
fun Preview() { fun Preview() {
TripMoneyTheme { TripMoneyTheme {
StatisticsScreen( Scaffold {
summaryPerCategoryList, StatisticsScreen(
summaryAmount = 125.24, summaryPerCategoryList,
Currencies.entries.random() summaryAmount = 125.24,
) Currencies.entries.random()
)
}
} }
} }

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -50,16 +48,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.R.string import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.Screens import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.screens.listexpense.DeleteConfirmationDialog 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.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@@ -70,9 +73,38 @@ fun TripPickerScreen(
) { ) {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel()
var showBottomSheet by remember { mutableStateOf(false) } val tripsFlow = tripViewModel.getTrips()
val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems()
val currentTripId by settingsViewModel.currentTrip.collectAsState() 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) } var tripToEdit by remember { mutableStateOf<Trip?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = { Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
FloatingActionButton( FloatingActionButton(
@@ -91,12 +123,12 @@ fun TripPickerScreen(
val trip = trips[i] val trip = trips[i]
if (trip != null) { if (trip != null) {
SwipeToDeleteTripCard( SwipeToDeleteTripCard(
trip, onDelete = { trip = trip,
tripViewModel.delete(trip) onDelete = {
}, onClick = { onDelete(trip)
settingsViewModel.setCurrentTrip(trip.id) }, onClick = {
navController.navigate(Screens.LIST_EXPENSE) onClick(trip)
}, isSelected = currentTripId == trip.id, }, isSelected = currentTripId == trip.id,
onLongClick = { trip -> onLongClick = { trip ->
tripToEdit = trip tripToEdit = trip
showBottomSheet = true showBottomSheet = true
@@ -113,7 +145,7 @@ fun TripPickerScreen(
tripToEdit = null tripToEdit = null
}, },
onSave = { trip -> onSave = { trip ->
tripViewModel.save(trip) onSave(trip)
showBottomSheet = false showBottomSheet = false
tripToEdit = null tripToEdit = null
}, },
@@ -152,7 +184,6 @@ fun SwipeToDeleteTripCard(
} }
SwipeToDismissBox( SwipeToDismissBox(
modifier = Modifier.alpha(if (isSelected) 1.0f else 0.7f),
state = dismissState, state = dismissState,
enableDismissFromStartToEnd = false, enableDismissFromStartToEnd = false,
backgroundContent = { backgroundContent = {
@@ -160,7 +191,7 @@ fun SwipeToDeleteTripCard(
Modifier Modifier
.clip(CardDefaults.elevatedShape) .clip(CardDefaults.elevatedShape)
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.onError) .background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp), .padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd contentAlignment = Alignment.CenterEnd
) { ) {
@@ -186,7 +217,7 @@ fun TripCard(
containerColor = if (isSelected) { containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.secondary MaterialTheme.colorScheme.surfaceContainer
} }
), ),
modifier = Modifier modifier = Modifier
@@ -195,7 +226,7 @@ fun TripCard(
haptics.performHapticFeedback(HapticFeedbackType.LongPress) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(trip) onLongClick(trip)
}, onClick = { onClick(trip) }), }, onClick = { onClick(trip) }),
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp) elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) { ) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -216,4 +247,39 @@ 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 private val tripRepo: TripRepository
) : ViewModel() { ) : ViewModel() {
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> = fun archiveCategory(category: Category) {
expenseRepo.getExpensesDtoPaged(tripId).cachedIn(viewModelScope) 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) @RequiresApi(Build.VERSION_CODES.O)
fun getExpensesWithHeadersPaged( fun getExpensesWithHeadersPaged(
tripId: Int tripId: Int,
filter: String = ""
): Flow<PagingData<ExpenseListItemUi>> { ): Flow<PagingData<ExpenseListItemUi>> {
val pagingFlow = getExpensesDtoPaged(tripId) val pagingFlow = getExpensesDtoPaged(tripId, filter)
val sumsFlow = getDailySums(tripId) val sumsFlow = getDailySums(tripId, 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 ?: ""
@@ -80,8 +93,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
} }
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> = fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> =
expenseRepo.getExpensesDto(tripId) expenseRepo.getExpensesDto(tripId, filter)
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun save(expense: Expense, trip: Trip) { fun save(expense: Expense, trip: Trip) {
@@ -96,14 +109,20 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
} }
fun delete(expense: Expense) { fun delete(expense: Expense) {
viewModelScope.launch { viewModelScope.launch {
expenseRepo.delete(expense) expenseRepo.delete(expense)
} }
} }
fun delete(category: Category) {
viewModelScope.launch {
categoryRepo.delete(category)
}
}
fun getCategories(): Flow<List<Category>> = categoryRepo.getCategories() fun getCategories(): Flow<List<Category>> = categoryRepo.getCategories()
fun getArchivedCategories(): Flow<List<Category>> = categoryRepo.getArchivedCategories()
fun save(category: Category) { fun save(category: Category) {
viewModelScope.launch { viewModelScope.launch {
@@ -132,8 +151,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun getDailySums(tripId: Int): Flow<Map<LocalDate, Double>> { fun getDailySums(tripId: Int, filter: String): Flow<Map<LocalDate, Double>> {
return getExpensesDto(tripId) return getExpensesDto(tripId, filter)
.map { expenses -> .map { expenses ->
expenses.groupBy { it.expense.datetime.toLocalDate() } expenses.groupBy { it.expense.datetime.toLocalDate() }
.mapValues { (_, list) -> .mapValues { (_, list) ->
@@ -182,7 +201,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
sealed class ExpenseListItemUi { sealed class ExpenseListItemUi {
data class Item(val expenseDto: ExpenseDto) : 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 package cc.n0th1ng.tripmoney.viewmodel
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
import cc.n0th1ng.tripmoney.data.repository.TripRepository import cc.n0th1ng.tripmoney.data.repository.TripRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @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 getTrips(): Flow<PagingData<Trip>> = repository.getTrips().cachedIn(viewModelScope)
fun getTrip(tripId: Int): Flow<Trip?> = repository.getTrip(tripId) 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) { fun save(trip: Trip) {
viewModelScope.launch { viewModelScope.launch {
repository.save(trip) 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="save">Zapisz</string>
<string name="backspace">Usuń</string> <string name="backspace">Usuń</string>
<string name="pick_category">Wybierz kategorie</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="pick_currency">Wybierz kategorie</string>
<string name="cancel">Anuluj</string> <string name="cancel">Anuluj</string>
<string name="add_expense">Dodaj wydatek</string> <string name="add_expense">Dodaj wydatek</string>
@@ -27,4 +27,9 @@
<string name="list_of_expenses">Lista wydatków</string> <string name="list_of_expenses">Lista wydatków</string>
<string name="statistics">Statystyki</string> <string name="statistics">Statystyki</string>
<string name="export_to_csv">Eksport do CSV</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> </resources>

View File

@@ -5,7 +5,7 @@
<string name="save">Save</string> <string name="save">Save</string>
<string name="backspace">Backspace</string> <string name="backspace">Backspace</string>
<string name="pick_category">Pick a category</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="pick_currency">Pick a currency</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="add_expense">Add expense</string> <string name="add_expense">Add expense</string>
@@ -27,4 +27,13 @@
<string name="list_of_expenses">List of expenses</string> <string name="list_of_expenses">List of expenses</string>
<string name="statistics">Statistics</string> <string name="statistics">Statistics</string>
<string name="export_to_csv">Export to CSV</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> </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.devtools.ksp") version "2.2.21-2.0.5" apply false
id("com.google.dagger.hilt.android") version "2.57.1" 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

@@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the # Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
org.gradle.configuration-cache=true

View File

@@ -10,6 +10,10 @@ activityCompose = "1.8.0"
composeBom = "2024.09.00" composeBom = "2024.09.00"
navigationCompose = "2.9.7" navigationCompose = "2.9.7"
foundationLayout = "1.10.5" foundationLayout = "1.10.5"
uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.2.4"
baselineprofile = "1.2.4"
profileinstaller = "1.3.1"
[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" }
@@ -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-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } 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-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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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" rootProject.name = "tripMoney"
include(":app") include(":app")
include(":baselineprofile")