This commit is contained in:
Rafal Wisniewski
2026-03-20 14:32:47 +01:00
parent f625a6975c
commit 96cdd056a0
16 changed files with 596 additions and 124 deletions

View File

@@ -98,4 +98,8 @@ dependencies {
implementation("com.google.dagger:hilt-android:2.57.1") implementation("com.google.dagger:hilt-android:2.57.1")
ksp("com.google.dagger:hilt-android-compiler:2.57.1") ksp("com.google.dagger:hilt-android-compiler:2.57.1")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0") implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("io.ktor:ktor-client-core:3.4.1")
implementation("io.ktor:ktor-client-okhttp:3.4.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0")
} }

View File

@@ -11,9 +11,11 @@ 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.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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
@@ -28,6 +30,7 @@ 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.SettingsViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -49,6 +52,8 @@ class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun NavigationDrawer() { fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
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
@@ -74,7 +79,7 @@ fun NavigationDrawer() {
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) {

View File

@@ -8,9 +8,11 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
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.Icons import cc.n0th1ng.tripmoney.utils.Icons
@@ -26,11 +28,12 @@ import java.time.LocalDateTime
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Database(entities = [Trip::class, Expense::class, Category::class], version = 1) @Database(entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], version = 1)
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
} }
@@ -74,6 +77,12 @@ object DatabaseModule {
fun provideCategoryDao(database: TripDatabase): CategoryDao { fun provideCategoryDao(database: TripDatabase): CategoryDao {
return database.categoryDao() return database.categoryDao()
} }
@Provides
@Singleton
fun provideExchangeRateDao(database: TripDatabase): ExchangeRateDao {
return database.exchangeRateDao()
}
} }

View File

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

View File

@@ -16,6 +16,7 @@ interface TripDao {
@Query( @Query(
""" """
SELECT * FROM trip SELECT * FROM trip
ORDER BY DATE(trip.start_date) DESC
""" """
) )
fun tripsPaged(): PagingSource<Int, Trip> fun tripsPaged(): PagingSource<Int, Trip>

View File

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

View File

@@ -0,0 +1,54 @@
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 {
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()
)
)
clearOldRates()
rate
}
}
@RequiresApi(Build.VERSION_CODES.O)
private suspend fun clearOldRates(daysToKeep: Int = 180) {
val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString()
exchangeRateDao.deleteOldRates(cutoffDate)
}
}

View File

@@ -1,8 +1,13 @@
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.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -10,16 +15,21 @@ 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.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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
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.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton 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.TextButton
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
@@ -29,11 +39,17 @@ 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.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.platform.LocalFocusManager
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -50,26 +66,38 @@ import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun AddExpenseBottomSheet( fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit, onSave: (Expense) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
categories: List<Category>, expenseDtoToEdit: ExpenseDto?,
expenseDtoToEdit: ExpenseDto? state: SheetState,
// categories: List<Category> = emptyList()
) { ) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
// val currentTripId = 1
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
if (categories.isEmpty()) {
return
}
var amount by remember { var amount by remember {
mutableStateOf( mutableStateOf(
expenseDtoToEdit?.expense?.amount?.toString() ?: "0.00" expenseDtoToEdit?.expense?.amount?.toString() ?: "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 showDateTimePicker by remember { mutableStateOf(false) } var showDateTimePicker by remember { mutableStateOf(false) }
@@ -88,21 +116,33 @@ fun AddExpenseBottomSheet(
} }
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,
) { ) {
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)
) { ) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 15.dp),
text = stringResource(if (expenseDtoToEdit == null) R.string.add_expense else R.string.edit_expense),
fontWeight = FontWeight.Bold,
fontSize = 35.sp,
textAlign = TextAlign.Start
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
Row( Row(
modifier = Modifier.fillMaxWidth(0.9f),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp) horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = amount.ifEmpty { "0.00" }, text = amount.ifEmpty { "0.00" },
@@ -111,25 +151,61 @@ fun AddExpenseBottomSheet(
) )
CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency) CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency)
} }
Spacer(Modifier.height(14.dp)) Row(
OutlinedButton(onClick = { showDateTimePicker = true }) { modifier = Modifier.fillMaxWidth(0.9f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = { showDateTimePicker = true },
modifier = Modifier.weight(1f)
) {
Text( Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")), text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
fontSize = 17.sp fontSize = 17.sp
) )
} }
Spacer(Modifier.height(14.dp)) CategoryButton(
CategoryButton(onClick = { showCategoryDialog = true }, category = category) onClick = { showCategoryDialog = true },
Spacer(Modifier.height(14.dp)) category = category,
modifier = Modifier.weight(1f)
)
}
Row( Row(
modifier = Modifier.height(50.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
NoteInput(note = note) { newNote -> note = newNote } NoteInput(
SaveButton( note = note,
enabled = enableSave, onTextChange = { newNote -> note = newNote },
onClick = { modifier = Modifier.fillMaxWidth(0.9f),
focusRequester = dummyFocusRequester
)
}
Box(
modifier = Modifier
.size(0.dp)
.focusRequester(dummyFocusRequester)
.focusable()
)
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
}
dummyFocusRequester.requestFocus()
},
onBackspaceClick = {
if (amount == "0.00") return@NumberKeyboard
amount = amount.safeSubstring(0, amount.length - 1)
enableSave = amount.isDoubleTwoDigitsAboveZero()
},
onSave = {
val expenseToSave = Expense( val expenseToSave = Expense(
amount = amount.toDouble(), amount = amount.toDouble(),
currency = currency, currency = currency,
@@ -142,26 +218,8 @@ fun AddExpenseBottomSheet(
if (expenseDtoToEdit == null) expenseToSave if (expenseDtoToEdit == null) expenseToSave
else expenseToSave.copy(id = expenseDtoToEdit.expense.id) else expenseToSave.copy(id = expenseDtoToEdit.expense.id)
) )
} }, enableSave = enableSave
) )
}
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()
})
} }
} }
@@ -210,29 +268,39 @@ fun String.isDoubleTwoDigitsAboveZero(): Boolean {
} }
@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) { OutlinedButton(onClick = onClick, modifier = modifier) {
Text(text) Text(text)
} }
} }
@Composable @Composable
fun CategoryButton(onClick: () -> Unit, category: Category) { fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
OutlinedButton( OutlinedButton(
onClick = onClick, onClick = onClick,
modifier = Modifier.fillMaxWidth(0.5f) modifier = modifier
) { ) {
Icon( Icon(
modifier = Modifier.padding(end = 10.dp), modifier = Modifier.padding(end = 10.dp),
@@ -258,20 +326,13 @@ 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,
onSave: () -> Unit,
enableSave: Boolean
) { ) {
val buttonModifier = Modifier val buttonModifier = Modifier
.padding(4.dp) .padding(4.dp)
@@ -285,91 +346,109 @@ fun NumberKeyboard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
OutlinedButton( TextButton(
onClick = { onNumberClick("1") }, onClick = { onNumberClick("1") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("1", fontSize = 20.sp) Text("1", fontSize = 20.sp)
} }
OutlinedButton( TextButton(
onClick = { onNumberClick("2") }, onClick = { onNumberClick("2") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("2", fontSize = 20.sp) Text("2", fontSize = 20.sp)
} }
OutlinedButton( TextButton(
onClick = { onNumberClick("3") }, onClick = { onNumberClick("3") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("3", fontSize = 20.sp) Text("3", fontSize = 20.sp)
} }
TextButton(
onClick = { onNumberClick("") },
modifier = buttonModifier.weight(1f)
) {
Text("+", fontSize = 20.sp)
}
} }
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
OutlinedButton( TextButton(
onClick = { onNumberClick("4") }, onClick = { onNumberClick("4") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("4", fontSize = 20.sp) Text("4", fontSize = 20.sp)
} }
OutlinedButton( TextButton(
onClick = { onNumberClick("5") }, onClick = { onNumberClick("5") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("5", fontSize = 20.sp) Text("5", fontSize = 20.sp)
} }
OutlinedButton( TextButton(
onClick = { onNumberClick("6") }, onClick = { onNumberClick("6") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("6", fontSize = 20.sp) Text("6", fontSize = 20.sp)
} }
TextButton(
onClick = { onNumberClick("") },
modifier = buttonModifier.weight(1f)
) {
Text("-", fontSize = 20.sp)
}
} }
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
OutlinedButton( TextButton(
onClick = { onNumberClick("7") }, onClick = { onNumberClick("7") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("7", fontSize = 20.sp) Text("7", fontSize = 20.sp)
} }
OutlinedButton( TextButton(
onClick = { onNumberClick("8") }, onClick = { onNumberClick("8") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("8", fontSize = 20.sp) Text("8", fontSize = 20.sp)
} }
OutlinedButton( TextButton(
onClick = { onNumberClick("9") }, onClick = { onNumberClick("9") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("9", fontSize = 20.sp) Text("9", fontSize = 20.sp)
} }
TextButton(
onClick = { onNumberClick("") },
modifier = buttonModifier.weight(1f)
) {
Text("*", fontSize = 20.sp)
}
} }
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
OutlinedButton( TextButton(
onClick = { onNumberClick(".") }, onClick = { onNumberClick(".") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text(".", fontSize = 20.sp) Text(".", fontSize = 20.sp)
} }
OutlinedButton( TextButton(
onClick = { onNumberClick("0") }, onClick = { onNumberClick("0") },
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
Text("0", fontSize = 20.sp) Text("0", fontSize = 20.sp)
} }
OutlinedButton( TextButton(
onClick = onBackspaceClick, onClick = onBackspaceClick,
modifier = buttonModifier.weight(1f) modifier = buttonModifier.weight(1f)
) { ) {
@@ -378,6 +457,147 @@ fun NumberKeyboard(
contentDescription = stringResource(R.string.backspace) contentDescription = stringResource(R.string.backspace)
) )
} }
TextButton(
onClick = onSave,
modifier = buttonModifier.weight(1f),
enabled = enableSave
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.backspace)
)
}
} }
} }
} }
//@SuppressLint("CoroutineCreationDuringComposition")
//@RequiresApi(Build.VERSION_CODES.O)
//@OptIn(ExperimentalMaterial3Api::class)
//@Preview
//@Composable
//fun PreviewLight() {
// val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// CoroutineScope(Dispatchers.IO).launch {
// sheetState.show()
// }
//
// TripMoneyTheme {
// AddExpenseBottomSheet(
// {}, {}, null, sheetState,
// categories = listOf(
// Category(
// name = "Hotel",
// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
// color = "#B3E5FC"
// ),
// Category(
// name = "Jedzenie",
// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
// color = "#C8E6C9"
// ),
// Category(
// name = "Transport",
// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
// color = "#FFCDD2"
// ),
// Category(
// name = "Rozrywka",
// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
// color = "#FFF9C4"
// ),
// Category(
// name = "Zakupy",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#E1BEE7"
// ),
// Category(
// name = "Zakupy1",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#D7CCC8"
// ),
// Category(
// name = "Zakupy2",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#BBDEFB"
// ),
// Category(
// name = "Zakupy3",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#D1C4E9"
// ),
// Category(
// name = "Zakupy4",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#DCEDC8"
// ),
// )
// )
// }
//}
//
//@SuppressLint("CoroutineCreationDuringComposition")
//@RequiresApi(Build.VERSION_CODES.O)
//@OptIn(ExperimentalMaterial3Api::class)
//@Preview
//@Composable
//fun PreviewDark() {
// val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// CoroutineScope(Dispatchers.IO).launch {
// sheetState.show()
// }
//
// TripMoneyTheme(darkTheme = true) {
// AddExpenseBottomSheet(
// {}, {}, null, sheetState,
// categories = listOf(
// Category(
// name = "Hotel",
// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
// color = "#B3E5FC"
// ),
// Category(
// name = "Jedzenie",
// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
// color = "#C8E6C9"
// ),
// Category(
// name = "Transport",
// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
// color = "#FFCDD2"
// ),
// Category(
// name = "Rozrywka",
// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
// color = "#FFF9C4"
// ),
// Category(
// name = "Zakupy",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#E1BEE7"
// ),
// Category(
// name = "Zakupy1",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#D7CCC8"
// ),
// Category(
// name = "Zakupy2",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#BBDEFB"
// ),
// Category(
// name = "Zakupy3",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#D1C4E9"
// ),
// Category(
// name = "Zakupy4",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = "#DCEDC8"
// ),
// )
// )
// }
//}

View File

@@ -5,6 +5,7 @@ import android.os.Build
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
@@ -33,6 +34,7 @@ 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.collectAsState import androidx.compose.runtime.collectAsState
@@ -56,10 +58,14 @@ import cc.n0th1ng.tripmoney.R.string
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.screens.addexpense.AddExpenseBottomSheet import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.service.ExchangeService
import cc.n0th1ng.tripmoney.utils.Currencies
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 java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import javax.inject.Inject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -71,8 +77,6 @@ fun ListExpenseScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTrip by settingsViewModel.currentTrip.collectAsState() val currentTrip by settingsViewModel.currentTrip.collectAsState()
val categories by expenseAndCategoryViewModel.getCategories()
.collectAsState(initial = emptyList())
val expenses = expenseAndCategoryViewModel.getExpenses(currentTrip).collectAsLazyPagingItems() val expenses = expenseAndCategoryViewModel.getExpenses(currentTrip).collectAsLazyPagingItems()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
@@ -98,27 +102,13 @@ fun ListExpenseScreen() {
val expenseDto = expenses[index] val expenseDto = expenses[index]
if (expenseDto != null) { if (expenseDto != null) {
val previousExpense = expenses.itemSnapshotList.items.getOrNull(index - 1) val previousExpense = expenses.itemSnapshotList.items.getOrNull(index - 1)
val showDayDivider = val showDayDivider =
index == 0 || LocalDateTime.parse(previousExpense?.expense?.datetime) index == 0 || LocalDateTime.parse(previousExpense?.expense?.datetime)
.toLocalDate() != LocalDateTime.parse(expenseDto.expense.datetime) .toLocalDate() != LocalDateTime.parse(expenseDto.expense.datetime)
.toLocalDate() .toLocalDate()
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
if (showDayDivider) { if (showDayDivider) {
Row( CustomDivider(expenseDto)
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)) Spacer(Modifier.height(5.dp))
SwipeToDeleteExpenseCard( SwipeToDeleteExpenseCard(
@@ -143,13 +133,32 @@ fun ListExpenseScreen() {
expenseDtoToEdit = null expenseDtoToEdit = null
showBottomSheet = false showBottomSheet = false
}, },
categories = categories, expenseDtoToEdit = expenseDtoToEdit,
expenseDtoToEdit = expenseDtoToEdit state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) )
} }
} }
} }
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun CustomDivider(expenseDto: ExpenseDto) {
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))
}
}
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun SwipeToDeleteExpenseCard( fun SwipeToDeleteExpenseCard(
@@ -257,7 +266,10 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.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(
@@ -312,8 +324,16 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) { if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val amount by
expenseAndCategoryViewModel.convertAmount(
amount = expenseDto.expense.amount,
base = Currencies.valueOf(expenseDto.expense.currency),
target = Currencies.valueOf(expenseDto.trip.currency),
date = LocalDateTime.parse(expenseDto.expense.datetime).toLocalDate()
).collectAsState(initial = 0.0)
Text( Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.amount), text = "≈ %.2f ${expenseDto.trip.currency}".format(amount),
fontSize = 12.sp fontSize = 12.sp
) )
} }

View File

@@ -2,20 +2,27 @@ package cc.n0th1ng.tripmoney.screens.trippicker
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.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.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Shapes
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -23,31 +30,32 @@ 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.graphics.Shape
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import cc.n0th1ng.tripmoney.R import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.CurrencyButton import cc.n0th1ng.tripmoney.screens.addexpense.CurrencyButton
import cc.n0th1ng.tripmoney.screens.addexpense.isDoubleTwoDigitsAboveZero
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DatePicker import cc.n0th1ng.tripmoney.screens.listexpense.DatePicker
import cc.n0th1ng.tripmoney.screens.listexpense.DateTimePicker
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddTripBottomSheet(onDismiss: () -> Unit, onSave: (Trip) -> Unit, tripToEdit: Trip?) { fun AddTripBottomSheet(
onDismiss: () -> Unit,
onSave: (Trip) -> Unit,
tripToEdit: Trip?,
sheetState: SheetState
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var name by remember { mutableStateOf(tripToEdit?.name ?: "") } var name by remember { mutableStateOf(tripToEdit?.name ?: "") }
var startDate by remember { var startDate by remember {
mutableStateOf( mutableStateOf(
@@ -65,31 +73,61 @@ fun AddTripBottomSheet(onDismiss: () -> Unit, onSave: (Trip) -> Unit, tripToEdit
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(),
.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.Start 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 -> NameInput(name = name, onTextChange = { newText ->
name = newText name = newText
enableSave = !name.isEmpty() enableSave = !name.isEmpty()
}) })
CurrencyButton(onClick = {showCurrencyDialog = true}, currency) Row(
OutlinedButton(onClick = { showDatePicker = true }) { modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
CurrencyButton(
modifier = Modifier
.weight(1f)
.fillMaxWidth(1f),
onClick = { showCurrencyDialog = true }, text = currency
)
OutlinedButton(
modifier = Modifier
.fillMaxWidth(1f)
.weight(1f),
onClick = { showDatePicker = true }) {
Text( Text(
text = startDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")), text = startDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
fontSize = 17.sp fontSize = 17.sp
) )
} }
OutlinedButton(
}
Button(
modifier = Modifier.fillMaxWidth(0.9f),
enabled = enableSave, enabled = enableSave,
onClick = { onClick = {
onSave(Trip(name = name, startDate = startDate.toString(), currency = currency)) val trip = Trip(name = name, startDate = startDate.toString(), currency = currency)
onSave(if(tripToEdit == null) trip else trip.copy(id = tripToEdit.id))
}) { }) {
Icon( Icon(
imageVector = Icons.Filled.Check, imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.save) contentDescription = stringResource(R.string.save)
) )
} }
Spacer(Modifier.height(5.dp))
} }
} }
@@ -105,7 +143,7 @@ fun AddTripBottomSheet(onDismiss: () -> Unit, onSave: (Trip) -> Unit, tripToEdit
} }
if (showDatePicker) { if (showDatePicker) {
DatePicker(startDate, onDismiss = {showDatePicker = false}, onConfirm = { newDate -> DatePicker(startDate, onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
startDate = newDate startDate = newDate
showDatePicker = false showDatePicker = false
}) })
@@ -116,10 +154,10 @@ fun AddTripBottomSheet(onDismiss: () -> Unit, onSave: (Trip) -> Unit, tripToEdit
fun NameInput(name: String, onTextChange: (String) -> Unit) { fun NameInput(name: String, onTextChange: (String) -> Unit) {
var text by remember { mutableStateOf(name) } var text by remember { mutableStateOf(name) }
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(0.9f),
label = { Text(stringResource(R.string.name)) }, value = name, onValueChange = { newText -> label = { Text(stringResource(R.string.name)) }, value = name, onValueChange = { newText ->
text = newText text = newText
onTextChange(text) onTextChange(text)
}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
) )
} }

View File

@@ -5,6 +5,7 @@ import android.os.Build
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
@@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -28,6 +30,7 @@ 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.collectAsState import androidx.compose.runtime.collectAsState
@@ -39,6 +42,8 @@ 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.draw.clip 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.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
@@ -56,6 +61,7 @@ import cc.n0th1ng.tripmoney.screens.listexpense.DeleteConfirmationDialog
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@@ -67,6 +73,7 @@ fun TripPickerScreen(
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems() val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
var tripToEdit by remember { mutableStateOf<Trip?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = { Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { showBottomSheet = true }) { onClick = { showBottomSheet = true }) {
@@ -83,12 +90,17 @@ fun TripPickerScreen(
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
val trip = trips[i] val trip = trips[i]
if (trip != null) { if (trip != null) {
SwipeToDeleteTripCard(trip, onDelete = { SwipeToDeleteTripCard(
trip, onDelete = {
tripViewModel.delete(trip) tripViewModel.delete(trip)
}, onClick = { }, onClick = {
settingsViewModel.setCurrentTrip(trip.id) settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE) navController.navigate(Screens.LIST_EXPENSE)
}, isSelected = currentTripId == trip.id) }, isSelected = currentTripId == trip.id,
onLongClick = { trip ->
tripToEdit = trip
showBottomSheet = true
})
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
} }
@@ -98,12 +110,15 @@ fun TripPickerScreen(
AddTripBottomSheet( AddTripBottomSheet(
onDismiss = { onDismiss = {
showBottomSheet = false showBottomSheet = false
tripToEdit = null
}, },
onSave = { trip -> onSave = { trip ->
tripViewModel.save(trip) tripViewModel.save(trip)
showBottomSheet = false showBottomSheet = false
tripToEdit = null
}, },
null tripToEdit = tripToEdit,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) )
} }
} }
@@ -112,7 +127,8 @@ fun TripPickerScreen(
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun SwipeToDeleteTripCard( fun SwipeToDeleteTripCard(
trip: Trip, onDelete: (Trip) -> Unit, onClick: (Trip) -> Unit, isSelected: Boolean trip: Trip, onDelete: (Trip) -> Unit, onClick: (Trip) -> Unit, isSelected: Boolean,
onLongClick: (Trip) -> Unit
) { ) {
var dismissed by remember { mutableStateOf(false) } var dismissed by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
@@ -151,18 +167,27 @@ fun SwipeToDeleteTripCard(
Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete)) Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete))
} }
}) { }) {
TripCard(trip, isSelected, onClick = onClick) TripCard(trip, isSelected, onClick = onClick, onLongClick = onLongClick)
} }
} }
} }
@Composable @Composable
fun TripCard(trip: Trip, isSelected: Boolean, onClick: (Trip) -> Unit) { fun TripCard(
trip: Trip,
isSelected: Boolean,
onClick: (Trip) -> Unit,
onLongClick: (Trip) -> Unit
) {
val haptics = LocalHapticFeedback.current
ElevatedCard( ElevatedCard(
modifier = Modifier modifier = Modifier
.height(100.dp) .height(100.dp)
.clickable(true, onClick = { onClick(trip) }), .combinedClickable(enabled = true, onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(trip)
}, onClick = { onClick(trip) }),
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp) elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp)
) { ) {
Row( Row(

View File

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

View File

@@ -2,13 +2,16 @@ 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 DarkColorScheme = darkColorScheme(
primary = Purple80, primary = Purple80,
@@ -44,7 +47,6 @@ 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 -> DarkColorScheme darkTheme -> DarkColorScheme
else -> LightColorScheme else -> LightColorScheme
} }

View File

@@ -9,16 +9,23 @@ 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.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.service.ExchangeService
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.client.request.get
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ExpenseAndCategoryViewModel @Inject constructor( class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository, private val expenseRepo: ExpenseRepository,
private val categoryRepo: CategoryRepository private val categoryRepo: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository
) : ViewModel() { ) : ViewModel() {
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> = fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> =
@@ -43,4 +50,10 @@ class ExpenseAndCategoryViewModel @Inject constructor(
categoryRepo.save(category) categoryRepo.save(category)
} }
} }
fun convertAmount(amount: Double, base: Currencies, target: Currencies, date: LocalDate): Flow<Double> {
return flow {
emit(amount * exchangeRateRepository.getRate(base, target, date))
}
}
} }

View File

@@ -16,6 +16,8 @@
<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="add_trip">Dodaj wycieczkę</string>
<string name="name">Nazwa</string> <string name="name">Nazwa</string>
<string name="edit_trip">Edytuj wycieczkę</string>
<string name="edit_expense">Edytuj wydatek</string>
</resources> </resources>

View File

@@ -18,4 +18,6 @@
<string name="system_settings">System settings</string> <string name="system_settings">System settings</string>
<string name="add_trip">Add Trip</string> <string name="add_trip">Add Trip</string>
<string name="name">Name</string> <string name="name">Name</string>
<string name="edit_trip">Edit trip</string>
<string name="edit_expense">Edit expense</string>
</resources> </resources>