init #48

Merged
admin merged 18 commits from develop into main 2026-04-30 10:35:58 +02:00
16 changed files with 596 additions and 124 deletions
Showing only changes of commit 96cdd056a0 - Show all commits

View File

@@ -98,4 +98,8 @@ dependencies {
implementation("com.google.dagger:hilt-android:2.57.1")
ksp("com.google.dagger:hilt-android-compiler:2.57.1")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("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.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -28,6 +30,7 @@ import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen
import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen
import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@@ -49,6 +52,8 @@ class MainActivity : ComponentActivity() {
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val navController = rememberNavController()
val navBackStack by navController.currentBackStackEntryAsState()
val current = navBackStack?.destination?.route
@@ -74,7 +79,7 @@ fun NavigationDrawer() {
bottomBar = { BottomNavigation(navController) }) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screens.TRIP_PICKER,
startDestination = if(currentTripId == -1) Screens.TRIP_PICKER else Screens.LIST_EXPENSE,
modifier = Modifier.padding(innerPadding)
) {
composable(Screens.LIST_EXPENSE) {

View File

@@ -8,9 +8,11 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.utils.Icons
@@ -26,11 +28,12 @@ import java.time.LocalDateTime
import javax.inject.Inject
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 fun tripDao(): TripDao
abstract fun expenseDao(): ExpenseDao
abstract fun categoryDao(): CategoryDao
abstract fun exchangeRateDao(): ExchangeRateDao
}
@@ -74,6 +77,12 @@ object DatabaseModule {
fun provideCategoryDao(database: TripDatabase): 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(
"""
SELECT * FROM trip
ORDER BY DATE(trip.start_date) DESC
"""
)
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
import android.annotation.SuppressLint
import android.os.Build
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -29,11 +39,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.viewmodel.ExpenseAndCategoryViewModel
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.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun AddExpenseBottomSheet(
onSave: (Expense) -> 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 currentTripId by settingsViewModel.currentTrip.collectAsState()
// val currentTripId = 1
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
if (categories.isEmpty()) {
return
}
var amount by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.amount?.toString() ?: "0.00"
)
}
val dummyFocusRequester = remember { FocusRequester() }
var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoryDialog 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 enableSave by remember { mutableStateOf(expenseDtoToEdit != null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
sheetState = state,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
.padding(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 15.dp),
text = stringResource(if (expenseDtoToEdit == null) R.string.add_expense else R.string.edit_expense),
fontWeight = FontWeight.Bold,
fontSize = 35.sp,
textAlign = TextAlign.Start
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
Row(
modifier = Modifier.fillMaxWidth(0.9f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp)
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = amount.ifEmpty { "0.00" },
@@ -111,41 +151,44 @@ fun AddExpenseBottomSheet(
)
CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency)
}
Spacer(Modifier.height(14.dp))
OutlinedButton(onClick = { showDateTimePicker = true }) {
Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
fontSize = 17.sp
)
}
Spacer(Modifier.height(14.dp))
CategoryButton(onClick = { showCategoryDialog = true }, category = category)
Spacer(Modifier.height(14.dp))
Row(
modifier = Modifier.height(50.dp),
modifier = Modifier.fillMaxWidth(0.9f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
NoteInput(note = note) { newNote -> note = newNote }
SaveButton(
enabled = enableSave,
onClick = {
val expenseToSave = Expense(
amount = amount.toDouble(),
currency = currency,
note = note,
datetime = datetime.toString(),
categoryId = category.id,
tripId = currentTripId
)
onSave(
if (expenseDtoToEdit == null) expenseToSave
else expenseToSave.copy(id = expenseDtoToEdit.expense.id)
)
}
OutlinedButton(
onClick = { showDateTimePicker = true },
modifier = Modifier.weight(1f)
) {
Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
fontSize = 17.sp
)
}
CategoryButton(
onClick = { showCategoryDialog = true },
category = category,
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
NoteInput(
note = note,
onTextChange = { newNote -> note = newNote },
modifier = Modifier.fillMaxWidth(0.9f),
focusRequester = dummyFocusRequester
)
}
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.size(0.dp)
.focusRequester(dummyFocusRequester)
.focusable()
)
NumberKeyboard(
onNumberClick = { number ->
val newText = (if (amount == "0.00") "" else amount) + number
@@ -155,13 +198,28 @@ fun AddExpenseBottomSheet(
} 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(
amount = amount.toDouble(),
currency = currency,
note = note,
datetime = datetime.toString(),
categoryId = category.id,
tripId = currentTripId
)
onSave(
if (expenseDtoToEdit == null) expenseToSave
else expenseToSave.copy(id = expenseDtoToEdit.expense.id)
)
}, enableSave = enableSave
)
}
}
@@ -210,29 +268,39 @@ fun String.isDoubleTwoDigitsAboveZero(): Boolean {
}
@Composable
fun NoteInput(note: String, onTextChange: (String) -> Unit) {
fun NoteInput(note: String, onTextChange: (String) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester) {
var text by remember { mutableStateOf(note) }
OutlinedTextField(
modifier = modifier,
label = { Text(stringResource(R.string.note)) }, value = note, onValueChange = { newText ->
text = newText
onTextChange(text)
}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusRequester.requestFocus()
}
)
)
}
@Composable
fun CurrencyButton(onClick: () -> Unit, text: String) {
OutlinedButton(onClick = onClick) {
fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) {
OutlinedButton(onClick = onClick, modifier = modifier) {
Text(text)
}
}
@Composable
fun CategoryButton(onClick: () -> Unit, category: Category) {
fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth(0.5f)
modifier = modifier
) {
Icon(
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
fun NumberKeyboard(
modifier: Modifier = Modifier,
onNumberClick: (String) -> Unit,
onBackspaceClick: () -> Unit
onBackspaceClick: () -> Unit,
onSave: () -> Unit,
enableSave: Boolean
) {
val buttonModifier = Modifier
.padding(4.dp)
@@ -285,91 +346,109 @@ fun NumberKeyboard(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
TextButton(
onClick = { onNumberClick("1") },
modifier = buttonModifier.weight(1f)
) {
Text("1", fontSize = 20.sp)
}
OutlinedButton(
TextButton(
onClick = { onNumberClick("2") },
modifier = buttonModifier.weight(1f)
) {
Text("2", fontSize = 20.sp)
}
OutlinedButton(
TextButton(
onClick = { onNumberClick("3") },
modifier = buttonModifier.weight(1f)
) {
Text("3", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("") },
modifier = buttonModifier.weight(1f)
) {
Text("+", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
TextButton(
onClick = { onNumberClick("4") },
modifier = buttonModifier.weight(1f)
) {
Text("4", fontSize = 20.sp)
}
OutlinedButton(
TextButton(
onClick = { onNumberClick("5") },
modifier = buttonModifier.weight(1f)
) {
Text("5", fontSize = 20.sp)
}
OutlinedButton(
TextButton(
onClick = { onNumberClick("6") },
modifier = buttonModifier.weight(1f)
) {
Text("6", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("") },
modifier = buttonModifier.weight(1f)
) {
Text("-", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
TextButton(
onClick = { onNumberClick("7") },
modifier = buttonModifier.weight(1f)
) {
Text("7", fontSize = 20.sp)
}
OutlinedButton(
TextButton(
onClick = { onNumberClick("8") },
modifier = buttonModifier.weight(1f)
) {
Text("8", fontSize = 20.sp)
}
OutlinedButton(
TextButton(
onClick = { onNumberClick("9") },
modifier = buttonModifier.weight(1f)
) {
Text("9", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("") },
modifier = buttonModifier.weight(1f)
) {
Text("*", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
TextButton(
onClick = { onNumberClick(".") },
modifier = buttonModifier.weight(1f)
) {
Text(".", fontSize = 20.sp)
}
OutlinedButton(
TextButton(
onClick = { onNumberClick("0") },
modifier = buttonModifier.weight(1f)
) {
Text("0", fontSize = 20.sp)
}
OutlinedButton(
TextButton(
onClick = onBackspaceClick,
modifier = buttonModifier.weight(1f)
) {
@@ -378,6 +457,147 @@ fun NumberKeyboard(
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.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
@@ -33,6 +34,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -56,10 +58,14 @@ import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
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.SettingsViewModel
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
@OptIn(ExperimentalMaterial3Api::class)
@@ -71,8 +77,6 @@ fun ListExpenseScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTrip by settingsViewModel.currentTrip.collectAsState()
val categories by expenseAndCategoryViewModel.getCategories()
.collectAsState(initial = emptyList())
val expenses = expenseAndCategoryViewModel.getExpenses(currentTrip).collectAsLazyPagingItems()
val listState = rememberLazyListState()
var showBottomSheet by remember { mutableStateOf(false) }
@@ -98,27 +102,13 @@ fun ListExpenseScreen() {
val expenseDto = expenses[index]
if (expenseDto != null) {
val previousExpense = expenses.itemSnapshotList.items.getOrNull(index - 1)
val showDayDivider =
index == 0 || LocalDateTime.parse(previousExpense?.expense?.datetime)
.toLocalDate() != LocalDateTime.parse(expenseDto.expense.datetime)
.toLocalDate()
Spacer(Modifier.height(5.dp))
if (showDayDivider) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.Center,
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
LocalDateTime.parse(expenseDto.expense.datetime).format(
DateTimeFormatter.ofPattern("dd EEEE")
).toString(),
modifier = Modifier.background(Color.White.copy(alpha = 0f))
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
CustomDivider(expenseDto)
}
Spacer(Modifier.height(5.dp))
SwipeToDeleteExpenseCard(
@@ -143,13 +133,32 @@ fun ListExpenseScreen() {
expenseDtoToEdit = null
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)
@Composable
fun SwipeToDeleteExpenseCard(
@@ -257,7 +266,10 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
modifier = Modifier
.fillMaxWidth(0.9f)
.height(70.dp)
.clickable { onClick(expenseDto) },
.combinedClickable(
enabled = true,
onClick = { onClick(expenseDto) },
onLongClick = { onClick(expenseDto) }),
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) {
Row(
@@ -312,8 +324,16 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
fontWeight = FontWeight.Bold
)
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 = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.amount),
text = "≈ %.2f ${expenseDto.trip.currency}".format(amount),
fontSize = 12.sp
)
}

View File

@@ -2,20 +2,27 @@ package cc.n0th1ng.tripmoney.screens.trippicker
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.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.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -23,31 +30,32 @@ 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.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Trip
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.DatePicker
import cc.n0th1ng.tripmoney.screens.listexpense.DateTimePicker
import cc.n0th1ng.tripmoney.utils.Currencies
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@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 startDate by remember {
mutableStateOf(
@@ -65,31 +73,61 @@ fun AddTripBottomSheet(onDismiss: () -> Unit, onSave: (Trip) -> Unit, tripToEdit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.Start
.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()
})
CurrencyButton(onClick = {showCurrencyDialog = true}, currency)
OutlinedButton(onClick = { showDatePicker = true }) {
Text(
text = startDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
fontSize = 17.sp
Row(
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 = startDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
fontSize = 17.sp
)
}
}
OutlinedButton(
Button(
modifier = Modifier.fillMaxWidth(0.9f),
enabled = enableSave,
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(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.save)
)
}
Spacer(Modifier.height(5.dp))
}
}
@@ -105,7 +143,7 @@ fun AddTripBottomSheet(onDismiss: () -> Unit, onSave: (Trip) -> Unit, tripToEdit
}
if (showDatePicker) {
DatePicker(startDate, onDismiss = {showDatePicker = false}, onConfirm = { newDate ->
DatePicker(startDate, onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
startDate = newDate
showDatePicker = false
})
@@ -116,10 +154,10 @@ fun AddTripBottomSheet(onDismiss: () -> Unit, onSave: (Trip) -> Unit, tripToEdit
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)
)
}
}

View File

@@ -5,6 +5,7 @@ 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
@@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
@@ -28,6 +30,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -39,6 +42,8 @@ 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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -56,6 +61,7 @@ import cc.n0th1ng.tripmoney.screens.listexpense.DeleteConfirmationDialog
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@@ -67,6 +73,7 @@ fun TripPickerScreen(
var showBottomSheet by remember { mutableStateOf(false) }
val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
var tripToEdit by remember { mutableStateOf<Trip?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
FloatingActionButton(
onClick = { showBottomSheet = true }) {
@@ -83,12 +90,17 @@ fun TripPickerScreen(
Spacer(Modifier.height(10.dp))
val trip = trips[i]
if (trip != null) {
SwipeToDeleteTripCard(trip, onDelete = {
SwipeToDeleteTripCard(
trip, onDelete = {
tripViewModel.delete(trip)
}, onClick = {
settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
}, isSelected = currentTripId == trip.id)
}, isSelected = currentTripId == trip.id,
onLongClick = { trip ->
tripToEdit = trip
showBottomSheet = true
})
}
Spacer(Modifier.height(10.dp))
}
@@ -98,12 +110,15 @@ fun TripPickerScreen(
AddTripBottomSheet(
onDismiss = {
showBottomSheet = false
tripToEdit = null
},
onSave = { trip ->
tripViewModel.save(trip)
showBottomSheet = false
tripToEdit = null
},
null
tripToEdit = tripToEdit,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
)
}
}
@@ -112,7 +127,8 @@ fun TripPickerScreen(
@RequiresApi(Build.VERSION_CODES.O)
@Composable
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 showDialog by remember { mutableStateOf(false) }
@@ -151,18 +167,27 @@ fun SwipeToDeleteTripCard(
Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete))
}
}) {
TripCard(trip, isSelected, onClick = onClick)
TripCard(trip, isSelected, onClick = onClick, onLongClick = onLongClick)
}
}
}
@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(
modifier = Modifier
.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)
) {
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 androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
@@ -44,7 +47,6 @@ fun TripMoneyTheme(
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
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.ExpenseDto
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.service.ExchangeService
import cc.n0th1ng.tripmoney.utils.Currencies
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.launch
import java.time.LocalDate
import javax.inject.Inject
@HiltViewModel
class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository,
private val categoryRepo: CategoryRepository
private val categoryRepo: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository
) : ViewModel() {
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> =
@@ -43,4 +50,10 @@ class ExpenseAndCategoryViewModel @Inject constructor(
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="theme">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="edit_trip">Edytuj wycieczkę</string>
<string name="edit_expense">Edytuj wydatek</string>
</resources>

View File

@@ -18,4 +18,6 @@
<string name="system_settings">System settings</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>
</resources>