init #48
@@ -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)
|
||||
|
||||
|
||||
@@ -13,13 +13,19 @@ import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import cc.n0th1ng.tripmoney.data.entity.Trip
|
||||
import cc.n0th1ng.tripmoney.navigation.BottomNavigation
|
||||
import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer
|
||||
@@ -27,6 +33,7 @@ import cc.n0th1ng.tripmoney.navigation.Screens
|
||||
import cc.n0th1ng.tripmoney.navigation.TopBar
|
||||
import cc.n0th1ng.tripmoney.navigation.TopBarSettings
|
||||
import cc.n0th1ng.tripmoney.screens.listexpense.ListExpenseScreen
|
||||
import cc.n0th1ng.tripmoney.screens.managecategories.ManageCategoriesScreen
|
||||
import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen
|
||||
import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen
|
||||
import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen
|
||||
@@ -40,21 +47,25 @@ import kotlinx.coroutines.launch
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
TripMoneyTheme {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val currentTripId by settingsViewModel.currentTrip.collectAsState()
|
||||
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
|
||||
expenseAndCategoryViewModel.clearOldRates()
|
||||
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId)
|
||||
.collectAsLazyPagingItems()
|
||||
NavigationDrawer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
fun NavigationDrawer() {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
@@ -66,15 +77,19 @@ fun NavigationDrawer() {
|
||||
val current = navBackStack?.destination?.route
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var filter by remember { mutableStateOf("") }
|
||||
|
||||
CustomNavigationDrawer(navController, drawerState) {
|
||||
Scaffold(
|
||||
modifier = Modifier.semantics {
|
||||
testTagsAsResourceId = true
|
||||
},
|
||||
topBar = {
|
||||
if (current == Screens.SETTINGS) TopBarSettings(
|
||||
navController
|
||||
) else TopBar(
|
||||
title = currentTrip?.name ?: "",
|
||||
onClick = {
|
||||
onDrawerClick = {
|
||||
scope.launch {
|
||||
if (drawerState.isClosed) {
|
||||
drawerState.open()
|
||||
@@ -82,7 +97,9 @@ fun NavigationDrawer() {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
isSearchable = current == Screens.LIST_EXPENSE,
|
||||
onFilterChange = { newFilter -> filter = newFilter})
|
||||
},
|
||||
|
||||
bottomBar = { BottomNavigation(navController) }) { innerPadding ->
|
||||
@@ -92,7 +109,7 @@ fun NavigationDrawer() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
|
||||
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
|
||||
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
|
||||
@@ -15,24 +14,34 @@ import cc.n0th1ng.tripmoney.data.dao.TripDao
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
|
||||
import cc.n0th1ng.tripmoney.data.entity.Expense
|
||||
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
|
||||
import cc.n0th1ng.tripmoney.data.entity.Trip
|
||||
import cc.n0th1ng.tripmoney.screens.listexpense.toEpochMilli
|
||||
import cc.n0th1ng.tripmoney.utils.Currencies
|
||||
import cc.n0th1ng.tripmoney.utils.Icons
|
||||
import cc.n0th1ng.tripmoney.utils.colors
|
||||
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import javax.inject.Inject
|
||||
import java.time.ZoneOffset
|
||||
import javax.inject.Singleton
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
|
||||
@Database(entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], version = 1)
|
||||
@Database(
|
||||
entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class],
|
||||
version = 1
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class TripDatabase : RoomDatabase() {
|
||||
abstract fun tripDao(): TripDao
|
||||
@@ -46,21 +55,28 @@ abstract class TripDatabase : RoomDatabase() {
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTripDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
// expenseAndCategoryViewModel: ExpenseAndCategoryViewModel
|
||||
@ApplicationContext context: Context
|
||||
): TripDatabase {
|
||||
|
||||
val db: TripDatabase = Room.inMemoryDatabaseBuilder(
|
||||
context, TripDatabase::class.java
|
||||
).allowMainThreadQueries().build()
|
||||
// val db: TripDatabase = Room.databaseBuilder(
|
||||
// name = "tripmoney_db",
|
||||
context = context,
|
||||
klass = TripDatabase::class.java,
|
||||
)
|
||||
.allowMainThreadQueries() // TODO Remove in production!
|
||||
.fallbackToDestructiveMigration() // TODO Handle schema changes during dev
|
||||
.build()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
DatabasePrepopulator(
|
||||
tripDao = db.tripDao(), categoryDao = db.categoryDao(), expenseDao = db.expenseDao()
|
||||
tripDao = db.tripDao(),
|
||||
categoryDao = db.categoryDao(),
|
||||
expenseDao = db.expenseDao()
|
||||
).prepopulate()
|
||||
}
|
||||
return db
|
||||
@@ -99,169 +115,92 @@ private class DatabasePrepopulator(
|
||||
) {
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
suspend fun prepopulate() {
|
||||
tripDao.insert(Trip(name = "Włochy", startDate = LocalDate.parse("2026-03-01"), currency = "PLN"))
|
||||
tripDao.insert(Trip(name = "Szwajcaria", startDate =LocalDate.parse("2025-03-01"), currency = "EUR"))
|
||||
tripDao.insert(Trip(name = "Portugalia", startDate = LocalDate.parse("2025-03-01"), currency = "USD"))
|
||||
categoryDao.insert(Category(name = "Accomodation", icon = Icons.HOTEL, color = colors.random()))
|
||||
categoryDao.insert(Category(name = "Transport", icon = Icons.TRANSPORT, color = colors.random()))
|
||||
categoryDao.insert(Category(name = "Flight", icon = Icons.FLIGHT, color = colors.random()))
|
||||
categoryDao.insert(Category(name = "Restaurants", icon = Icons.RESTAURANT, color = colors.random()))
|
||||
categoryDao.insert(Category(name = "Groceries", icon = Icons.GROCERIES, color = colors.random()))
|
||||
categoryDao.insert(Category(name = "Coffee", icon = Icons.COFFEE,color = colors.random()))
|
||||
categoryDao.insert(Category(name = "Entertainment", icon = Icons.ENTERTAINMENT,color = colors.random()))
|
||||
categoryDao.insert(Category(name = "Laundry", icon = Icons.LAUNDRY,color = colors.random()))
|
||||
|
||||
tripDao.insert(
|
||||
Trip(
|
||||
name = "Włochy",
|
||||
startDate = LocalDate.parse("2026-03-01"),
|
||||
currency = "PLN"
|
||||
)
|
||||
)
|
||||
tripDao.insert(
|
||||
Trip(
|
||||
name = "Szwajcaria",
|
||||
startDate = LocalDate.parse("2025-03-01"),
|
||||
currency = "EUR"
|
||||
)
|
||||
)
|
||||
tripDao.insert(
|
||||
Trip(
|
||||
name = "Portugalia",
|
||||
startDate = LocalDate.parse("2025-03-01"),
|
||||
currency = "USD"
|
||||
)
|
||||
)
|
||||
for (category in sampleCategories) {
|
||||
categoryDao.insert(category)
|
||||
}
|
||||
for (expense in sampleExpenses) {
|
||||
expenseDao.insert(expense)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
val sampleCategories = listOf(
|
||||
Category(
|
||||
name = "Hotel",
|
||||
icon = Icons.HOTEL,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Jedzenie",
|
||||
icon = Icons.RESTAURANT,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Transport",
|
||||
icon = Icons.FLIGHT,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Rozrywka",
|
||||
icon = Icons.ATTRACTION,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy",
|
||||
icon = Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
val sampleExpenses = (0..150).map { i ->
|
||||
|
||||
val datetime = if (i > 4) {
|
||||
val now = LocalDateTime.now()
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 120.50,
|
||||
currency = "PLN",
|
||||
note = "Hotel overnight",
|
||||
datetime = now.minusDays(10),
|
||||
categoryId = 1,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 45.75,
|
||||
currency = "PLN",
|
||||
note = "Dinner",
|
||||
datetime = now.minusDays(9),
|
||||
categoryId = 2,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 15.20,
|
||||
currency = "PLN",
|
||||
note = "Bus ticket",
|
||||
datetime = now.minusDays(8),
|
||||
categoryId = 3,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 89.99,
|
||||
currency = "PLN",
|
||||
note = "Concert tickets",
|
||||
datetime = now.minusDays(7),
|
||||
categoryId = 4,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 32.50,
|
||||
currency = "PLN",
|
||||
note = "Souvenirs",
|
||||
datetime = now.minusDays(6),
|
||||
categoryId = 5,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 180.00,
|
||||
currency = "PLN",
|
||||
note = "Hotel 3 nights",
|
||||
datetime = now.minusDays(5),
|
||||
categoryId = 1,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 67.30,
|
||||
currency = "PLN",
|
||||
note = "Lunch",
|
||||
datetime = now.minusDays(4),
|
||||
categoryId = 2,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 22.00,
|
||||
currency = "PLN",
|
||||
note = "Train ticket",
|
||||
datetime = now.minusDays(3),
|
||||
categoryId = 3,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 55.00,
|
||||
currency = "PLN",
|
||||
note = "Museum entry",
|
||||
datetime = now.minusDays(2),
|
||||
categoryId = 4,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 12.99,
|
||||
currency = "PLN",
|
||||
note = "Snacks",
|
||||
datetime = now.minusDays(1),
|
||||
categoryId = 2,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 210.00,
|
||||
currency = "PLN",
|
||||
note = "Hotel 5 nights",
|
||||
datetime = now,
|
||||
categoryId = 1,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 95.50,
|
||||
currency = "EUR",
|
||||
note = "Dinner for two",
|
||||
datetime = now.minusHours(12),
|
||||
categoryId = 2,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 30.00,
|
||||
currency = "EUR",
|
||||
note = "Taxi",
|
||||
datetime = now.minusHours(6),
|
||||
categoryId = 3,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 40.00,
|
||||
currency = "USD",
|
||||
note = "Gifts",
|
||||
datetime = now.minusHours(3),
|
||||
categoryId = 5,
|
||||
tripId = 1
|
||||
)
|
||||
)
|
||||
expenseDao.insert(
|
||||
Expense(
|
||||
amount = 75.00,
|
||||
currency = "PLN",
|
||||
note = "Sightseeing tour",
|
||||
datetime = now.minusHours(1),
|
||||
categoryId = 4,
|
||||
tripId = 1
|
||||
)
|
||||
val min = now.minusDays(10).toInstant(ZoneOffset.UTC).toEpochMilli()
|
||||
val max = now.toInstant(ZoneOffset.UTC).toEpochMilli()
|
||||
val randomMillis = Random.nextLong(min, max)
|
||||
LocalDateTime.ofInstant(Instant.ofEpochMilli(randomMillis), ZoneOffset.UTC)
|
||||
} else {
|
||||
LocalDateTime.now()
|
||||
}
|
||||
|
||||
|
||||
val expense = Expense(
|
||||
categoryId = Random.nextInt(1, 5),
|
||||
tripId = 1,
|
||||
amount = Random.nextDouble(0.1, 300.0),
|
||||
currency = Currencies.entries.random().name,
|
||||
note = if (i % 3 == 0) "Some note" else "",
|
||||
datetime = datetime,
|
||||
rate = if (Random.nextBoolean()) Random.nextDouble(
|
||||
0.1,
|
||||
5.0
|
||||
) else 1.0
|
||||
)
|
||||
expense
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cc.n0th1ng.tripmoney.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
@@ -14,12 +15,23 @@ interface CategoryDao {
|
||||
suspend fun insert(category: Category)
|
||||
|
||||
|
||||
@Delete
|
||||
suspend fun delete(category: Category)
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM category
|
||||
SELECT * FROM category WHERE archived is 0
|
||||
"""
|
||||
)
|
||||
fun categories(): Flow<List<Category>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM category WHERE archived is 1
|
||||
"""
|
||||
)
|
||||
fun archivedCategories(): Flow<List<Category>>
|
||||
|
||||
}
|
||||
|
||||
@@ -16,22 +16,38 @@ interface ExpenseDao {
|
||||
@Upsert
|
||||
suspend fun insert(expense: Expense)
|
||||
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM expense WHERE trip_id = :tripId
|
||||
SELECT expense.*, category.*
|
||||
FROM expense
|
||||
JOIN category ON expense.category_id = category.id
|
||||
WHERE expense.trip_id = :tripId
|
||||
AND
|
||||
(
|
||||
(:filter IS NULL OR category.name LIKE '%' || :filter || '%')
|
||||
OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%')
|
||||
)
|
||||
ORDER BY expense.datetime DESC
|
||||
"""
|
||||
)
|
||||
fun expenseDtoPaged(tripId: Int): PagingSource<Int, ExpenseDto>
|
||||
fun expenseDtoPaged(tripId: Int, filter: String): PagingSource<Int, ExpenseDto>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM expense WHERE trip_id = :tripId
|
||||
SELECT * FROM expense
|
||||
JOIN category ON expense.category_id = category.id
|
||||
WHERE trip_id = :tripId
|
||||
AND
|
||||
(
|
||||
(:filter IS NULL OR category.name LIKE '%' || :filter || '%')
|
||||
OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%')
|
||||
)
|
||||
ORDER BY expense.datetime DESC
|
||||
"""
|
||||
)
|
||||
fun expenseDto(tripId: Int): Flow<List<ExpenseDto>>
|
||||
fun expenseDto(tripId: Int, filter: String): Flow<List<ExpenseDto>>
|
||||
|
||||
@Delete
|
||||
suspend fun delete(expense: Expense)
|
||||
|
||||
@@ -17,7 +17,7 @@ interface TripDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM trip
|
||||
ORDER BY DATE(trip.start_date) DESC
|
||||
ORDER BY trip.start_date DESC
|
||||
"""
|
||||
)
|
||||
fun tripsPaged(): PagingSource<Int, Trip>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,16 @@ class CategoryRepository @Inject constructor(private val categoryDao: CategoryDa
|
||||
categoryDao.insert(category)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun delete(category: Category) {
|
||||
categoryDao.delete(category)
|
||||
}
|
||||
|
||||
fun getCategories(): Flow<List<Category>> {
|
||||
return categoryDao.categories()
|
||||
}
|
||||
|
||||
fun getArchivedCategories(): Flow<List<Category>> {
|
||||
return categoryDao.archivedCategories()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -12,8 +12,11 @@ import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw
|
||||
import cc.n0th1ng.tripmoney.data.entity.Expense
|
||||
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
|
||||
import cc.n0th1ng.tripmoney.utils.Currencies
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -32,15 +35,15 @@ class ExpenseRepository @Inject constructor(
|
||||
expenseDao.delete(expense)
|
||||
}
|
||||
|
||||
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> {
|
||||
fun getExpensesDtoPaged(tripId: Int, filter: String): Flow<PagingData<ExpenseDto>> {
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = 50, enablePlaceholders = false),
|
||||
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) }
|
||||
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId, filter) }
|
||||
).flow
|
||||
}
|
||||
|
||||
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> {
|
||||
return expenseDao.expenseDto(tripId)
|
||||
fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> {
|
||||
return expenseDao.expenseDto(tripId, filter)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,27 +1,130 @@
|
||||
package cc.n0th1ng.tripmoney.navigation
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldColors
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import cc.n0th1ng.tripmoney.R
|
||||
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
|
||||
import cc.n0th1ng.tripmoney.utils.AllPreviews
|
||||
import com.composables.icons.materialsymbols.outlined.R.drawable
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TopBar(onClick: () -> Unit, title: String = "") {
|
||||
fun TopBar(
|
||||
onDrawerClick: () -> Unit,
|
||||
title: String = "",
|
||||
isSearchable: Boolean = false,
|
||||
onFilterChange: (String) -> Unit
|
||||
) {
|
||||
var isSearch by remember { mutableStateOf(false) }
|
||||
var value by remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
title = {
|
||||
if (isSearch && isSearchable) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
OutlinedTextField(
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(0.9f).focusRequester(focusRequester),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
errorContainerColor = Color.Transparent
|
||||
),
|
||||
value = value,
|
||||
onValueChange = { newText ->
|
||||
value = newText
|
||||
},
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
isSearch = false
|
||||
value = ""
|
||||
onFilterChange("")
|
||||
}),
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
LaunchedEffect(key1 = value) {
|
||||
delay(1000)
|
||||
onFilterChange(value)
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onClick() }) {
|
||||
IconButton(onClick = { onDrawerClick() }) {
|
||||
Icon(Icons.Default.Menu, contentDescription = "Menu")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!isSearch && isSearchable) {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 13.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(15.dp)
|
||||
) {
|
||||
Icon(
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
painter = painterResource(drawable.materialsymbols_ic_filter_alt_outlined),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable(onClick = {})
|
||||
)
|
||||
Icon(
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
painter = painterResource(drawable.materialsymbols_ic_search_outlined),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
isSearch = true
|
||||
})
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -40,3 +143,15 @@ fun TopBarSettings(navController: NavHostController) {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewTopBar() {
|
||||
TripMoneyTheme {
|
||||
TopBar(
|
||||
onDrawerClick = {},
|
||||
title = "Essa",
|
||||
onFilterChange = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package cc.n0th1ng.tripmoney.screens
|
||||
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
@@ -8,14 +11,17 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -27,44 +33,68 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.toColorInt
|
||||
import cc.n0th1ng.tripmoney.R
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
|
||||
import cc.n0th1ng.tripmoney.utils.AllPreviews
|
||||
import cc.n0th1ng.tripmoney.utils.Icons
|
||||
import cc.n0th1ng.tripmoney.utils.colors
|
||||
|
||||
@Composable
|
||||
fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var icon by remember { mutableStateOf(Icons.entries[0]) }
|
||||
var color by remember { mutableStateOf(colors[0]) }
|
||||
fun AddCategoryDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Category) -> Unit,
|
||||
categoryToEdit: Category? = null
|
||||
) {
|
||||
var name by remember { mutableStateOf(categoryToEdit?.name ?: "") }
|
||||
var icon by remember { mutableStateOf(categoryToEdit?.icon ?: Icons.entries[0]) }
|
||||
var color by remember { mutableStateOf(categoryToEdit?.color ?: colors[0]) }
|
||||
var isArchived by remember { mutableStateOf(categoryToEdit?.archived ?: false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss, title = { Text("Add new category") }, text = {
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(if (categoryToEdit == null) R.string.add_new_category else R.string.edit_category)) },
|
||||
text = {
|
||||
AlertDialogFill(
|
||||
onTextChange = { newText ->
|
||||
name = newText
|
||||
},
|
||||
onIconChange = { newIcon -> icon = newIcon },
|
||||
onColorChange = { newColor -> color = newColor }
|
||||
onColorChange = { newColor -> color = newColor },
|
||||
onArchivedChange = { newArchived ->
|
||||
isArchived = newArchived
|
||||
},
|
||||
name = name,
|
||||
icon = icon,
|
||||
color = color,
|
||||
isArchived = isArchived
|
||||
)
|
||||
}, confirmButton = {
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
enabled = !name.isEmpty(),
|
||||
onClick = {
|
||||
onSave(
|
||||
Category(
|
||||
val categoryToSave = Category(
|
||||
name = name,
|
||||
icon = icon,
|
||||
color = color
|
||||
color = color,
|
||||
archived = isArchived
|
||||
)
|
||||
onSave(
|
||||
if (categoryToEdit != null) categoryToSave.copy(id = categoryToEdit.id) else categoryToSave
|
||||
)
|
||||
}) { Text("Save") }
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
Row() {
|
||||
Button(
|
||||
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error),
|
||||
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.secondary),
|
||||
onClick = onDismiss
|
||||
) { Text("close") }
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,24 +102,26 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
|
||||
fun AlertDialogFill(
|
||||
onTextChange: (String) -> Unit,
|
||||
onIconChange: (Icons) -> Unit,
|
||||
onColorChange: (String) -> Unit
|
||||
onColorChange: (String) -> Unit,
|
||||
onArchivedChange: (Boolean) -> Unit,
|
||||
name: String,
|
||||
icon: Icons,
|
||||
color: String,
|
||||
isArchived: Boolean
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) }
|
||||
var colorHex by remember { mutableStateOf(colors[0]) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(30.dp),
|
||||
painter = painterResource(iconId), contentDescription = null,
|
||||
tint = Color(colorHex.toColorInt())
|
||||
painter = painterResource(icon.resource), contentDescription = null,
|
||||
tint = Color(color.toColorInt())
|
||||
)
|
||||
OutlinedTextField(label = { Text("Name") }, value = text, onValueChange = { newText ->
|
||||
text = newText
|
||||
onTextChange(text)
|
||||
OutlinedTextField(label = { Text("Name") }, value = name, onValueChange = { newText ->
|
||||
onTextChange(newText)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,7 +136,6 @@ fun AlertDialogFill(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clickable(onClick = {
|
||||
iconId = icon.resource
|
||||
onIconChange(icon)
|
||||
}),
|
||||
painter = painterResource(icon.resource),
|
||||
@@ -123,8 +154,7 @@ fun AlertDialogFill(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = {
|
||||
colorHex = color
|
||||
onColorChange(colorHex)
|
||||
onColorChange(color)
|
||||
})
|
||||
.size(30.dp)
|
||||
.aspectRatio(1f)
|
||||
@@ -132,5 +162,49 @@ fun AlertDialogFill(
|
||||
) {}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Switch(
|
||||
checked = isArchived,
|
||||
onCheckedChange = onArchivedChange
|
||||
)
|
||||
Text(
|
||||
text = "Archived",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewAddCategoryDialog() {
|
||||
TripMoneyTheme {
|
||||
AddCategoryDialog(
|
||||
onDismiss = {},
|
||||
onSave = {})
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewEditCategoryDialog() {
|
||||
TripMoneyTheme {
|
||||
AddCategoryDialog(
|
||||
onDismiss = {},
|
||||
onSave = {},
|
||||
categoryToEdit = Category(
|
||||
0, "Hotel",
|
||||
icon = cc.n0th1ng.tripmoney.utils.Icons.entries.random(),
|
||||
color = colors.random(),
|
||||
archived = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import cc.n0th1ng.tripmoney.R.*
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
|
||||
import cc.n0th1ng.tripmoney.utils.Icons
|
||||
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
|
||||
import com.composables.icons.materialsymbols.outlined.R
|
||||
|
||||
@@ -37,7 +36,7 @@ fun CategorySelectionDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCategorySelected: (Category) -> Unit,
|
||||
selected: Category,
|
||||
categories: List<Category>
|
||||
categories: List<Category>,
|
||||
) {
|
||||
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
|
||||
val listState = rememberLazyListState()
|
||||
@@ -91,7 +90,7 @@ fun CategorySelectionDialog(
|
||||
contentDescription = stringResource(string.category)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(string.add_new_category), modifier = Modifier.padding(start = 8.dp),
|
||||
text = stringResource(string.add_new), modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
@@ -47,6 +46,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -58,33 +58,46 @@ import androidx.paging.PagingData
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import cc.n0th1ng.tripmoney.R.string
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
import cc.n0th1ng.tripmoney.data.entity.Expense
|
||||
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
|
||||
import cc.n0th1ng.tripmoney.data.entity.Trip
|
||||
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
|
||||
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
|
||||
import cc.n0th1ng.tripmoney.utils.AllPreviews
|
||||
import cc.n0th1ng.tripmoney.utils.Currencies
|
||||
import cc.n0th1ng.tripmoney.utils.colors
|
||||
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
|
||||
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi
|
||||
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
|
||||
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@Composable
|
||||
fun ListExpenseScreen() {
|
||||
fun ListExpenseScreen(filter: String) {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val tripViewModel: TripViewModel = hiltViewModel()
|
||||
val currentTripId by settingsViewModel.currentTrip.collectAsState()
|
||||
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
|
||||
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
|
||||
val expensesFlow = expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId)
|
||||
val expensesFlow =
|
||||
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, filter)
|
||||
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
|
||||
|
||||
ListExpenseScreen(
|
||||
expensesFlow = expensesFlow,
|
||||
onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
|
||||
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
|
||||
isRecalculatingRate = isRecalculatingRate
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,7 +107,8 @@ fun ListExpenseScreen() {
|
||||
@Composable
|
||||
fun ListExpenseScreen(
|
||||
expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
|
||||
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit
|
||||
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
|
||||
isRecalculatingRate: Boolean
|
||||
) {
|
||||
|
||||
val items = expensesFlow.collectAsLazyPagingItems()
|
||||
@@ -111,13 +125,7 @@ fun ListExpenseScreen(
|
||||
)
|
||||
})
|
||||
{
|
||||
if (items.loadState.refresh == LoadState.Loading) {
|
||||
// Show loading indicator
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
else {
|
||||
Box {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -208,7 +216,7 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
|
||||
date.format(
|
||||
DateTimeFormatter.ofPattern("dd EEEE")
|
||||
).toString(),
|
||||
modifier = Modifier.background(Color.White.copy(alpha = 0f)),
|
||||
modifier = Modifier.padding(horizontal = 5.dp).background(Color.White.copy(alpha = 0f)),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Row(
|
||||
@@ -221,8 +229,14 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
|
||||
HorizontalDivider(modifier = Modifier.weight(2f))
|
||||
Text(
|
||||
"%.2f %s".format(sum, currency),
|
||||
modifier = Modifier.background(Color.White.copy(alpha = 0f)),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
modifier = Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.padding(5.dp),
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
@@ -256,7 +270,7 @@ fun SwipeToDeleteExpenseCard(
|
||||
Modifier
|
||||
.clip(CardDefaults.elevatedShape)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.onError)
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
@@ -329,7 +343,7 @@ fun ExpenseCard(
|
||||
) {
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors()
|
||||
.copy(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.height(70.dp)
|
||||
@@ -344,14 +358,29 @@ fun ExpenseCard(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
//TODO
|
||||
// .background(
|
||||
// Brush.horizontalGradient(
|
||||
// colorStops = arrayOf(
|
||||
// 1f to Color(expenseDto.category.color.toColorInt()),
|
||||
// 4f to MaterialTheme.colorScheme.surfaceDim
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(15.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceDim,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.padding(10.dp),
|
||||
painter = painterResource(expenseDto.category.icon.resource),
|
||||
contentDescription = "Category",
|
||||
tint = Color(expenseDto.category.color.toColorInt())
|
||||
@@ -367,13 +396,13 @@ fun ExpenseCard(
|
||||
Text(
|
||||
text = expenseDto.category.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(0.dp),
|
||||
text = expenseDto.expense.note,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@@ -382,7 +411,7 @@ fun ExpenseCard(
|
||||
DateTimeFormatter.ofPattern("dd MMM HH:mm")
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -390,7 +419,7 @@ fun ExpenseCard(
|
||||
Text(
|
||||
text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
|
||||
)
|
||||
@@ -398,7 +427,7 @@ fun ExpenseCard(
|
||||
Text(
|
||||
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@@ -407,107 +436,125 @@ fun ExpenseCard(
|
||||
}
|
||||
}
|
||||
|
||||
//@RequiresApi(Build.VERSION_CODES.O)
|
||||
//@AllPreviews
|
||||
//@Composable
|
||||
//fun PreviewListExpenseScreen() {
|
||||
// TripMoneyTheme() {
|
||||
// val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
|
||||
// ListExpenseScreen(
|
||||
// expensesDtoFlow = MutableStateFlow(pagingData),
|
||||
// onSaveExpense = {},
|
||||
// onDeleteExpense = {},
|
||||
// dailySums = emptyMap()
|
||||
// )
|
||||
//
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@AllPreviews
|
||||
//@Composable
|
||||
//fun PreviewDeleteConfirmationDialog() {
|
||||
// TripMoneyTheme() {
|
||||
// DeleteConfirmationDialog(
|
||||
// onConfirm = {},
|
||||
// onCancel = {})
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//
|
||||
//@RequiresApi(Build.VERSION_CODES.O)
|
||||
//private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseDto> {
|
||||
// val sampleCategories = listOf(
|
||||
// Category(
|
||||
// name = "Hotel",
|
||||
// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
|
||||
// color = colors.random()
|
||||
// ),
|
||||
// Category(
|
||||
// name = "Jedzenie",
|
||||
// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
|
||||
// color = colors.random()
|
||||
// ),
|
||||
// Category(
|
||||
// name = "Transport",
|
||||
// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
|
||||
// color = colors.random()
|
||||
// ),
|
||||
// Category(
|
||||
// name = "Rozrywka",
|
||||
// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
|
||||
// color = colors.random()
|
||||
// ),
|
||||
// Category(
|
||||
// name = "Zakupy",
|
||||
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
|
||||
// color = colors.random()
|
||||
// ),
|
||||
// )
|
||||
//
|
||||
// val trip = Trip(
|
||||
// id = 1,
|
||||
// name = "Vacation",
|
||||
// currency = "USD",
|
||||
// startDate = LocalDate.parse("2026-01-01")
|
||||
// )
|
||||
//
|
||||
// val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
|
||||
// val endLong = LocalDateTime.now().toEpochMilli()
|
||||
//
|
||||
// val result: MutableList<ExpenseDto> = mutableListOf()
|
||||
// for (i in 0..15) {
|
||||
// val category = sampleCategories.random()
|
||||
// val datetime = if (i > 4) {
|
||||
// LocalDateTime.ofEpochSecond(
|
||||
// Random.nextLong(startLong, endLong),
|
||||
// 0,
|
||||
// ZoneOffset.UTC
|
||||
// )
|
||||
// } else LocalDateTime.now()
|
||||
//
|
||||
// val expense = Expense(
|
||||
// id = i,
|
||||
// categoryId = category.id,
|
||||
// tripId = 1,
|
||||
// amount = Random.nextDouble(0.1, 300.0),
|
||||
// currency = Currencies.entries.random().name,
|
||||
// note = if (i % 3 == 0) "Some note" else "",
|
||||
// datetime = datetime,
|
||||
// rate = if (Random.nextBoolean()) Random.nextDouble(
|
||||
// 0.1,
|
||||
// 5.0
|
||||
// ) else 1.0
|
||||
// )
|
||||
//
|
||||
//
|
||||
// val expenseDto = ExpenseDto(
|
||||
// expense = expense,
|
||||
// category = category,
|
||||
// trip = trip
|
||||
// )
|
||||
// result.add(
|
||||
// expenseDto
|
||||
// )
|
||||
// }
|
||||
// return result
|
||||
//}
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewListExpenseScreen() {
|
||||
TripMoneyTheme() {
|
||||
val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
|
||||
ListExpenseScreen(
|
||||
expensesFlow = MutableStateFlow(pagingData),
|
||||
onSaveExpense = {},
|
||||
onDeleteExpense = {},
|
||||
true
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewDeleteConfirmationDialog() {
|
||||
TripMoneyTheme() {
|
||||
DeleteConfirmationDialog(
|
||||
onConfirm = {},
|
||||
onCancel = {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseListItemUi> {
|
||||
val sampleCategories = listOf(
|
||||
Category(
|
||||
name = "Hotel",
|
||||
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Jedzenie",
|
||||
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Transport",
|
||||
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Rozrywka",
|
||||
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
|
||||
color = colors.random()
|
||||
),
|
||||
Category(
|
||||
name = "Zakupy",
|
||||
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
|
||||
color = colors.random()
|
||||
),
|
||||
)
|
||||
|
||||
val trip = Trip(
|
||||
id = 1,
|
||||
name = "Vacation",
|
||||
currency = "USD",
|
||||
startDate = LocalDate.parse("2026-01-01")
|
||||
)
|
||||
|
||||
val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
|
||||
val endLong = LocalDateTime.now().toEpochMilli()
|
||||
|
||||
val result: MutableList<ExpenseListItemUi> = mutableListOf()
|
||||
result.add(
|
||||
ExpenseListItemUi.Header(
|
||||
LocalDateTime.ofEpochSecond(
|
||||
Random.nextLong(startLong, endLong),
|
||||
0,
|
||||
ZoneOffset.UTC
|
||||
).toLocalDate(), Random.nextDouble(0.1, 300.0), Currencies.entries.random().name
|
||||
)
|
||||
)
|
||||
for (i in 0..15) {
|
||||
val category = sampleCategories.random()
|
||||
val datetime = if (i > 4) {
|
||||
LocalDateTime.ofEpochSecond(
|
||||
Random.nextLong(startLong, endLong),
|
||||
0,
|
||||
ZoneOffset.UTC
|
||||
)
|
||||
} else LocalDateTime.now()
|
||||
|
||||
val expense = Expense(
|
||||
id = i,
|
||||
categoryId = category.id,
|
||||
tripId = 1,
|
||||
amount = Random.nextDouble(0.1, 300.0),
|
||||
currency = Currencies.entries.random().name,
|
||||
note = if (i % 3 == 0) "Some note" else "",
|
||||
datetime = datetime,
|
||||
rate = if (Random.nextBoolean()) Random.nextDouble(
|
||||
0.1,
|
||||
5.0
|
||||
) else 1.0
|
||||
)
|
||||
|
||||
|
||||
val expenseDto = ExpenseDto(
|
||||
expense = expense,
|
||||
category = category,
|
||||
trip = trip
|
||||
)
|
||||
result.add(
|
||||
ExpenseListItemUi.Item(expenseDto)
|
||||
)
|
||||
if (i % 5 == 0) {
|
||||
result.add(
|
||||
ExpenseListItemUi.Header(
|
||||
datetime.toLocalDate(),
|
||||
Random.nextDouble(0.1, 300.0),
|
||||
Currencies.entries.random().name
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package cc.n0th1ng.tripmoney.screens.managecategories
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import cc.n0th1ng.tripmoney.R.string
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
|
||||
import cc.n0th1ng.tripmoney.screens.addexpense.categoriesToPreview
|
||||
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
|
||||
import cc.n0th1ng.tripmoney.utils.AllPreviews
|
||||
import cc.n0th1ng.tripmoney.utils.colors
|
||||
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
|
||||
import com.composables.icons.materialsymbols.outlined.R
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.collections.emptyList
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@Composable
|
||||
fun ManageCategoriesScreen() {
|
||||
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
|
||||
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
|
||||
val archivedCategories by expenseAndCategoryViewModel.getArchivedCategories()
|
||||
.collectAsState(emptyList())
|
||||
ManageCategoriesScreen(
|
||||
categories = categories,
|
||||
archivedCategories = archivedCategories,
|
||||
onSaveCategory = { expenseAndCategoryViewModel.save(it) },
|
||||
onDeleteCategory = {
|
||||
expenseAndCategoryViewModel.delete(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@Composable
|
||||
fun ManageCategoriesScreen(
|
||||
categories: List<Category>,
|
||||
archivedCategories: List<Category>,
|
||||
onSaveCategory: (Category) -> Unit,
|
||||
onDeleteCategory: (Category) -> Unit,
|
||||
) {
|
||||
|
||||
var categoryToEdit by remember { mutableStateOf<Category?>(null) }
|
||||
var showAddCategoryDialog by remember { mutableStateOf(false) }
|
||||
var itemToDelete by remember { mutableStateOf<Category?>(null) }
|
||||
var itemToArchive by remember { mutableStateOf<Category?>(null) }
|
||||
|
||||
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddCategoryDialog = true },
|
||||
icon = { Icon(Icons.Filled.Add, stringResource(string.add_new)) },
|
||||
text = { Text(text = stringResource(string.add_new)) },
|
||||
)
|
||||
})
|
||||
{
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
items(categories, key = { it.id }) { category ->
|
||||
SwipeToDeleteExpenseCard(
|
||||
category = category,
|
||||
onDelete = { itemToArchive = category },
|
||||
onClick = {
|
||||
categoryToEdit = category
|
||||
showAddCategoryDialog = true
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
if (archivedCategories.isNotEmpty()) {
|
||||
item {
|
||||
CustomDivider()
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
items(archivedCategories, key = { it.id }) { archivedCategory ->
|
||||
SwipeToDeleteExpenseCard(
|
||||
category = archivedCategory,
|
||||
onDelete = { itemToDelete = archivedCategory },
|
||||
onClick = {
|
||||
categoryToEdit = archivedCategory
|
||||
showAddCategoryDialog = true
|
||||
},
|
||||
isArchived = true
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
}
|
||||
if (showAddCategoryDialog) {
|
||||
AddCategoryDialog(
|
||||
onDismiss = {
|
||||
showAddCategoryDialog = false
|
||||
}, onSave = { category ->
|
||||
onSaveCategory(category)
|
||||
showAddCategoryDialog = false
|
||||
},
|
||||
categoryToEdit = categoryToEdit
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (itemToDelete != null) {
|
||||
DeleteConfirmationDialog(
|
||||
bodyText = stringResource(string.delete_category_info),
|
||||
onConfirm = {
|
||||
onDeleteCategory(itemToDelete!!)
|
||||
itemToDelete = null
|
||||
},
|
||||
onCancel = {
|
||||
itemToDelete = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (itemToArchive != null) {
|
||||
DeleteConfirmationDialog(
|
||||
title = stringResource(string.you_want_archive),
|
||||
buttonText = stringResource(string.archive),
|
||||
bodyText = stringResource(string.archive_category_info),
|
||||
onConfirm = {
|
||||
onSaveCategory(itemToArchive!!.copy(archived = true))
|
||||
itemToArchive = null
|
||||
},
|
||||
onCancel = {
|
||||
itemToArchive = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@Composable
|
||||
private fun CustomDivider() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Absolute.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
"Archived",
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.background(Color.White.copy(alpha = 0f)),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@Composable
|
||||
fun SwipeToDeleteExpenseCard(
|
||||
category: Category,
|
||||
onDelete: (Category) -> Unit,
|
||||
onClick: (Category) -> Unit,
|
||||
isArchived: Boolean = false
|
||||
) {
|
||||
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { dismissValue ->
|
||||
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
|
||||
onDelete(category)
|
||||
false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
backgroundContent = {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CardDefaults.elevatedShape)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.onError)
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isArchived) R.drawable.materialsymbols_ic_delete_outlined
|
||||
else R.drawable.materialsymbols_ic_archive_outlined
|
||||
),
|
||||
contentDescription = stringResource(string.delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
CategoryCard(
|
||||
category = category,
|
||||
onClick = onClick,
|
||||
isArchived = isArchived
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeleteConfirmationDialog(
|
||||
title: String = stringResource(string.delete_confirmation),
|
||||
buttonText: String = stringResource(string.delete),
|
||||
bodyText: String = "",
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = { onCancel() }
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = bodyText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = onCancel,
|
||||
colors = ButtonDefaults.buttonColors().copy(containerColor = MaterialTheme.colorScheme.secondary),
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
){
|
||||
Text(text = stringResource(string.cancel),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondary)
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.buttonColors().copy(containerColor = MaterialTheme.colorScheme.error),
|
||||
){
|
||||
Text(text = buttonText,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@Composable
|
||||
fun CategoryCard(
|
||||
category: Category,
|
||||
onClick: (Category) -> Unit,
|
||||
isArchived: Boolean = false
|
||||
) {
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors()
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.height(70.dp)
|
||||
.combinedClickable(
|
||||
enabled = true,
|
||||
onClick = { onClick(category) },
|
||||
onLongClick = { onClick(category) }),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(if (isArchived) 0.6f else 1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(15.dp),
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceDim,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.padding(10.dp),
|
||||
painter = painterResource(category.icon.resource),
|
||||
contentDescription = "Category",
|
||||
tint = Color(category.color.toColorInt())
|
||||
)
|
||||
|
||||
Column()
|
||||
{
|
||||
Text(
|
||||
text = category.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewManageCategoriesScreen() {
|
||||
TripMoneyTheme {
|
||||
ManageCategoriesScreen(categories = categoriesToPreview.subList(0,2), categoriesToPreview.subList(3,5), {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewAddCategoryDialog() {
|
||||
TripMoneyTheme {
|
||||
AddCategoryDialog(
|
||||
onDismiss = {},
|
||||
onSave = {})
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewEditCategoryDialog() {
|
||||
TripMoneyTheme {
|
||||
AddCategoryDialog(
|
||||
onDismiss = {},
|
||||
onSave = {},
|
||||
categoryToEdit = Category(
|
||||
0, "Hotel",
|
||||
icon = cc.n0th1ng.tripmoney.utils.Icons.entries.random(),
|
||||
color = colors.random(),
|
||||
archived = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewDeleteConfirmationDialog() {
|
||||
TripMoneyTheme {
|
||||
DeleteConfirmationDialog(
|
||||
onConfirm = {},
|
||||
onCancel = {},
|
||||
bodyText = "Your all expenses with category Hotel will be removed.",
|
||||
title = "Do you want to delete?",
|
||||
buttonText = "Delete"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
{},
|
||||
{})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cc.n0th1ng.tripmoney.screens.statistics
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.background
|
||||
@@ -13,9 +14,15 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -30,6 +37,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import cc.n0th1ng.tripmoney.R.string
|
||||
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
|
||||
import cc.n0th1ng.tripmoney.data.entity.Category
|
||||
import cc.n0th1ng.tripmoney.data.entity.Trip
|
||||
@@ -82,9 +90,10 @@ fun StatisticsScreen(
|
||||
|
||||
@Composable
|
||||
fun Summary(summaryAmount: Double, currency: String) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors()
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -94,7 +103,10 @@ fun Summary(summaryAmount: Double, currency: String) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses), style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Text(
|
||||
"%.2f %s".format(summaryAmount, currency),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
@@ -118,7 +130,11 @@ fun Summary(summaryAmount: Double, currency: String) {
|
||||
|
||||
@Composable
|
||||
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors()
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(15.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
@@ -191,11 +207,13 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun Preview() {
|
||||
TripMoneyTheme {
|
||||
Scaffold {
|
||||
StatisticsScreen(
|
||||
summaryPerCategoryList,
|
||||
summaryAmount = 125.24,
|
||||
@@ -203,6 +221,7 @@ fun Preview() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val categories = listOf(
|
||||
Category(name = "Jedzenie", icon = Icons.RESTAURANT, color = colors.random()),
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -21,7 +20,6 @@ import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -50,16 +48,21 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import cc.n0th1ng.tripmoney.R.string
|
||||
import cc.n0th1ng.tripmoney.data.entity.Trip
|
||||
import cc.n0th1ng.tripmoney.navigation.Screens
|
||||
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
|
||||
import cc.n0th1ng.tripmoney.screens.listexpense.DeleteConfirmationDialog
|
||||
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
|
||||
import cc.n0th1ng.tripmoney.utils.AllPreviews
|
||||
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
|
||||
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.time.LocalDate
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@@ -70,9 +73,38 @@ fun TripPickerScreen(
|
||||
) {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val tripViewModel: TripViewModel = hiltViewModel()
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems()
|
||||
val tripsFlow = tripViewModel.getTrips()
|
||||
val currentTripId by settingsViewModel.currentTrip.collectAsState()
|
||||
|
||||
TripPickerScreen(
|
||||
tripsFlow = tripsFlow,
|
||||
currentTripId = currentTripId,
|
||||
onDelete = { trip -> tripViewModel.delete(trip) },
|
||||
onClick = { trip ->
|
||||
settingsViewModel.setCurrentTrip(trip.id)
|
||||
navController.navigate(Screens.LIST_EXPENSE)
|
||||
},
|
||||
onSave = { trip ->
|
||||
tripViewModel.save(trip)
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@Composable
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
fun TripPickerScreen(
|
||||
tripsFlow: Flow<PagingData<Trip>>,
|
||||
currentTripId: Int,
|
||||
onDelete: (Trip) -> Unit,
|
||||
onClick: (Trip) -> Unit,
|
||||
onSave: (Trip) -> Unit
|
||||
) {
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
val trips: LazyPagingItems<Trip> = tripsFlow.collectAsLazyPagingItems()
|
||||
|
||||
var tripToEdit by remember { mutableStateOf<Trip?>(null) }
|
||||
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
@@ -91,11 +123,11 @@ fun TripPickerScreen(
|
||||
val trip = trips[i]
|
||||
if (trip != null) {
|
||||
SwipeToDeleteTripCard(
|
||||
trip, onDelete = {
|
||||
tripViewModel.delete(trip)
|
||||
trip = trip,
|
||||
onDelete = {
|
||||
onDelete(trip)
|
||||
}, onClick = {
|
||||
settingsViewModel.setCurrentTrip(trip.id)
|
||||
navController.navigate(Screens.LIST_EXPENSE)
|
||||
onClick(trip)
|
||||
}, isSelected = currentTripId == trip.id,
|
||||
onLongClick = { trip ->
|
||||
tripToEdit = trip
|
||||
@@ -113,7 +145,7 @@ fun TripPickerScreen(
|
||||
tripToEdit = null
|
||||
},
|
||||
onSave = { trip ->
|
||||
tripViewModel.save(trip)
|
||||
onSave(trip)
|
||||
showBottomSheet = false
|
||||
tripToEdit = null
|
||||
},
|
||||
@@ -152,7 +184,6 @@ fun SwipeToDeleteTripCard(
|
||||
}
|
||||
|
||||
SwipeToDismissBox(
|
||||
modifier = Modifier.alpha(if (isSelected) 1.0f else 0.7f),
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
backgroundContent = {
|
||||
@@ -160,7 +191,7 @@ fun SwipeToDeleteTripCard(
|
||||
Modifier
|
||||
.clip(CardDefaults.elevatedShape)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.onError)
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
@@ -186,7 +217,7 @@ fun TripCard(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
@@ -195,7 +226,7 @@ fun TripCard(
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick(trip)
|
||||
}, onClick = { onClick(trip) }),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp)
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -217,3 +248,38 @@ fun TripCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AllPreviews
|
||||
@Composable
|
||||
fun PreviewTripPickerScreen() {
|
||||
val tripsToPreview = listOf(
|
||||
Trip(
|
||||
1,
|
||||
name = "Włochy",
|
||||
startDate = LocalDate.parse("2026-03-01"),
|
||||
currency = "PLN"
|
||||
),
|
||||
Trip(
|
||||
2,
|
||||
name = "Szwajcaria",
|
||||
startDate = LocalDate.parse("2025-03-01"),
|
||||
currency = "EUR"
|
||||
),
|
||||
Trip(
|
||||
3,
|
||||
name = "Portugalia",
|
||||
startDate = LocalDate.parse("2025-03-01"),
|
||||
currency = "USD"
|
||||
)
|
||||
)
|
||||
TripMoneyTheme {
|
||||
TripPickerScreen(
|
||||
tripsFlow = MutableStateFlow(PagingData.from(tripsToPreview)),
|
||||
currentTripId = 1,
|
||||
onDelete = {},
|
||||
onClick = {},
|
||||
onSave = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -40,15 +40,28 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
|
||||
private val tripRepo: TripRepository
|
||||
) : ViewModel() {
|
||||
|
||||
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> =
|
||||
expenseRepo.getExpensesDtoPaged(tripId).cachedIn(viewModelScope)
|
||||
fun archiveCategory(category: Category) {
|
||||
viewModelScope.launch {
|
||||
categoryRepo.save(category.copy(archived = true))
|
||||
}
|
||||
}
|
||||
|
||||
fun deArchiveCategory(category: Category) {
|
||||
viewModelScope.launch {
|
||||
categoryRepo.save(category.copy(archived = false))
|
||||
}
|
||||
}
|
||||
|
||||
fun getExpensesDtoPaged(tripId: Int, filter: String = ""): Flow<PagingData<ExpenseDto>> =
|
||||
expenseRepo.getExpensesDtoPaged(tripId, filter).cachedIn(viewModelScope)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun getExpensesWithHeadersPaged(
|
||||
tripId: Int
|
||||
tripId: Int,
|
||||
filter: String = ""
|
||||
): Flow<PagingData<ExpenseListItemUi>> {
|
||||
val pagingFlow = getExpensesDtoPaged(tripId)
|
||||
val sumsFlow = getDailySums(tripId)
|
||||
val pagingFlow = getExpensesDtoPaged(tripId, filter)
|
||||
val sumsFlow = getDailySums(tripId, filter)
|
||||
val tripFlow = tripRepo.getTrip(tripId)
|
||||
return combine(pagingFlow, sumsFlow, tripFlow) { pagingData, sums, trip ->
|
||||
val currency = trip?.currency ?: ""
|
||||
@@ -80,8 +93,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
|
||||
}.cachedIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> =
|
||||
expenseRepo.getExpensesDto(tripId)
|
||||
fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> =
|
||||
expenseRepo.getExpensesDto(tripId, filter)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun save(expense: Expense, trip: Trip) {
|
||||
@@ -96,14 +109,20 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun delete(expense: Expense) {
|
||||
viewModelScope.launch {
|
||||
expenseRepo.delete(expense)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(category: Category) {
|
||||
viewModelScope.launch {
|
||||
categoryRepo.delete(category)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCategories(): Flow<List<Category>> = categoryRepo.getCategories()
|
||||
fun getArchivedCategories(): Flow<List<Category>> = categoryRepo.getArchivedCategories()
|
||||
|
||||
fun save(category: Category) {
|
||||
viewModelScope.launch {
|
||||
@@ -132,8 +151,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun getDailySums(tripId: Int): Flow<Map<LocalDate, Double>> {
|
||||
return getExpensesDto(tripId)
|
||||
fun getDailySums(tripId: Int, filter: String): Flow<Map<LocalDate, Double>> {
|
||||
return getExpensesDto(tripId, filter)
|
||||
.map { expenses ->
|
||||
expenses.groupBy { it.expense.datetime.toLocalDate() }
|
||||
.mapValues { (_, list) ->
|
||||
@@ -182,7 +201,8 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
sealed class ExpenseListItemUi {
|
||||
data class Item(val expenseDto: ExpenseDto) : ExpenseListItemUi()
|
||||
data class Header(val date: LocalDate, val sum: Double, val currency: String) : ExpenseListItemUi()
|
||||
data class Header(val date: LocalDate, val sum: Double, val currency: String) :
|
||||
ExpenseListItemUi()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
package cc.n0th1ng.tripmoney.viewmodel
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import cc.n0th1ng.tripmoney.data.entity.Expense
|
||||
import cc.n0th1ng.tripmoney.data.entity.Trip
|
||||
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
|
||||
import cc.n0th1ng.tripmoney.data.repository.TripRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TripViewModel @Inject constructor(private val repository: TripRepository) : ViewModel() {
|
||||
|
||||
class TripViewModel @Inject constructor(
|
||||
private val repository: TripRepository,
|
||||
private val expenseRepository: ExpenseRepository
|
||||
) : ViewModel() {
|
||||
private val _isRecalculating = MutableStateFlow(false)
|
||||
val isRecalculating: StateFlow<Boolean> = _isRecalculating
|
||||
fun getTrips(): Flow<PagingData<Trip>> = repository.getTrips().cachedIn(viewModelScope)
|
||||
|
||||
fun getTrip(tripId: Int): Flow<Trip?> = repository.getTrip(tripId)
|
||||
@@ -26,9 +35,15 @@ class TripViewModel @Inject constructor(private val repository: TripRepository)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun save(trip: Trip) {
|
||||
viewModelScope.launch {
|
||||
repository.save(trip)
|
||||
_isRecalculating.value = true
|
||||
withContext(Dispatchers.IO) {
|
||||
expenseRepository.recalculateTripExpenses(trip.id)
|
||||
}
|
||||
_isRecalculating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="save">Zapisz</string>
|
||||
<string name="backspace">Usuń</string>
|
||||
<string name="pick_category">Wybierz kategorie</string>
|
||||
<string name="add_new_category">dodaj nową</string>
|
||||
<string name="add_new">dodaj nową</string>
|
||||
<string name="pick_currency">Wybierz kategorie</string>
|
||||
<string name="cancel">Anuluj</string>
|
||||
<string name="add_expense">Dodaj wydatek</string>
|
||||
@@ -27,4 +27,9 @@
|
||||
<string name="list_of_expenses">Lista wydatków</string>
|
||||
<string name="statistics">Statystyki</string>
|
||||
<string name="export_to_csv">Eksport do CSV</string>
|
||||
<string name="manage_categories">Zarządzaj kategoriami</string>
|
||||
<string name="categories">Kategorie</string>
|
||||
<string name="export_csv_subttext">Zapisz wydatki z %s do pliku</string>
|
||||
<string name="add_new_category">Dodaj kategorie</string>
|
||||
<string name="edit_category">Edytuj kategorie</string>
|
||||
</resources>
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="save">Save</string>
|
||||
<string name="backspace">Backspace</string>
|
||||
<string name="pick_category">Pick a category</string>
|
||||
<string name="add_new_category">Add new</string>
|
||||
<string name="add_new">Add new</string>
|
||||
<string name="pick_currency">Pick a currency</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="add_expense">Add expense</string>
|
||||
@@ -27,4 +27,13 @@
|
||||
<string name="list_of_expenses">List of expenses</string>
|
||||
<string name="statistics">Statistics</string>
|
||||
<string name="export_to_csv">Export to CSV</string>
|
||||
<string name="export_csv_subttext">Save expenses from %s to a file</string>
|
||||
<string name="manage_categories">Manage categories</string>
|
||||
<string name="categories">Categories</string>
|
||||
<string name="add_new_category">Add category</string>
|
||||
<string name="edit_category">Edit category</string>
|
||||
<string name="archive">Archive</string>
|
||||
<string name="you_want_archive">Do you want to archive?</string>
|
||||
<string name="archive_category_info">No expense will be deleted.</string>
|
||||
<string name="delete_category_info">Your all expenses with category Hotel will be removed.</string>
|
||||
</resources>
|
||||
1
baselineprofile/.gitignore
vendored
Normal file
1
baselineprofile/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
52
baselineprofile/build.gradle.kts
Normal file
52
baselineprofile/build.gradle.kts
Normal file
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.test)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.baselineprofile)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cc.n0th1ng.baselineprofile"
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
targetProjectPath = ":app"
|
||||
|
||||
}
|
||||
|
||||
// This is the configuration block for the Baseline Profile plugin.
|
||||
// You can specify to run the generators on a managed devices or connected devices.
|
||||
baselineProfile {
|
||||
useConnectedDevices = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.junit)
|
||||
implementation(libs.androidx.espresso.core)
|
||||
implementation(libs.androidx.uiautomator)
|
||||
implementation(libs.androidx.benchmark.macro.junit4)
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { v ->
|
||||
val artifactsLoader = v.artifacts.getBuiltArtifactsLoader()
|
||||
v.instrumentationRunnerArguments.put(
|
||||
"targetAppId",
|
||||
v.testedApks.map { artifactsLoader.load(it)?.applicationId }
|
||||
)
|
||||
}
|
||||
}
|
||||
1
baselineprofile/src/main/AndroidManifest.xml
Normal file
1
baselineprofile/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -21,3 +21,4 @@ kotlin.code.style=official
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.configuration-cache=true
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -21,4 +21,4 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "tripMoney"
|
||||
include(":app")
|
||||
|
||||
include(":baselineprofile")
|
||||
|
||||
Reference in New Issue
Block a user