diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c485d7d..bf3193c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.compose) id("com.google.devtools.ksp") id("com.google.dagger.hilt.android") + alias(libs.plugins.baselineprofile) } android { @@ -22,7 +23,8 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -54,11 +56,13 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.profileinstaller) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + "baselineProfile"(project(":baselineprofile")) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/MainActivity.kt b/app/src/main/java/cc/n0th1ng/tripmoney/MainActivity.kt index 7cc20c8..db70c55 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/MainActivity.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/MainActivity.kt @@ -13,13 +13,19 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.paging.compose.collectAsLazyPagingItems import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.navigation.BottomNavigation import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer @@ -27,6 +33,7 @@ import cc.n0th1ng.tripmoney.navigation.Screens import cc.n0th1ng.tripmoney.navigation.TopBar import cc.n0th1ng.tripmoney.navigation.TopBarSettings import cc.n0th1ng.tripmoney.screens.listexpense.ListExpenseScreen +import cc.n0th1ng.tripmoney.screens.managecategories.ManageCategoriesScreen import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen @@ -40,21 +47,25 @@ import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { - @RequiresApi(Build.VERSION_CODES.O) + @RequiresApi(Build.VERSION_CODES.S) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { TripMoneyTheme { + val settingsViewModel: SettingsViewModel = hiltViewModel() + val currentTripId by settingsViewModel.currentTrip.collectAsState() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() expenseAndCategoryViewModel.clearOldRates() + expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId) + .collectAsLazyPagingItems() NavigationDrawer() } } } } -@RequiresApi(Build.VERSION_CODES.O) +@RequiresApi(Build.VERSION_CODES.S) @Composable fun NavigationDrawer() { val settingsViewModel: SettingsViewModel = hiltViewModel() @@ -66,33 +77,39 @@ fun NavigationDrawer() { val current = navBackStack?.destination?.route val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() + var filter by remember { mutableStateOf("") } CustomNavigationDrawer(navController, drawerState) { Scaffold( + modifier = Modifier.semantics { + testTagsAsResourceId = true + }, topBar = { if (current == Screens.SETTINGS) TopBarSettings( navController ) else TopBar( title = currentTrip?.name ?: "", - onClick = { - scope.launch { - if (drawerState.isClosed) { - drawerState.open() - } else { - drawerState.close() + onDrawerClick = { + scope.launch { + if (drawerState.isClosed) { + drawerState.open() + } else { + drawerState.close() + } } - } - }) + }, + isSearchable = current == Screens.LIST_EXPENSE, + onFilterChange = { newFilter -> filter = newFilter}) }, bottomBar = { BottomNavigation(navController) }) { innerPadding -> NavHost( navController = navController, - startDestination = if(currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE, + startDestination = if (currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE, modifier = Modifier.padding(innerPadding) ) { composable(Screens.LIST_EXPENSE) { - ListExpenseScreen() + ListExpenseScreen(filter) } composable(Screens.TRIP_PICKER) { TripPickerScreen(navController) @@ -101,7 +118,10 @@ fun NavigationDrawer() { StatisticsScreen() } composable(Screens.SETTINGS) { - SettingsScreen() + SettingsScreen(navController) + } + composable(Screens.MANAGE_CATEGORIES) { + ManageCategoriesScreen() } } } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/TripDatabase.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/TripDatabase.kt index bc99cb8..597409c 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/TripDatabase.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/TripDatabase.kt @@ -7,7 +7,6 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters -import androidx.sqlite.db.SupportSQLiteDatabase import cc.n0th1ng.tripmoney.data.dao.CategoryDao import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao import cc.n0th1ng.tripmoney.data.dao.ExpenseDao @@ -15,24 +14,34 @@ import cc.n0th1ng.tripmoney.data.dao.TripDao import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.ExchangeRate import cc.n0th1ng.tripmoney.data.entity.Expense +import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.Trip +import cc.n0th1ng.tripmoney.screens.listexpense.toEpochMilli +import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.colors -import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Delay import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime -import javax.inject.Inject +import java.time.ZoneOffset import javax.inject.Singleton +import kotlin.random.Random +import kotlin.random.nextInt -@Database(entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], version = 1) +@Database( + entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], + version = 1 +) @TypeConverters(Converters::class) abstract class TripDatabase : RoomDatabase() { abstract fun tripDao(): TripDao @@ -46,21 +55,28 @@ abstract class TripDatabase : RoomDatabase() { @InstallIn(SingletonComponent::class) object DatabaseModule { + @RequiresApi(Build.VERSION_CODES.O) @Provides @Singleton fun provideTripDatabase( - @ApplicationContext context: Context, -// expenseAndCategoryViewModel: ExpenseAndCategoryViewModel + @ApplicationContext context: Context ): TripDatabase { - val db: TripDatabase = Room.inMemoryDatabaseBuilder( - context, TripDatabase::class.java - ).allowMainThreadQueries().build() +// val db: TripDatabase = Room.databaseBuilder( +// name = "tripmoney_db", + context = context, + klass = TripDatabase::class.java, + ) + .allowMainThreadQueries() // TODO Remove in production! + .fallbackToDestructiveMigration() // TODO Handle schema changes during dev + .build() CoroutineScope(Dispatchers.IO).launch { DatabasePrepopulator( - tripDao = db.tripDao(), categoryDao = db.categoryDao(), expenseDao = db.expenseDao() + tripDao = db.tripDao(), + categoryDao = db.categoryDao(), + expenseDao = db.expenseDao() ).prepopulate() } return db @@ -99,169 +115,92 @@ private class DatabasePrepopulator( ) { @RequiresApi(Build.VERSION_CODES.O) suspend fun prepopulate() { - tripDao.insert(Trip(name = "Włochy", startDate = LocalDate.parse("2026-03-01"), currency = "PLN")) - tripDao.insert(Trip(name = "Szwajcaria", startDate =LocalDate.parse("2025-03-01"), currency = "EUR")) - tripDao.insert(Trip(name = "Portugalia", startDate = LocalDate.parse("2025-03-01"), currency = "USD")) - categoryDao.insert(Category(name = "Accomodation", icon = Icons.HOTEL, color = colors.random())) - categoryDao.insert(Category(name = "Transport", icon = Icons.TRANSPORT, color = colors.random())) - categoryDao.insert(Category(name = "Flight", icon = Icons.FLIGHT, color = colors.random())) - categoryDao.insert(Category(name = "Restaurants", icon = Icons.RESTAURANT, color = colors.random())) - categoryDao.insert(Category(name = "Groceries", icon = Icons.GROCERIES, color = colors.random())) - categoryDao.insert(Category(name = "Coffee", icon = Icons.COFFEE,color = colors.random())) - categoryDao.insert(Category(name = "Entertainment", icon = Icons.ENTERTAINMENT,color = colors.random())) - categoryDao.insert(Category(name = "Laundry", icon = Icons.LAUNDRY,color = colors.random())) + + tripDao.insert( + Trip( + name = "Włochy", + startDate = LocalDate.parse("2026-03-01"), + currency = "PLN" + ) + ) + tripDao.insert( + Trip( + name = "Szwajcaria", + startDate = LocalDate.parse("2025-03-01"), + currency = "EUR" + ) + ) + tripDao.insert( + Trip( + name = "Portugalia", + startDate = LocalDate.parse("2025-03-01"), + currency = "USD" + ) + ) + for (category in sampleCategories) { + categoryDao.insert(category) + } + for (expense in sampleExpenses) { + expenseDao.insert(expense) + } - val 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/CategoryDao.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/CategoryDao.kt index dea2912..a8057f4 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/CategoryDao.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/CategoryDao.kt @@ -1,6 +1,7 @@ package cc.n0th1ng.tripmoney.data.dao import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction @@ -14,12 +15,23 @@ interface CategoryDao { suspend fun insert(category: Category) + @Delete + suspend fun delete(category: Category) + @Transaction @Query( """ - SELECT * FROM category + SELECT * FROM category WHERE archived is 0 """ ) fun categories(): Flow> + @Transaction + @Query( + """ + SELECT * FROM category WHERE archived is 1 + """ + ) + fun archivedCategories(): Flow> + } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/ExpenseDao.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/ExpenseDao.kt index 1b803b8..7368c89 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/ExpenseDao.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/ExpenseDao.kt @@ -16,22 +16,38 @@ interface ExpenseDao { @Upsert suspend fun insert(expense: Expense) + @Query( """ - SELECT * FROM expense WHERE trip_id = :tripId + SELECT expense.*, category.* + FROM expense + JOIN category ON expense.category_id = category.id + WHERE expense.trip_id = :tripId + AND + ( + (:filter IS NULL OR category.name LIKE '%' || :filter || '%') + OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%') + ) ORDER BY expense.datetime DESC - """ + """ ) - fun expenseDtoPaged(tripId: Int): PagingSource + fun expenseDtoPaged(tripId: Int, filter: String): PagingSource @Transaction @Query( """ - SELECT * FROM expense WHERE trip_id = :tripId + SELECT * FROM expense + JOIN category ON expense.category_id = category.id + WHERE trip_id = :tripId + AND + ( + (:filter IS NULL OR category.name LIKE '%' || :filter || '%') + OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%') + ) ORDER BY expense.datetime DESC """ ) - fun expenseDto(tripId: Int): Flow> + fun expenseDto(tripId: Int, filter: String): Flow> @Delete suspend fun delete(expense: Expense) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/TripDao.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/TripDao.kt index cbd5c7e..3c95cf7 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/TripDao.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/TripDao.kt @@ -17,7 +17,7 @@ interface TripDao { @Query( """ SELECT * FROM trip - ORDER BY DATE(trip.start_date) DESC + ORDER BY trip.start_date DESC """ ) fun tripsPaged(): PagingSource diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Category.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Category.kt index a5f0bd8..a73efe1 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Category.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Category.kt @@ -1,14 +1,17 @@ package cc.n0th1ng.tripmoney.data.entity +import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import cc.n0th1ng.tripmoney.utils.Icons @Entity(tableName = "category") +@Immutable data class Category( @PrimaryKey(autoGenerate = true) val id: Int = 0, @ColumnInfo("name") val name: String, @ColumnInfo("icon") val icon: Icons, - @ColumnInfo("color") val color: String + @ColumnInfo("color") val color: String, + @ColumnInfo("archived") val archived: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Expense.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Expense.kt index e330fb4..c4894ab 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Expense.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Expense.kt @@ -1,13 +1,25 @@ package cc.n0th1ng.tripmoney.data.entity +import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.PrimaryKey import androidx.room.Relation import java.time.LocalDateTime -@Entity(tableName = "expense") +@Entity( + tableName = "expense", + foreignKeys = [ForeignKey( + entity = Category::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("category_id"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + )] +) +@Immutable data class Expense( @PrimaryKey(autoGenerate = true) val id: Int = 0, @ColumnInfo("amount") val amount: Double, diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Trip.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Trip.kt index c211c5a..2efc7c4 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Trip.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Trip.kt @@ -2,6 +2,7 @@ package cc.n0th1ng.tripmoney.data.entity import android.os.Build import androidx.annotation.RequiresApi +import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -9,14 +10,16 @@ import cc.n0th1ng.tripmoney.utils.Currencies import java.time.LocalDate @Entity(tableName = "trip") +@Immutable data class Trip( @PrimaryKey(autoGenerate = true) val id: Int = 0, @ColumnInfo("name") val name: String, @ColumnInfo("start_date") val startDate: LocalDate, - @ColumnInfo("currency") val currency: String + @ColumnInfo("currency") val currency: String, + @ColumnInfo("budget") val budget: Double ){ companion object { @RequiresApi(Build.VERSION_CODES.O) - val DUMMY = Trip(-1, "dummy", LocalDate.now(), Currencies.default().name) + val DUMMY = Trip(-1, "", LocalDate.now(), Currencies.default().name, budget = 0.0) } } \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/CategoryRepository.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/CategoryRepository.kt index b16f5ca..785175e 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/CategoryRepository.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/CategoryRepository.kt @@ -13,7 +13,16 @@ class CategoryRepository @Inject constructor(private val categoryDao: CategoryDa categoryDao.insert(category) } + @WorkerThread + suspend fun delete(category: Category) { + categoryDao.delete(category) + } + fun getCategories(): Flow> { return categoryDao.categories() } + + fun getArchivedCategories(): Flow> { + return categoryDao.archivedCategories() + } } \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExchangeRateRepository.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExchangeRateRepository.kt index a4a62a0..82f16d2 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExchangeRateRepository.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExchangeRateRepository.kt @@ -45,7 +45,6 @@ class ExchangeRateRepository @Inject constructor( } } - @RequiresApi(Build.VERSION_CODES.O) suspend fun clearOldRates(daysToKeep: Int = 180) { val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString() diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExpenseRepository.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExpenseRepository.kt index 2625cf1..7e259c9 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExpenseRepository.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExpenseRepository.kt @@ -12,8 +12,11 @@ import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.utils.Currencies +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -32,15 +35,15 @@ class ExpenseRepository @Inject constructor( expenseDao.delete(expense) } - fun getExpensesDtoPaged(tripId: Int): Flow> { + fun getExpensesDtoPaged(tripId: Int, filter: String): Flow> { return Pager( config = PagingConfig(pageSize = 50, enablePlaceholders = false), - pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) } + pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId, filter) } ).flow } - fun getExpensesDto(tripId: Int): Flow> { - return expenseDao.expenseDto(tripId) + fun getExpensesDto(tripId: Int, filter: String = ""): Flow> { + return expenseDao.expenseDto(tripId, filter) } @RequiresApi(Build.VERSION_CODES.O) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/TripRepository.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/TripRepository.kt index 70b29ae..1ff0370 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/TripRepository.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/TripRepository.kt @@ -9,7 +9,10 @@ import androidx.paging.PagingData import cc.n0th1ng.tripmoney.data.dao.TripDao import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import javax.inject.Inject class TripRepository @Inject constructor( @@ -18,9 +21,7 @@ class TripRepository @Inject constructor( ) { @RequiresApi(Build.VERSION_CODES.O) - @WorkerThread suspend fun save(trip: Trip) { - expenseRepository.recalculateTripExpenses(trip.id) tripDao.insert(trip) } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/BottomNavigation.kt b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/BottomNavigation.kt index 7c05e28..2c5e83a 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/BottomNavigation.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/BottomNavigation.kt @@ -7,6 +7,8 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState @@ -37,7 +39,7 @@ fun BottomNavigation(navController: NavController) { painter = painterResource( R.drawable.materialsymbols_ic_list_outlined, ), - null + "list screen" ) } ) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/CustomNavigationDrawer.kt b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/CustomNavigationDrawer.kt index 3ff77a1..b778602 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/CustomNavigationDrawer.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/CustomNavigationDrawer.kt @@ -100,4 +100,5 @@ object Screens { const val TRIP_PICKER = "trip_picker" const val STATISTICS = "statistics" const val SETTINGS = "settings" + const val MANAGE_CATEGORIES = "manage_categories" } \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/TopBar.kt b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/TopBar.kt index fe8eb9e..dc2ae7c 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/TopBar.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/TopBar.kt @@ -1,27 +1,130 @@ package cc.n0th1ng.tripmoney.navigation +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import cc.n0th1ng.tripmoney.R +import cc.n0th1ng.tripmoney.theme.TripMoneyTheme +import cc.n0th1ng.tripmoney.utils.AllPreviews +import com.composables.icons.materialsymbols.outlined.R.drawable +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TopBar(onClick: () -> Unit, title: String = "") { +fun TopBar( + onDrawerClick: () -> Unit, + title: String = "", + isSearchable: Boolean = false, + onFilterChange: (String) -> Unit +) { + var isSearch by remember { mutableStateOf(false) } + var value by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } TopAppBar( - title = { Text(title) }, + title = { + if (isSearch && isSearchable) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + OutlinedTextField( + textStyle = MaterialTheme.typography.bodyMedium, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(0.9f).focusRequester(focusRequester), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent + ), + value = value, + onValueChange = { newText -> + value = newText + }, + singleLine = true, + trailingIcon = { + Icon( + modifier = Modifier.clickable(onClick = { + isSearch = false + value = "" + onFilterChange("") + }), + imageVector = Icons.Default.Close, + contentDescription = null + ) + } + ) + LaunchedEffect(key1 = value) { + delay(1000) + onFilterChange(value) + } + } else { + Text(title) + } + }, navigationIcon = { - IconButton(onClick = { onClick() }) { + IconButton(onClick = { onDrawerClick() }) { Icon(Icons.Default.Menu, contentDescription = "Menu") } + }, + actions = { + if (!isSearch && isSearchable) { + Row( + modifier = Modifier.padding(end = 13.dp), + horizontalArrangement = Arrangement.spacedBy(15.dp) + ) { + Icon( + tint = MaterialTheme.colorScheme.primary, + painter = painterResource(drawable.materialsymbols_ic_filter_alt_outlined), + contentDescription = null, + modifier = Modifier.clickable(onClick = {}) + ) + Icon( + tint = MaterialTheme.colorScheme.primary, + painter = painterResource(drawable.materialsymbols_ic_search_outlined), + contentDescription = null, + modifier = Modifier.clickable(onClick = { + isSearch = true + }) + ) + + } + } + } ) } @@ -39,4 +142,16 @@ fun TopBarSettings(navController: NavHostController) { } } ) -} \ No newline at end of file +} + +@AllPreviews +@Composable +fun PreviewTopBar() { + TripMoneyTheme { + TopBar( + onDrawerClick = {}, + title = "Essa", + onFilterChange = {} + ) + } +} diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/AddCetegoryDialog.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/AddCetegoryDialog.kt index 34a285c..de2e0a1 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/AddCetegoryDialog.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/AddCetegoryDialog.kt @@ -1,5 +1,8 @@ package cc.n0th1ng.tripmoney.screens +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -8,14 +11,17 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -27,44 +33,68 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.graphics.toColorInt +import cc.n0th1ng.tripmoney.R import cc.n0th1ng.tripmoney.data.entity.Category +import cc.n0th1ng.tripmoney.theme.TripMoneyTheme +import cc.n0th1ng.tripmoney.utils.AllPreviews import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.colors @Composable -fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) { - var name by remember { mutableStateOf("") } - var icon by remember { mutableStateOf(Icons.entries[0]) } - var color by remember { mutableStateOf(colors[0]) } +fun AddCategoryDialog( + onDismiss: () -> Unit, + onSave: (Category) -> Unit, + categoryToEdit: Category? = null +) { + var name by remember { mutableStateOf(categoryToEdit?.name ?: "") } + var icon by remember { mutableStateOf(categoryToEdit?.icon ?: Icons.entries[0]) } + var color by remember { mutableStateOf(categoryToEdit?.color ?: colors[0]) } + var isArchived by remember { mutableStateOf(categoryToEdit?.archived ?: false) } AlertDialog( - onDismissRequest = onDismiss, title = { Text("Add new category") }, text = { + onDismissRequest = onDismiss, + title = { Text(stringResource(if (categoryToEdit == null) R.string.add_new_category else R.string.edit_category)) }, + text = { AlertDialogFill( onTextChange = { newText -> name = newText }, onIconChange = { newIcon -> icon = newIcon }, - onColorChange = { newColor -> color = newColor } + onColorChange = { newColor -> color = newColor }, + onArchivedChange = { newArchived -> + isArchived = newArchived + }, + name = name, + icon = icon, + color = color, + isArchived = isArchived ) - }, confirmButton = { + }, + confirmButton = { Button( enabled = !name.isEmpty(), onClick = { - onSave( - Category( - name = name, - icon = icon, - color = color - ) + val categoryToSave = Category( + name = name, + icon = icon, + color = color, + archived = isArchived ) - }) { Text("Save") } + onSave( + if (categoryToEdit != null) categoryToSave.copy(id = categoryToEdit.id) else categoryToSave + ) + }) { Text(stringResource(R.string.save)) } }, dismissButton = { - Button( - colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error), - onClick = onDismiss - ) { Text("close") } + Row() { + Button( + colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.secondary), + onClick = onDismiss + ) { Text(stringResource(R.string.cancel)) } + } + }) } @@ -72,24 +102,26 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) { fun AlertDialogFill( onTextChange: (String) -> Unit, onIconChange: (Icons) -> Unit, - onColorChange: (String) -> Unit + onColorChange: (String) -> Unit, + onArchivedChange: (Boolean) -> Unit, + name: String, + icon: Icons, + color: String, + isArchived: Boolean ) { - var text by remember { mutableStateOf("") } - var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) } - var colorHex by remember { mutableStateOf(colors[0]) } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Icon( modifier = Modifier.size(30.dp), - painter = painterResource(iconId), contentDescription = null, - tint = Color(colorHex.toColorInt()) + painter = painterResource(icon.resource), contentDescription = null, + tint = Color(color.toColorInt()) ) - OutlinedTextField(label = { Text("Name") }, value = text, onValueChange = { newText -> - text = newText - onTextChange(text) + OutlinedTextField(label = { Text("Name") }, value = name, onValueChange = { newText -> + onTextChange(newText) }) } @@ -104,7 +136,6 @@ fun AlertDialogFill( modifier = Modifier .size(30.dp) .clickable(onClick = { - iconId = icon.resource onIconChange(icon) }), painter = painterResource(icon.resource), @@ -123,8 +154,7 @@ fun AlertDialogFill( Box( modifier = Modifier .clickable(onClick = { - colorHex = color - onColorChange(colorHex) + onColorChange(color) }) .size(30.dp) .aspectRatio(1f) @@ -132,5 +162,49 @@ fun AlertDialogFill( ) {} } } + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = isArchived, + onCheckedChange = onArchivedChange + ) + Text( + text = "Archived", + style = MaterialTheme.typography.titleMedium + ) + + } + + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@AllPreviews +@Composable +fun PreviewAddCategoryDialog() { + TripMoneyTheme { + AddCategoryDialog( + onDismiss = {}, + onSave = {}) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@AllPreviews +@Composable +fun PreviewEditCategoryDialog() { + TripMoneyTheme { + AddCategoryDialog( + onDismiss = {}, + onSave = {}, + categoryToEdit = Category( + 0, "Hotel", + icon = cc.n0th1ng.tripmoney.utils.Icons.entries.random(), + color = colors.random(), + archived = true + ) + ) } } \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt index c956796..6a048b8 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.os.Build import android.util.Log import androidx.annotation.RequiresApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -11,6 +12,7 @@ import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -224,7 +226,7 @@ fun AddExpenseBottomSheet( ) { Text( text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")), - fontSize = 17.sp + style = MaterialTheme.typography.titleMedium ) } CategoryButton( @@ -265,6 +267,11 @@ fun AddExpenseBottomSheet( equationResult = evaluate(amount) enableSave = amount.isDoubleTwoDigitsOrEquation() && equationResult > 0 }, + onLongBackspaceClick = { + amount = "0.00" + equationResult = evaluate(amount) + enableSave = false + } ) SaveButton( @@ -394,7 +401,13 @@ fun NoteInput( @Composable fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) { - Button(onClick = onClick, modifier = modifier, shape = MaterialTheme.shapes.medium) { + Button( + onClick = onClick, + modifier = modifier, + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.buttonColors() + .copy(containerColor = MaterialTheme.colorScheme.secondary) + ) { Text(text) } } @@ -402,18 +415,35 @@ fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: Str @Composable fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) { Button( + contentPadding = PaddingValues(0.dp), onClick = onClick, modifier = modifier, shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors() - .copy(containerColor = Color(category.color.toColorInt()), contentColor = Color.Black) + .copy( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) ) { +// Row(modifier = modifier.fillMaxWidth()) { Icon( - modifier = Modifier.padding(end = 10.dp), + tint = Color(category.color.toColorInt()), + modifier = Modifier + .size(30.dp) +// .background( +// color = MaterialTheme.colorScheme.prima, +// shape = MaterialTheme.shapes.small +// ) + .padding(end = 10.dp), painter = painterResource(category.icon.resource), contentDescription = stringResource(R.string.category), ) - Text(category.name) + Text( + text = category.name, + style = MaterialTheme.typography.titleMedium + ) + +// } } } @@ -437,7 +467,8 @@ fun NumberKeyboard( modifier: Modifier = Modifier, onNumberClick: (String) -> Unit, onBackspaceClick: () -> Unit, - onOperatorClick: (String) -> Unit + onOperatorClick: (String) -> Unit, + onLongBackspaceClick: () -> Unit, ) { Column( modifier = modifier, @@ -455,22 +486,23 @@ fun NumberKeyboard( onClick = onBackspaceClick, modifier = Modifier .weight(1f), - containerColor = MaterialTheme.colorScheme.primary + containerColor = Color.Transparent, + onLongClick = onLongBackspaceClick ) "+", "/", "-", "*" -> KeyboardButton( text = key, onClick = { onOperatorClick(key) }, modifier = Modifier.weight(1f), - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer ) else -> KeyboardButton( text = key, onClick = { onNumberClick(key) }, modifier = Modifier.weight(1f), - containerColor = MaterialTheme.colorScheme.secondary, + containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSecondary ) } @@ -487,26 +519,24 @@ fun KeyboardButton( icon: Painter? = null, onClick: () -> Unit, enabled: Boolean = true, + onLongClick: () -> Unit = {}, containerColor: Color = MaterialTheme.colorScheme.primary, contentColor: Color = MaterialTheme.colorScheme.onPrimary ) { - Button( - onClick = onClick, - shape = MaterialTheme.shapes.medium, + Box( + contentAlignment = Alignment.Center, modifier = modifier .padding(2.dp) - .aspectRatio(2.5f), - enabled = enabled, - colors = ButtonDefaults.buttonColors( - containerColor = containerColor, - contentColor = contentColor - ) - ) { + .aspectRatio(2.5f) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .background(containerColor, shape = MaterialTheme.shapes.medium), + + ) { when { text != null -> Text( text, - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.headlineMedium ) icon != null -> Icon(painter = icon, contentDescription = null) @@ -594,26 +624,31 @@ fun PreviewAddExpenseEnabled() { val categoriesToPreview = listOf( Category( + 1, name = "Hotel", icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, color = colors.random() ), Category( + 2, name = "Jedzenie", icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, color = colors.random() ), Category( + 3, name = "Transport", icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, color = colors.random() ), Category( + 4, name = "Rozrywka", icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, color = colors.random() ), Category( + 5, name = "Zakupy", icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, color = colors.random() diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/CategorySelectionDialog.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/CategorySelectionDialog.kt index 40dbfc4..a7ecf30 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/CategorySelectionDialog.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/CategorySelectionDialog.kt @@ -28,7 +28,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import cc.n0th1ng.tripmoney.R.* import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.screens.AddCategoryDialog -import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import com.composables.icons.materialsymbols.outlined.R @@ -37,7 +36,7 @@ fun CategorySelectionDialog( onDismiss: () -> Unit, onCategorySelected: (Category) -> Unit, selected: Category, - categories: List + categories: List, ) { val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val listState = rememberLazyListState() @@ -91,7 +90,7 @@ fun CategorySelectionDialog( contentDescription = stringResource(string.category) ) Text( - text = stringResource(string.add_new_category), modifier = Modifier.padding(start = 8.dp), + text = stringResource(string.add_new), modifier = Modifier.padding(start = 8.dp), ) } } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt index a709b17..0546af4 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -47,6 +46,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -58,33 +58,46 @@ import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import cc.n0th1ng.tripmoney.R.string +import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet +import cc.n0th1ng.tripmoney.theme.TripMoneyTheme +import cc.n0th1ng.tripmoney.utils.AllPreviews +import cc.n0th1ng.tripmoney.utils.Currencies +import cc.n0th1ng.tripmoney.utils.colors import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.TripViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset import java.time.format.DateTimeFormatter +import kotlin.random.Random @RequiresApi(Build.VERSION_CODES.O) @Composable -fun ListExpenseScreen() { +fun ListExpenseScreen(filter: String) { val settingsViewModel: SettingsViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel() val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY) val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() - val expensesFlow = expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId) + val expensesFlow = + expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, filter) + val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState() ListExpenseScreen( expensesFlow = expensesFlow, onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) }, onDeleteExpense = { expenseAndCategoryViewModel.delete(it) }, + isRecalculatingRate = isRecalculatingRate ) } @@ -94,7 +107,8 @@ fun ListExpenseScreen() { @Composable fun ListExpenseScreen( expensesFlow: Flow>, - onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit + onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit, + isRecalculatingRate: Boolean ) { val items = expensesFlow.collectAsLazyPagingItems() @@ -111,58 +125,52 @@ fun ListExpenseScreen( ) }) { - if (items.loadState.refresh == LoadState.Loading) { - // Show loading indicator - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } - else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - state = listState - ) { - items( - count = items.itemCount, - key = items.itemKey { item -> - when (item) { - is ExpenseListItemUi.Item -> item.expenseDto.expense.id - is ExpenseListItemUi.Header -> "header_${item.date}" - } + Box { + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + state = listState + ) { + 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]) { - - 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 -> {} + when (val item = items[index]) { + 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) { DeleteConfirmationDialog( @@ -208,7 +216,7 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) { date.format( DateTimeFormatter.ofPattern("dd EEEE") ).toString(), - modifier = Modifier.background(Color.White.copy(alpha = 0f)), + modifier = Modifier.padding(horizontal = 5.dp).background(Color.White.copy(alpha = 0f)), style = MaterialTheme.typography.titleMedium ) Row( @@ -221,8 +229,14 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) { HorizontalDivider(modifier = Modifier.weight(2f)) Text( "%.2f %s".format(sum, currency), - modifier = Modifier.background(Color.White.copy(alpha = 0f)), - style = MaterialTheme.typography.bodyMedium + modifier = Modifier + .background( + MaterialTheme.colorScheme.tertiaryContainer, + shape = MaterialTheme.shapes.small + ) + .padding(5.dp), + color = MaterialTheme.colorScheme.onTertiaryContainer, + style = MaterialTheme.typography.bodySmall ) HorizontalDivider(modifier = Modifier.weight(1f)) } @@ -256,7 +270,7 @@ fun SwipeToDeleteExpenseCard( Modifier .clip(CardDefaults.elevatedShape) .fillMaxSize() - .background(MaterialTheme.colorScheme.onError) + .background(MaterialTheme.colorScheme.errorContainer) .padding(horizontal = 20.dp), contentAlignment = Alignment.CenterEnd ) { @@ -329,7 +343,7 @@ fun ExpenseCard( ) { ElevatedCard( colors = CardDefaults.elevatedCardColors() - .copy(containerColor = MaterialTheme.colorScheme.secondaryContainer), + .copy(containerColor = MaterialTheme.colorScheme.surfaceContainer), modifier = Modifier .fillMaxWidth(0.9f) .height(70.dp) @@ -344,14 +358,29 @@ fun ExpenseCard( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxSize() + //TODO +// .background( +// Brush.horizontalGradient( +// colorStops = arrayOf( +// 1f to Color(expenseDto.category.color.toColorInt()), +// 4f to MaterialTheme.colorScheme.surfaceDim +// ) +// ) +// ) .padding(horizontal = 16.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(15.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxHeight() ) { Icon( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surfaceDim, + shape = MaterialTheme.shapes.small + ) + .padding(10.dp), painter = painterResource(expenseDto.category.icon.resource), contentDescription = "Category", tint = Color(expenseDto.category.color.toColorInt()) @@ -367,13 +396,13 @@ fun ExpenseCard( Text( text = expenseDto.category.name, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer + color = MaterialTheme.colorScheme.onSurface ) Text( modifier = Modifier.padding(0.dp), text = expenseDto.expense.note, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer + color = MaterialTheme.colorScheme.onSurface ) } @@ -382,7 +411,7 @@ fun ExpenseCard( DateTimeFormatter.ofPattern("dd MMM HH:mm") ), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -390,7 +419,7 @@ fun ExpenseCard( Text( text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer + color = MaterialTheme.colorScheme.onSurface ) @@ -398,7 +427,7 @@ fun ExpenseCard( Text( text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer + color = MaterialTheme.colorScheme.onSurface ) } @@ -407,107 +436,125 @@ fun ExpenseCard( } } -//@RequiresApi(Build.VERSION_CODES.O) -//@AllPreviews -//@Composable -//fun PreviewListExpenseScreen() { -// TripMoneyTheme() { -// val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList()) -// ListExpenseScreen( -// expensesDtoFlow = MutableStateFlow(pagingData), -// onSaveExpense = {}, -// onDeleteExpense = {}, -// dailySums = emptyMap() -// ) -// -// } -//} -// -//@AllPreviews -//@Composable -//fun PreviewDeleteConfirmationDialog() { -// TripMoneyTheme() { -// DeleteConfirmationDialog( -// onConfirm = {}, -// onCancel = {}) -// } -//} -// -// -//@RequiresApi(Build.VERSION_CODES.O) -//private fun sampleExpenseDtoWithConvertedAmountList(): List { -// val sampleCategories = listOf( -// Category( -// name = "Hotel", -// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, -// color = colors.random() -// ), -// Category( -// name = "Jedzenie", -// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, -// color = colors.random() -// ), -// Category( -// name = "Transport", -// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, -// color = colors.random() -// ), -// Category( -// name = "Rozrywka", -// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, -// color = colors.random() -// ), -// Category( -// name = "Zakupy", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = colors.random() -// ), -// ) -// -// val trip = Trip( -// id = 1, -// name = "Vacation", -// currency = "USD", -// startDate = LocalDate.parse("2026-01-01") -// ) -// -// val startLong = LocalDateTime.now().minusDays(10).toEpochMilli() -// val endLong = LocalDateTime.now().toEpochMilli() -// -// val result: MutableList = mutableListOf() -// for (i in 0..15) { -// val category = sampleCategories.random() -// val datetime = if (i > 4) { -// LocalDateTime.ofEpochSecond( -// Random.nextLong(startLong, endLong), -// 0, -// ZoneOffset.UTC -// ) -// } else LocalDateTime.now() -// -// val expense = Expense( -// id = i, -// categoryId = category.id, -// tripId = 1, -// amount = Random.nextDouble(0.1, 300.0), -// currency = Currencies.entries.random().name, -// note = if (i % 3 == 0) "Some note" else "", -// datetime = datetime, -// rate = if (Random.nextBoolean()) Random.nextDouble( -// 0.1, -// 5.0 -// ) else 1.0 -// ) -// -// -// val expenseDto = ExpenseDto( -// expense = expense, -// category = category, -// trip = trip -// ) -// result.add( -// expenseDto -// ) -// } -// return result -//} \ No newline at end of file +@RequiresApi(Build.VERSION_CODES.O) +@AllPreviews +@Composable +fun PreviewListExpenseScreen() { + TripMoneyTheme() { + val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList()) + ListExpenseScreen( + expensesFlow = MutableStateFlow(pagingData), + onSaveExpense = {}, + onDeleteExpense = {}, + true + ) + + } +} + +@AllPreviews +@Composable +fun PreviewDeleteConfirmationDialog() { + TripMoneyTheme() { + DeleteConfirmationDialog( + onConfirm = {}, + onCancel = {}) + } +} + + +@RequiresApi(Build.VERSION_CODES.O) +private fun sampleExpenseDtoWithConvertedAmountList(): List { + val sampleCategories = listOf( + Category( + name = "Hotel", + icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, + color = colors.random() + ), + Category( + name = "Jedzenie", + icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, + color = colors.random() + ), + Category( + name = "Transport", + icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, + color = colors.random() + ), + Category( + name = "Rozrywka", + icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, + color = colors.random() + ), + Category( + name = "Zakupy", + icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, + color = colors.random() + ), + ) + + val trip = Trip( + id = 1, + name = "Vacation", + currency = "USD", + startDate = LocalDate.parse("2026-01-01") + ) + + val startLong = LocalDateTime.now().minusDays(10).toEpochMilli() + val endLong = LocalDateTime.now().toEpochMilli() + + val result: MutableList = mutableListOf() + result.add( + ExpenseListItemUi.Header( + LocalDateTime.ofEpochSecond( + Random.nextLong(startLong, endLong), + 0, + ZoneOffset.UTC + ).toLocalDate(), Random.nextDouble(0.1, 300.0), Currencies.entries.random().name + ) + ) + for (i in 0..15) { + val category = sampleCategories.random() + val datetime = if (i > 4) { + LocalDateTime.ofEpochSecond( + Random.nextLong(startLong, endLong), + 0, + ZoneOffset.UTC + ) + } else LocalDateTime.now() + + val expense = Expense( + id = i, + categoryId = category.id, + tripId = 1, + amount = Random.nextDouble(0.1, 300.0), + currency = Currencies.entries.random().name, + note = if (i % 3 == 0) "Some note" else "", + datetime = datetime, + rate = if (Random.nextBoolean()) Random.nextDouble( + 0.1, + 5.0 + ) else 1.0 + ) + + + val expenseDto = ExpenseDto( + expense = expense, + category = category, + trip = trip + ) + result.add( + ExpenseListItemUi.Item(expenseDto) + ) + if (i % 5 == 0) { + result.add( + ExpenseListItemUi.Header( + datetime.toLocalDate(), + Random.nextDouble(0.1, 300.0), + Currencies.entries.random().name + ) + ) + } + } + return result +} \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/managecategories/ManageCategoriesScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/managecategories/ManageCategoriesScreen.kt new file mode 100644 index 0000000..5e30ae1 --- /dev/null +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/managecategories/ManageCategoriesScreen.kt @@ -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, + archivedCategories: List, + onSaveCategory: (Category) -> Unit, + onDeleteCategory: (Category) -> Unit, +) { + + var categoryToEdit by remember { mutableStateOf(null) } + var showAddCategoryDialog by remember { mutableStateOf(false) } + var itemToDelete by remember { mutableStateOf(null) } + var itemToArchive by remember { mutableStateOf(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" + ) + } +} + diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/settings/SettingsScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/settings/SettingsScreen.kt index 2d73a2e..ff4465d 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/settings/SettingsScreen.kt @@ -38,10 +38,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController import cc.n0th1ng.tripmoney.R.* +import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.repository.AppTheme +import cc.n0th1ng.tripmoney.navigation.Screens +import cc.n0th1ng.tripmoney.screens.listexpense.CategorySelectionDialog import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog +import cc.n0th1ng.tripmoney.screens.statistics.categories import cc.n0th1ng.tripmoney.theme.TripMoneyTheme import cc.n0th1ng.tripmoney.utils.AllPreviews import cc.n0th1ng.tripmoney.utils.Currencies @@ -60,7 +66,7 @@ import java.nio.file.Files @RequiresApi(Build.VERSION_CODES.S) @Composable -fun SettingsScreen() { +fun SettingsScreen(navController: NavHostController) { val settingsViewModel: SettingsViewModel = hiltViewModel() val currentTheme by settingsViewModel.theme.collectAsState() val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState() @@ -68,6 +74,7 @@ fun SettingsScreen() { val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel() val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY) + val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList()) val context = LocalContext.current val tripName = currentTrip?.name ?: "" val scope = rememberCoroutineScope() @@ -90,7 +97,8 @@ fun SettingsScreen() { e.printStackTrace() } } - } + }, + onCategoriesClick = {navController.navigate(Screens.MANAGE_CATEGORIES)} ) } @@ -103,11 +111,13 @@ fun SettingsScreen( onCurrencySave: (Currencies) -> Unit, tripName: String, onExportToCsv: () -> Unit, + onCategoriesClick: () -> Unit ) { Scaffold { padding -> var showThemeDialog by remember { mutableStateOf(false) } var showCurrencyDialog by remember { mutableStateOf(false) } + var showCategoriesDialog by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxWidth() @@ -142,9 +152,15 @@ fun SettingsScreen( SettingsListItem( onClick = onExportToCsv, stringResource(string.export_to_csv), - supportingText = "Save expenses from %s to a file".format(tripName), + supportingText = stringResource(string.export_csv_subttext).format(tripName), iconResource = R.drawable.materialsymbols_ic_csv_outlined ) + SettingsListItem( + onClick = onCategoriesClick, + stringResource(string.categories), + supportingText = stringResource(string.manage_categories), + iconResource = R.drawable.materialsymbols_ic_label_outlined + ) if (showThemeDialog) { ThemeSelectionDialog( @@ -258,7 +274,14 @@ fun ThemeSelectionDialog( @Composable fun PreviewSettingsScreen() { TripMoneyTheme { - SettingsScreen(Currencies.entries.random(), AppTheme.entries.random(), {}, {}, "Włochy", {}) + SettingsScreen( + Currencies.entries.random(), + AppTheme.entries.random(), + {}, + {}, + "Włochy", + {}, + {}) } } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt index 44710d7..b29fde8 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt @@ -1,5 +1,6 @@ package cc.n0th1ng.tripmoney.screens.statistics +import android.annotation.SuppressLint import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.foundation.background @@ -13,9 +14,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.filled.Add import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -30,6 +37,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.graphics.toColorInt import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import cc.n0th1ng.tripmoney.R.string import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Trip @@ -82,9 +90,10 @@ fun StatisticsScreen( @Composable fun Summary(summaryAmount: Double, currency: String) { - Card( - modifier = Modifier - .fillMaxWidth() + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors() + .copy(containerColor = MaterialTheme.colorScheme.surfaceContainer) ) { Row( modifier = Modifier @@ -94,7 +103,10 @@ fun Summary(summaryAmount: Double, currency: String) { horizontalArrangement = Arrangement.SpaceBetween ) { Column { - Text(stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses), style = MaterialTheme.typography.titleSmall) + Text( + stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses), + style = MaterialTheme.typography.titleSmall + ) Text( "%.2f %s".format(summaryAmount, currency), style = MaterialTheme.typography.headlineLarge @@ -118,7 +130,11 @@ fun Summary(summaryAmount: Double, currency: String) { @Composable fun SummaryPerCategoryCard(summaryPerCategoryList: List) { - Card(modifier = Modifier.fillMaxWidth()) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors() + .copy(containerColor = MaterialTheme.colorScheme.surfaceContainer) + ) { Column( modifier = Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(5.dp) @@ -191,16 +207,19 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa } } +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @RequiresApi(Build.VERSION_CODES.O) @AllPreviews @Composable fun Preview() { TripMoneyTheme { - StatisticsScreen( - summaryPerCategoryList, - summaryAmount = 125.24, - Currencies.entries.random() - ) + Scaffold { + StatisticsScreen( + summaryPerCategoryList, + summaryAmount = 125.24, + Currencies.entries.random() + ) + } } } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/TripPickerScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/TripPickerScreen.kt index e3e69f5..63954f9 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/TripPickerScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/TripPickerScreen.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -21,7 +20,6 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FabPosition import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -50,16 +48,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController +import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import cc.n0th1ng.tripmoney.R.string import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.navigation.Screens -import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet import cc.n0th1ng.tripmoney.screens.listexpense.DeleteConfirmationDialog +import cc.n0th1ng.tripmoney.theme.TripMoneyTheme +import cc.n0th1ng.tripmoney.utils.AllPreviews import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.TripViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import java.time.LocalDate @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(Build.VERSION_CODES.O) @@ -70,9 +73,38 @@ fun TripPickerScreen( ) { val settingsViewModel: SettingsViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel() - var showBottomSheet by remember { mutableStateOf(false) } - val trips: LazyPagingItems = tripViewModel.getTrips().collectAsLazyPagingItems() + val tripsFlow = tripViewModel.getTrips() val currentTripId by settingsViewModel.currentTrip.collectAsState() + + TripPickerScreen( + tripsFlow = tripsFlow, + currentTripId = currentTripId, + onDelete = { trip -> tripViewModel.delete(trip) }, + onClick = { trip -> + settingsViewModel.setCurrentTrip(trip.id) + navController.navigate(Screens.LIST_EXPENSE) + }, + onSave = { trip -> + tripViewModel.save(trip) + } + + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@RequiresApi(Build.VERSION_CODES.O) +@Composable +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +fun TripPickerScreen( + tripsFlow: Flow>, + currentTripId: Int, + onDelete: (Trip) -> Unit, + onClick: (Trip) -> Unit, + onSave: (Trip) -> Unit +) { + var showBottomSheet by remember { mutableStateOf(false) } + val trips: LazyPagingItems = tripsFlow.collectAsLazyPagingItems() + var tripToEdit by remember { mutableStateOf(null) } Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = { FloatingActionButton( @@ -91,12 +123,12 @@ fun TripPickerScreen( val trip = trips[i] if (trip != null) { SwipeToDeleteTripCard( - trip, onDelete = { - tripViewModel.delete(trip) - }, onClick = { - settingsViewModel.setCurrentTrip(trip.id) - navController.navigate(Screens.LIST_EXPENSE) - }, isSelected = currentTripId == trip.id, + trip = trip, + onDelete = { + onDelete(trip) + }, onClick = { + onClick(trip) + }, isSelected = currentTripId == trip.id, onLongClick = { trip -> tripToEdit = trip showBottomSheet = true @@ -113,7 +145,7 @@ fun TripPickerScreen( tripToEdit = null }, onSave = { trip -> - tripViewModel.save(trip) + onSave(trip) showBottomSheet = false tripToEdit = null }, @@ -152,7 +184,6 @@ fun SwipeToDeleteTripCard( } SwipeToDismissBox( - modifier = Modifier.alpha(if (isSelected) 1.0f else 0.7f), state = dismissState, enableDismissFromStartToEnd = false, backgroundContent = { @@ -160,7 +191,7 @@ fun SwipeToDeleteTripCard( Modifier .clip(CardDefaults.elevatedShape) .fillMaxSize() - .background(MaterialTheme.colorScheme.onError) + .background(MaterialTheme.colorScheme.errorContainer) .padding(horizontal = 20.dp), contentAlignment = Alignment.CenterEnd ) { @@ -186,7 +217,7 @@ fun TripCard( containerColor = if (isSelected) { MaterialTheme.colorScheme.primary } else { - MaterialTheme.colorScheme.secondary + MaterialTheme.colorScheme.surfaceContainer } ), modifier = Modifier @@ -195,7 +226,7 @@ fun TripCard( haptics.performHapticFeedback(HapticFeedbackType.LongPress) onLongClick(trip) }, onClick = { onClick(trip) }), - elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 7.dp) ) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -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 = {} + ) + } } \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt index ce01167..269acb8 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt @@ -40,15 +40,28 @@ open class ExpenseAndCategoryViewModel @Inject constructor( private val tripRepo: TripRepository ) : ViewModel() { - fun getExpensesDtoPaged(tripId: Int): Flow> = - expenseRepo.getExpensesDtoPaged(tripId).cachedIn(viewModelScope) + fun archiveCategory(category: Category) { + viewModelScope.launch { + categoryRepo.save(category.copy(archived = true)) + } + } + + fun deArchiveCategory(category: Category) { + viewModelScope.launch { + categoryRepo.save(category.copy(archived = false)) + } + } + + fun getExpensesDtoPaged(tripId: Int, filter: String = ""): Flow> = + expenseRepo.getExpensesDtoPaged(tripId, filter).cachedIn(viewModelScope) @RequiresApi(Build.VERSION_CODES.O) fun getExpensesWithHeadersPaged( - tripId: Int + tripId: Int, + filter: String = "" ): Flow> { - val pagingFlow = getExpensesDtoPaged(tripId) - val sumsFlow = getDailySums(tripId) + val pagingFlow = getExpensesDtoPaged(tripId, filter) + val sumsFlow = getDailySums(tripId, filter) val tripFlow = tripRepo.getTrip(tripId) return combine(pagingFlow, sumsFlow, tripFlow) { pagingData, sums, trip -> val currency = trip?.currency ?: "" @@ -80,8 +93,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor( }.cachedIn(viewModelScope) } - fun getExpensesDto(tripId: Int): Flow> = - expenseRepo.getExpensesDto(tripId) + fun getExpensesDto(tripId: Int, filter: String = ""): Flow> = + expenseRepo.getExpensesDto(tripId, filter) @RequiresApi(Build.VERSION_CODES.O) fun save(expense: Expense, trip: Trip) { @@ -96,14 +109,20 @@ open class ExpenseAndCategoryViewModel @Inject constructor( } - fun delete(expense: Expense) { viewModelScope.launch { expenseRepo.delete(expense) } } + fun delete(category: Category) { + viewModelScope.launch { + categoryRepo.delete(category) + } + } + fun getCategories(): Flow> = categoryRepo.getCategories() + fun getArchivedCategories(): Flow> = categoryRepo.getArchivedCategories() fun save(category: Category) { viewModelScope.launch { @@ -132,8 +151,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor( } @RequiresApi(Build.VERSION_CODES.O) - fun getDailySums(tripId: Int): Flow> { - return getExpensesDto(tripId) + fun getDailySums(tripId: Int, filter: String): Flow> { + return getExpensesDto(tripId, filter) .map { expenses -> expenses.groupBy { it.expense.datetime.toLocalDate() } .mapValues { (_, list) -> @@ -182,7 +201,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor( @RequiresApi(Build.VERSION_CODES.O) sealed class ExpenseListItemUi { data class Item(val expenseDto: ExpenseDto) : ExpenseListItemUi() - data class Header(val date: LocalDate, val sum: Double, val currency: String) : ExpenseListItemUi() + data class Header(val date: LocalDate, val sum: Double, val currency: String) : + ExpenseListItemUi() } } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/TripViewModel.kt b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/TripViewModel.kt index 766c7af..b6b8b9e 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/TripViewModel.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/TripViewModel.kt @@ -1,21 +1,30 @@ package cc.n0th1ng.tripmoney.viewmodel +import android.os.Build +import androidx.annotation.RequiresApi import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn -import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Trip +import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository import cc.n0th1ng.tripmoney.data.repository.TripRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel -class TripViewModel @Inject constructor(private val repository: TripRepository) : ViewModel() { - +class TripViewModel @Inject constructor( + private val repository: TripRepository, + private val expenseRepository: ExpenseRepository +) : ViewModel() { + private val _isRecalculating = MutableStateFlow(false) + val isRecalculating: StateFlow = _isRecalculating fun getTrips(): Flow> = repository.getTrips().cachedIn(viewModelScope) fun getTrip(tripId: Int): Flow = repository.getTrip(tripId) @@ -26,9 +35,15 @@ class TripViewModel @Inject constructor(private val repository: TripRepository) } } + @RequiresApi(Build.VERSION_CODES.O) fun save(trip: Trip) { viewModelScope.launch { repository.save(trip) + _isRecalculating.value = true + withContext(Dispatchers.IO) { + expenseRepository.recalculateTripExpenses(trip.id) + } + _isRecalculating.value = false } } diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1fd2749..c19f565 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -5,7 +5,7 @@ Zapisz Usuń Wybierz kategorie - dodaj nową + dodaj nową Wybierz kategorie Anuluj Dodaj wydatek @@ -27,4 +27,9 @@ Lista wydatków Statystyki Eksport do CSV + Zarządzaj kategoriami + Kategorie + Zapisz wydatki z %s do pliku + Dodaj kategorie + Edytuj kategorie \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 68bde61..59ee799 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,7 +5,7 @@ Save Backspace Pick a category - Add new + Add new Pick a currency Cancel Add expense @@ -27,4 +27,13 @@ List of expenses Statistics Export to CSV + Save expenses from %s to a file + Manage categories + Categories + Add category + Edit category + Archive + Do you want to archive? + No expense will be deleted. + Your all expenses with category Hotel will be removed. \ No newline at end of file diff --git a/baselineprofile/.gitignore b/baselineprofile/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/baselineprofile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts new file mode 100644 index 0000000..1f81fc5 --- /dev/null +++ b/baselineprofile/build.gradle.kts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/baselineprofile/src/main/AndroidManifest.xml b/baselineprofile/src/main/AndroidManifest.xml new file mode 100644 index 0000000..227314e --- /dev/null +++ b/baselineprofile/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/baselineprofile/src/main/java/cc/n0th1ng/baselineprofile/BaselineProfileGenerator.kt b/baselineprofile/src/main/java/cc/n0th1ng/baselineprofile/BaselineProfileGenerator.kt new file mode 100644 index 0000000..52d45e7 --- /dev/null +++ b/baselineprofile/src/main/java/cc/n0th1ng/baselineprofile/BaselineProfileGenerator.kt @@ -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") + } + } +} \ No newline at end of file diff --git a/baselineprofile/src/main/java/cc/n0th1ng/baselineprofile/StartupBenchmarks.kt b/baselineprofile/src/main/java/cc/n0th1ng/baselineprofile/StartupBenchmarks.kt new file mode 100644 index 0000000..851db23 --- /dev/null +++ b/baselineprofile/src/main/java/cc/n0th1ng/baselineprofile/StartupBenchmarks.kt @@ -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 + } + ) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 6d3e77f..2eeeb8d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,6 @@ plugins { id("com.google.devtools.ksp") version "2.2.21-2.0.5" apply false id("com.google.dagger.hilt.android") version "2.57.1" apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.baselineprofile) apply false } diff --git a/gradle.properties b/gradle.properties index 20e2a01..316a8ed 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,5 @@ kotlin.code.style=official # 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, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7616298..1b29632 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,10 @@ activityCompose = "1.8.0" composeBom = "2024.09.00" navigationCompose = "2.9.7" foundationLayout = "1.10.5" +uiautomator = "2.3.0" +benchmarkMacroJunit4 = "1.2.4" +baselineprofile = "1.2.4" +profileinstaller = "1.3.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -28,9 +32,14 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } +androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" } +androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +android-test = { id = "com.android.test", version.ref = "agp" } +baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } diff --git a/settings.gradle.kts b/settings.gradle.kts index ec9298e..e638d04 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,4 +21,4 @@ dependencyResolutionManagement { rootProject.name = "tripMoney" include(":app") - \ No newline at end of file +include(":baselineprofile")