Merge pull request 'init' (#48) from develop into main

Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
2026-04-30 10:35:57 +02:00
61 changed files with 13274 additions and 737 deletions

View File

@@ -0,0 +1 @@
-dontobfuscate

View File

@@ -4,12 +4,15 @@ plugins {
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
alias(libs.plugins.baselineprofile)
}
android {
namespace = "cc.n0th1ng.tripmoney"
compileSdk = 36
buildFeatures {
buildConfig = true
}
defaultConfig {
applicationId = "cc.n0th1ng.tripmoney"
minSdk = 24
@@ -18,23 +21,39 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["androidx.compose.ui.test.tagsAsResourceId"] = "true"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
isDebuggable = true
}
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
create("benchmark") {
isMinifyEnabled = false
isShrinkResources = false
initWith(buildTypes.getByName("release"))
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release")
isDebuggable = false
proguardFiles("baseline-profiles-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode")
}
buildFeatures {
@@ -54,6 +73,7 @@ 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)
@@ -62,40 +82,32 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
val room_version = "2.8.4"
coreLibraryDesugaring(libs.tools.desugar.jdk.libs)
implementation("androidx.room:room-runtime:$room_version")
implementation(libs.androidx.room.runtime)
// If this project uses any Kotlin source, use Kotlin Symbol Processing (KSP)
// See Add the KSP plugin to your project
ksp("androidx.room:room-compiler:$room_version")
ksp(libs.androidx.room.compiler)
// optional - Kotlin Extensions and Coroutines support for Room
implementation("androidx.room:room-ktx:$room_version")
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.rxjava2)
implementation(libs.androidx.room.rxjava3)
implementation(libs.androidx.room.guava)
testImplementation(libs.androidx.room.testing)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.icons.material.symbols.outlined.android)
implementation(libs.icons.material.symbols.outlined.filled.android)
// optional - RxJava2 support for Room
implementation("androidx.room:room-rxjava2:$room_version")
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// optional - RxJava3 support for Room
implementation("androidx.room:room-rxjava3:$room_version")
// optional - Guava support for Room, including Optional and ListenableFuture
implementation("androidx.room:room-guava:$room_version")
// optional - Test helpers
testImplementation("androidx.room:room-testing:$room_version")
// optional - Paging 3 Integration
implementation("androidx.room:room-paging:$room_version")
implementation("androidx.paging:paging-runtime:3.4.2")
implementation("androidx.paging:paging-compose:3.4.2")
implementation("androidx.datastore:datastore-preferences:1.2.1")
implementation("com.composables:icons-material-symbols-outlined-android:2.2.1")
implementation("com.google.dagger:hilt-android:2.57.1")
ksp("com.google.dagger:hilt-android-compiler:2.57.1")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.kotlinx.serialization.json.jvm)
implementation(libs.commons.csv)
}

View File

@@ -19,3 +19,4 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings

View File

@@ -12,15 +12,31 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TripMoney">
<profileable
android:shell="true"
tools:targetApi="29" />
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.TripMoney">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ package cc.n0th1ng.tripmoney
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
@@ -11,65 +12,83 @@ import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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 cc.n0th1ng.tripmoney.data.DatabasePrepopulator
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.BottomNavigation
import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer
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
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var databasePrePopulate: DatabasePrepopulator
@RequiresApi(Build.VERSION_CODES.O)
@RequiresApi(Build.VERSION_CODES.S)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CoroutineScope(Dispatchers.IO).launch {
databasePrePopulate.prepopulate()
}
enableEdgeToEdge()
setContent {
TripMoneyTheme {
NavigationDrawer()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val navController = rememberNavController()
val navBackStack by navController.currentBackStackEntryAsState()
val current = navBackStack?.destination?.route
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
var search by remember { mutableStateOf("") }
var filter by remember { mutableStateOf(Filter()) }
val autoOpenPref by settingsViewModel.autoOpenStartupPref.collectAsState()
var hasHandledStartupOpen by rememberSaveable { mutableStateOf(false) }
val shouldTriggerAutoOpen = autoOpenPref == true && !hasHandledStartupOpen
ReportDrawnWhen { !categories.isEmpty() }
CustomNavigationDrawer(navController, drawerState) {
Scaffold(
topBar = {
if (current == Screens.SETTINGS) TopBarSettings(
navController
) else TopBar(onClick = {
) else TopBar(
title = currentTrip?.name ?: "",
onDrawerClick = {
scope.launch {
if (drawerState.isClosed) {
drawerState.open()
@@ -77,17 +96,26 @@ fun NavigationDrawer() {
drawerState.close()
}
}
})
},
isSearchable = current == Screens.LIST_EXPENSE,
onSearchChange = { newSearch -> search = newSearch },
onFilterChange = { newFilter -> filter = newFilter },
categories = categories,
filter = filter
)
},
bottomBar = { BottomNavigation(navController) }) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screens.TRIP_PICKER,
startDestination = if (currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE,
modifier = Modifier.padding(innerPadding)
) {
composable(Screens.LIST_EXPENSE) {
ListExpenseScreen()
ListExpenseScreen(
filter = filter, search = search,
initialAutoOpen = shouldTriggerAutoOpen,
onAutoOpenConsumed = { hasHandledStartupOpen = true })
}
composable(Screens.TRIP_PICKER) {
TripPickerScreen(navController)
@@ -96,10 +124,37 @@ fun NavigationDrawer() {
StatisticsScreen()
}
composable(Screens.SETTINGS) {
SettingsScreen()
SettingsScreen(navController)
}
composable(Screens.MANAGE_CATEGORIES) {
ManageCategoriesScreen()
}
}
}
}
}
data class Filter(
val categories: List<Category> = emptyList(), val startAmount: Double = 0.0,
val endAmount: Double = Double.MAX_VALUE
) {
fun with(category: Category): Filter {
return this.copy(categories = categories + category)
}
fun withStartAmount(amount: Double): Filter {
return this.copy(startAmount = amount)
}
fun withEndAmount(amount: Double): Filter {
return this.copy(endAmount = amount)
}
fun without(category: Category): Filter {
return this.copy(categories = categories - category)
}
fun isDefault(): Boolean {
return this.categories.isEmpty() && startAmount == 0.0 && endAmount == Double.MAX_VALUE
}
}

View File

@@ -0,0 +1,41 @@
package cc.n0th1ng.tripmoney.data
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.room.TypeConverter
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
class Converters {
@RequiresApi(Build.VERSION_CODES.O)
@TypeConverter
fun fromLocalDatetime(value: LocalDateTime): Long {
return value
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}
@RequiresApi(Build.VERSION_CODES.O)
@TypeConverter
fun toLocalDateTime(value: Long): LocalDateTime {
return Instant.ofEpochMilli(value)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
}
@RequiresApi(Build.VERSION_CODES.O)
@TypeConverter
fun fromLocalDate(value: LocalDate): Long {
return value.toEpochDay()
}
@RequiresApi(Build.VERSION_CODES.O)
@TypeConverter
fun toLocalDate(value: Long): LocalDate {
return LocalDate.ofEpochDay(value)
}
}

View File

@@ -6,14 +6,19 @@ import androidx.annotation.RequiresApi
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.room.TypeConverters
import cc.n0th1ng.tripmoney.BuildConfig
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
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.Trip
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -22,46 +27,59 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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
@Database(entities = [Trip::class, Expense::class, Category::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
abstract fun expenseDao(): ExpenseDao
abstract fun categoryDao(): CategoryDao
companion object {
@Volatile
private var INSTANCE: TripDatabase? = null
fun getInstance(context: Context): TripDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.inMemoryDatabaseBuilder(
context,
TripDatabase::class.java
).allowMainThreadQueries().build().also { INSTANCE = it }
}
}
}
abstract fun exchangeRateDao(): ExchangeRateDao
}
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O)
@Provides
@Singleton
fun provideTripDatabase(
@ApplicationContext context: Context
): TripDatabase {
return Room.inMemoryDatabaseBuilder(
context,
TripDatabase::class.java
val builder = if (BuildConfig.DEBUG) Room.inMemoryDatabaseBuilder(
context = context, klass = TripDatabase::class.java
) else Room.databaseBuilder(
name = "tripmoney_db",
context = context,
klass = TripDatabase::class.java,
)
.allowMainThreadQueries() // Only for in-memory DB, not for production!
val db: TripDatabase =
builder.fallbackToDestructiveMigration() // TODO Handle schema changes during dev
.build()
if (BuildConfig.DEBUG) {
CoroutineScope(Dispatchers.IO).launch {
DatabasePrepopulator(
tripDao = db.tripDao(),
categoryDao = db.categoryDao(),
expenseDao = db.expenseDao()
).prepopulate()
}
}
return db
}
@Provides
@@ -84,55 +102,124 @@ object DatabaseModule {
@Provides
@Singleton
fun provideDatabasePrepopulator(
tripDao: TripDao,
categoryDao: CategoryDao,
expenseDao: ExpenseDao
): DatabasePrepopulator {
return DatabasePrepopulator(tripDao, categoryDao, expenseDao)
fun provideExchangeRateDao(database: TripDatabase): ExchangeRateDao {
return database.exchangeRateDao()
}
}
class DatabasePrepopulator @Inject constructor(
private class DatabasePrepopulator(
private val tripDao: TripDao,
private val categoryDao: CategoryDao,
private val expenseDao: ExpenseDao
) {
@RequiresApi(Build.VERSION_CODES.O)
suspend fun prepopulate() {
tripDao.insert(Trip(name = "Włochy", startDate = "2025-01-01", currency = "PLN"))
tripDao.insert(Trip(name = "Szwajcaria", startDate = "2025-03-01", currency = "EUR"))
tripDao.insert(Trip(name = "Portugalia", startDate = "2026-03-01", currency = "USD"))
categoryDao.insert(Category(name = "Hotel", icon = Icons.HOTEL, color = "#B3E5FC"))
categoryDao.insert(Category(name = "Jedzenie", icon = Icons.RESTAURANT, color = "#C8E6C9"))
categoryDao.insert(Category(name = "Transport", icon = Icons.FLIGHT, color = "#FFCDD2"))
categoryDao.insert(Category(name = "Rozrywka", icon = Icons.ATTRACTION, color = "#FFF9C4"))
categoryDao.insert(Category(name = "Zakupy", icon = Icons.GROCERIES, color = "#E1BEE7"))
categoryDao.insert(Category(name = "Zakupy1", icon = Icons.GROCERIES, color = "#D7CCC8"))
categoryDao.insert(Category(name = "Zakupy2", icon = Icons.GROCERIES, color = "#BBDEFB"))
categoryDao.insert(Category(name = "Zakupy3", icon = Icons.GROCERIES, color = "#D1C4E9"))
categoryDao.insert(Category(name = "Zakupy4", icon = Icons.GROCERIES, color = "#DCEDC8"))
categoryDao.insert(Category(name = "Zakupy5", icon = Icons.GROCERIES, color = "#F0F4C3"))
categoryDao.insert(Category(name = "Zakupy6", icon = Icons.GROCERIES, color = "#FFE0B2"))
categoryDao.insert(Category(name = "Zakupy7", icon = Icons.GROCERIES, color = "#D7CCC8"))
categoryDao.insert(Category(name = "Zakupy8", icon = Icons.GROCERIES, color = "#CFD8DC"))
tripDao.insert(
Trip(
name = "Włochy",
startDate = LocalDate.parse("2026-03-01"),
endDate = LocalDate.parse("2026-03-15"),
currency = "PLN"
)
)
tripDao.insert(
Trip(
name = "Szwajcaria",
startDate = LocalDate.parse("2025-03-01"),
endDate = LocalDate.parse("2025-03-15"),
currency = "EUR"
)
)
tripDao.insert(
Trip(
name = "Portugalia",
startDate = LocalDate.parse("2025-03-01"),
endDate = LocalDate.parse("2025-03-15"),
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()
),
Category(
name = "Zakupy1", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy2", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy3", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy4", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy5", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy6", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy7", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy8", icon = Icons.GROCERIES, color = colors.random()
),
Category(
name = "Zakupy9", 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).toString(), categoryId = 1, tripId = 1))
expenseDao.insert(Expense(amount = 45.75, currency = "PLN", note = "Dinner", datetime = now.minusDays(9).toString(), categoryId = 2, tripId = 1))
expenseDao.insert(Expense(amount = 15.20, currency = "PLN", note = "Bus ticket", datetime = now.minusDays(8).toString(), categoryId = 3, tripId = 1))
expenseDao.insert(Expense(amount = 89.99, currency = "PLN", note = "Concert tickets", datetime = now.minusDays(7).toString(), categoryId = 4, tripId = 1))
expenseDao.insert(Expense(amount = 32.50, currency = "PLN", note = "Souvenirs", datetime = now.minusDays(6).toString(), categoryId = 5, tripId = 1))
expenseDao.insert(Expense(amount = 180.00, currency = "PLN", note = "Hotel 3 nights", datetime = now.minusDays(5).toString(), categoryId = 1, tripId = 1))
expenseDao.insert(Expense(amount = 67.30, currency = "PLN", note = "Lunch", datetime = now.minusDays(4).toString(), categoryId = 2, tripId = 1))
expenseDao.insert(Expense(amount = 22.00, currency = "PLN", note = "Train ticket", datetime = now.minusDays(3).toString(), categoryId = 3, tripId = 1))
expenseDao.insert(Expense(amount = 55.00, currency = "PLN", note = "Museum entry", datetime = now.minusDays(2).toString(), categoryId = 4, tripId = 1))
expenseDao.insert(Expense(amount = 12.99, currency = "PLN", note = "Snacks", datetime = now.minusDays(1).toString(), categoryId = 2, tripId = 1))
expenseDao.insert(Expense(amount = 210.00, currency = "PLN", note = "Hotel 5 nights", datetime = now.toString(), categoryId = 1, tripId = 1))
expenseDao.insert(Expense(amount = 95.50, currency = "EUR", note = "Dinner for two", datetime = now.minusHours(12).toString(), categoryId = 2, tripId = 1))
expenseDao.insert(Expense(amount = 30.00, currency = "EUR", note = "Taxi", datetime = now.minusHours(6).toString(), categoryId = 3, tripId = 1))
expenseDao.insert(Expense(amount = 40.00, currency = "USD", note = "Gifts", datetime = now.minusHours(3).toString(), categoryId = 5, tripId = 1))
expenseDao.insert(Expense(amount = 75.00, currency = "PLN", note = "Sightseeing tour", datetime = now.minusHours(1).toString(), 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, sampleCategories.size),
tripId = 1,
amount = Random.nextDouble(0.1, 300.0),
currency = Currencies.entries.random().name,
note = if (i % 3 == 0) "Some note" else "",
datetime = datetime,
rate = if (Random.nextBoolean()) Random.nextDouble(
0.1, 5.0
) else 1.0
)
expense
}
}

View File

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

View File

@@ -0,0 +1,22 @@
package cc.n0th1ng.tripmoney.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import kotlinx.coroutines.flow.Flow
@Dao
interface ExchangeRateDao {
@Upsert
suspend fun insert(exchangeRate: ExchangeRate)
@Query("SELECT * FROM exchange_rate WHERE id = :id")
suspend fun getById(id: String): ExchangeRate?
@Query("DELETE FROM exchange_rate WHERE DATE(date) < :cutoffDate")
suspend fun deleteOldRates(cutoffDate: String)
}

View File

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

View File

@@ -2,10 +2,12 @@ package cc.n0th1ng.tripmoney.data.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Trip
import kotlinx.coroutines.flow.Flow
@Dao
interface TripDao {
@@ -15,8 +17,16 @@ interface TripDao {
@Query(
"""
SELECT * FROM trip
ORDER BY trip.start_date DESC
"""
)
fun tripsPaged(): PagingSource<Int, Trip>
@Delete
suspend fun delete(trip: Trip)
@Query(
"SELECT * FROM trip where trip.id = :tripId"
)
fun trip(tripId: Int): Flow<Trip?>
}

View File

@@ -0,0 +1,22 @@
package cc.n0th1ng.tripmoney.data.dto
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
data class SummaryPerCategory(
val category: Category,
val amount: Double,
val percent: Float,
val currency: Currencies
)
data class SummaryPerCategoryRaw(
val categoryId: Int,
val categoryName: String,
val icon: Icons,
val color: String,
val amount: Double,
val currency: String
)

View File

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

View File

@@ -0,0 +1,20 @@
package cc.n0th1ng.tripmoney.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity("exchange_rate")
data class ExchangeRate(
@PrimaryKey
val id: String,
val base: String,
val target: String,
val rate: Double,
val date: String
) {
companion object {
fun buildKey(base: String, target: String, date: String): String {
return "${base}_${target}_${date}"
}
}
}

View File

@@ -1,21 +1,41 @@
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.Index
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
)],
indices = [Index(value = ["category_id"])]
)
@Immutable
data class Expense(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("amount") val amount: Double,
@ColumnInfo("currency") val currency: String,
@ColumnInfo("note") val note: String,
@ColumnInfo("datetime") val datetime: String,
@ColumnInfo("datetime") val datetime: LocalDateTime,
@ColumnInfo("category_id") val categoryId: Int,
@ColumnInfo("trip_id") val tripId: Int
)
@ColumnInfo("trip_id") val tripId: Int,
@ColumnInfo("rate") val rate: Double = 1.0
) {
fun convertedAmount(): Double {
return this.amount * this.rate
}
}
data class ExpenseDto(
@Embedded val expense: Expense,

View File

@@ -1,13 +1,31 @@
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
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: String,
@ColumnInfo("currency") val currency: String
)
@ColumnInfo("start_date") val startDate: LocalDate,
@ColumnInfo("end_date") val endDate: LocalDate,
@ColumnInfo("currency") val currency: String,
@ColumnInfo("budget") val budget: Double = 0.0
) {
companion object {
@RequiresApi(Build.VERSION_CODES.O)
val DUMMY = Trip(
-1,
"",
LocalDate.now(),
endDate = LocalDate.now(), Currencies.default().name, budget = 0.0,
)
}
}

View File

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

View File

@@ -0,0 +1,53 @@
package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.service.ExchangeService
import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import javax.inject.Inject
class ExchangeRateRepository @Inject constructor(
private val exchangeRateDao: ExchangeRateDao,
private val exchangeService: ExchangeService
) {
@WorkerThread
suspend fun save(exchangeRate: ExchangeRate) {
exchangeRateDao.insert(exchangeRate)
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun getRate(base: Currencies, target: Currencies, date: LocalDate): Double {
if(base == target) return 1.0
val id = ExchangeRate.buildKey(base.name, target.name, date.toString())
val cachedRate = exchangeRateDao.getById(id)
return if (cachedRate != null) {
cachedRate.rate
} else {
val rate = exchangeService.getRate(base, target, date)
exchangeRateDao.insert(
ExchangeRate(
id = id,
base = base.name,
target = target.name,
rate = rate,
date = date.toString()
)
)
rate
}
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun clearOldRates(daysToKeep: Int = 180) {
val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString()
exchangeRateDao.deleteOldRates(cutoffDate)
}
}

View File

@@ -1,16 +1,28 @@
package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class ExpenseRepository @Inject constructor(private val expenseDao: ExpenseDao) {
class ExpenseRepository @Inject constructor(
private val expenseDao: ExpenseDao,
private val exchangeRateRepository: ExchangeRateRepository
) {
fun getBudgetLeft(tripId: Int): Flow<Double?> {
return expenseDao.budgetLeft(tripId)
}
@WorkerThread
suspend fun save(expense: Expense) {
@@ -22,10 +34,58 @@ class ExpenseRepository @Inject constructor(private val expenseDao: ExpenseDao)
expenseDao.delete(expense)
}
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> {
fun getExpensesDtoPaged(
tripId: Int,
search: String,
filter: Filter,
): Flow<PagingData<ExpenseDto>> {
return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDto(tripId) }
pagingSourceFactory = {
val categoryIds = filter.categories.map { it.id }
expenseDao.expenseDtoPaged(
tripId = tripId,
search = search.takeIf { it.isNotBlank() },
categoryIds = categoryIds,
categoriesEmpty = categoryIds.isEmpty(),
startAmount = filter.startAmount,
endAmount = filter.endAmount
)
}
).flow
}
fun getExpensesDto(
tripId: Int,
search: String = "",
filter: Filter = Filter()
): Flow<List<ExpenseDto>> {
val categoryIds = filter.categories.map { it.id }
return expenseDao.expenseDto(
tripId = tripId,
search = search.takeIf { it.isNotBlank() },
categoryIds = categoryIds,
categoriesEmpty = categoryIds.isEmpty(),
startAmount = filter.startAmount,
endAmount = filter.endAmount
)
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun recalculateTripExpenses(tripId: Int) {
val expenses = getExpensesDto(tripId).first()
expenses.forEach { expenseDto ->
val newRate = exchangeRateRepository.getRate(
Currencies.valueOf(expenseDto.expense.currency),
Currencies.valueOf(expenseDto.trip.currency),
expenseDto.expense.datetime.toLocalDate()
)
save(
expenseDto.expense.copy(rate = newRate)
)
}
}
}

View File

@@ -1,15 +1,21 @@
package cc.n0th1ng.tripmoney.data.repository
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.ADD_EXPENSE_SWITCH
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.APP_THEME
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.CURRENT_TRIP
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.DEFAULT_CURRENCY
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.DEFAULT_CONCURRENCY
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Currency
import javax.inject.Inject
@@ -18,6 +24,8 @@ val Context.preferencesDataStore by preferencesDataStore(name = "app_preferences
object PreferenceKeys {
val APP_THEME = intPreferencesKey("app_theme")
val CURRENT_TRIP = intPreferencesKey("current_trip")
val DEFAULT_CURRENCY = stringPreferencesKey("default_currency")
val ADD_EXPENSE_SWITCH = booleanPreferencesKey("add_expense_switch")
}
@@ -29,11 +37,28 @@ class PreferencesRepository @Inject constructor(@ApplicationContext private val
AppTheme.fromValue(value)
}
val currentAddExpenseSwitchFlow: Flow<Boolean> =
context.preferencesDataStore.data.map { prefs ->
val value = prefs[ADD_EXPENSE_SWITCH]
?: false
value
}
val currentTripFlow: Flow<Int> =
context.preferencesDataStore.data.map { prefs ->
prefs[CURRENT_TRIP] ?: -1
}
val defaultCurrencyFlow: Flow<Currencies> =
context.preferencesDataStore.data.map { prefs ->
Currencies.valueOf(prefs[DEFAULT_CURRENCY] ?: Currencies.default().name)
}
suspend fun saveDefaultCurrency(currency: Currencies) {
context.preferencesDataStore.edit { prefs ->
prefs[DEFAULT_CURRENCY] = currency.name
}
}
suspend fun saveCurrentTrip(tripId: Int) {
context.preferencesDataStore.edit { prefs ->
prefs[CURRENT_TRIP] = tripId
@@ -46,6 +71,12 @@ class PreferencesRepository @Inject constructor(@ApplicationContext private val
}
}
suspend fun saveAddExpenseSwitch(value: Boolean) {
context.preferencesDataStore.edit { prefs ->
prefs[ADD_EXPENSE_SWITCH] = value
}
}
}
enum class AppTheme(val value: Int) {

View File

@@ -1,17 +1,26 @@
package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread
import androidx.paging.Pager
import androidx.paging.PagingConfig
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(private val tripDao: TripDao) {
class TripRepository @Inject constructor(
private val tripDao: TripDao,
private val expenseRepository: ExpenseRepository
) {
@WorkerThread
@RequiresApi(Build.VERSION_CODES.O)
suspend fun save(trip: Trip) {
tripDao.insert(trip)
}
@@ -22,4 +31,13 @@ class TripRepository @Inject constructor(private val tripDao: TripDao) {
pagingSourceFactory = { tripDao.tripsPaged() }
).flow
}
fun getTrip(tripId: Int): Flow<Trip?> {
return tripDao.trip(tripId)
}
@WorkerThread
suspend fun delete(trip: Trip) {
tripDao.delete(trip)
}
}

View File

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

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import cc.n0th1ng.tripmoney.R.string
import com.composables.icons.materialsymbols.outlined.R
import kotlinx.coroutines.launch
@@ -32,7 +33,7 @@ fun CustomNavigationDrawer(
Text("Trip Money", modifier = Modifier.padding(16.dp))
HorizontalDivider()
NavigationDrawerItem(
label = { Text(text = "Pick trip") },
label = { Text(text = stringResource(string.pick_trip)) },
selected = false,
onClick = {
navController.navigate(Screens.TRIP_PICKER)
@@ -48,7 +49,7 @@ fun CustomNavigationDrawer(
)
})
NavigationDrawerItem(
label = { Text(text = "List of expenses") },
label = { Text(text = stringResource(string.list_of_expenses)) },
selected = false,
onClick = {
navController.navigate(Screens.LIST_EXPENSE)
@@ -64,7 +65,7 @@ fun CustomNavigationDrawer(
)
})
NavigationDrawerItem(
label = { Text(text = "Statistics") },
label = { Text(text = stringResource(string.statistics)) },
selected = false,
onClick = {
navController.navigate(Screens.STATISTICS)
@@ -80,7 +81,7 @@ fun CustomNavigationDrawer(
)
})
NavigationDrawerItem(
label = { Text(text = "Settings") },
label = { Text(text = stringResource(string.settings)) },
selected = false,
onClick = {
navController.navigate(Screens.SETTINGS)
@@ -88,7 +89,7 @@ fun CustomNavigationDrawer(
drawerState.close()
}
},
icon = { Icon(Icons.Default.Settings, contentDescription = "settings") }
icon = { Icon(Icons.Default.Settings, contentDescription = stringResource(string.settings)) }
)
}
}) { content() }
@@ -99,4 +100,5 @@ object Screens {
const val TRIP_PICKER = "trip_picker"
const val STATISTICS = "statistics"
const val SETTINGS = "settings"
const val MANAGE_CATEGORIES = "manage_categories"
}

View File

@@ -1,37 +1,259 @@
package cc.n0th1ng.tripmoney.navigation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.addexpense.categoriesToPreview
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import com.composables.icons.materialsymbols.outlined.R.drawable
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopBar(onClick: () -> Unit) {
fun TopBar(
onDrawerClick: () -> Unit,
title: String = "",
isSearchable: Boolean = false,
onSearchChange: (String) -> Unit,
filter: Filter,
onFilterChange: (Filter) -> Unit,
categories: List<Category>
) {
var showSearch by remember { mutableStateOf(false) }
var showFilter by remember { mutableStateOf(false) }
var value by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = { onClick() }) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
title = {
if (showSearch && 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 = {
showSearch = false
value = ""
onSearchChange("")
}),
imageVector = Icons.Default.Close,
contentDescription = null
)
}
)
LaunchedEffect(key1 = value) {
delay(1000)
onSearchChange(value)
}
} else {
Text(title)
}
},
navigationIcon = {
IconButton(onClick = { onDrawerClick() }) {
Icon(Icons.Default.Menu, contentDescription = "Menu")
}
},
actions = {
if (!showSearch && isSearchable) {
Row(
modifier = Modifier.padding(end = 13.dp),
horizontalArrangement = Arrangement.spacedBy(15.dp)
) {
Icon(
tint = MaterialTheme.colorScheme.primary,
painter = painterResource(
if (filter.isDefault())
drawable.materialsymbols_ic_filter_alt_outlined
else com.composables.icons.materialsymbols.outlinedfilled.R.drawable.materialsymbols_ic_filter_alt_outlined_filled
),
contentDescription = null,
modifier = Modifier.clickable(onClick = {
showFilter = true
})
)
Icon(
tint = MaterialTheme.colorScheme.primary,
painter = painterResource(drawable.materialsymbols_ic_search_outlined),
contentDescription = null,
modifier = Modifier.clickable(onClick = {
showSearch = true
})
)
}
}
}
)
if (showFilter) {
FilterDialog(
onDismiss = { showFilter = false },
onSave = { newFilter ->
onFilterChange(newFilter)
showFilter = false
},
categories = categories,
filter = filter,
onClear = {
onFilterChange(Filter())
showFilter = false
}
)
}
}
@Composable
fun FilterDialog(
onDismiss: () -> Unit,
onSave: (Filter) -> Unit,
onClear: () -> Unit,
categories: List<Category>,
filter: Filter
) {
var filter by remember { mutableStateOf(filter) }
var fromAmountString by remember { mutableStateOf(filter.startAmount.toString()) }
var toAmountString by remember { mutableStateOf(filter.endAmount.toString()) }
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
Button(
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
enabled = true,
onClick = onClear
) { Text(stringResource(R.string.clear)) }
},
confirmButton = {
Button(
enabled = true,
onClick = {
onSave(
filter.withStartAmount(fromAmountString.safeToDouble())
.withEndAmount(toAmountString.safeToDouble())
)
}) { Text(stringResource(R.string.save)) }
}, title = { Text("Filter") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Categories")
FlowRow(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
categories.forEach {
FilterChip(selected = filter.categories.contains(it), onClick = {
filter = if (filter.categories.contains(it)) {
filter.without(it)
} else {
filter.with(it)
}
}, label = {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(painterResource(it.icon.resource), contentDescription = null)
Text(text = it.name)
}
})
}
}
AmountTextField(label = "from", onValueChange = { newText ->
fromAmountString = newText
}, value = fromAmountString)
AmountTextField(label = "to", onValueChange = { newText ->
toAmountString = newText
}, value = toAmountString)
}
})
}
@Composable
fun AmountTextField(label: String, onValueChange: (String) -> Unit, value: String) {
var value by remember { mutableStateOf(value) }
OutlinedTextField(
label = { Text(label) },
value = if (value == Double.MAX_VALUE.toString()) "" else value,
onValueChange = { newText ->
if (newText == Double.MAX_VALUE.toString()) {
value = ""
return@OutlinedTextField
}
val regex = Regex("^\\d*\\.?\\d{0,2}$")
if (regex.matches(newText)) {
value = newText
onValueChange(value)
}
},
placeholder = { Text("0.00") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopBarSettings(navController: NavHostController) {
TopAppBar(
title = { Text("Settings") },
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@@ -39,3 +261,39 @@ fun TopBarSettings(navController: NavHostController) {
}
)
}
@AllPreviews
@Composable
fun PreviewTopBar() {
TripMoneyTheme {
TopBar(
onDrawerClick = {},
title = "Essa",
onSearchChange = {},
onFilterChange = {},
isSearchable = true,
categories = categoriesToPreview,
filter = Filter()
)
}
}
@AllPreviews
@Composable
fun PreviewFilterDialog() {
TripMoneyTheme {
FilterDialog(
onDismiss = {},
onSave = {},
categories = categoriesToPreview,
filter = Filter(),
onClear = {}
)
}
}
private fun String.safeToDouble(): Double {
if (this == "") return Double.MAX_VALUE
if (this.isEmpty()) return 0.0
return this.toDouble()
}

View File

@@ -1,6 +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
@@ -9,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
@@ -28,67 +33,95 @@ 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.tooling.preview.Preview
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.Colors
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.entries[0].hexString) }
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)) }
}
})
}
@Composable
fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Unit, onColorChange: (String) -> Unit) {
var text by remember { mutableStateOf("") }
var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) }
var colorHex by remember { mutableStateOf(Colors.entries[0].hexString) }
fun AlertDialogFill(
onTextChange: (String) -> Unit,
onIconChange: (Icons) -> Unit,
onColorChange: (String) -> Unit,
onArchivedChange: (Boolean) -> Unit,
name: String,
icon: Icons,
color: String,
isArchived: Boolean
) {
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)
})
}
@@ -103,7 +136,6 @@ fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Uni
modifier = Modifier
.size(30.dp)
.clickable(onClick = {
iconId = icon.resource
onIconChange(icon)
}),
painter = painterResource(icon.resource),
@@ -118,18 +150,61 @@ fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Uni
rememberScrollState()
)
) {
Colors.entries.forEach { color ->
colors.forEach { color ->
Box(
modifier = Modifier
.clickable(onClick = {
colorHex = color.hexString
onColorChange(colorHex)
onColorChange(color)
})
.size(30.dp)
.aspectRatio(1f)
.background(Color(color.hexString.toColorInt()))
.background(Color(color.toColorInt()))
) {}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = isArchived,
onCheckedChange = onArchivedChange
)
Text(
text = "Archived",
style = MaterialTheme.typography.titleMedium
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewAddCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewEditCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {},
categoryToEdit = Category(
0, "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.entries.random(),
color = colors.random(),
archived = true
)
)
}
}

View File

@@ -1,55 +1,80 @@
package cc.n0th1ng.tripmoney.screens.addexpense
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R
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.listexpense.CategorySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DateTimePicker
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.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R.drawable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@@ -57,73 +82,204 @@ import java.time.LocalDateTime
fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit,
onDismiss: () -> Unit,
settingsViewModel: SettingsViewModel,
categories: List<Category>,
expenseAndCategoryViewModel: ExpenseAndCategoryViewModel,
expenseDtoToEdit: ExpenseDto?
expenseDtoToEdit: ExpenseDto?,
state: SheetState
) {
val tripViewModel: TripViewModel = hiltViewModel()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
AddExpenseBottomSheet(
onSave = onSave,
onDismiss = onDismiss,
expenseDtoToEdit = expenseDtoToEdit,
state = state,
currentTrip = currentTrip ?: Trip.DUMMY,
categories = categories
)
}
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit,
onDismiss: () -> Unit,
expenseDtoToEdit: ExpenseDto?,
state: SheetState,
currentTrip: Trip,
categories: List<Category>
) {
val currentTripId = currentTrip.id
if (categories.isEmpty()) {
return
}
var amount by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.amount?.toString() ?: "0.00"
"%.2f".format(expenseDtoToEdit?.expense?.amount ?: 0.00)
)
}
var equationResult by remember {
mutableDoubleStateOf(
expenseDtoToEdit?.expense?.amount ?: 0.00
)
}
val dummyFocusRequester = remember { FocusRequester() }
var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoryDialog by remember { mutableStateOf(false) }
var currency by remember { mutableStateOf(expenseDtoToEdit?.expense?.currency ?: "PLN") }
var showDateTimePicker by remember { mutableStateOf(false) }
var currency by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.currency ?: currentTrip.currency
)
}
var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }
var datetime by remember {
mutableStateOf(
LocalDateTime.parse(expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now().toString())
expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now()
)
}
var note by remember { mutableStateOf(expenseDtoToEdit?.expense?.note ?: "") }
var enableSave by remember { mutableStateOf(expenseDtoToEdit != null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
sheetState = state,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
.padding(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 15.dp),
text = stringResource(if (expenseDtoToEdit == null) R.string.add_expense else R.string.edit_expense),
style = MaterialTheme.typography.displaySmall,
textAlign = TextAlign.Start
)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp)
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = amount.ifEmpty { "0.00" },
fontSize = 25.sp,
fontWeight = FontWeight.Bold
)
Text(
text = if (amount.contains(Regex("[+/*-]\\d+"))) "%.2f".format(
equationResult
) else "",
fontSize = 14.sp,
)
}
CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency)
}
Spacer(Modifier.height(14.dp))
DateTimePicker(
dateTime = datetime,
onChange = { datetime = it }
Box(
modifier = Modifier
.size(0.dp)
.focusRequester(dummyFocusRequester)
.focusable()
)
NoteInput(
note = note,
onTextChange = { newNote -> note = newNote },
modifier = Modifier.fillMaxWidth(),
focusRequester = dummyFocusRequester
)
Spacer(Modifier.height(14.dp))
CategoryButton(onClick = { showCategoryDialog = true }, category = category)
Spacer(Modifier.height(14.dp))
Row(
modifier = Modifier.height(50.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
NoteInput(note = note) { newNote -> note = newNote }
Button(
onClick = { showDateTimePicker = true },
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.medium,
) {
Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
style = MaterialTheme.typography.titleMedium
)
}
CategoryButton(
onClick = { showCategoryDialog = true },
category = category,
modifier = Modifier.weight(1f)
)
}
NumberKeyboard(
modifier = Modifier.fillMaxWidth(),
onOperatorClick = { operator ->
if (amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+/*-]\\d+"))) {
amount = evaluate(amount).toString()
}
val newText = amount + operator
if (newText.isDoubleTwoDigitsOrEquation()) {
amount = newText
enableSave = false
}
},
onNumberClick = { number ->
val newText = (if (amount == "0.00") "" else amount) + number
if (newText.isDoubleTwoDigitsOrEquation()) {
amount = newText
equationResult = evaluate(amount)
enableSave = equationResult > 0
} else if (amount == "0.00") {
enableSave = false
}
dummyFocusRequester.requestFocus()
},
onBackspaceClick = {
if (amount == "0.00") return@NumberKeyboard
amount = amount.safeSubstring(0, amount.length - 1)
enableSave = amount.isDoubleTwoDigitsOrEquation()
equationResult = evaluate(amount)
enableSave = amount.isDoubleTwoDigitsOrEquation() && equationResult > 0
},
onLongBackspaceClick = {
amount = "0.00"
equationResult = evaluate(amount)
enableSave = false
}
)
SaveButton(
modifier = Modifier.fillMaxWidth(),
enabled = enableSave,
onClick = {
val expenseToSave = Expense(
amount = amount.toDouble(),
amount = equationResult,
currency = currency,
note = note,
datetime = datetime.toString(),
datetime = datetime,
categoryId = category.id,
tripId = currentTripId
)
@@ -131,31 +287,18 @@ fun AddExpenseBottomSheet(
if (expenseDtoToEdit == null) expenseToSave
else expenseToSave.copy(id = expenseDtoToEdit.expense.id)
)
}
)
}
Spacer(Modifier.height(14.dp))
NumberKeyboard(
onNumberClick = { number ->
val newText = (if (amount == "0.00") "" else amount) + number
if (newText.isDoubleTwoDigitsAboveZero()) {
amount = newText
enableSave = true
} else if (amount == "0.00") {
enableSave = false
}
},
onBackspaceClick = {
if (amount == "0.00") return@NumberKeyboard
amount = amount.safeSubstring(0, amount.length - 1)
enableSave = amount.isDoubleTwoDigitsAboveZero()
})
}
}
}
if (showDateTimePicker) {
DateTimePicker(datetime, onChange = { newDateTime ->
datetime = newDateTime
showDateTimePicker = false
})
}
if (showCurrencyDialog) {
CurrencySelectionDialog(
@@ -164,8 +307,7 @@ fun AddExpenseBottomSheet(
showCurrencyDialog = false
currency = selectedCurrency
},
selected = currency,
listOfCurrencies = listOf("PLN", "EUR", "USD")
selected = currency
)
}
@@ -177,8 +319,7 @@ fun AddExpenseBottomSheet(
category = selectedCategory
},
selected = category,
categories = categories,
settingsAndCategoryViewModel = expenseAndCategoryViewModel
categories = categories
)
}
}
@@ -186,56 +327,130 @@ fun AddExpenseBottomSheet(
fun String.safeSubstring(start: Int, end: Int): String {
return try {
this.substring(start, end)
} catch (e: Exception) {
} catch (_: Exception) {
"0.00"
}
}
fun String.isDoubleTwoDigitsAboveZero(): Boolean {
return this.toDoubleOrNull() != null && this.matches(Regex("^\\d*(\\.\\d{0,2})?$")) && this.toDouble() > 0
private fun evaluate(equation: String): Double {
if (equation.isEmpty()) return 0.0
val operatorIndex = equation.indexOfFirstIndexed { i, c ->
i != 0 && c in "+-*/"
}
if (operatorIndex == -1) return equation.toDouble()
val leftString = equation.substring(0, operatorIndex)
val rightString = equation.substring(operatorIndex + 1)
if (leftString.isEmpty() || rightString.isEmpty()) return 0.0
val left = leftString.toDouble()
val right = rightString.toDouble()
return when (equation[operatorIndex]) {
'+' -> left + right
'-' -> left - right
'*' -> left * right
'/' -> left / right
else -> 0.0
}
}
private inline fun String.indexOfFirstIndexed(predicate: (index: Int, Char) -> Boolean): Int {
for (i in indices) {
if (predicate(i, this[i])) return i
}
return -1
}
private fun String.isDoubleTwoDigitsOrEquation(): Boolean {
return this != "0.00" && this.matches(Regex("^(-?(0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?))([+/*-](0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?)?)?$"))
}
@Composable
fun NoteInput(note: String, onTextChange: (String) -> Unit) {
fun NoteInput(
note: String,
onTextChange: (String) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester
) {
var text by remember { mutableStateOf(note) }
OutlinedTextField(
modifier = modifier,
label = { Text(stringResource(R.string.note)) }, value = note, onValueChange = { newText ->
text = newText
onTextChange(text)
}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusRequester.requestFocus()
}
)
)
}
@Composable
fun CurrencyButton(onClick: () -> Unit, text: String) {
OutlinedButton(onClick = onClick) {
fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) {
Button(
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors()
.copy(containerColor = MaterialTheme.colorScheme.secondary)
) {
Text(text)
}
}
@Composable
fun CategoryButton(onClick: () -> Unit, category: Category) {
OutlinedButton(
fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
Button(
contentPadding = PaddingValues(0.dp),
onClick = onClick,
modifier = Modifier.fillMaxWidth(0.5f)
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors()
.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),
tint = Color(category.color.toColorInt())
)
Text(category.name, color = Color(category.color.toColorInt()))
Text(
text = category.name,
style = MaterialTheme.typography.titleMedium
)
// }
}
}
@Composable
fun SaveButton(enabled: Boolean, onClick: () -> Unit) {
OutlinedButton(
fun SaveButton(modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier
modifier = modifier,
shape = MaterialTheme.shapes.medium
) {
Icon(
imageVector = Icons.Filled.Check,
@@ -244,126 +459,199 @@ fun SaveButton(enabled: Boolean, onClick: () -> Unit) {
}
}
@Preview
@Composable
fun Preview() {
TripMoneyTheme(darkTheme = true) {
NumberKeyboard(onNumberClick = {}, onBackspaceClick = {})
}
}
@Composable
fun NumberKeyboard(
modifier: Modifier = Modifier,
onNumberClick: (String) -> Unit,
onBackspaceClick: () -> Unit
onBackspaceClick: () -> Unit,
onOperatorClick: (String) -> Unit,
onLongBackspaceClick: () -> Unit,
) {
val buttonModifier = Modifier
.padding(4.dp)
.aspectRatio(2f)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
keyboard.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
onClick = { onNumberClick("1") },
modifier = buttonModifier.weight(1f)
) {
Text("1", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("2") },
modifier = buttonModifier.weight(1f)
) {
Text("2", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("3") },
modifier = buttonModifier.weight(1f)
) {
Text("3", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
onClick = { onNumberClick("4") },
modifier = buttonModifier.weight(1f)
) {
Text("4", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("5") },
modifier = buttonModifier.weight(1f)
) {
Text("5", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("6") },
modifier = buttonModifier.weight(1f)
) {
Text("6", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
onClick = { onNumberClick("7") },
modifier = buttonModifier.weight(1f)
) {
Text("7", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("8") },
modifier = buttonModifier.weight(1f)
) {
Text("8", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("9") },
modifier = buttonModifier.weight(1f)
) {
Text("9", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
onClick = { onNumberClick(".") },
modifier = buttonModifier.weight(1f)
) {
Text(".", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("0") },
modifier = buttonModifier.weight(1f)
) {
Text("0", fontSize = 20.sp)
}
OutlinedButton(
row.forEach { key ->
when (key) {
"backspace" -> KeyboardButton(
icon = painterResource(drawable.materialsymbols_ic_arrow_left_alt_outlined),
onClick = onBackspaceClick,
modifier = buttonModifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.backspace)
modifier = Modifier
.weight(1f),
containerColor = Color.Transparent,
onLongClick = onLongBackspaceClick
)
"+", "÷", "", "×" -> KeyboardButton(
text = key,
onClick = { onOperatorClick(key) },
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
)
else -> KeyboardButton(
text = key,
onClick = { onNumberClick(key) },
modifier = Modifier.weight(1f),
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSecondary
)
}
}
}
}
}
}
@Composable
fun KeyboardButton(
modifier: Modifier = Modifier,
text: String? = null,
icon: Painter? = null,
onClick: () -> Unit,
onLongClick: () -> Unit = {},
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.padding(2.dp)
.aspectRatio(2.5f)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.background(containerColor, shape = MaterialTheme.shapes.medium),
) {
when {
text != null -> Text(
text = text,
style = MaterialTheme.typography.headlineMedium
)
icon != null -> Icon(painter = icon, contentDescription = null)
}
}
}
val keyboard = listOf(
listOf("+", "", "×", "÷"),
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf(".", "0", "backspace")
)
@SuppressLint("CoroutineCreationDuringComposition")
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
fun PreviewAddExpenseDisabled() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
CoroutineScope(Dispatchers.IO).launch {
sheetState.show()
}
TripMoneyTheme {
AddExpenseBottomSheet(
onSave = {},
onDismiss = {},
expenseDtoToEdit = null,
state = sheetState,
currentTrip = Trip(
1,
"Trip",
LocalDate.parse("2020-01-01"),
LocalDate.parse("2020-01-15"),
Currencies.entries.random().name
),
categories = categoriesToPreview
)
}
}
@SuppressLint("CoroutineCreationDuringComposition")
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
fun PreviewAddExpenseEnabled() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
CoroutineScope(Dispatchers.IO).launch {
sheetState.show()
}
TripMoneyTheme {
AddExpenseBottomSheet(
onSave = {},
onDismiss = {},
expenseDtoToEdit = ExpenseDto(
Expense(
amount = 10.31,
currency = "PLN",
note = "some note",
datetime = LocalDateTime.now(),
categoryId = 1,
tripId = 1
),
category = categoriesToPreview[0],
Trip(
1, "Włochy", LocalDate.parse("2025-01-02"),
LocalDate.parse("2025-01-15"), "PLN"
)
),
state = sheetState,
currentTrip = Trip(
1,
"Trip",
LocalDate.parse("2020-01-01"),
LocalDate.parse("2020-01-11"),
Currencies.entries.random().name
),
categories = categoriesToPreview
)
}
}
val categoriesToPreview = listOf(
Category(
1,
name = "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
color = colors.random()
),
Category(
2,
name = "Jedzenie",
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
color = colors.random()
),
Category(
3,
name = "Transport",
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
color = colors.random()
),
Category(
4,
name = "Rozrywka",
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
color = colors.random()
),
Category(
5,
name = "Zakupy",
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
color = colors.random()
),
)

View File

@@ -24,10 +24,10 @@ 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.*
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,8 +37,8 @@ fun CategorySelectionDialog(
onCategorySelected: (Category) -> Unit,
selected: Category,
categories: List<Category>,
settingsAndCategoryViewModel: ExpenseAndCategoryViewModel
) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val listState = rememberLazyListState()
var showAddCategoryDialog by remember { mutableStateOf(false) }
AlertDialog(
@@ -90,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),
)
}
}
@@ -99,7 +99,7 @@ fun CategorySelectionDialog(
AddCategoryDialog(onDismiss = {
showAddCategoryDialog = false
}, onSave = { category ->
settingsAndCategoryViewModel.save(category)
expenseAndCategoryViewModel.save(category)
showAddCategoryDialog = false
})
}

View File

@@ -14,20 +14,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.utils.Currencies
@Composable
fun CurrencySelectionDialog(
onDismiss: () -> Unit,
onCurrencySelected: (String) -> Unit,
selected: String,
listOfCurrencies: List<String>
selected: String
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pick_currency)) },
text = {
Column {
listOfCurrencies.forEach { currency ->
Currencies.names().forEach { currency ->
Row(
modifier = Modifier
.fillMaxWidth()

View File

@@ -2,15 +2,18 @@ package cc.n0th1ng.tripmoney.screens.listexpense
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerState
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -18,13 +21,130 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import androidx.compose.ui.tooling.preview.Preview
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateRangePicker(
startDate: LocalDate,
endDate: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate, LocalDate) -> Unit
) {
val datePickerState =
rememberDateRangePickerState(
initialSelectedStartDateMillis = startDate.toEpochMilli(),
initialSelectedEndDateMillis = endDate.toEpochMilli()
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val selectedStartDateMillis = datePickerState.selectedStartDateMillis
val selectedEndDateMillis = datePickerState.selectedEndDateMillis
if (selectedStartDateMillis != null && selectedEndDateMillis != null) {
val selectedStartDate = Instant.ofEpochMilli(selectedStartDateMillis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
val selectedEndDate =
Instant.ofEpochMilli(selectedEndDateMillis).atZone(ZoneId.systemDefault())
.toLocalDate()
onConfirm(selectedStartDate, selectedEndDate)
}
}) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) }
}
) {
DateRangePicker(
state = datePickerState, showModeToggle = false,
title = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePicker(
date: LocalDate = LocalDate.now(),
onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit
) {
val datePickerState =
rememberDatePickerState(initialSelectedDateMillis = date.toEpochMilli())
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
Row() {
TextButton(onClick = {
onConfirm(LocalDate.now().minusDays(1))
}) {
Text(stringResource(string.yesterday))
}
TextButton(onClick = {
val selectedMillis = datePickerState.selectedDateMillis
if (selectedMillis != null) {
val selectedDate = Instant.ofEpochMilli(selectedMillis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
onConfirm(selectedDate)
}
}) {
Text("OK")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) }
}
) {
DatePicker(state = datePickerState)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePicker(
onDismiss: () -> Unit,
onConfirm: (TimePickerState) -> Unit,
time: LocalTime = LocalTime.now()
) {
val timePickerState = rememberTimePickerState(
initialHour = time.hour,
initialMinute = time.minute,
is24Hour = true
)
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onConfirm(timePickerState) }) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(string.cancel)) }
},
text = { TimePicker(state = timePickerState) }
)
}
@Composable
@RequiresApi(Build.VERSION_CODES.O)
@@ -33,76 +153,47 @@ fun DateTimePicker(
dateTime: LocalDateTime = LocalDateTime.now(),
onChange: (LocalDateTime) -> Unit
) {
val datePickerState =
rememberDatePickerState(initialSelectedDateMillis = dateTime.toEpochMilli())
val timePickerState = rememberTimePickerState(
initialHour = dateTime.hour,
initialMinute = dateTime.minute
)
var showDatePicker by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(true) }
var showTimePicker by remember { mutableStateOf(false) }
val formatter = DateTimeFormatter.ofPattern("dd.MM HH:mm")
OutlinedButton(onClick = { showDatePicker = true }) {
Text(text = dateTime.format(formatter), fontSize = 17.sp)
}
var date by remember { mutableStateOf(dateTime.toLocalDate()) }
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
DatePicker(
date = dateTime.toLocalDate(),
onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
date = newDate
showDatePicker = false
val selectedMillis = datePickerState.selectedDateMillis
if (selectedMillis != null) {
val selectedDate = Instant.ofEpochMilli(selectedMillis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
// open time picker next
showTimePicker = true
onChange(
LocalDateTime.of(
selectedDate,
dateTime.toLocalTime()
)
)
}
}) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = {
showDatePicker = false
}) { Text(stringResource(string.cancel)) }
}
) {
DatePicker(state = datePickerState)
}
})
}
if (showTimePicker) {
AlertDialog(
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(onClick = {
TimePicker(onDismiss = {
showTimePicker = false
showDatePicker = true
}, onConfirm = { timePickerState ->
showTimePicker = false
showDatePicker = true
val newTime = LocalTime.of(timePickerState.hour, timePickerState.minute)
onChange(LocalDateTime.of(dateTime.toLocalDate(), newTime))
}) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) { Text(stringResource(string.cancel)) }
},
text = { TimePicker(state = timePickerState) }
)
onChange(LocalDateTime.of(date, newTime))
}, time = dateTime.toLocalTime())
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun LocalDateTime.toEpochMilli(): Long =
this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
@RequiresApi(Build.VERSION_CODES.O)
fun LocalDate.toEpochMilli(): Long =
this.atStartOfDay().atZone(ZoneId.of("UTC")).toInstant().toEpochMilli()
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun DatePickerPreview() {
TripMoneyTheme {
DatePicker(LocalDate.now(), {}, {})
}
}

View File

@@ -2,10 +2,10 @@ package cc.n0th1ng.tripmoney.screens.listexpense
import android.annotation.SuppressLint
import android.os.Build
import androidx.activity.viewModels
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
@@ -34,8 +34,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -45,40 +47,94 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import cc.n0th1ng.tripmoney.R.*
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.Filter
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 java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.getValue
import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen(
filter: Filter,
search: String,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
) {
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, search, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
ListExpenseScreen(
expensesFlow = expensesFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
isRecalculatingRate = isRecalculatingRate,
initialAutoOpen = initialAutoOpen,
onAutoOpenConsumed = onAutoOpenConsumed
)
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
fun ListExpenseScreen(
expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
isRecalculatingRate: Boolean,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
) {
val currentTrip by settingsViewModel.currentTrip.collectAsState()
val categories by expenseAndCategoryViewModel.getCategories()
.collectAsState(initial = emptyList())
val expenses = expenseAndCategoryViewModel.getExpenses(currentTrip).collectAsLazyPagingItems()
val listState = rememberLazyListState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showBottomSheet by remember { mutableStateOf(false) }
var expenseDtoToEdit: ExpenseDto? = null
LaunchedEffect(initialAutoOpen) {
if (initialAutoOpen) {
showBottomSheet = true
onAutoOpenConsumed()
}
}
val items = expensesFlow.collectAsLazyPagingItems()
val listState = rememberLazyListState()
var expenseDtoToEdit by remember { mutableStateOf<ExpenseDto?>(null) }
var itemToDelete by remember { mutableStateOf<Expense?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
ExtendedFloatingActionButton(
@@ -88,56 +144,71 @@ fun ListExpenseScreen() {
)
})
{
Box {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().semantics {
contentDescription = "expensesList"
},
horizontalAlignment = Alignment.CenterHorizontally,
state = listState
) {
items(
count = expenses.itemCount,
key = { index -> expenses[index]?.expense?.id ?: index }
count = items.itemCount,
key = items.itemKey { item ->
when (item) {
is ExpenseListItemUi.Item -> item.expenseDto.expense.id
is ExpenseListItemUi.Header -> "header_${item.date}"
}
}
) { index ->
val expenseDto = expenses[index]
if (expenseDto != null) {
val previousExpense = expenses.itemSnapshotList.items.getOrNull(index - 1)
val showDayDivider =
index == 0 || LocalDateTime.parse(previousExpense?.expense?.datetime)
.toLocalDate() != LocalDateTime.parse(expenseDto.expense.datetime)
.toLocalDate()
Spacer(Modifier.height(5.dp))
if (showDayDivider) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.Center,
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
LocalDateTime.parse(expenseDto.expense.datetime).format(
DateTimeFormatter.ofPattern("dd EEEE")
).toString(),
modifier = Modifier.background(Color.White.copy(alpha = 0f))
when (val item = items[index]) {
is ExpenseListItemUi.Header -> {
CustomDivider(
date = item.date,
sum = item.sum,
currency = item.currency
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
}
Spacer(Modifier.height(5.dp))
is ExpenseListItemUi.Item -> {
SwipeToDeleteExpenseCard(
expenseDto = expenseDto,
onDelete = { expense -> expenseAndCategoryViewModel.delete(expense) },
expenseDto = item.expenseDto,
onDelete = { expense -> itemToDelete = expense },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
})
}
)
}
null -> {}
}
Spacer(Modifier.height(10.dp))
}
}
}
if (itemToDelete != null) {
DeleteConfirmationDialog(
onConfirm = {
onDeleteExpense(itemToDelete!!)
itemToDelete = null
},
onCancel = {
itemToDelete = null
}
)
}
if (showBottomSheet) {
AddExpenseBottomSheet(
onSave = { expense ->
expenseAndCategoryViewModel.save(expense)
onSaveExpense(expense)
showBottomSheet = false
expenseDtoToEdit = null
},
@@ -145,15 +216,57 @@ fun ListExpenseScreen() {
expenseDtoToEdit = null
showBottomSheet = false
},
settingsViewModel = settingsViewModel,
categories = categories,
expenseAndCategoryViewModel = expenseAndCategoryViewModel,
expenseDtoToEdit = expenseDtoToEdit
expenseDtoToEdit = expenseDtoToEdit,
state = sheetState
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.Center,
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
date.format(
DateTimeFormatter.ofPattern("dd EEEE")
).toString(),
modifier = Modifier
.padding(horizontal = 5.dp)
.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.titleMedium
)
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.Absolute.Center,
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(2f))
Text(
"%.2f %s".format(sum, currency),
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))
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun SwipeToDeleteExpenseCard(
@@ -161,34 +274,18 @@ fun SwipeToDeleteExpenseCard(
onDelete: (Expense) -> Unit,
onClick: (ExpenseDto) -> Unit
) {
var dismissed by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
if (!dismissed) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart
) {
showDialog = true
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
onDelete(expenseDto.expense)
false
} else {
false
}
}
)
if (showDialog) {
DeleteConfirmationDialog(
onConfirm = {
showDialog = false
dismissed = true
onDelete(expenseDto.expense)
},
onCancel = { showDialog = false }
)
}
SwipeToDismissBox(
modifier = Modifier,
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
@@ -196,16 +293,21 @@ fun SwipeToDeleteExpenseCard(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete))
Icon(
Icons.Default.Delete,
contentDescription = stringResource(string.delete)
)
}
}
) {
ExpenseCard(expenseDto, onClick = onClick)
}
ExpenseCard(
expenseDto = expenseDto,
onClick = onClick
)
}
}
@@ -221,15 +323,15 @@ fun DeleteConfirmationDialog(
Column(
Modifier
.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium
)
.padding(24.dp)
) {
Text(
stringResource(string.delete_confirmation),
fontWeight = FontWeight.Bold,
fontSize = 20.sp
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Row(
horizontalArrangement = Arrangement.End,
@@ -239,6 +341,8 @@ fun DeleteConfirmationDialog(
) {
Text(
text = stringResource(string.cancel),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.padding(end = 24.dp)
.clickable { onCancel() }
@@ -246,7 +350,7 @@ fun DeleteConfirmationDialog(
Text(
text = stringResource(string.delete),
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { onConfirm() }
)
}
@@ -256,12 +360,20 @@ fun DeleteConfirmationDialog(
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
fun ExpenseCard(
expenseDto: ExpenseDto,
onClick: (ExpenseDto) -> Unit
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth(0.9f)
.fillMaxWidth(0.95f)
.height(70.dp)
.clickable { onClick(expenseDto) },
.combinedClickable(
enabled = true,
onClick = { onClick(expenseDto) },
onLongClick = { onClick(expenseDto) }),
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) {
Row(
@@ -269,14 +381,29 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
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())
@@ -287,38 +414,43 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
.fillMaxHeight()
.padding(vertical = 8.dp)
) {
Column(
) {
Column()
{
Text(
text = expenseDto.category.name,
fontWeight = FontWeight.Bold,
lineHeight = 5.sp
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
modifier = Modifier.padding(0.dp),
text = expenseDto.expense.note,
fontSize = 11.sp,
lineHeight = 5.sp
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
Text(
text = LocalDateTime.parse(expenseDto.expense.datetime).format(
text = expenseDto.expense.datetime.format(
DateTimeFormatter.ofPattern("dd MMM HH:mm")
),
fontSize = 12.sp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Column {
Text(
text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount),
fontWeight = FontWeight.Bold
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) {
Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.amount),
fontSize = 12.sp
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
@@ -326,3 +458,129 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewListExpenseScreen() {
TripMoneyTheme() {
val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
ListExpenseScreen(
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
isRecalculatingRate = true,
false,
{}
)
}
}
@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"),
endDate = LocalDate.parse("2026-01-11"),
)
val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
val endLong = LocalDateTime.now().toEpochMilli()
val result: MutableList<ExpenseListItemUi> = mutableListOf()
result.add(
ExpenseListItemUi.Header(
LocalDateTime.ofEpochSecond(
Random.nextLong(startLong, endLong),
0,
ZoneOffset.UTC
).toLocalDate(), Random.nextDouble(0.1, 300.0), Currencies.entries.random().name
)
)
for (i in 0..15) {
val category = sampleCategories.random()
val datetime = if (i > 4) {
LocalDateTime.ofEpochSecond(
Random.nextLong(startLong, endLong),
0,
ZoneOffset.UTC
)
} else LocalDateTime.now()
val expense = Expense(
id = i,
categoryId = category.id,
tripId = 1,
amount = Random.nextDouble(0.1, 300.0),
currency = Currencies.entries.random().name,
note = if (i % 3 == 0) "Some note" else "",
datetime = datetime,
rate = if (Random.nextBoolean()) Random.nextDouble(
0.1,
5.0
) else 1.0
)
val expenseDto = ExpenseDto(
expense = expense,
category = category,
trip = trip
)
result.add(
ExpenseListItemUi.Item(expenseDto)
)
if (i % 5 == 0) {
result.add(
ExpenseListItemUi.Header(
datetime.toLocalDate(),
Random.nextDouble(0.1, 300.0),
Currencies.entries.random().name
)
)
}
}
return result
}

View File

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

View File

@@ -12,9 +12,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -27,47 +31,162 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.*
import androidx.navigation.NavHostController
import cc.n0th1ng.tripmoney.R.string
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.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.shareCsv
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R
import kotlinx.coroutines.launch
import java.io.File
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun SettingsScreen() {
fun SettingsScreen(navController: NavHostController) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState()
var showDialog by remember { mutableStateOf(false) }
val currentAddExpenseSwitch by settingsViewModel.addExpenseSwitch.collectAsState()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val context = LocalContext.current
val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope()
SettingsScreen(
currentDefaultCurrency = currentDefaultCurrency,
currentTheme = currentTheme,
onThemeSave = { settingsViewModel.setTheme(it) },
onCurrencySave = { settingsViewModel.setDefaultCurrency(it) },
onAddExpenseSwitch = {
settingsViewModel.setCurrentAddExpenseSwitch(it)
},
tripName = tripName,
onExportToCsv = {
scope.launch {
try {
val safeTripName = tripName.replace(Regex("[^a-zA-Z0-9_]"), "_")
val file = File(context.cacheDir, "$safeTripName.csv")
expenseAndCategoryViewModel.generateCSVToFile(currentTripId, file)
shareCsv(context, file)
} catch (e: Exception) {
e.printStackTrace()
}
}
},
onCategoriesClick = { navController.navigate(Screens.MANAGE_CATEGORIES) },
currentAddExpenseSwitch = currentAddExpenseSwitch
)
}
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun SettingsScreen(
currentDefaultCurrency: Currencies,
currentTheme: AppTheme,
onThemeSave: (AppTheme) -> Unit,
onCurrencySave: (Currencies) -> Unit,
tripName: String,
onExportToCsv: () -> Unit,
onCategoriesClick: () -> Unit,
onAddExpenseSwitch: (Boolean) -> Unit,
currentAddExpenseSwitch: Boolean
) {
Scaffold { padding ->
var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Card {
SettingsListItem(onClick = { showDialog = true }, stringResource(string.theme)) {
Text(
if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme
SettingsListItem(
onClick = { showCurrencyDialog = true },
headlineText = stringResource(string.default_currency),
supportingText = currentDefaultCurrency.name,
iconResource = R.drawable.materialsymbols_ic_currency_yen_outlined
)
)
}
}
if (showDialog) {
SettingsCard(string.theme) {
SettingsListItem(
onClick = { showThemeDialog = true },
stringResource(string.theme),
supportingText = if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme
),
iconResource = R.drawable.materialsymbols_ic_format_paint_outlined
)
SettingsListItem(
onClick = { },
"Pallete",
supportingText = if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme
),
iconResource = R.drawable.materialsymbols_ic_palette_outlined
)
}
SettingsListItem(
onClick = onExportToCsv,
stringResource(string.export_to_csv),
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
)
SettingsListItem(
onClick = {},
stringResource(string.add_expense),
supportingText = stringResource(string.add_expense_settings),
iconResource = R.drawable.materialsymbols_ic_payments_outlined,
trailingContent = {
Switch(checked = currentAddExpenseSwitch, onCheckedChange = {onAddExpenseSwitch(it)})
}
)
if (showThemeDialog) {
ThemeSelectionDialog(
onDismiss = { showDialog = false },
onDismiss = { showThemeDialog = false },
onThemeSelected = { theme ->
settingsViewModel.setTheme(theme)
showDialog = false
onThemeSave(theme)
showThemeDialog = false
},
selected = currentTheme
)
}
if (showCurrencyDialog) {
CurrencySelectionDialog(
onDismiss = { showCurrencyDialog = false },
onCurrencySelected = { currencyString ->
onCurrencySave(Currencies.valueOf(currencyString))
showCurrencyDialog = false
},
currentDefaultCurrency.name
)
}
}
}
}
@@ -77,7 +196,7 @@ fun SettingsCard(@StringRes title: Int = -1, content: @Composable () -> Unit) {
if (title != -1) {
Text(
text = stringResource(title),
fontSize = 13.sp,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.padding(start = 15.dp, top = 15.dp, end = 15.dp)
.alpha(0.6f)
@@ -92,18 +211,25 @@ fun SettingsListItem(
onClick: () -> Unit,
headlineText: String,
trailingContent: @Composable () -> Unit = {},
supportingContent: @Composable () -> Unit
supportingText: String,
iconResource: Int,
) {
Card {
ListItem(
leadingContent = {
Icon(painter = painterResource(iconResource), contentDescription = null)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(headlineText) },
supportingContent = supportingContent,
supportingContent = { Text(supportingText) },
trailingContent = trailingContent,
modifier = Modifier
.clickable(true, onClick = onClick)
)
}
}
@Composable
fun ThemeSelectionDialog(
onDismiss: () -> Unit,
@@ -146,3 +272,44 @@ fun ThemeSelectionDialog(
confirmButton = {}
)
}
@RequiresApi(Build.VERSION_CODES.S)
@AllPreviews
@Composable
fun PreviewSettingsScreen() {
TripMoneyTheme {
SettingsScreen(
currentDefaultCurrency = Currencies.entries.random(),
currentTheme = AppTheme.entries.random(),
onThemeSave = {},
onCurrencySave = {},
onExportToCsv = {},
tripName = "Włochy",
onCategoriesClick = {},
onAddExpenseSwitch = {},
currentAddExpenseSwitch = false
)
}
}
@RequiresApi(Build.VERSION_CODES.S)
@AllPreviews
@Composable
fun PreviewThemeSelectionDialog() {
TripMoneyTheme {
ThemeSelectionDialog(onDismiss = {}, onThemeSelected = {}, AppTheme.SYSTEM)
}
}
@RequiresApi(Build.VERSION_CODES.S)
@AllPreviews
@Composable
fun PreviewCurrencySelectionDialog() {
TripMoneyTheme {
CurrencySelectionDialog(
onDismiss = {},
onCurrencySelected = {},
selected = Currencies.entries.random().name
)
}
}

View File

@@ -1,9 +1,271 @@
package cc.n0th1ng.tripmoney.screens.statistics
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
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
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
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.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
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 cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun StatisticsScreen() {
Text("TODO")
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTripId)
.collectAsState(emptyList())
val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId)
.collectAsState(0.0)
val moneyLeft by expenseAndCategoryViewModel.getBudgetLeft(currentTripId).collectAsState(null)
StatisticsScreen(
summaryPerCategoryList,
summaryAmount,
Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name),
moneyLeft
)
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun StatisticsScreen(
summaryPerCategoryList: List<SummaryPerCategory>,
summaryAmount: Double,
tripCurrency: Currencies,
moneyLeft: Double?
) {
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Summary(
Modifier.weight(1f), -1 * summaryAmount, tripCurrency.name,
stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses),
R.drawable.materialsymbols_ic_payment_arrow_down_outlined,
iconColor = MaterialTheme.colorScheme.error
)
Summary(
Modifier.weight(1f), moneyLeft, tripCurrency.name,
stringResource(cc.n0th1ng.tripmoney.R.string.money_left),
R.drawable.materialsymbols_ic_payments_outlined,
iconColor = colorResource(cc.n0th1ng.tripmoney.R.color.good_green)
)
}
SummaryPerCategoryCard(summaryPerCategoryList)
}
}
@Composable
fun Summary(
modifier: Modifier = Modifier,
amount: Double?,
currency: String,
text: String,
icon: Int,
iconColor: Color
) {
ElevatedCard(
modifier = modifier,
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceDim,
shape = MaterialTheme.shapes.small
)
.padding(5.dp),
painter = painterResource(icon),
tint = iconColor,
contentDescription = null,
)
Text(
text,
style = MaterialTheme.typography.titleSmall
)
}
Text(
if (amount == null) "" else
"%.2f %s".format(amount, currency),
style = MaterialTheme.typography.titleLarge,
)
}
}
}
@Composable
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Column(
modifier = Modifier
.padding(15.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
summaryPerCategoryList.forEach {
CategoryCard(
summaryPerCategory = it, modifier = Modifier
.fillMaxWidth()
)
}
}
}
}
@Composable
fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCategory) {
Column(modifier = modifier) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
Icon(
painter = painterResource(summaryPerCategory.category.icon.resource),
contentDescription = null,
modifier = Modifier.size(MaterialTheme.typography.bodyLarge.fontSize.value.dp),
tint = Color(summaryPerCategory.category.color.toColorInt())
)
Text(
"%s".format(
summaryPerCategory.category.name,
(summaryPerCategory.percent * 100).toInt()
),
style = MaterialTheme.typography.bodyLarge,
color = Color(summaryPerCategory.category.color.toColorInt())
)
}
Text(
"%.2f ${summaryPerCategory.currency}".format(summaryPerCategory.amount),
style = MaterialTheme.typography.bodyMedium
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.height(30.dp)
.fillMaxWidth(0.12f + (0.90f - 0.12f) * summaryPerCategory.percent)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primary)
) {
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(vertical = 5.dp, horizontal = 10.dp)
) {
Text(
"%d%%".format((summaryPerCategory.percent * 100).toInt()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun Preview() {
TripMoneyTheme {
Scaffold {
StatisticsScreen(
summaryPerCategoryList,
summaryAmount = 125.24,
Currencies.entries.random(),
432.14
)
}
}
}
val categories = listOf(
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()),
Category(name = "Zakupy1", icon = Icons.GROCERIES, color = colors.random()),
Category(name = "Zakupy2", icon = Icons.GROCERIES, color = colors.random()),
Category(name = "Zakupy3", icon = Icons.GROCERIES, color = colors.random())
)
val summaryPerCategoryList = listOf(
SummaryPerCategory(categories[0], 50.0, 1f, Currencies.PLN),
SummaryPerCategory(categories[1], 120.0, 0.3f, Currencies.PLN),
SummaryPerCategory(categories[4], 120.0, 0.3f, Currencies.PLN),
SummaryPerCategory(categories[2], 80.0, 0.2f, Currencies.PLN),
SummaryPerCategory(categories[3], 50.0, 0.1f, Currencies.PLN),
SummaryPerCategory(categories[5], 50.0, 0.0001f, Currencies.PLN),
)

View File

@@ -0,0 +1,298 @@
package cc.n0th1ng.tripmoney.screens.trippicker
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Shapes
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
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.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.CurrencyButton
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DatePicker
import cc.n0th1ng.tripmoney.screens.listexpense.DateRangePicker
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.pretty
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import io.ktor.http.hostIsIp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTripBottomSheet(
onDismiss: () -> Unit,
onSave: (Trip) -> Unit,
tripToEdit: Trip?,
sheetState: SheetState
) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val defaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
AddTripBottomSheet(
onDismiss = onDismiss,
onSave = onSave,
tripToEdit = tripToEdit,
sheetState = sheetState,
defaultCurrency = defaultCurrency
)
}
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTripBottomSheet(
onDismiss: () -> Unit,
onSave: (Trip) -> Unit,
tripToEdit: Trip?,
sheetState: SheetState,
defaultCurrency: Currencies
) {
var name by remember { mutableStateOf(tripToEdit?.name ?: "") }
var startDate by remember {
mutableStateOf(
tripToEdit?.startDate ?: LocalDate.now()
)
}
var endDate by remember {
mutableStateOf(
tripToEdit?.endDate ?: LocalDate.now()
)
}
var showCurrencyDialog by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
var budgetString by remember { mutableStateOf(tripToEdit?.budget?.toString() ?: "") }
var currency by remember { mutableStateOf(tripToEdit?.currency ?: defaultCurrency.name) }
var enableSave by remember { mutableStateOf(tripToEdit != null) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 15.dp),
text = stringResource(if (tripToEdit == null) R.string.add_trip else R.string.edit_trip),
fontWeight = FontWeight.Bold,
fontSize = 35.sp,
textAlign = TextAlign.Start
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
NameInput(name = name, onTextChange = { newText ->
name = newText
enableSave = !name.isEmpty()
})
Row(
modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(15.dp),
verticalAlignment = Alignment.CenterVertically
) {
BudgetInput(
modifier = Modifier.fillMaxWidth(0.7f),
budget = budgetString,
onTextChange = { newBudget -> budgetString = newBudget })
CurrencyButton(
modifier = Modifier
.weight(1f)
.fillMaxWidth(1f),
onClick = { showCurrencyDialog = true }, text = currency
)
}
Row(
modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
modifier = Modifier
.fillMaxWidth(1f)
.weight(1f),
shape = MaterialTheme.shapes.medium,
onClick = { showDatePicker = true }) {
val startDateFormatted = startDate.pretty()
val endDateFormatted = endDate.pretty()
Text(
text = "$startDateFormatted - $endDateFormatted",
fontSize = 17.sp
)
}
}
Button(
modifier = Modifier.fillMaxWidth(0.9f),
enabled = enableSave,
shape = MaterialTheme.shapes.medium,
onClick = {
val trip =
Trip(
name = name,
startDate = startDate,
endDate = endDate,
currency = currency,
budget = budgetString.toDoubleOrNull() ?: 0.0
)
onSave(if (tripToEdit == null) trip else trip.copy(id = tripToEdit.id))
}) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.save)
)
}
Spacer(Modifier.height(5.dp))
}
}
if (showCurrencyDialog) {
CurrencySelectionDialog(
onDismiss = { showCurrencyDialog = false },
onCurrencySelected = { selectedCurrency ->
showCurrencyDialog = false
currency = selectedCurrency
},
selected = currency
)
}
if (showDatePicker) {
DateRangePicker(
startDate = startDate,
endDate = endDate,
onDismiss = { showDatePicker = false },
onConfirm = { newStartDate, newEndDate ->
startDate = newStartDate
endDate = newEndDate
showDatePicker = false
})
}
}
@Composable
fun NameInput(name: String, onTextChange: (String) -> Unit) {
var text by remember { mutableStateOf(name) }
OutlinedTextField(
modifier = Modifier.fillMaxWidth(0.9f),
label = { Text(stringResource(R.string.name)) }, value = name, onValueChange = { newText ->
text = newText
onTextChange(text)
}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
@Composable
fun BudgetInput(modifier: Modifier = Modifier, budget: String, onTextChange: (String) -> Unit) {
var text by remember { mutableStateOf(budget) }
OutlinedTextField(
placeholder = { Text("0.0") },
modifier = modifier,
label = { Text(stringResource(R.string.budget)) },
value = text,
onValueChange = { newText ->
val regex = Regex("^\\d*\\.?\\d{0,2}$")
if (regex.matches(newText)) {
text = newText
onTextChange(text)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
)
)
}
@RequiresApi(Build.VERSION_CODES.O)
@SuppressLint("CoroutineCreationDuringComposition")
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
fun PreviewAddTripBottomSheet() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
CoroutineScope(Dispatchers.IO).launch {
sheetState.show()
}
TripMoneyTheme {
AddTripBottomSheet({}, {}, null, sheetState, defaultCurrency = Currencies.entries.random())
}
}
@RequiresApi(Build.VERSION_CODES.O)
@SuppressLint("CoroutineCreationDuringComposition")
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
fun PreviewAddTripBottomSheetEditTrip() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
CoroutineScope(Dispatchers.IO).launch {
sheetState.show()
}
TripMoneyTheme {
AddTripBottomSheet(
{},
{},
Trip(1, "Włochy", LocalDate.parse("2025-01-02"),
LocalDate.parse("2025-01-15"), "PLN", budget = 0.0),
sheetState,
defaultCurrency = Currencies.entries.random()
)
}
}

View File

@@ -1,81 +1,234 @@
package cc.n0th1ng.tripmoney.screens.trippicker
import androidx.compose.foundation.clickable
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
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.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.runtime.Composable
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
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.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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.listexpense.DeleteConfirmationDialog
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.pretty
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)
@Composable
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
fun TripPickerScreen(
navController: NavController
) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
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(
onClick = { showBottomSheet = true }) {
Icon(Icons.Filled.Add, stringResource(string.add_trip))
}
}) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(horizontal = 15.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.Center
) {
items(trips.itemCount, trips.itemKey { it.id }) { i ->
Spacer(Modifier.height(10.dp))
val trip = trips[i]
if (trip != null) {
TripCard(trip, currentTripId == trip.id, onClick = {
settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
SwipeToDeleteTripCard(
trip = trip,
onDelete = {
onDelete(trip)
}, onClick = {
onClick(trip)
}, isSelected = currentTripId == trip.id,
onLongClick = { trip ->
tripToEdit = trip
showBottomSheet = true
})
}
Spacer(Modifier.height(10.dp))
}
}
if (showBottomSheet) {
AddTripBottomSheet(
onDismiss = {
showBottomSheet = false
tripToEdit = null
},
onSave = { trip ->
onSave(trip)
showBottomSheet = false
tripToEdit = null
},
tripToEdit = tripToEdit,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun TripCard(trip: Trip, isSelected: Boolean, onClick: () -> Unit) {
fun SwipeToDeleteTripCard(
trip: Trip, onDelete: (Trip) -> Unit, onClick: (Trip) -> Unit, isSelected: Boolean,
onLongClick: (Trip) -> Unit
) {
var dismissed by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
if (!dismissed) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
showDialog = true
false
} else {
false
}
})
if (showDialog) {
DeleteConfirmationDialog(onConfirm = {
showDialog = false
dismissed = true
onDelete(trip)
}, onCancel = { showDialog = false })
}
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
Box(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete))
}
}) {
TripCard(trip, isSelected, onClick = onClick, onLongClick = onLongClick)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun TripCard(
trip: Trip,
isSelected: Boolean,
onClick: (Trip) -> Unit,
onLongClick: (Trip) -> Unit
) {
val haptics = LocalHapticFeedback.current
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceContainer
}
),
modifier = Modifier
.height(100.dp)
.clickable(true, onClick = onClick)
.alpha(if (isSelected) 1.0f else 0.7f),
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp)
.combinedClickable(enabled = true, onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(trip)
}, onClick = { onClick(trip) }),
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -83,19 +236,75 @@ fun TripCard(trip: Trip, isSelected: Boolean, onClick: () -> Unit) {
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.padding(16.dp)
modifier = Modifier.padding(16.dp)
) {
Text(fontSize = 25.sp, fontWeight = FontWeight.SemiBold, text = trip.name)
Text(trip.startDate)
}
Text(
trip.currency.uppercase(),
modifier = Modifier.padding(20.dp),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
text = trip.name
)
Text(
style = MaterialTheme.typography.bodySmall,
text = "start: " + trip.startDate.pretty() + "\nend: " + trip.endDate.pretty()
)
}
Column(
modifier = Modifier.padding(end = 20.dp),
horizontalAlignment = Alignment.End) {
Text(
trip.currency.uppercase(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
"budget:",
style = MaterialTheme.typography.bodySmall,
)
Text(
"%.2f".format(trip.budget),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewTripPickerScreen() {
val tripsToPreview = listOf(
Trip(
1,
name = "Włochy",
startDate = LocalDate.parse("2026-03-01"),
endDate = LocalDate.parse("2026-03-14"),
currency = "PLN",
budget = 1053.53
),
Trip(
2,
name = "Szwajcaria",
startDate = LocalDate.parse("2025-03-01"),
endDate = LocalDate.parse("2025-03-11"),
currency = "EUR"
),
Trip(
3,
name = "Portugalia",
startDate = LocalDate.parse("2025-03-01"),
endDate = LocalDate.parse("2025-03-11"),
currency = "USD"
)
)
TripMoneyTheme {
TripPickerScreen(
tripsFlow = MutableStateFlow(PagingData.from(tripsToPreview)),
currentTripId = 1,
onDelete = {},
onClick = {},
onSave = {}
)
}
}

View File

@@ -0,0 +1,35 @@
package cc.n0th1ng.tripmoney.service
import cc.n0th1ng.tripmoney.utils.Currencies
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.json.JSONObject
import java.time.LocalDate
import javax.inject.Inject
class ExchangeService @Inject() constructor() {
private val API_URL: String = "https://api.frankfurter.dev"
private val client = HttpClient()
suspend fun getRate(base: Currencies, target: Currencies, date: LocalDate): Double {
return try {
val response = client.get("$API_URL/v1/$date") {
url {
parameters.append("base", base.name)
parameters.append("symbols", target.name)
}
}
val json = Json
json.parseToJsonElement(response.bodyAsText()).jsonObject["rates"]?.jsonObject[target.name]?.jsonPrimitive?.double
?: throw Exception("can not find rates")
} catch (e: Exception) {
throw IllegalStateException("Error fetching exchange rate: ${e.message}")
}
}
}

View File

@@ -2,10 +2,218 @@ package cc.n0th1ng.tripmoney.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val primaryLight = Color(0xFF48672E)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFC9EEA7)
val onPrimaryContainerLight = Color(0xFF324E19)
val secondaryLight = Color(0xFF56624A)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFDAE7C9)
val onSecondaryContainerLight = Color(0xFF3F4A34)
val tertiaryLight = Color(0xFF386664)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFBBECE9)
val onTertiaryContainerLight = Color(0xFF1E4E4D)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFF9FAEF)
val onBackgroundLight = Color(0xFF1A1D16)
val surfaceLight = Color(0xFFF9FAEF)
val onSurfaceLight = Color(0xFF1A1D16)
val surfaceVariantLight = Color(0xFFE0E4D6)
val onSurfaceVariantLight = Color(0xFF44483E)
val outlineLight = Color(0xFF74796D)
val outlineVariantLight = Color(0xFFC4C8BA)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2E312A)
val inverseOnSurfaceLight = Color(0xFFF0F2E7)
val inversePrimaryLight = Color(0xFFAED18D)
val surfaceDimLight = Color(0xFFD9DBD0)
val surfaceBrightLight = Color(0xFFF9FAEF)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF3F5EA)
val surfaceContainerLight = Color(0xFFEDEFE4)
val surfaceContainerHighLight = Color(0xFFE7E9DE)
val surfaceContainerHighestLight = Color(0xFFE2E3D9)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val primaryLightMediumContrast = Color(0xFF213D08)
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val primaryContainerLightMediumContrast = Color(0xFF57763B)
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val secondaryLightMediumContrast = Color(0xFF2F3925)
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
val secondaryContainerLightMediumContrast = Color(0xFF657158)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryLightMediumContrast = Color(0xFF073D3C)
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightMediumContrast = Color(0xFF477573)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val errorLightMediumContrast = Color(0xFF740006)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFF9FAEF)
val onBackgroundLightMediumContrast = Color(0xFF1A1D16)
val surfaceLightMediumContrast = Color(0xFFF9FAEF)
val onSurfaceLightMediumContrast = Color(0xFF0F120C)
val surfaceVariantLightMediumContrast = Color(0xFFE0E4D6)
val onSurfaceVariantLightMediumContrast = Color(0xFF33382E)
val outlineLightMediumContrast = Color(0xFF4F5449)
val outlineVariantLightMediumContrast = Color(0xFF6A6F63)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF2E312A)
val inverseOnSurfaceLightMediumContrast = Color(0xFFF0F2E7)
val inversePrimaryLightMediumContrast = Color(0xFFAED18D)
val surfaceDimLightMediumContrast = Color(0xFFC5C7BD)
val surfaceBrightLightMediumContrast = Color(0xFFF9FAEF)
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightMediumContrast = Color(0xFFF3F5EA)
val surfaceContainerLightMediumContrast = Color(0xFFE7E9DE)
val surfaceContainerHighLightMediumContrast = Color(0xFFDCDED3)
val surfaceContainerHighestLightMediumContrast = Color(0xFFD1D3C8)
val primaryLightHighContrast = Color(0xFF183301)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF34511B)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF252F1B)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF414D36)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF003231)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF21504F)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF600004)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF98000A)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFF9FAEF)
val onBackgroundLightHighContrast = Color(0xFF1A1D16)
val surfaceLightHighContrast = Color(0xFFF9FAEF)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFE0E4D6)
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
val outlineLightHighContrast = Color(0xFF292E24)
val outlineVariantLightHighContrast = Color(0xFF464B40)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF2E312A)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFFAED18D)
val surfaceDimLightHighContrast = Color(0xFFB8BAB0)
val surfaceBrightLightHighContrast = Color(0xFFF9FAEF)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFF0F2E7)
val surfaceContainerLightHighContrast = Color(0xFFE2E3D9)
val surfaceContainerHighLightHighContrast = Color(0xFFD3D5CB)
val surfaceContainerHighestLightHighContrast = Color(0xFFC5C7BD)
val primaryDark = Color(0xFFAED18D)
val onPrimaryDark = Color(0xFF1C3704)
val primaryContainerDark = Color(0xFF324E19)
val onPrimaryContainerDark = Color(0xFFC9EEA7)
val secondaryDark = Color(0xFFBECBAE)
val onSecondaryDark = Color(0xFF29341F)
val secondaryContainerDark = Color(0xFF3F4A34)
val onSecondaryContainerDark = Color(0xFFDAE7C9)
val tertiaryDark = Color(0xFFA0CFCD)
val onTertiaryDark = Color(0xFF003736)
val tertiaryContainerDark = Color(0xFF1E4E4D)
val onTertiaryContainerDark = Color(0xFFBBECE9)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF11140E)
val onBackgroundDark = Color(0xFFE2E3D9)
val surfaceDark = Color(0xFF11140E)
val onSurfaceDark = Color(0xFFE2E3D9)
val surfaceVariantDark = Color(0xFF44483E)
val onSurfaceVariantDark = Color(0xFFC4C8BA)
val outlineDark = Color(0xFF8E9286)
val outlineVariantDark = Color(0xFF44483E)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE2E3D9)
val inverseOnSurfaceDark = Color(0xFF2E312A)
val inversePrimaryDark = Color(0xFF48672E)
val surfaceDimDark = Color(0xFF11140E)
val surfaceBrightDark = Color(0xFF373A33)
val surfaceContainerLowestDark = Color(0xFF0C0F09)
val surfaceContainerLowDark = Color(0xFF1A1D16)
val surfaceContainerDark = Color(0xFF1E211A)
val surfaceContainerHighDark = Color(0xFF282B24)
val surfaceContainerHighestDark = Color(0xFF33362E)
val primaryDarkMediumContrast = Color(0xFFC3E8A1)
val onPrimaryDarkMediumContrast = Color(0xFF132B00)
val primaryContainerDarkMediumContrast = Color(0xFF799A5C)
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
val secondaryDarkMediumContrast = Color(0xFFD4E1C3)
val onSecondaryDarkMediumContrast = Color(0xFF1E2915)
val secondaryContainerDarkMediumContrast = Color(0xFF89957A)
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
val tertiaryDarkMediumContrast = Color(0xFFB5E5E3)
val onTertiaryDarkMediumContrast = Color(0xFF002B2A)
val tertiaryContainerDarkMediumContrast = Color(0xFF6B9997)
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
val errorDarkMediumContrast = Color(0xFFFFD2CC)
val onErrorDarkMediumContrast = Color(0xFF540003)
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF11140E)
val onBackgroundDarkMediumContrast = Color(0xFFE2E3D9)
val surfaceDarkMediumContrast = Color(0xFF11140E)
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkMediumContrast = Color(0xFF44483E)
val onSurfaceVariantDarkMediumContrast = Color(0xFFDADED0)
val outlineDarkMediumContrast = Color(0xFFAFB4A6)
val outlineVariantDarkMediumContrast = Color(0xFF8E9285)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFE2E3D9)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF282B24)
val inversePrimaryDarkMediumContrast = Color(0xFF33501A)
val surfaceDimDarkMediumContrast = Color(0xFF11140E)
val surfaceBrightDarkMediumContrast = Color(0xFF43453E)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF060804)
val surfaceContainerLowDarkMediumContrast = Color(0xFF1C1F18)
val surfaceContainerDarkMediumContrast = Color(0xFF262922)
val surfaceContainerHighDarkMediumContrast = Color(0xFF31342C)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C3F37)
val primaryDarkHighContrast = Color(0xFFD7FCB3)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFFAACD89)
val onPrimaryContainerDarkHighContrast = Color(0xFF040E00)
val secondaryDarkHighContrast = Color(0xFFE8F5D6)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFBAC7AA)
val onSecondaryContainerDarkHighContrast = Color(0xFF050E01)
val tertiaryDarkHighContrast = Color(0xFFC9F9F7)
val onTertiaryDarkHighContrast = Color(0xFF000000)
val tertiaryContainerDarkHighContrast = Color(0xFF9CCBC9)
val onTertiaryContainerDarkHighContrast = Color(0xFF000E0D)
val errorDarkHighContrast = Color(0xFFFFECE9)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
val onErrorContainerDarkHighContrast = Color(0xFF220001)
val backgroundDarkHighContrast = Color(0xFF11140E)
val onBackgroundDarkHighContrast = Color(0xFFE2E3D9)
val surfaceDarkHighContrast = Color(0xFF11140E)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF44483E)
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
val outlineDarkHighContrast = Color(0xFFEEF2E3)
val outlineVariantDarkHighContrast = Color(0xFFC0C4B6)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFE2E3D9)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF33501A)
val surfaceDimDarkHighContrast = Color(0xFF11140E)
val surfaceBrightDarkHighContrast = Color(0xFF4E5149)
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
val surfaceContainerLowDarkHighContrast = Color(0xFF1E211A)
val surfaceContainerDarkHighContrast = Color(0xFF2E312A)
val surfaceContainerHighDarkHighContrast = Color(0xFF393C35)
val surfaceContainerHighestDarkHighContrast = Color(0xFF454840)

View File

@@ -2,41 +2,98 @@ package cc.n0th1ng.tripmoney.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
@Composable
fun TripMoneyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
@@ -44,9 +101,8 @@ fun TripMoneyTheme(
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
darkTheme -> darkScheme
else -> lightScheme
}
MaterialTheme(

View File

@@ -0,0 +1,20 @@
package cc.n0th1ng.tripmoney.utils
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.content.res.Configuration.UI_MODE_TYPE_NORMAL
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cc.n0th1ng.tripmoney.screens.addexpense.PreviewAddExpenseDisabled
import cc.n0th1ng.tripmoney.screens.addexpense.PreviewAddExpenseEnabled
import cc.n0th1ng.tripmoney.screens.settings.PreviewSettingsScreen
import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
@Preview(name = "Light")
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
annotation class AllPreviews

View File

@@ -0,0 +1,30 @@
package cc.n0th1ng.tripmoney.utils
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
fun saveCsv(context: Context, fileName: String, content: String): File {
val file = File(context.cacheDir, "$fileName.csv")
file.writeText(content)
return file
}
fun shareCsv(context: Context, file: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/csv"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(intent, "Share CSV")
)
}

View File

@@ -1,25 +1,15 @@
package cc.n0th1ng.tripmoney.utils
enum class Colors(val hexString: String) {
RED("#D53E0F"),
PINK("#FF69B4"),
ORANGE("#FF8C00"),
YELLOW("#FFD700"),
LIME("#32CD32"),
GREEN("#228B22"),
MINT("#98FB98"),
TEAL("#008080"),
CYAN("#00CED1"),
SKY_BLUE("#1E90FF"),
BLUE("#0000FF"),
LAVENDER("#8A2BE2"),
LILAC("#C8A2C8"),
PURPLE("#800080"),
MAUVE("#D8BFD8"),
MAGENTA("#FF00FF"),
VIOLET("#9400D3"),
INDIGO("#4B0082"),
PERIWINKLE("#8A2BE2"),
GRAY("#696969");
}
val colors: List<String> = listOf(
"#af1b3f",
"#083D77",
"#5998c5",
"#f7934c",
"#ec0b43",
"#87A330",
"#6F8AB7",
"#F26CA7",
"#5E4AE3",
"#2A7F62",
"#0B7189"
)

View File

@@ -0,0 +1,17 @@
package cc.n0th1ng.tripmoney.utils
enum class Currencies {
PLN,
EUR,
USD,
RON;
companion object {
fun default(): Currencies {
return PLN
}
fun names(): List<String> {
return Currencies.entries.map { it.name }
}
}
}

View File

@@ -0,0 +1,11 @@
package cc.n0th1ng.tripmoney.utils
import android.os.Build
import androidx.annotation.RequiresApi
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@RequiresApi(Build.VERSION_CODES.O)
fun LocalDate.pretty(): String {
return this.format(DateTimeFormatter.ofPattern("dd MMM yyyy"))
}

View File

@@ -7,16 +7,12 @@ import com.composables.icons.materialsymbols.outlined.R
enum class Icons(@DrawableRes val resource: Int) {
HOTEL(R.drawable.materialsymbols_ic_hotel_outlined),
RESTAURANT(R.drawable.materialsymbols_ic_restaurant_outlined),
TRANSPORT(R.drawable.materialsymbols_ic_local_taxi_outlined),
FLIGHT(R.drawable.materialsymbols_ic_flight_outlined),
ATTRACTION(R.drawable.materialsymbols_ic_museum_outlined),
ATTRACTION(R.drawable.materialsymbols_ic_theater_comedy_outlined),
GROCERIES(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES1(R.drawable.materialsymbols_ic_airline_seat_recline_normal_outlined),
GROCERIES2(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES3(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES4(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES5(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES6(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES7(R.drawable.materialsymbols_ic_grocery_outlined)
COFFEE(R.drawable.materialsymbols_ic_local_cafe_outlined),
GENERAL(R.drawable.materialsymbols_ic_shoppingmode_outlined),
ENTERTAINMENT(R.drawable.materialsymbols_ic_theaters_outlined),
LAUNDRY(R.drawable.materialsymbols_ic_local_laundry_service_outlined)
}

View File

@@ -1,46 +1,201 @@
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 androidx.paging.insertSeparators
import androidx.paging.map
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
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.data.repository.CategoryRepository
import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
import cc.n0th1ng.tripmoney.data.repository.TripRepository
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVPrinter
import java.io.File
import java.time.LocalDate
import javax.inject.Inject
@HiltViewModel
class ExpenseAndCategoryViewModel @Inject constructor(
open class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository,
private val categoryRepo: CategoryRepository
private val categoryRepo: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository,
private val tripRepo: TripRepository
) : ViewModel() {
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpenses(tripId).cachedIn(viewModelScope)
fun getBudgetLeft(tripId: Int): Flow<Double?> {
return expenseRepo.getBudgetLeft(tripId)
}
fun save(expense: Expense) {
fun getExpensesDtoPaged(tripId: Int, search: String = "", filter: Filter = Filter()): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesDtoPaged(tripId, search, filter).cachedIn(viewModelScope)
@RequiresApi(Build.VERSION_CODES.O)
fun getExpensesWithHeadersPaged(
tripId: Int,
search: String = "",
filter: Filter
): Flow<PagingData<ExpenseListItemUi>> {
val pagingFlow = getExpensesDtoPaged(tripId, search, filter)
val sumsFlow = getDailySums(tripId, search, filter)
val tripFlow = tripRepo.getTrip(tripId)
return combine(pagingFlow, sumsFlow, tripFlow) { pagingData, sums, trip ->
val currency = trip?.currency ?: ""
pagingData
.map<ExpenseDto, ExpenseListItemUi> {
ExpenseListItemUi.Item(it)
}
.insertSeparators { before, after ->
if (after == null) return@insertSeparators null
val afterItem = after as ExpenseListItemUi.Item
val afterDate = afterItem.expenseDto.expense.datetime.toLocalDate()
val beforeDate = (before as? ExpenseListItemUi.Item)
?.expenseDto
?.expense
?.datetime
?.toLocalDate()
if (before == null || beforeDate != afterDate) {
ExpenseListItemUi.Header(
date = afterDate,
sum = sums[afterDate] ?: 0.0,
currency = currency
)
} else {
null
}
}
}.cachedIn(viewModelScope)
}
fun getExpensesDto(tripId: Int, search: String = "", filter: Filter = Filter()): Flow<List<ExpenseDto>> =
expenseRepo.getExpensesDto(tripId, search, filter)
@RequiresApi(Build.VERSION_CODES.O)
fun save(expense: Expense, trip: Trip) {
viewModelScope.launch {
expenseRepo.save(expense)
val rate = exchangeRateRepository.getRate(
Currencies.valueOf(expense.currency),
Currencies.valueOf(trip.currency),
expense.datetime.toLocalDate()
)
expenseRepo.save(expense.copy(rate = rate))
}
}
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 {
categoryRepo.save(category)
}
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun generateCSVToFile(tripId: Int, file: File) {
file.writer().use { writer ->
CSVPrinter(
writer,
CSVFormat.DEFAULT.withHeader("date", "category", "currency", "amount")
).use { printer ->
expenseRepo.getExpensesDto(tripId).first().forEach { expenseDto ->
printer.printRecord(
expenseDto.expense.datetime,
expenseDto.category.name,
expenseDto.expense.currency,
expenseDto.expense.amount
)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getDailySums(tripId: Int, search: String, filter: Filter): Flow<Map<LocalDate, Double>> {
return getExpensesDto(tripId, search, filter)
.map { expenses ->
expenses.groupBy { it.expense.datetime.toLocalDate() }
.mapValues { (_, list) ->
list.sumOf { it.expense.amount * it.expense.rate }
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSummaryAmount(tripId: Int): Flow<Double> {
return getExpensesDto(tripId).map { list ->
list.sumOf { it.expense.amount * it.expense.rate }
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategory>> {
val tripFlow = tripRepo.getTrip(tripId)
val expensesFlow = getExpensesDto(tripId)
return tripFlow.combine(expensesFlow) { trip, expenses ->
val tripCurrency = trip?.currency ?: Currencies.default().name
val sumOfAll = expenses.sumOf { it.expense.convertedAmount() }
expenses.groupBy { it.category }
.map { (category, expensesForCategory) ->
val total = expensesForCategory.sumOf { it.expense.convertedAmount() }
SummaryPerCategory(
category = category,
amount = total,
percent = (total / sumOfAll).toFloat(),
currency = Currencies.valueOf(tripCurrency)
)
}
.sortedByDescending { it.percent }
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun clearOldRates() {
viewModelScope.launch {
exchangeRateRepository.clearOldRates()
}
}
@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()
}
}

View File

@@ -5,14 +5,23 @@ import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.data.repository.PreferencesRepository
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.Provides
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ViewScoped
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,6 +30,7 @@ class SettingsViewModel @Inject constructor(
private val repo: PreferencesRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
private val uiModeManager: UiModeManager =
context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
val theme = repo.themeFlow
@@ -30,11 +40,39 @@ class SettingsViewModel @Inject constructor(
AppTheme.SYSTEM
)
val autoOpenStartupPref = repo.currentAddExpenseSwitchFlow
.take(1)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null
)
val addExpenseSwitch = repo.currentAddExpenseSwitchFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000), false
)
val currentTrip = repo.currentTripFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000),
-1
)
val defaultCurrency = repo.defaultCurrencyFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000),
Currencies.default()
)
fun setDefaultCurrency(currency: Currencies) {
viewModelScope.launch {
repo.saveDefaultCurrency(currency)
}
}
fun setCurrentAddExpenseSwitch(value: Boolean) {
viewModelScope.launch {
repo.saveAddExpenseSwitch(value)
}
}
fun setCurrentTrip(tripId: Int) {
viewModelScope.launch {
repo.saveCurrentTrip(tripId)
@@ -65,5 +103,3 @@ class SettingsViewModel @Inject constructor(
}
}

View File

@@ -1,19 +1,50 @@
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.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)
fun delete(trip: Trip) {
viewModelScope.launch {
repository.delete(trip)
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun save(trip: Trip) {
viewModelScope.launch {
repository.save(trip)
_isRecalculating.value = true
withContext(Dispatchers.IO) {
expenseRepository.recalculateTripExpenses(trip.id)
}
_isRecalculating.value = false
}
}
}

View File

@@ -5,7 +5,7 @@
<string name="save">Zapisz</string>
<string name="backspace">Usuń</string>
<string name="pick_category">Wybierz kategorie</string>
<string name="add_new_category">dodaj nową</string>
<string name="add_new">dodaj nową</string>
<string name="pick_currency">Wybierz kategorie</string>
<string name="cancel">Anuluj</string>
<string name="add_expense">Dodaj wydatek</string>
@@ -16,4 +16,20 @@
<string name="system_settings">Zgodnie z systemem</string>
<string name="theme">Motyw</string>
<string name="dark_theme">Ciemny motyw</string>
<string name="add_trip">Dodaj wycieczkę</string>
<string name="name">Nazwa</string>
<string name="edit_trip">Edytuj wycieczkę</string>
<string name="edit_expense">Edytuj wydatek</string>
<string name="default_currency">Domyślna waluta</string>
<string name="total_expenses">Suma wydatków</string>
<string name="settings">Ustawienia</string>
<string name="pick_trip">Wybierz wycieczkę</string>
<string name="list_of_expenses">Lista wydatków</string>
<string name="statistics">Statystyki</string>
<string name="export_to_csv">Eksport do CSV</string>
<string name="manage_categories">Zarządzaj kategoriami</string>
<string name="categories">Kategorie</string>
<string name="export_csv_subttext">Zapisz wydatki z %s do pliku</string>
<string name="add_new_category">Dodaj kategorie</string>
<string name="edit_category">Edytuj kategorie</string>
</resources>

View File

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="good_green">#A0D585</color>
</resources>

View File

@@ -5,7 +5,7 @@
<string name="save">Save</string>
<string name="backspace">Backspace</string>
<string name="pick_category">Pick a category</string>
<string name="add_new_category">Add new</string>
<string name="add_new">Add new</string>
<string name="pick_currency">Pick a currency</string>
<string name="cancel">Cancel</string>
<string name="add_expense">Add expense</string>
@@ -16,5 +16,29 @@
<string name="light_theme">Light theme</string>
<string name="pick_theme">Pick a theme</string>
<string name="system_settings">System settings</string>
<!-- <string name="theme">Theme</string>-->
<string name="add_trip">Add Trip</string>
<string name="name">Name</string>
<string name="edit_trip">Edit trip</string>
<string name="edit_expense">Edit expense</string>
<string name="default_currency">Default currency</string>
<string name="total_expenses">Total expenses</string>
<string name="settings">Settings</string>
<string name="pick_trip">Pick trip</string>
<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>
<string name="budget">Budget</string>
<string name="money_left">Money left</string>
<string name="add_expense_settings">Open add expense form on startup</string>
<string name="yesterday">Yesterday</string>
<string name="clear">Clear</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
</paths>

1
benchmark/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,54 @@
import com.android.build.api.dsl.ManagedVirtualDevice
kotlin {
jvmToolchain(21)
}
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "cc.n0th1ng.benchmark"
compileSdk = 36
defaultConfig {
minSdk = 24
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["androidx.compose.ui.test.tagsAsResourceId"] = "true"
}
testOptions.managedDevices.devices {
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "aosp"
}
}
buildTypes {
create("benchmark") {
isDebuggable = true
signingConfig = getByName("debug").signingConfig
matchingFallbacks += listOf("release")
}
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.espresso.core)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}
androidComponents {
beforeVariants(selector().all()) {
it.enable = it.buildType == "benchmark"
}
}

View File

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

View File

@@ -0,0 +1,46 @@
package cc.n0th1ng.benchmark
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
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(AndroidJUnit4ClassRunner::class)
class BaselineProfileGenerator {
@RequiresApi(Build.VERSION_CODES.P)
@get:Rule
val rule = BaselineProfileRule()
@RequiresApi(Build.VERSION_CODES.P)
@Test
fun startup() = rule.collect(
maxIterations = 1,
packageName = "cc.n0th1ng.tripmoney",
profileBlock = {
device.executeShellCommand(
"rm -rf /data/data/cc.n0th1ng.tripmoney/files/datastore/"
)
startActivityAndWait()
device.wait(Until.hasObject(By.text("Włochy")), 10_000)
device.findObject(By.text("Włochy")).click()
val isVisible = device.wait(Until.hasObject(By.desc("expensesList")), 10_000)
assert(isVisible)
val expensesList = device.findObject(By.desc("expensesList"))
expensesList.setGestureMargin(device.displayWidth / 5)
expensesList.fling(Direction.DOWN)
expensesList.fling(Direction.UP)
expensesList.fling(Direction.DOWN)
expensesList.fling(Direction.UP)
}
)
}

View File

@@ -0,0 +1,43 @@
package cc.n0th1ng.benchmark
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.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* This is an example startup benchmark.
*
* It navigates to the device's home screen, and launches the default activity.
*
* Before running this benchmark:
* 1) switch your app's active build variant in the Studio (affedaggcts Studio runs only)
* 2) add `<profileable android:shell="true" />` to your app's manifest, within the `<application>` tag
*
* Run this benchmark from Studio to see startup measurements, and captured system traces
* for investigating your app's performance.
*/
@RunWith(AndroidJUnit4::class)
class ExampleStartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "cc.n0th1ng.tripmoney",
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
device.wait(Until.hasObject(By.pkg("cc.n0th1ng.tripmoney")), 10_000)
device.waitForIdle()
}
}

View File

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

View File

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

View File

@@ -1,18 +1,78 @@
[versions]
agp = "8.13.2"
commonsCsv = "1.14.1"
commonsCsvVersion = "1.14.1"
datastorePreferences = "1.2.1"
desugar_jdk_libsVersion = "2.1.5"
hiltAndroid = "2.59.2"
hiltAndroidCompiler = "2.57.1"
hiltNavigationCompose = "1.3.0"
hiltNavigationComposeVersion = "1.3.0"
iconsMaterialSymbolsOutlinedAndroid = "2.2.1"
kotlin = "2.2.21"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
kotlinxSerializationJsonJvm = "1.11.0"
ktorClientCore = "3.4.3"
ktorClientOkhttp = "3.4.3"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
navigationCompose = "2.9.7"
foundationLayout = "1.10.5"
pagingCompose = "3.4.2"
pagingComposeVersion = "3.4.2"
pagingRuntime = "3.4.2"
roomCompiler = "2.8.4"
roomCompilerVersion = "2.8.4"
roomGuava = "2.8.4"
roomKtx = "2.8.4"
roomPaging = "2.8.4"
roomRuntime = "2.8.4"
roomRxjava2 = "2.8.4"
roomRxjava3 = "2.8.4"
roomTesting = "2.8.4"
uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.4.1"
baselineprofile = "1.4.1"
profileinstaller = "1.4.1"
uiTestJunit4 = "1.11.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
#androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
#androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingCompose" }
#androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingCompose" }
#androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
#androidx-room-guava = { module = "androidx.room:room-guava", version.ref = "roomCompiler" }
#androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomCompiler" }
#androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "roomCompiler" }
#androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomCompiler" }
#androidx-room-rxjava3 = { module = "androidx.room:room-rxjava3", version.ref = "roomCompiler" }
#androidx-room-rxjava2 = { module = "androidx.room:room-rxjava2", version.ref = "roomCompiler" }
#androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomCompiler" }
#commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commonsCsv" }
#hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
#hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationComposeVersion" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingComposeVersion" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompilerVersion" }
androidx-room-guava = { module = "androidx.room:room-guava", version.ref = "roomGuava" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "roomPaging" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
androidx-room-rxjava2 = { module = "androidx.room:room-rxjava2", version.ref = "roomRxjava2" }
androidx-room-rxjava3 = { module = "androidx.room:room-rxjava3", version.ref = "roomRxjava3" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomTesting" }
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commonsCsvVersion" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidCompiler" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" }
icons-material-symbols-outlined-android = { module = "com.composables:icons-material-symbols-outlined-android", version.ref = "iconsMaterialSymbolsOutlinedAndroid" }
icons-material-symbols-outlined-filled-android = { module = "com.composables:icons-material-symbols-outlined-filled-android", version.ref = "iconsMaterialSymbolsOutlinedAndroid" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -28,9 +88,19 @@ 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" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "uiTestJunit4" }
kotlinx-serialization-json-jvm = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "kotlinxSerializationJsonJvm" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorClientOkhttp" }
tools-desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libsVersion" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-test = { id = "com.android.test", version.ref = "agp" }
baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }

View File

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