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

View File

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

View File

@@ -12,15 +12,31 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.TripMoney"> android:theme="@style/Theme.TripMoney">
<profileable
android:shell="true"
tools:targetApi="29" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.TripMoney"> android:theme="@style/Theme.TripMoney">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </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.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
@@ -11,83 +12,110 @@ import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import 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.BottomNavigation
import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer
import cc.n0th1ng.tripmoney.navigation.Screens import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.navigation.TopBar import cc.n0th1ng.tripmoney.navigation.TopBar
import cc.n0th1ng.tripmoney.navigation.TopBarSettings import cc.n0th1ng.tripmoney.navigation.TopBarSettings
import cc.n0th1ng.tripmoney.screens.listexpense.ListExpenseScreen import cc.n0th1ng.tripmoney.screens.listexpense.ListExpenseScreen
import cc.n0th1ng.tripmoney.screens.managecategories.ManageCategoriesScreen
import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen
import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen
import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme 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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject
lateinit var databasePrePopulate: DatabasePrepopulator
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.S)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
CoroutineScope(Dispatchers.IO).launch {
databasePrePopulate.prepopulate()
}
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
TripMoneyTheme { TripMoneyTheme {
NavigationDrawer() NavigationDrawer()
} }
} }
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.S)
@Composable @Composable
fun NavigationDrawer() { 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 navController = rememberNavController()
val navBackStack by navController.currentBackStackEntryAsState() val navBackStack by navController.currentBackStackEntryAsState()
val current = navBackStack?.destination?.route val current = navBackStack?.destination?.route
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var 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) { CustomNavigationDrawer(navController, drawerState) {
Scaffold( Scaffold(
topBar = { topBar = {
if (current == Screens.SETTINGS) TopBarSettings( if (current == Screens.SETTINGS) TopBarSettings(
navController navController
) else TopBar(onClick = { ) else TopBar(
scope.launch { title = currentTrip?.name ?: "",
if (drawerState.isClosed) { onDrawerClick = {
drawerState.open() scope.launch {
} else { if (drawerState.isClosed) {
drawerState.close() drawerState.open()
} else {
drawerState.close()
}
} }
} },
}) isSearchable = current == Screens.LIST_EXPENSE,
onSearchChange = { newSearch -> search = newSearch },
onFilterChange = { newFilter -> filter = newFilter },
categories = categories,
filter = filter
)
}, },
bottomBar = { BottomNavigation(navController) }) { innerPadding -> bottomBar = { BottomNavigation(navController) }) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screens.TRIP_PICKER, startDestination = if (currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
composable(Screens.LIST_EXPENSE) { composable(Screens.LIST_EXPENSE) {
ListExpenseScreen() ListExpenseScreen(
filter = filter, search = search,
initialAutoOpen = shouldTriggerAutoOpen,
onAutoOpenConsumed = { hasHandledStartupOpen = true })
} }
composable(Screens.TRIP_PICKER) { composable(Screens.TRIP_PICKER) {
TripPickerScreen(navController) TripPickerScreen(navController)
@@ -96,10 +124,37 @@ fun NavigationDrawer() {
StatisticsScreen() StatisticsScreen()
} }
composable(Screens.SETTINGS) { 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.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase 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.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
import cc.n0th1ng.tripmoney.data.dao.TripDao import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -22,46 +27,59 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.inject.Inject import java.time.ZoneOffset
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.random.Random
@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 class TripDatabase : RoomDatabase() {
abstract fun tripDao(): TripDao abstract fun tripDao(): TripDao
abstract fun expenseDao(): ExpenseDao abstract fun expenseDao(): ExpenseDao
abstract fun categoryDao(): CategoryDao abstract fun categoryDao(): CategoryDao
abstract fun exchangeRateDao(): ExchangeRateDao
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 }
}
}
}
} }
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DatabaseModule { object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O)
@Provides @Provides
@Singleton @Singleton
fun provideTripDatabase( fun provideTripDatabase(
@ApplicationContext context: Context @ApplicationContext context: Context
): TripDatabase { ): TripDatabase {
return Room.inMemoryDatabaseBuilder( val builder = if (BuildConfig.DEBUG) Room.inMemoryDatabaseBuilder(
context, context = context, klass = TripDatabase::class.java
TripDatabase::class.java ) else Room.databaseBuilder(
name = "tripmoney_db",
context = context,
klass = TripDatabase::class.java,
) )
.allowMainThreadQueries() // Only for in-memory DB, not for production!
.build() 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 @Provides
@@ -84,55 +102,124 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabasePrepopulator( fun provideExchangeRateDao(database: TripDatabase): ExchangeRateDao {
tripDao: TripDao, return database.exchangeRateDao()
categoryDao: CategoryDao,
expenseDao: ExpenseDao
): DatabasePrepopulator {
return DatabasePrepopulator(tripDao, categoryDao, expenseDao)
} }
} }
class DatabasePrepopulator @Inject constructor( private class DatabasePrepopulator(
private val tripDao: TripDao, private val tripDao: TripDao,
private val categoryDao: CategoryDao, private val categoryDao: CategoryDao,
private val expenseDao: ExpenseDao private val expenseDao: ExpenseDao
) { ) {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
suspend fun prepopulate() { 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"))
val now = LocalDateTime.now() tripDao.insert(
expenseDao.insert(Expense(amount = 120.50, currency = "PLN", note = "Hotel overnight", datetime = now.minusDays(10).toString(), categoryId = 1, tripId = 1)) Trip(
expenseDao.insert(Expense(amount = 45.75, currency = "PLN", note = "Dinner", datetime = now.minusDays(9).toString(), categoryId = 2, tripId = 1)) name = "Włochy",
expenseDao.insert(Expense(amount = 15.20, currency = "PLN", note = "Bus ticket", datetime = now.minusDays(8).toString(), categoryId = 3, tripId = 1)) startDate = LocalDate.parse("2026-03-01"),
expenseDao.insert(Expense(amount = 89.99, currency = "PLN", note = "Concert tickets", datetime = now.minusDays(7).toString(), categoryId = 4, tripId = 1)) endDate = LocalDate.parse("2026-03-15"),
expenseDao.insert(Expense(amount = 32.50, currency = "PLN", note = "Souvenirs", datetime = now.minusDays(6).toString(), categoryId = 5, tripId = 1)) currency = "PLN"
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)) tripDao.insert(
expenseDao.insert(Expense(amount = 55.00, currency = "PLN", note = "Museum entry", datetime = now.minusDays(2).toString(), categoryId = 4, tripId = 1)) Trip(
expenseDao.insert(Expense(amount = 12.99, currency = "PLN", note = "Snacks", datetime = now.minusDays(1).toString(), categoryId = 2, tripId = 1)) name = "Szwajcaria",
expenseDao.insert(Expense(amount = 210.00, currency = "PLN", note = "Hotel 5 nights", datetime = now.toString(), categoryId = 1, tripId = 1)) startDate = LocalDate.parse("2025-03-01"),
expenseDao.insert(Expense(amount = 95.50, currency = "EUR", note = "Dinner for two", datetime = now.minusHours(12).toString(), categoryId = 2, tripId = 1)) endDate = LocalDate.parse("2025-03-15"),
expenseDao.insert(Expense(amount = 30.00, currency = "EUR", note = "Taxi", datetime = now.minusHours(6).toString(), categoryId = 3, tripId = 1)) currency = "EUR"
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)) )
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()
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 package cc.n0th1ng.tripmoney.data.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
@@ -14,12 +15,23 @@ interface CategoryDao {
suspend fun insert(category: Category) suspend fun insert(category: Category)
@Delete
suspend fun delete(category: Category)
@Transaction @Transaction
@Query( @Query(
""" """
SELECT * FROM category SELECT * FROM category WHERE archived is 0
""" """
) )
fun categories(): Flow<List<Category>> fun categories(): Flow<List<Category>>
@Transaction
@Query(
"""
SELECT * FROM category WHERE archived is 1
"""
)
fun archivedCategories(): Flow<List<Category>>
} }

View File

@@ -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.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ExpenseDao { interface ExpenseDao {
@Upsert @Upsert
suspend fun insert(expense: Expense) 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 @Transaction
@Query( @Query(
""" """
SELECT * FROM expense WHERE trip_id = :tripId SELECT * FROM expense
ORDER BY DATETIME(expense.datetime) DESC 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 @Delete
suspend fun delete(expense: Expense) suspend fun delete(expense: Expense)

View File

@@ -2,10 +2,12 @@ package cc.n0th1ng.tripmoney.data.dao
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TripDao { interface TripDao {
@@ -15,8 +17,16 @@ interface TripDao {
@Query( @Query(
""" """
SELECT * FROM trip SELECT * FROM trip
ORDER BY trip.start_date DESC
""" """
) )
fun tripsPaged(): PagingSource<Int, Trip> 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 package cc.n0th1ng.tripmoney.data.entity
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.Icons
@Entity(tableName = "category") @Entity(tableName = "category")
@Immutable
data class Category( data class Category(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String, @ColumnInfo("name") val name: String,
@ColumnInfo("icon") val icon: Icons, @ColumnInfo("icon") val icon: Icons,
@ColumnInfo("color") val color: String @ColumnInfo("color") val color: String,
@ColumnInfo("archived") val archived: Boolean = false
) )

View File

@@ -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 package cc.n0th1ng.tripmoney.data.entity
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Relation 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( data class Expense(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("amount") val amount: Double, @ColumnInfo("amount") val amount: Double,
@ColumnInfo("currency") val currency: String, @ColumnInfo("currency") val currency: String,
@ColumnInfo("note") val note: String, @ColumnInfo("note") val note: String,
@ColumnInfo("datetime") val datetime: String, @ColumnInfo("datetime") val datetime: LocalDateTime,
@ColumnInfo("category_id") val categoryId: Int, @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( data class ExpenseDto(
@Embedded val expense: Expense, @Embedded val expense: Expense,

View File

@@ -1,13 +1,31 @@
package cc.n0th1ng.tripmoney.data.entity 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.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import cc.n0th1ng.tripmoney.utils.Currencies
import java.time.LocalDate
@Entity(tableName = "trip") @Entity(tableName = "trip")
@Immutable
data class Trip( data class Trip(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String, @ColumnInfo("name") val name: String,
@ColumnInfo("start_date") val startDate: String, @ColumnInfo("start_date") val startDate: LocalDate,
@ColumnInfo("currency") val currency: String @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) categoryDao.insert(category)
} }
@WorkerThread
suspend fun delete(category: Category) {
categoryDao.delete(category)
}
fun getCategories(): Flow<List<Category>> { fun getCategories(): Flow<List<Category>> {
return categoryDao.categories() return categoryDao.categories()
} }
fun getArchivedCategories(): Flow<List<Category>> {
return categoryDao.archivedCategories()
}
} }

View File

@@ -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 package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.Filter
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import javax.inject.Inject 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 @WorkerThread
suspend fun save(expense: Expense) { suspend fun save(expense: Expense) {
@@ -22,10 +34,58 @@ class ExpenseRepository @Inject constructor(private val expenseDao: ExpenseDao)
expenseDao.delete(expense) expenseDao.delete(expense)
} }
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> { fun getExpensesDtoPaged(
tripId: Int,
search: String,
filter: Filter,
): Flow<PagingData<ExpenseDto>> {
return Pager( return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false), 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 ).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 package cc.n0th1ng.tripmoney.data.repository
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore 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.APP_THEME
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.CURRENT_TRIP 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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.DEFAULT_CONCURRENCY
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.util.Currency
import javax.inject.Inject import javax.inject.Inject
@@ -18,6 +24,8 @@ val Context.preferencesDataStore by preferencesDataStore(name = "app_preferences
object PreferenceKeys { object PreferenceKeys {
val APP_THEME = intPreferencesKey("app_theme") val APP_THEME = intPreferencesKey("app_theme")
val CURRENT_TRIP = intPreferencesKey("current_trip") 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) AppTheme.fromValue(value)
} }
val currentAddExpenseSwitchFlow: Flow<Boolean> =
context.preferencesDataStore.data.map { prefs ->
val value = prefs[ADD_EXPENSE_SWITCH]
?: false
value
}
val currentTripFlow: Flow<Int> = val currentTripFlow: Flow<Int> =
context.preferencesDataStore.data.map { prefs -> context.preferencesDataStore.data.map { prefs ->
prefs[CURRENT_TRIP] ?: -1 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) { suspend fun saveCurrentTrip(tripId: Int) {
context.preferencesDataStore.edit { prefs -> context.preferencesDataStore.edit { prefs ->
prefs[CURRENT_TRIP] = tripId 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) { enum class AppTheme(val value: Int) {

View File

@@ -1,17 +1,26 @@
package cc.n0th1ng.tripmoney.data.repository package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.data.dao.TripDao import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class TripRepository @Inject constructor(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) { suspend fun save(trip: Trip) {
tripDao.insert(trip) tripDao.insert(trip)
} }
@@ -22,4 +31,13 @@ class TripRepository @Inject constructor(private val tripDao: TripDao) {
pagingSourceFactory = { tripDao.tripsPaged() } pagingSourceFactory = { tripDao.tripsPaged() }
).flow ).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.material3.NavigationBarItem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -37,7 +39,7 @@ fun BottomNavigation(navController: NavController) {
painter = painterResource( painter = painterResource(
R.drawable.materialsymbols_ic_list_outlined, R.drawable.materialsymbols_ic_list_outlined,
), ),
null "list screen"
) )
} }
) )

View File

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

View File

@@ -1,37 +1,259 @@
package cc.n0th1ng.tripmoney.navigation package cc.n0th1ng.tripmoney.navigation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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( TopAppBar(
title = {}, 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 = { navigationIcon = {
IconButton(onClick = { onClick() }) { IconButton(onClick = { onDrawerClick() }) {
Icon(Icons.Default.Menu, contentDescription = "Menu") 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TopBarSettings(navController: NavHostController) { fun TopBarSettings(navController: NavHostController) {
TopAppBar( TopAppBar(
title = { Text("Settings") }, title = { Text(stringResource(R.string.settings)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") 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 package cc.n0th1ng.tripmoney.screens
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
@@ -9,14 +11,17 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -28,67 +33,95 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme import cc.n0th1ng.tripmoney.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.Icons
import cc.n0th1ng.tripmoney.utils.colors
@Composable @Composable
fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) { fun AddCategoryDialog(
var name by remember { mutableStateOf("") } onDismiss: () -> Unit,
var icon by remember { mutableStateOf(Icons.entries[0]) } onSave: (Category) -> Unit,
var color by remember { mutableStateOf(Colors.entries[0].hexString) } categoryToEdit: Category? = null
) {
var name by remember { mutableStateOf(categoryToEdit?.name ?: "") }
var icon by remember { mutableStateOf(categoryToEdit?.icon ?: Icons.entries[0]) }
var color by remember { mutableStateOf(categoryToEdit?.color ?: colors[0]) }
var isArchived by remember { mutableStateOf(categoryToEdit?.archived ?: false) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, title = { Text("Add new category") }, text = { onDismissRequest = onDismiss,
title = { Text(stringResource(if (categoryToEdit == null) R.string.add_new_category else R.string.edit_category)) },
text = {
AlertDialogFill( AlertDialogFill(
onTextChange = { newText -> onTextChange = { newText ->
name = newText name = newText
}, },
onIconChange = { newIcon -> icon = newIcon }, onIconChange = { newIcon -> icon = newIcon },
onColorChange = {newColor -> color = newColor} onColorChange = { newColor -> color = newColor },
onArchivedChange = { newArchived ->
isArchived = newArchived
},
name = name,
icon = icon,
color = color,
isArchived = isArchived
) )
}, confirmButton = { },
confirmButton = {
Button( Button(
enabled = !name.isEmpty(), enabled = !name.isEmpty(),
onClick = { onClick = {
onSave( val categoryToSave = Category(
Category( name = name,
name = name, icon = icon,
icon = icon, color = color,
color = color archived = isArchived
)
) )
}) { Text("Save") } onSave(
if (categoryToEdit != null) categoryToSave.copy(id = categoryToEdit.id) else categoryToSave
)
}) { Text(stringResource(R.string.save)) }
}, },
dismissButton = { dismissButton = {
Button( Row() {
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error), Button(
onClick = onDismiss colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.secondary),
) { Text("close") } onClick = onDismiss
) { Text(stringResource(R.string.cancel)) }
}
}) })
} }
@Composable @Composable
fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Unit, onColorChange: (String) -> Unit) { fun AlertDialogFill(
var text by remember { mutableStateOf("") } onTextChange: (String) -> Unit,
var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) } onIconChange: (Icons) -> Unit,
var colorHex by remember { mutableStateOf(Colors.entries[0].hexString) } onColorChange: (String) -> Unit,
onArchivedChange: (Boolean) -> Unit,
name: String,
icon: Icons,
color: String,
isArchived: Boolean
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Icon( Icon(
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
painter = painterResource(iconId), contentDescription = null, painter = painterResource(icon.resource), contentDescription = null,
tint = Color(colorHex.toColorInt()) tint = Color(color.toColorInt())
) )
OutlinedTextField(label = { Text("Name") }, value = text, onValueChange = { newText -> OutlinedTextField(label = { Text("Name") }, value = name, onValueChange = { newText ->
text = newText onTextChange(newText)
onTextChange(text)
}) })
} }
@@ -103,7 +136,6 @@ fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Uni
modifier = Modifier modifier = Modifier
.size(30.dp) .size(30.dp)
.clickable(onClick = { .clickable(onClick = {
iconId = icon.resource
onIconChange(icon) onIconChange(icon)
}), }),
painter = painterResource(icon.resource), painter = painterResource(icon.resource),
@@ -118,18 +150,61 @@ fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Uni
rememberScrollState() rememberScrollState()
) )
) { ) {
Colors.entries.forEach { color -> colors.forEach { color ->
Box( Box(
modifier = Modifier modifier = Modifier
.clickable(onClick = { .clickable(onClick = {
colorHex = color.hexString onColorChange(color)
onColorChange(colorHex)
}) })
.size(30.dp) .size(30.dp)
.aspectRatio(1f) .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 package cc.n0th1ng.tripmoney.screens.addexpense
import android.annotation.SuppressLint
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check 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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.listexpense.CategorySelectionDialog import cc.n0th1ng.tripmoney.screens.listexpense.CategorySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DateTimePicker import cc.n0th1ng.tripmoney.screens.listexpense.DateTimePicker
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel 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.LocalDateTime
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@@ -57,73 +82,204 @@ import java.time.LocalDateTime
fun AddExpenseBottomSheet( fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit, onSave: (Expense) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
settingsViewModel: SettingsViewModel, expenseDtoToEdit: ExpenseDto?,
categories: List<Category>, state: SheetState
expenseAndCategoryViewModel: ExpenseAndCategoryViewModel,
expenseDtoToEdit: ExpenseDto?
) { ) {
val tripViewModel: TripViewModel = hiltViewModel()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState() 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 { var amount by remember {
mutableStateOf( 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 showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoryDialog 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 category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }
var datetime by remember { var datetime by remember {
mutableStateOf( mutableStateOf(
LocalDateTime.parse(expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now().toString()) expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now()
) )
} }
var note by remember { mutableStateOf(expenseDtoToEdit?.expense?.note ?: "") } var note by remember { mutableStateOf(expenseDtoToEdit?.expense?.note ?: "") }
var enableSave by remember { mutableStateOf(expenseDtoToEdit != null) } var enableSave by remember { mutableStateOf(expenseDtoToEdit != null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = state,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(0.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Row( Text(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(9.dp) .fillMaxWidth()
) { .padding(start = 15.dp),
Text( text = stringResource(if (expenseDtoToEdit == null) R.string.add_expense else R.string.edit_expense),
text = amount.ifEmpty { "0.00" }, style = MaterialTheme.typography.displaySmall,
fontSize = 25.sp, textAlign = TextAlign.Start
fontWeight = FontWeight.Bold
)
CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency)
}
Spacer(Modifier.height(14.dp))
DateTimePicker(
dateTime = datetime,
onChange = { datetime = it }
) )
Spacer(Modifier.height(14.dp)) HorizontalDivider(
CategoryButton(onClick = { showCategoryDialog = true }, category = category) modifier = Modifier.fillMaxWidth(),
Spacer(Modifier.height(14.dp)) color = MaterialTheme.colorScheme.onSecondaryContainer
Row( )
modifier = Modifier.height(50.dp), Column(
verticalAlignment = Alignment.CenterVertically, horizontalAlignment = Alignment.CenterHorizontally,
horizontalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(10.dp)
) { ) {
NoteInput(note = note) { newNote -> note = newNote } Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
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)
}
Box(
modifier = Modifier
.size(0.dp)
.focusRequester(dummyFocusRequester)
.focusable()
)
NoteInput(
note = note,
onTextChange = { newNote -> note = newNote },
modifier = Modifier.fillMaxWidth(),
focusRequester = dummyFocusRequester
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
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( SaveButton(
modifier = Modifier.fillMaxWidth(),
enabled = enableSave, enabled = enableSave,
onClick = { onClick = {
val expenseToSave = Expense( val expenseToSave = Expense(
amount = amount.toDouble(), amount = equationResult,
currency = currency, currency = currency,
note = note, note = note,
datetime = datetime.toString(), datetime = datetime,
categoryId = category.id, categoryId = category.id,
tripId = currentTripId tripId = currentTripId
) )
@@ -131,31 +287,18 @@ fun AddExpenseBottomSheet(
if (expenseDtoToEdit == null) expenseToSave if (expenseDtoToEdit == null) expenseToSave
else expenseToSave.copy(id = expenseDtoToEdit.expense.id) 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) { if (showCurrencyDialog) {
CurrencySelectionDialog( CurrencySelectionDialog(
@@ -164,8 +307,7 @@ fun AddExpenseBottomSheet(
showCurrencyDialog = false showCurrencyDialog = false
currency = selectedCurrency currency = selectedCurrency
}, },
selected = currency, selected = currency
listOfCurrencies = listOf("PLN", "EUR", "USD")
) )
} }
@@ -177,8 +319,7 @@ fun AddExpenseBottomSheet(
category = selectedCategory category = selectedCategory
}, },
selected = category, selected = category,
categories = categories, categories = categories
settingsAndCategoryViewModel = expenseAndCategoryViewModel
) )
} }
} }
@@ -186,56 +327,130 @@ fun AddExpenseBottomSheet(
fun String.safeSubstring(start: Int, end: Int): String { fun String.safeSubstring(start: Int, end: Int): String {
return try { return try {
this.substring(start, end) this.substring(start, end)
} catch (e: Exception) { } catch (_: Exception) {
"0.00" "0.00"
} }
} }
fun String.isDoubleTwoDigitsAboveZero(): Boolean { private fun evaluate(equation: String): Double {
return this.toDoubleOrNull() != null && this.matches(Regex("^\\d*(\\.\\d{0,2})?$")) && this.toDouble() > 0 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 @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) } var text by remember { mutableStateOf(note) }
OutlinedTextField( OutlinedTextField(
modifier = modifier,
label = { Text(stringResource(R.string.note)) }, value = note, onValueChange = { newText -> label = { Text(stringResource(R.string.note)) }, value = note, onValueChange = { newText ->
text = newText text = newText
onTextChange(text) onTextChange(text)
}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusRequester.requestFocus()
}
)
) )
} }
@Composable @Composable
fun CurrencyButton(onClick: () -> Unit, text: String) { fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) {
OutlinedButton(onClick = onClick) { Button(
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors()
.copy(containerColor = MaterialTheme.colorScheme.secondary)
) {
Text(text) Text(text)
} }
} }
@Composable @Composable
fun CategoryButton(onClick: () -> Unit, category: Category) { fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
OutlinedButton( Button(
contentPadding = PaddingValues(0.dp),
onClick = onClick, 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( Icon(
modifier = Modifier.padding(end = 10.dp), tint = Color(category.color.toColorInt()),
modifier = Modifier
.size(30.dp)
// .background(
// color = MaterialTheme.colorScheme.prima,
// shape = MaterialTheme.shapes.small
// )
.padding(end = 10.dp),
painter = painterResource(category.icon.resource), painter = painterResource(category.icon.resource),
contentDescription = stringResource(R.string.category), contentDescription = stringResource(R.string.category),
tint = Color(category.color.toColorInt())
) )
Text(category.name, color = Color(category.color.toColorInt())) Text(
text = category.name,
style = MaterialTheme.typography.titleMedium
)
// }
} }
} }
@Composable @Composable
fun SaveButton(enabled: Boolean, onClick: () -> Unit) { fun SaveButton(modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit) {
OutlinedButton( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
modifier = Modifier modifier = modifier,
shape = MaterialTheme.shapes.medium
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Check, 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 @Composable
fun NumberKeyboard( fun NumberKeyboard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onNumberClick: (String) -> Unit, onNumberClick: (String) -> Unit,
onBackspaceClick: () -> Unit onBackspaceClick: () -> Unit,
onOperatorClick: (String) -> Unit,
onLongBackspaceClick: () -> Unit,
) { ) {
val buttonModifier = Modifier
.padding(4.dp)
.aspectRatio(2f)
Column( Column(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Row( keyboard.forEach { row ->
modifier = Modifier.fillMaxWidth(), Row(
horizontalArrangement = Arrangement.spacedBy(4.dp) modifier = Modifier.fillMaxWidth(),
) { horizontalArrangement = Arrangement.spacedBy(4.dp)
OutlinedButton(
onClick = { onNumberClick("1") },
modifier = buttonModifier.weight(1f)
) { ) {
Text("1", fontSize = 20.sp) row.forEach { key ->
} when (key) {
OutlinedButton( "backspace" -> KeyboardButton(
onClick = { onNumberClick("2") }, icon = painterResource(drawable.materialsymbols_ic_arrow_left_alt_outlined),
modifier = buttonModifier.weight(1f) onClick = onBackspaceClick,
) { modifier = Modifier
Text("2", fontSize = 20.sp) .weight(1f),
} containerColor = Color.Transparent,
OutlinedButton( onLongClick = onLongBackspaceClick
onClick = { onNumberClick("3") }, )
modifier = buttonModifier.weight(1f)
) {
Text("3", fontSize = 20.sp)
}
}
Row( "+", "÷", "", "×" -> KeyboardButton(
modifier = Modifier.fillMaxWidth(), text = key,
horizontalArrangement = Arrangement.spacedBy(4.dp) onClick = { onOperatorClick(key) },
) { modifier = Modifier.weight(1f),
OutlinedButton( containerColor = MaterialTheme.colorScheme.tertiaryContainer,
onClick = { onNumberClick("4") }, contentColor = MaterialTheme.colorScheme.onTertiaryContainer
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( else -> KeyboardButton(
modifier = Modifier.fillMaxWidth(), text = key,
horizontalArrangement = Arrangement.spacedBy(4.dp) onClick = { onNumberClick(key) },
) { modifier = Modifier.weight(1f),
OutlinedButton( containerColor = Color.Transparent,
onClick = { onNumberClick("7") }, contentColor = MaterialTheme.colorScheme.onSecondary
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(
onClick = onBackspaceClick,
modifier = buttonModifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.backspace)
)
} }
} }
} }
} }
@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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.* import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import com.composables.icons.materialsymbols.outlined.R import com.composables.icons.materialsymbols.outlined.R
@@ -37,8 +37,8 @@ fun CategorySelectionDialog(
onCategorySelected: (Category) -> Unit, onCategorySelected: (Category) -> Unit,
selected: Category, selected: Category,
categories: List<Category>, categories: List<Category>,
settingsAndCategoryViewModel: ExpenseAndCategoryViewModel
) { ) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var showAddCategoryDialog by remember { mutableStateOf(false) } var showAddCategoryDialog by remember { mutableStateOf(false) }
AlertDialog( AlertDialog(
@@ -90,7 +90,7 @@ fun CategorySelectionDialog(
contentDescription = stringResource(string.category) contentDescription = stringResource(string.category)
) )
Text( Text(
text = stringResource(string.add_new_category), modifier = Modifier.padding(start = 8.dp), text = stringResource(string.add_new), modifier = Modifier.padding(start = 8.dp),
) )
} }
} }
@@ -99,7 +99,7 @@ fun CategorySelectionDialog(
AddCategoryDialog(onDismiss = { AddCategoryDialog(onDismiss = {
showAddCategoryDialog = false showAddCategoryDialog = false
}, onSave = { category -> }, onSave = { category ->
settingsAndCategoryViewModel.save(category) expenseAndCategoryViewModel.save(category)
showAddCategoryDialog = false showAddCategoryDialog = false
}) })
} }

View File

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

View File

@@ -2,15 +2,18 @@ package cc.n0th1ng.tripmoney.screens.listexpense
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerState
import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.material3.rememberTimePickerState import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -18,13 +21,130 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource 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.R.*
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId 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 @Composable
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@@ -33,76 +153,47 @@ fun DateTimePicker(
dateTime: LocalDateTime = LocalDateTime.now(), dateTime: LocalDateTime = LocalDateTime.now(),
onChange: (LocalDateTime) -> Unit 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) } var showTimePicker by remember { mutableStateOf(false) }
var date by remember { mutableStateOf(dateTime.toLocalDate()) }
val formatter = DateTimeFormatter.ofPattern("dd.MM HH:mm")
OutlinedButton(onClick = { showDatePicker = true }) {
Text(text = dateTime.format(formatter), fontSize = 17.sp)
}
if (showDatePicker) { if (showDatePicker) {
DatePickerDialog( DatePicker(
onDismissRequest = { showDatePicker = false }, date = dateTime.toLocalDate(),
confirmButton = { onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
TextButton(onClick = { date = newDate
showDatePicker = false showDatePicker = false
val selectedMillis = datePickerState.selectedDateMillis showTimePicker = true
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) { if (showTimePicker) {
AlertDialog( TimePicker(onDismiss = {
onDismissRequest = { showTimePicker = false }, showTimePicker = false
confirmButton = { showDatePicker = true
TextButton(onClick = { }, onConfirm = { timePickerState ->
showTimePicker = false showTimePicker = false
val newTime = LocalTime.of(timePickerState.hour, timePickerState.minute) showDatePicker = true
onChange(LocalDateTime.of(dateTime.toLocalDate(), newTime)) val newTime = LocalTime.of(timePickerState.hour, timePickerState.minute)
}) { onChange(LocalDateTime.of(date, newTime))
Text("OK") }, time = dateTime.toLocalTime())
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) { Text(stringResource(string.cancel)) }
},
text = { TimePicker(state = timePickerState) }
)
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun LocalDateTime.toEpochMilli(): Long = fun LocalDateTime.toEpochMilli(): Long =
this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() 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.annotation.SuppressLint
import android.os.Build import android.os.Build
import androidx.activity.viewModels
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,8 +34,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -45,40 +47,94 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems 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.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel 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.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter 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) @OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ListExpenseScreen() { fun ListExpenseScreen(
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
val settingsViewModel: SettingsViewModel = hiltViewModel() onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
isRecalculatingRate: Boolean,
initialAutoOpen: Boolean,
onAutoOpenConsumed: () -> Unit
) {
val currentTrip by settingsViewModel.currentTrip.collectAsState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val categories by expenseAndCategoryViewModel.getCategories()
.collectAsState(initial = emptyList())
val expenses = expenseAndCategoryViewModel.getExpenses(currentTrip).collectAsLazyPagingItems()
val listState = rememberLazyListState()
var showBottomSheet by remember { mutableStateOf(false) } 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 = { Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
@@ -88,56 +144,71 @@ fun ListExpenseScreen() {
) )
}) })
{ {
LazyColumn( Box {
modifier = Modifier.fillMaxSize(), LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize().semantics {
state = listState contentDescription = "expensesList"
) { },
items( horizontalAlignment = Alignment.CenterHorizontally,
count = expenses.itemCount, state = listState
key = { index -> expenses[index]?.expense?.id ?: index } ) {
) { index -> items(
val expenseDto = expenses[index] count = items.itemCount,
if (expenseDto != null) { key = items.itemKey { item ->
val previousExpense = expenses.itemSnapshotList.items.getOrNull(index - 1) when (item) {
is ExpenseListItemUi.Item -> item.expenseDto.expense.id
val showDayDivider = is ExpenseListItemUi.Header -> "header_${item.date}"
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))
)
HorizontalDivider(modifier = Modifier.weight(1f))
} }
} }
Spacer(Modifier.height(5.dp)) ) { index ->
SwipeToDeleteExpenseCard(
expenseDto = expenseDto,
onDelete = { expense -> expenseAndCategoryViewModel.delete(expense) },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
})
}
}
when (val item = items[index]) {
is ExpenseListItemUi.Header -> {
CustomDivider(
date = item.date,
sum = item.sum,
currency = item.currency
)
}
is ExpenseListItemUi.Item -> {
SwipeToDeleteExpenseCard(
expenseDto = item.expenseDto,
onDelete = { expense -> itemToDelete = expense },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
}
)
}
null -> {}
}
Spacer(Modifier.height(10.dp))
}
}
} }
if (itemToDelete != null) {
DeleteConfirmationDialog(
onConfirm = {
onDeleteExpense(itemToDelete!!)
itemToDelete = null
},
onCancel = {
itemToDelete = null
}
)
}
if (showBottomSheet) { if (showBottomSheet) {
AddExpenseBottomSheet( AddExpenseBottomSheet(
onSave = { expense -> onSave = { expense ->
expenseAndCategoryViewModel.save(expense) onSaveExpense(expense)
showBottomSheet = false showBottomSheet = false
expenseDtoToEdit = null expenseDtoToEdit = null
}, },
@@ -145,15 +216,57 @@ fun ListExpenseScreen() {
expenseDtoToEdit = null expenseDtoToEdit = null
showBottomSheet = false showBottomSheet = false
}, },
settingsViewModel = settingsViewModel, expenseDtoToEdit = expenseDtoToEdit,
categories = categories, state = sheetState
expenseAndCategoryViewModel = expenseAndCategoryViewModel,
expenseDtoToEdit = expenseDtoToEdit
) )
} }
} }
} }
@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) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun SwipeToDeleteExpenseCard( fun SwipeToDeleteExpenseCard(
@@ -161,51 +274,40 @@ fun SwipeToDeleteExpenseCard(
onDelete: (Expense) -> Unit, onDelete: (Expense) -> Unit,
onClick: (ExpenseDto) -> Unit onClick: (ExpenseDto) -> Unit
) { ) {
var dismissed by remember { mutableStateOf(false) } val dismissState = rememberSwipeToDismissBoxState(
var showDialog by remember { mutableStateOf(false) } confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
if (!dismissed) { onDelete(expenseDto.expense)
val dismissState = rememberSwipeToDismissBoxState( false
confirmValueChange = { dismissValue -> } else {
if (dismissValue == SwipeToDismissBoxValue.EndToStart false
) {
showDialog = true
false
} else {
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)
)
}
}
) {
ExpenseCard(
expenseDto = expenseDto,
onClick = onClick
) )
if (showDialog) {
DeleteConfirmationDialog(
onConfirm = {
showDialog = false
dismissed = true
onDelete(expenseDto.expense)
},
onCancel = { showDialog = false }
)
}
SwipeToDismissBox(
modifier = Modifier,
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
Box(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete))
}
}
) {
ExpenseCard(expenseDto, onClick = onClick)
}
} }
} }
@@ -221,15 +323,15 @@ fun DeleteConfirmationDialog(
Column( Column(
Modifier Modifier
.background( .background(
MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) )
.padding(24.dp) .padding(24.dp)
) { ) {
Text( Text(
stringResource(string.delete_confirmation), stringResource(string.delete_confirmation),
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge,
fontSize = 20.sp color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Row( Row(
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -239,6 +341,8 @@ fun DeleteConfirmationDialog(
) { ) {
Text( Text(
text = stringResource(string.cancel), text = stringResource(string.cancel),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier modifier = Modifier
.padding(end = 24.dp) .padding(end = 24.dp)
.clickable { onCancel() } .clickable { onCancel() }
@@ -246,7 +350,7 @@ fun DeleteConfirmationDialog(
Text( Text(
text = stringResource(string.delete), text = stringResource(string.delete),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { onConfirm() } modifier = Modifier.clickable { onConfirm() }
) )
} }
@@ -256,12 +360,20 @@ fun DeleteConfirmationDialog(
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) { fun ExpenseCard(
expenseDto: ExpenseDto,
onClick: (ExpenseDto) -> Unit
) {
ElevatedCard( ElevatedCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.95f)
.height(70.dp) .height(70.dp)
.clickable { onClick(expenseDto) }, .combinedClickable(
enabled = true,
onClick = { onClick(expenseDto) },
onLongClick = { onClick(expenseDto) }),
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp) elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) { ) {
Row( Row(
@@ -269,14 +381,29 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
//TODO
// .background(
// Brush.horizontalGradient(
// colorStops = arrayOf(
// 1f to Color(expenseDto.category.color.toColorInt()),
// 4f to MaterialTheme.colorScheme.surfaceDim
// )
// )
// )
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxHeight() modifier = Modifier.fillMaxHeight()
) { ) {
Icon( Icon(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceDim,
shape = MaterialTheme.shapes.small
)
.padding(10.dp),
painter = painterResource(expenseDto.category.icon.resource), painter = painterResource(expenseDto.category.icon.resource),
contentDescription = "Category", contentDescription = "Category",
tint = Color(expenseDto.category.color.toColorInt()) tint = Color(expenseDto.category.color.toColorInt())
@@ -287,38 +414,43 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
.fillMaxHeight() .fillMaxHeight()
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
) { ) {
Column( Column()
) { {
Text( Text(
text = expenseDto.category.name, text = expenseDto.category.name,
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium,
lineHeight = 5.sp color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
modifier = Modifier.padding(0.dp), modifier = Modifier.padding(0.dp),
text = expenseDto.expense.note, text = expenseDto.expense.note,
fontSize = 11.sp, style = MaterialTheme.typography.labelSmall,
lineHeight = 5.sp color = MaterialTheme.colorScheme.onSurface
) )
} }
Text( Text(
text = LocalDateTime.parse(expenseDto.expense.datetime).format( text = expenseDto.expense.datetime.format(
DateTimeFormatter.ofPattern("dd MMM HH:mm") DateTimeFormatter.ofPattern("dd MMM HH:mm")
), ),
fontSize = 12.sp, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
Column { Column {
Text( Text(
text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount), 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()) { if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) {
Text( Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.amount), text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()),
fontSize = 12.sp 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.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -27,46 +31,161 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.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.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) @RequiresApi(Build.VERSION_CODES.S)
@Composable @Composable
fun SettingsScreen() { fun SettingsScreen(navController: NavHostController) {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState() val currentTheme by settingsViewModel.theme.collectAsState()
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() val scope = rememberCoroutineScope()
Column(
modifier = Modifier SettingsScreen(
.fillMaxWidth() currentDefaultCurrency = currentDefaultCurrency,
.padding(15.dp), currentTheme = currentTheme,
verticalArrangement = Arrangement.spacedBy(10.dp) onThemeSave = { settingsViewModel.setTheme(it) },
) { onCurrencySave = { settingsViewModel.setDefaultCurrency(it) },
Card { onAddExpenseSwitch = {
SettingsListItem(onClick = { showDialog = true }, stringResource(string.theme)) { settingsViewModel.setCurrentAddExpenseSwitch(it)
Text( },
if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource( 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)
) {
SettingsListItem(
onClick = { showCurrencyDialog = true },
headlineText = stringResource(string.default_currency),
supportingText = currentDefaultCurrency.name,
iconResource = R.drawable.materialsymbols_ic_currency_yen_outlined
)
SettingsCard(string.theme) {
SettingsListItem(
onClick = { showThemeDialog = true },
stringResource(string.theme),
supportingText = if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme 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,
if (showDialog) { stringResource(string.export_to_csv),
ThemeSelectionDialog( supportingText = stringResource(string.export_csv_subttext).format(tripName),
onDismiss = { showDialog = false }, iconResource = R.drawable.materialsymbols_ic_csv_outlined
onThemeSelected = { theme ->
settingsViewModel.setTheme(theme)
showDialog = false
},
selected = currentTheme
) )
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 = { showThemeDialog = false },
onThemeSelected = { theme ->
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) { if (title != -1) {
Text( Text(
text = stringResource(title), text = stringResource(title),
fontSize = 13.sp, style = MaterialTheme.typography.titleSmall,
modifier = Modifier modifier = Modifier
.padding(start = 15.dp, top = 15.dp, end = 15.dp) .padding(start = 15.dp, top = 15.dp, end = 15.dp)
.alpha(0.6f) .alpha(0.6f)
@@ -92,18 +211,25 @@ fun SettingsListItem(
onClick: () -> Unit, onClick: () -> Unit,
headlineText: String, headlineText: String,
trailingContent: @Composable () -> Unit = {}, trailingContent: @Composable () -> Unit = {},
supportingContent: @Composable () -> Unit supportingText: String,
iconResource: Int,
) { ) {
ListItem( Card {
colors = ListItemDefaults.colors(containerColor = Color.Transparent), ListItem(
headlineContent = { Text(headlineText) }, leadingContent = {
supportingContent = supportingContent, Icon(painter = painterResource(iconResource), contentDescription = null)
trailingContent = trailingContent, },
modifier = Modifier colors = ListItemDefaults.colors(containerColor = Color.Transparent),
.clickable(true, onClick = onClick) headlineContent = { Text(headlineText) },
) supportingContent = { Text(supportingText) },
trailingContent = trailingContent,
modifier = Modifier
.clickable(true, onClick = onClick)
)
}
} }
@Composable @Composable
fun ThemeSelectionDialog( fun ThemeSelectionDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -146,3 +272,44 @@ fun ThemeSelectionDialog(
confirmButton = {} 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 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.material3.Text
import androidx.compose.runtime.Composable 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 @Composable
fun StatisticsScreen() { 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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.material.icons.Icons
import androidx.paging.compose.LazyPagingItems import androidx.compose.material.icons.filled.Add
import androidx.paging.compose.collectAsLazyPagingItems import androidx.compose.material.icons.filled.Delete
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.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.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.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.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.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.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 @Composable
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
fun TripPickerScreen( fun TripPickerScreen(
navController: NavController navController: NavController
) { ) {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel()
val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems() val tripsFlow = tripViewModel.getTrips()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
LazyColumn( TripPickerScreen(
modifier = Modifier tripsFlow = tripsFlow,
.padding(horizontal = 15.dp) currentTripId = currentTripId,
.fillMaxSize(), onDelete = { trip -> tripViewModel.delete(trip) },
verticalArrangement = Arrangement.Center onClick = { trip ->
) { settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
items(trips.itemCount, trips.itemKey { it.id }) { i -> },
Spacer(Modifier.height(10.dp)) onSave = { trip ->
val trip = trips[i] tripViewModel.save(trip)
if (trip != null) {
TripCard(trip, currentTripId == trip.id, onClick = {
settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
})
}
Spacer(Modifier.height(10.dp))
} }
}
)
} }
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun TripCard(trip: Trip, isSelected: Boolean, onClick: () -> Unit) { @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) {
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 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( ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceContainer
}
),
modifier = Modifier modifier = Modifier
.height(100.dp) .height(100.dp)
.clickable(true, onClick = onClick) .combinedClickable(enabled = true, onLongClick = {
.alpha(if (isSelected) 1.0f else 0.7f), haptics.performHapticFeedback(HapticFeedbackType.LongPress)
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp) onLongClick(trip)
}, onClick = { onClick(trip) }),
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) { ) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -83,19 +236,75 @@ fun TripCard(trip: Trip, isSelected: Boolean, onClick: () -> Unit) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier.padding(16.dp)
.padding(16.dp)
) { ) {
Text(fontSize = 25.sp, fontWeight = FontWeight.SemiBold, text = trip.name) Text(
Text(trip.startDate) style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
text = trip.name
)
Text(
style = MaterialTheme.typography.bodySmall,
text = "start: " + trip.startDate.pretty() + "\nend: " + trip.endDate.pretty()
)
} }
Text( Column(
trip.currency.uppercase(), modifier = Modifier.padding(end = 20.dp),
modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.End) {
fontSize = 20.sp, Text(
fontWeight = FontWeight.SemiBold 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 import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) val primaryLight = Color(0xFF48672E)
val PurpleGrey80 = Color(0xFFCCC2DC) val onPrimaryLight = Color(0xFFFFFFFF)
val Pink80 = Color(0xFFEFB8C8) 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 primaryLightMediumContrast = Color(0xFF213D08)
val PurpleGrey40 = Color(0xFF625b71) val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val Pink40 = Color(0xFF7D5260) 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 android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
private val DarkColorScheme = darkColorScheme( private val lightScheme = lightColorScheme(
primary = Purple80, primary = primaryLight,
secondary = PurpleGrey80, onPrimary = onPrimaryLight,
tertiary = Pink80 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( private val darkScheme = darkColorScheme(
primary = Purple40, primary = primaryDark,
secondary = PurpleGrey40, onPrimary = onPrimaryDark,
tertiary = Pink40 primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
/* Other default colors to override secondary = secondaryDark,
background = Color(0xFFFFFBFE), onSecondary = onSecondaryDark,
surface = Color(0xFFFFFBFE), secondaryContainer = secondaryContainerDark,
onPrimary = Color.White, onSecondaryContainer = onSecondaryContainerDark,
onSecondary = Color.White, tertiary = tertiaryDark,
onTertiary = Color.White, onTertiary = onTertiaryDark,
onBackground = Color(0xFF1C1B1F), tertiaryContainer = tertiaryContainerDark,
onSurface = Color(0xFF1C1B1F), 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 @Composable
fun TripMoneyTheme( fun TripMoneyTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
@@ -44,9 +101,8 @@ fun TripMoneyTheme(
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> darkScheme
darkTheme -> DarkColorScheme else -> lightScheme
else -> LightColorScheme
} }
MaterialTheme( 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 package cc.n0th1ng.tripmoney.utils
enum class Colors(val hexString: String) { val colors: List<String> = listOf(
RED("#D53E0F"), "#af1b3f",
PINK("#FF69B4"), "#083D77",
ORANGE("#FF8C00"), "#5998c5",
YELLOW("#FFD700"), "#f7934c",
LIME("#32CD32"), "#ec0b43",
GREEN("#228B22"), "#87A330",
MINT("#98FB98"), "#6F8AB7",
TEAL("#008080"), "#F26CA7",
CYAN("#00CED1"), "#5E4AE3",
SKY_BLUE("#1E90FF"), "#2A7F62",
BLUE("#0000FF"), "#0B7189"
LAVENDER("#8A2BE2"), )
LILAC("#C8A2C8"),
PURPLE("#800080"),
MAUVE("#D8BFD8"),
MAGENTA("#FF00FF"),
VIOLET("#9400D3"),
INDIGO("#4B0082"),
PERIWINKLE("#8A2BE2"),
GRAY("#696969");
}

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) { enum class Icons(@DrawableRes val resource: Int) {
HOTEL(R.drawable.materialsymbols_ic_hotel_outlined), HOTEL(R.drawable.materialsymbols_ic_hotel_outlined),
RESTAURANT(R.drawable.materialsymbols_ic_restaurant_outlined), RESTAURANT(R.drawable.materialsymbols_ic_restaurant_outlined),
TRANSPORT(R.drawable.materialsymbols_ic_local_taxi_outlined),
FLIGHT(R.drawable.materialsymbols_ic_flight_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), GROCERIES(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES1(R.drawable.materialsymbols_ic_airline_seat_recline_normal_outlined), COFFEE(R.drawable.materialsymbols_ic_local_cafe_outlined),
GROCERIES2(R.drawable.materialsymbols_ic_grocery_outlined), GENERAL(R.drawable.materialsymbols_ic_shoppingmode_outlined),
GROCERIES3(R.drawable.materialsymbols_ic_grocery_outlined), ENTERTAINMENT(R.drawable.materialsymbols_ic_theaters_outlined),
GROCERIES4(R.drawable.materialsymbols_ic_grocery_outlined), LAUNDRY(R.drawable.materialsymbols_ic_local_laundry_service_outlined)
GROCERIES5(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES6(R.drawable.materialsymbols_ic_grocery_outlined),
GROCERIES7(R.drawable.materialsymbols_ic_grocery_outlined)
} }

View File

@@ -1,46 +1,201 @@
package cc.n0th1ng.tripmoney.viewmodel package cc.n0th1ng.tripmoney.viewmodel
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import 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.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.CategoryRepository 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.ExpenseRepository
import cc.n0th1ng.tripmoney.data.repository.TripRepository
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow 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 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ExpenseAndCategoryViewModel @Inject constructor( open class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository, private val expenseRepo: ExpenseRepository,
private val categoryRepo: CategoryRepository private val categoryRepo: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository,
private val tripRepo: TripRepository
) : ViewModel() { ) : ViewModel() {
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> = fun getBudgetLeft(tripId: Int): Flow<Double?> {
expenseRepo.getExpenses(tripId).cachedIn(viewModelScope) 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 { 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) { fun delete(expense: Expense) {
viewModelScope.launch { viewModelScope.launch {
expenseRepo.delete(expense) expenseRepo.delete(expense)
} }
} }
fun delete(category: Category) {
viewModelScope.launch {
categoryRepo.delete(category)
}
}
fun getCategories(): Flow<List<Category>> = categoryRepo.getCategories() fun getCategories(): Flow<List<Category>> = categoryRepo.getCategories()
fun getArchivedCategories(): Flow<List<Category>> = categoryRepo.getArchivedCategories()
fun save(category: Category) { fun save(category: Category) {
viewModelScope.launch { viewModelScope.launch {
categoryRepo.save(category) 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 android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import cc.n0th1ng.tripmoney.data.repository.AppTheme import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.data.repository.PreferencesRepository 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext 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.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.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -21,6 +30,7 @@ class SettingsViewModel @Inject constructor(
private val repo: PreferencesRepository, private val repo: PreferencesRepository,
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
private val uiModeManager: UiModeManager = private val uiModeManager: UiModeManager =
context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
val theme = repo.themeFlow val theme = repo.themeFlow
@@ -30,11 +40,39 @@ class SettingsViewModel @Inject constructor(
AppTheme.SYSTEM 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( val currentTrip = repo.currentTripFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000), viewModelScope, SharingStarted.WhileSubscribed(5000),
-1 -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) { fun setCurrentTrip(tripId: Int) {
viewModelScope.launch { viewModelScope.launch {
repo.saveCurrentTrip(tripId) repo.saveCurrentTrip(tripId)
@@ -65,5 +103,3 @@ class SettingsViewModel @Inject constructor(
} }
} }

View File

@@ -1,19 +1,50 @@
package cc.n0th1ng.tripmoney.viewmodel package cc.n0th1ng.tripmoney.viewmodel
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
import cc.n0th1ng.tripmoney.data.repository.TripRepository import cc.n0th1ng.tripmoney.data.repository.TripRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TripViewModel @Inject constructor(private val repository: TripRepository) : ViewModel() { class TripViewModel @Inject constructor(
private val repository: TripRepository,
private val expenseRepository: ExpenseRepository
) : ViewModel() {
private val _isRecalculating = MutableStateFlow(false)
val isRecalculating: StateFlow<Boolean> = _isRecalculating
fun getTrips(): Flow<PagingData<Trip>> = repository.getTrips().cachedIn(viewModelScope) fun getTrips(): Flow<PagingData<Trip>> = repository.getTrips().cachedIn(viewModelScope)
fun getTrip(tripId: Int): Flow<Trip?> = repository.getTrip(tripId)
fun 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="save">Zapisz</string>
<string name="backspace">Usuń</string> <string name="backspace">Usuń</string>
<string name="pick_category">Wybierz kategorie</string> <string name="pick_category">Wybierz kategorie</string>
<string name="add_new_category">dodaj nową</string> <string name="add_new">dodaj nową</string>
<string name="pick_currency">Wybierz kategorie</string> <string name="pick_currency">Wybierz kategorie</string>
<string name="cancel">Anuluj</string> <string name="cancel">Anuluj</string>
<string name="add_expense">Dodaj wydatek</string> <string name="add_expense">Dodaj wydatek</string>
@@ -16,4 +16,20 @@
<string name="system_settings">Zgodnie z systemem</string> <string name="system_settings">Zgodnie z systemem</string>
<string name="theme">Motyw</string> <string name="theme">Motyw</string>
<string name="dark_theme">Ciemny 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> </resources>

View File

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="purple_200">#FFBB86FC</color> <color name="good_green">#A0D585</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>
</resources> </resources>

View File

@@ -5,7 +5,7 @@
<string name="save">Save</string> <string name="save">Save</string>
<string name="backspace">Backspace</string> <string name="backspace">Backspace</string>
<string name="pick_category">Pick a category</string> <string name="pick_category">Pick a category</string>
<string name="add_new_category">Add new</string> <string name="add_new">Add new</string>
<string name="pick_currency">Pick a currency</string> <string name="pick_currency">Pick a currency</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="add_expense">Add expense</string> <string name="add_expense">Add expense</string>
@@ -16,5 +16,29 @@
<string name="light_theme">Light theme</string> <string name="light_theme">Light theme</string>
<string name="pick_theme">Pick a theme</string> <string name="pick_theme">Pick a theme</string>
<string name="system_settings">System settings</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> </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.devtools.ksp") version "2.2.21-2.0.5" apply false
id("com.google.dagger.hilt.android") version "2.57.1" apply false id("com.google.dagger.hilt.android") version "2.57.1" apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.baselineprofile) apply false
} }

View File

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

View File

@@ -1,18 +1,78 @@
[versions] [versions]
agp = "8.13.2" 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" kotlin = "2.2.21"
coreKtx = "1.10.1" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.1.5"
espressoCore = "3.5.1" espressoCore = "3.5.1"
kotlinxSerializationJsonJvm = "1.11.0"
ktorClientCore = "3.4.3"
ktorClientOkhttp = "3.4.3"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0" activityCompose = "1.8.0"
composeBom = "2024.09.00" composeBom = "2024.09.00"
navigationCompose = "2.9.7" navigationCompose = "2.9.7"
foundationLayout = "1.10.5" foundationLayout = "1.10.5"
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] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-test = { id = "com.android.test", version.ref = "agp" }
baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" }

View File

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