init #48
1
app/baseline-profiles-rules.pro
Normal file
1
app/baseline-profiles-rules.pro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-dontobfuscate
|
||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@@ -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
|
||||||
@@ -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>
|
||||||
8766
app/src/main/baseline-prof.txt
Normal file
8766
app/src/main/baseline-prof.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
41
app/src/main/java/cc/n0th1ng/tripmoney/data/Converters.kt
Normal file
41
app/src/main/java/cc/n0th1ng/tripmoney/data/Converters.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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?>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(), {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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(
|
||||||
|
|||||||
20
app/src/main/java/cc/n0th1ng/tripmoney/utils/AllPreviews.kt
Normal file
20
app/src/main/java/cc/n0th1ng/tripmoney/utils/AllPreviews.kt
Normal 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
|
||||||
30
app/src/main/java/cc/n0th1ng/tripmoney/utils/CSVUtils.kt
Normal file
30
app/src/main/java/cc/n0th1ng/tripmoney/utils/CSVUtils.kt
Normal 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")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
17
app/src/main/java/cc/n0th1ng/tripmoney/utils/Currencies.kt
Normal file
17
app/src/main/java/cc/n0th1ng/tripmoney/utils/Currencies.kt
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/src/main/java/cc/n0th1ng/tripmoney/utils/DateUtils.kt
Normal file
11
app/src/main/java/cc/n0th1ng/tripmoney/utils/DateUtils.kt
Normal 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"))
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal 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
1
benchmark/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
54
benchmark/build.gradle.kts
Normal file
54
benchmark/build.gradle.kts
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
benchmark/src/main/AndroidManifest.xml
Normal file
1
benchmark/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<manifest />
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "tripMoney"
|
rootProject.name = "tripMoney"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":benchmark")
|
||||||
|
|||||||
Reference in New Issue
Block a user