This commit is contained in:
Rafal Wisniewski
2026-03-25 14:49:02 +01:00
parent 916481e4e3
commit 160dda3bff
15 changed files with 818 additions and 449 deletions

View File

@@ -102,4 +102,5 @@ dependencies {
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")
implementation("org.apache.commons:commons-csv:1.5")
}

View File

@@ -21,6 +21,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
</application>
</manifest>

View File

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

View File

@@ -11,6 +11,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavHostController
import cc.n0th1ng.tripmoney.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -31,7 +32,7 @@ fun TopBar(onClick: () -> Unit, title: String = "") {
fun TopBarSettings(navController: NavHostController) {
TopAppBar(
title = { Text("Settings") },
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")

View File

@@ -1,6 +1,7 @@
package cc.n0th1ng.tripmoney.screens.addexpense
import android.annotation.SuppressLint
import android.graphics.drawable.PaintDrawable
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.clickable
@@ -21,9 +22,14 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -34,6 +40,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -42,10 +49,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.modifier.modifierLocalMapOf
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.sensitiveContent
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -59,21 +69,24 @@ import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.listexpense.CategorySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DateTimePicker
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R.drawable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.collections.listOf
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@@ -88,23 +101,48 @@ fun AddExpenseBottomSheet(
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip = tripViewModel.getTrip(currentTripId)
val currentTrip = tripViewModel.getTrip(currentTripId)!!
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
AddExpenseBottomSheet(
onSave = onSave,
onDismiss = onDismiss,
expenseDtoToEdit = expenseDtoToEdit,
state = state,
currentTrip = currentTrip,
categories = categories
)
}
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit,
onDismiss: () -> Unit,
expenseDtoToEdit: ExpenseDto?,
state: SheetState,
currentTrip: Trip,
categories: List<Category>
) {
val currentTripId = currentTrip.id
if (categories.isEmpty()) {
return
}
var amount by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.amount?.toString() ?: "0.00"
)
}
var equationResult by remember { mutableDoubleStateOf(0.0) }
val dummyFocusRequester = remember { FocusRequester() }
var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoryDialog by remember { mutableStateOf(false) }
var showDateTimePicker by remember { mutableStateOf(false) }
var currency by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.currency ?: currentTrip?.currency ?: Currencies.default().name
expenseDtoToEdit?.expense?.currency ?: currentTrip.currency
)
}
var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }
@@ -122,6 +160,8 @@ fun AddExpenseBottomSheet(
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = state,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Column(
modifier = Modifier
@@ -135,96 +175,127 @@ fun AddExpenseBottomSheet(
.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,
style = MaterialTheme.typography.displaySmall,
textAlign = TextAlign.Start
)
HorizontalDivider(modifier = Modifier.fillMaxWidth())
Row(
modifier = Modifier.fillMaxWidth(0.9f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(10.dp)
) {
Text(
text = amount.ifEmpty { "0.00" },
fontSize = 25.sp,
fontWeight = FontWeight.Bold
)
CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency)
}
Row(
modifier = Modifier.fillMaxWidth(0.9f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = { showDateTimePicker = true },
modifier = Modifier.weight(1f)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
fontSize = 17.sp
)
Column{
Text(
text = amount.ifEmpty { "0.00" },
fontSize = 25.sp,
fontWeight = FontWeight.Bold
)
Text(
text = if(amount.contains(Regex("[+\\/*-]\\d+"))) "%.2f".format(equationResult) else "",
fontSize = 14.sp,
)
}
CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency)
}
CategoryButton(
onClick = { showCategoryDialog = true },
category = category,
modifier = Modifier.weight(1f)
Box(
modifier = Modifier
.size(0.dp)
.focusRequester(dummyFocusRequester)
.focusable()
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
NoteInput(
note = note,
onTextChange = { newNote -> note = newNote },
modifier = Modifier.fillMaxWidth(0.9f),
modifier = Modifier.fillMaxWidth(),
focusRequester = dummyFocusRequester
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = { showDateTimePicker = true },
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.medium,
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
) {
Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
fontSize = 17.sp
)
}
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
CategoryButton(
onClick = { showCategoryDialog = true },
category = category,
modifier = Modifier.weight(1f)
)
onSave(
if (expenseDtoToEdit == null) expenseToSave
else expenseToSave.copy(id = expenseDtoToEdit.expense.id)
)
}, enableSave = enableSave
)
}
NumberKeyboard(
modifier = Modifier.fillMaxWidth(),
onOperatorClick = { operator ->
if(amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+\\/*-]\\d+"))) {
amount = evaluate(amount).toString()
// equationResult = 0.0
}
val newText = amount + operator
if(newText.isDoubleTwoDigitsOrEquation()) {
amount = newText
enableSave = false
}
},
onNumberClick = { number ->
val newText = (if (amount == "0.00") "" else amount) + number
if (newText.isDoubleTwoDigitsOrEquation()) {
amount = newText
equationResult = evaluate(amount)
enableSave = equationResult > 0
} else if (amount == "0.00") {
enableSave = false
}
dummyFocusRequester.requestFocus()
},
onBackspaceClick = {
if (amount == "0.00") return@NumberKeyboard
amount = amount.safeSubstring(0, amount.length - 1)
enableSave = amount.isDoubleTwoDigitsOrEquation()
equationResult = evaluate(amount)
enableSave = amount.isDoubleTwoDigitsOrEquation() && equationResult > 0
},
)
SaveButton(
modifier = Modifier.fillMaxWidth(),
enabled = enableSave,
onClick = {
val expenseToSave = Expense(
amount = equationResult,
currency = currency,
note = note,
datetime = datetime.toString(),
categoryId = category.id,
tripId = currentTripId
)
onSave(
if (expenseDtoToEdit == null) expenseToSave
else expenseToSave.copy(id = expenseDtoToEdit.expense.id)
)
})
}
}
}
if (showDateTimePicker) {
DateTimePicker(datetime, onChange = { newDateTime ->
datetime = newDateTime
@@ -264,12 +335,50 @@ fun String.safeSubstring(start: Int, end: Int): String {
}
}
fun String.isDoubleTwoDigitsAboveZero(): Boolean {
return this.toDoubleOrNull() != null && this.matches(Regex("^\\d*(\\.\\d{0,2})?$")) && this.toDouble() > 0
private fun evaluate(equation: String): Double {
if (equation.isEmpty()) return 0.0
val operatorIndex = equation.indexOfFirstIndexed { i, c ->
i != 0 && c in "+-*/"
}
if (operatorIndex == -1) return equation.toDouble()
val leftString = equation.substring(0, operatorIndex)
val rightString = equation.substring(operatorIndex + 1)
if (leftString.isEmpty() || rightString.isEmpty()) return 0.0
val left = leftString.toDouble()
val right = rightString.toDouble()
return when (equation[operatorIndex]) {
'+' -> left + right
'-' -> left - right
'*' -> left * right
'/' -> left / right
else -> 0.0
}
}
private inline fun String.indexOfFirstIndexed(predicate: (index: Int, Char) -> Boolean): Int {
for (i in indices) {
if (predicate(i, this[i])) return i
}
return -1
}
private fun String.isDoubleTwoDigitsOrEquation(): Boolean {
return this != "0.00" && this.matches(Regex("^(-?(0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?))([+\\/*-](0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?)?)?$"))
}
@Composable
fun NoteInput(note: String, onTextChange: (String) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester) {
fun NoteInput(
note: String,
onTextChange: (String) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester
) {
var text by remember { mutableStateOf(note) }
OutlinedTextField(
@@ -292,33 +401,36 @@ fun NoteInput(note: String, onTextChange: (String) -> Unit, modifier: Modifier =
@Composable
fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) {
OutlinedButton(onClick = onClick, modifier = modifier) {
Button(onClick = onClick, modifier = modifier, shape = MaterialTheme.shapes.medium) {
Text(text)
}
}
@Composable
fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
OutlinedButton(
Button(
onClick = onClick,
modifier = modifier
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors()
.copy(containerColor = Color(category.color.toColorInt()), contentColor = Color.Black)
) {
Icon(
modifier = Modifier.padding(end = 10.dp),
painter = painterResource(category.icon.resource),
contentDescription = stringResource(R.string.category),
tint = Color(category.color.toColorInt())
)
Text(category.name, color = Color(category.color.toColorInt()))
Text(category.name)
}
}
@Composable
fun SaveButton(enabled: Boolean, onClick: () -> Unit) {
OutlinedButton(
fun SaveButton(modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier
modifier = modifier,
shape = MaterialTheme.shapes.medium
) {
Icon(
imageVector = Icons.Filled.Check,
@@ -332,273 +444,171 @@ fun NumberKeyboard(
modifier: Modifier = Modifier,
onNumberClick: (String) -> Unit,
onBackspaceClick: () -> Unit,
onSave: () -> Unit,
enableSave: Boolean
onOperatorClick: (String) -> Unit
) {
val buttonModifier = Modifier
.padding(4.dp)
.aspectRatio(2f)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
TextButton(
onClick = { onNumberClick("1") },
modifier = buttonModifier.weight(1f)
keyboard.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text("1", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("2") },
modifier = buttonModifier.weight(1f)
) {
Text("2", fontSize = 20.sp)
}
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.forEach { key ->
when (key) {
"backspace" -> KeyboardButton(
icon = painterResource(drawable.materialsymbols_ic_arrow_left_alt_outlined),
onClick = onBackspaceClick,
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.primary
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
TextButton(
onClick = { onNumberClick("4") },
modifier = buttonModifier.weight(1f)
) {
Text("4", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("5") },
modifier = buttonModifier.weight(1f)
) {
Text("5", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("6") },
modifier = buttonModifier.weight(1f)
) {
Text("6", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("") },
modifier = buttonModifier.weight(1f)
) {
Text("-", fontSize = 20.sp)
}
}
"+", "/", "-", "*" -> KeyboardButton(
text = key,
onClick = { onOperatorClick(key) },
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
TextButton(
onClick = { onNumberClick("7") },
modifier = buttonModifier.weight(1f)
) {
Text("7", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("8") },
modifier = buttonModifier.weight(1f)
) {
Text("8", fontSize = 20.sp)
}
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)
) {
TextButton(
onClick = { onNumberClick(".") },
modifier = buttonModifier.weight(1f)
) {
Text(".", fontSize = 20.sp)
}
TextButton(
onClick = { onNumberClick("0") },
modifier = buttonModifier.weight(1f)
) {
Text("0", fontSize = 20.sp)
}
TextButton(
onClick = onBackspaceClick,
modifier = buttonModifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.backspace)
)
}
TextButton(
onClick = onSave,
modifier = buttonModifier.weight(1f),
enabled = enableSave
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.backspace)
)
else -> KeyboardButton(
text = key,
onClick = { onNumberClick(key) },
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
)
}
}
}
}
}
}
@Composable
fun KeyboardButton(
text: String? = null,
icon: Painter? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary
) {
Button(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
modifier = modifier
.padding(4.dp)
.aspectRatio(2.5f),
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor
)
) {
when {
text != null -> Text(
text,
style = MaterialTheme.typography.titleMedium
)
//@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"
// ),
// )
// )
// }
//}
icon != null -> Icon(painter = icon, contentDescription = null)
}
}
}
val keyboard = listOf(
listOf("+", "-", "*", "/"),
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf(".", "0", "backspace")
)
@SuppressLint("CoroutineCreationDuringComposition")
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
fun PreviewAddExpenseDisabled() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
CoroutineScope(Dispatchers.IO).launch {
sheetState.show()
}
TripMoneyTheme {
AddExpenseBottomSheet(
onSave = {},
onDismiss = {},
expenseDtoToEdit = null,
state = sheetState,
currentTrip = Trip(1, "Trip", "2020-01-01", Currencies.entries.random().name),
categories = categoriesToPreview
)
}
}
@SuppressLint("CoroutineCreationDuringComposition")
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
fun PreviewAddExpenseEnabled() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
CoroutineScope(Dispatchers.IO).launch {
sheetState.show()
}
TripMoneyTheme {
AddExpenseBottomSheet(
onSave = {},
onDismiss = {},
expenseDtoToEdit = ExpenseDto(
Expense(
amount = 10.31,
currency = "PLN",
note = "some note",
datetime = "2025-11-30T10:16:26.939",
categoryId = 1,
tripId = 1
), category = categoriesToPreview.get(0), Trip(1, "Włochy", "2025-01-02", "PLN")
),
state = sheetState,
currentTrip = Trip(1, "Trip", "2020-01-01", Currencies.entries.random().name),
categories = categoriesToPreview
)
}
}
val categoriesToPreview = listOf(
Category(
name = "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
color = colors.random()
),
Category(
name = "Jedzenie",
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
color = colors.random()
),
Category(
name = "Transport",
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
color = colors.random()
),
Category(
name = "Rozrywka",
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
color = colors.random()
),
Category(
name = "Zakupy",
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
color = colors.random()
),
)

View File

@@ -50,9 +50,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.PagingData
@@ -126,7 +124,6 @@ fun ListExpenseScreen(
sumMap.clear()
sumMap.putAll(newSums)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -513,4 +510,4 @@ private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseDtoWithConver
)
}
return result
}
}

View File

@@ -1,5 +1,6 @@
package cc.n0th1ng.tripmoney.screens.settings
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
@@ -12,9 +13,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -27,15 +31,31 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.saveCsv
import cc.n0th1ng.tripmoney.utils.shareCsv
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.file.Files
@RequiresApi(Build.VERSION_CODES.S)
@Composable
@@ -43,50 +63,109 @@ fun SettingsScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Card {
SettingsListItem(onClick = { showThemeDialog = true }, stringResource(string.theme)) {
Text(
if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme
)
)
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTrip = tripViewModel.getTrip(currentTripId)
val context = LocalContext.current
val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope()
SettingsScreen(
currentDefaultCurrency = currentDefaultCurrency,
currentTheme = currentTheme,
onThemeSave = { settingsViewModel.setTheme(it) },
onCurrencySave = { settingsViewModel.setDefaultCurrency(it) },
tripName = tripName,
onExportToCsv = {
scope.launch {
try {
val safeTripName = tripName.replace(Regex("[^a-zA-Z0-9_]"), "_")
val file = File(context.cacheDir, "$safeTripName.csv")
expenseAndCategoryViewModel.generateCSVToFile(currentTripId, file)
shareCsv(context, file)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
)
}
Card {
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun SettingsScreen(
currentDefaultCurrency: Currencies,
currentTheme: AppTheme,
onThemeSave: (AppTheme) -> Unit,
onCurrencySave: (Currencies) -> Unit,
tripName: String,
onExportToCsv: () -> Unit,
) {
Scaffold { padding ->
var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
SettingsListItem(
onClick = { showCurrencyDialog = true },
stringResource(string.default_currency)
) {
Text(currentDefaultCurrency.name)
}
}
if (showThemeDialog) {
ThemeSelectionDialog(
onDismiss = { showThemeDialog = false },
onThemeSelected = { theme ->
settingsViewModel.setTheme(theme)
showThemeDialog = false
},
selected = currentTheme
headlineText = stringResource(string.default_currency),
supportingText = currentDefaultCurrency.name,
iconResource = R.drawable.materialsymbols_ic_currency_yen_outlined
)
}
if (showCurrencyDialog) {
CurrencySelectionDialog(onDismiss = {showCurrencyDialog = false}, onCurrencySelected = {
currencyString ->
settingsViewModel.setDefaultCurrency(Currencies.valueOf(currencyString))
showCurrencyDialog = false
}, currentDefaultCurrency.name)
SettingsCard(string.theme) {
SettingsListItem(
onClick = { showThemeDialog = true },
stringResource(string.theme),
supportingText = if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme
),
iconResource = R.drawable.materialsymbols_ic_format_paint_outlined
)
SettingsListItem(
onClick = { },
"Pallete",
supportingText = if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme
),
iconResource = R.drawable.materialsymbols_ic_palette_outlined
)
}
SettingsListItem(
onClick = onExportToCsv,
stringResource(string.export_to_csv),
supportingText = "Save expenses from %s to a file".format(tripName),
iconResource = R.drawable.materialsymbols_ic_csv_outlined
)
if (showThemeDialog) {
ThemeSelectionDialog(
onDismiss = { showThemeDialog = false },
onThemeSelected = { theme ->
onThemeSave(theme)
showThemeDialog = false
},
selected = currentTheme
)
}
if (showCurrencyDialog) {
CurrencySelectionDialog(
onDismiss = { showCurrencyDialog = false },
onCurrencySelected = { currencyString ->
onCurrencySave(Currencies.valueOf(currencyString))
showCurrencyDialog = false
},
currentDefaultCurrency.name
)
}
}
}
}
@@ -97,7 +176,7 @@ fun SettingsCard(@StringRes title: Int = -1, content: @Composable () -> Unit) {
if (title != -1) {
Text(
text = stringResource(title),
fontSize = 13.sp,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.padding(start = 15.dp, top = 15.dp, end = 15.dp)
.alpha(0.6f)
@@ -112,16 +191,22 @@ fun SettingsListItem(
onClick: () -> Unit,
headlineText: String,
trailingContent: @Composable () -> Unit = {},
supportingContent: @Composable () -> Unit
supportingText: String,
iconResource: Int
) {
ListItem(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(headlineText) },
supportingContent = supportingContent,
trailingContent = trailingContent,
modifier = Modifier
.clickable(true, onClick = onClick)
)
Card {
ListItem(
leadingContent = {
Icon(painter = painterResource(iconResource), contentDescription = null)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(headlineText) },
supportingContent = { Text(supportingText) },
trailingContent = trailingContent,
modifier = Modifier
.clickable(true, onClick = onClick)
)
}
}
@Composable
@@ -165,4 +250,35 @@ fun ThemeSelectionDialog(
},
confirmButton = {}
)
}
@RequiresApi(Build.VERSION_CODES.S)
@AllPreviews
@Composable
fun PreviewSettingsScreen() {
TripMoneyTheme {
SettingsScreen(Currencies.entries.random(), AppTheme.entries.random(), {}, {}, "Włochy", {})
}
}
@RequiresApi(Build.VERSION_CODES.S)
@AllPreviews
@Composable
fun PreviewThemeSelectionDialog() {
TripMoneyTheme {
ThemeSelectionDialog(onDismiss = {}, onThemeSelected = {}, AppTheme.SYSTEM)
}
}
@RequiresApi(Build.VERSION_CODES.S)
@AllPreviews
@Composable
fun PreviewCurrencySelectionDialog() {
TripMoneyTheme {
CurrencySelectionDialog(
onDismiss = {},
onCurrencySelected = {},
selected = Currencies.entries.random().name
)
}
}

View File

@@ -7,7 +7,6 @@ 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -25,38 +24,97 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun StatisticsScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTrip by settingsViewModel.currentTrip.collectAsState()
val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTrip)
val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip = tripViewModel.getTrip(currentTripId)
val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTripId)
.collectAsState(emptyList())
val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId)
.collectAsState(0.0)
StatisticsScreen(
summaryPerCategoryList,
summaryAmount,
Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name)
)
}
Column(modifier = Modifier.padding(10.dp)) {
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun StatisticsScreen(
summaryPerCategoryList: List<SummaryPerCategory>,
summaryAmount: Double,
tripCurrency: Currencies
) {
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Summary(summaryAmount, tripCurrency.name)
SummaryPerCategoryCard(summaryPerCategoryList)
}
}
@Composable
fun Summary(summaryAmount: Double, currency: String) {
Card(
modifier = Modifier
.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses), style = MaterialTheme.typography.titleSmall)
Text(
"%.2f %s".format(summaryAmount, currency),
style = MaterialTheme.typography.headlineLarge
)
}
Row(
horizontalArrangement = Arrangement.Center
)
{
Icon(
painter = painterResource(R.drawable.materialsymbols_ic_payment_arrow_down_outlined),
contentDescription = null,
modifier = Modifier.size(45.dp)
)
}
}
}
}
@Composable
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
@@ -65,7 +123,6 @@ fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
// Text(text = "Summary", fontWeight = FontWeight.Bold, fontSize = 25.sp)
summaryPerCategoryList.forEach {
CategoryCard(
summaryPerCategory = it, modifier = Modifier
@@ -80,21 +137,35 @@ fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCategory) {
Column(modifier = modifier) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
Icon(
painter = painterResource(summaryPerCategory.category.icon.resource),
contentDescription = null,
modifier = Modifier.size(MaterialTheme.typography.bodyLarge.fontSize.value.dp),
tint = Color(summaryPerCategory.category.color.toColorInt())
)
Text("%s".format(summaryPerCategory.category.name, (summaryPerCategory.percent * 100).toInt()),
style = MaterialTheme.typography.bodyLarge, color = Color(summaryPerCategory.category.color.toColorInt()))
Text(
"%s".format(
summaryPerCategory.category.name,
(summaryPerCategory.percent * 100).toInt()
),
style = MaterialTheme.typography.bodyLarge,
color = Color(summaryPerCategory.category.color.toColorInt())
)
}
Text("%.2f ${summaryPerCategory.currency}".format(summaryPerCategory.amount),
style = MaterialTheme.typography.bodyMedium)
Text(
"%.2f ${summaryPerCategory.currency}".format(summaryPerCategory.amount),
style = MaterialTheme.typography.bodyMedium
)
}
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically){
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.height(40.dp)
@@ -102,31 +173,34 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primary)
) {
Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize().padding(11.dp)) {
Text("%d%%".format((summaryPerCategory.percent * 100).toInt()),
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimary)
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(11.dp)
) {
Text(
"%d%%".format((summaryPerCategory.percent * 100).toInt()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
// Text("%d%%".format((summaryPerCategory.percent * 100).toInt()),
// style = MaterialTheme.typography.labelSmall)
}
}
}
@Preview
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun previewLight() {
fun Preview() {
TripMoneyTheme {
SummaryPerCategoryCard(summaryPerCategoryList)
}
}
@Preview
@Composable
fun previewDark() {
TripMoneyTheme(darkTheme = true) {
SummaryPerCategoryCard(summaryPerCategoryList)
StatisticsScreen(
summaryPerCategoryList,
summaryAmount = 125.24,
Currencies.entries.random()
)
}
}
@@ -143,8 +217,8 @@ val categories = listOf(
val summaryPerCategoryList = listOf(
SummaryPerCategory(categories[0], 50.0, 1f, Currencies.PLN),
SummaryPerCategory(categories[1], 120.0, 0.3f, Currencies.PLN),
SummaryPerCategory(categories[4], 120.0, 0.3f, Currencies.PLN),
SummaryPerCategory(categories[2], 80.0, 0.2f, Currencies.PLN),
SummaryPerCategory(categories[3], 50.0, 0.1f, Currencies.PLN),
SummaryPerCategory(categories[4], 120.0, 0.3f, Currencies.PLN),
SummaryPerCategory(categories[5], 50.0, 0.0001f, Currencies.PLN),
)

View File

@@ -1,5 +1,6 @@
package cc.n0th1ng.tripmoney.screens.trippicker
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
@@ -17,12 +18,14 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Shapes
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -44,9 +47,14 @@ import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.CurrencyButton
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DatePicker
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import io.ktor.http.hostIsIp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@@ -59,6 +67,29 @@ fun AddTripBottomSheet(
tripToEdit: Trip?,
sheetState: SheetState
) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val defaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
AddTripBottomSheet(
onDismiss = onDismiss,
onSave = onSave,
tripToEdit = tripToEdit,
sheetState = sheetState,
defaultCurrency = defaultCurrency
)
}
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTripBottomSheet(
onDismiss: () -> Unit,
onSave: (Trip) -> Unit,
tripToEdit: Trip?,
sheetState: SheetState,
defaultCurrency: Currencies
) {
var name by remember { mutableStateOf(tripToEdit?.name ?: "") }
var startDate by remember {
@@ -66,8 +97,7 @@ fun AddTripBottomSheet(
LocalDate.parse(tripToEdit?.startDate ?: LocalDate.now().toString())
)
}
val settingsViewModel: SettingsViewModel = hiltViewModel()
val defaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
var showCurrencyDialog by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
var currency by remember { mutableStateOf(tripToEdit?.currency ?: defaultCurrency.name) }
@@ -87,7 +117,7 @@ fun AddTripBottomSheet(
modifier = Modifier
.fillMaxWidth()
.padding(start = 15.dp),
text = stringResource(if(tripToEdit == null) R.string.add_trip else R.string.edit_trip),
text = stringResource(if (tripToEdit == null) R.string.add_trip else R.string.edit_trip),
fontWeight = FontWeight.Bold,
fontSize = 35.sp,
textAlign = TextAlign.Start
@@ -101,32 +131,35 @@ fun AddTripBottomSheet(
modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
CurrencyButton(
modifier = Modifier
.weight(1f)
.fillMaxWidth(1f),
onClick = { showCurrencyDialog = true }, text = currency
)
OutlinedButton(
Button(
modifier = Modifier
.fillMaxWidth(1f)
.weight(1f),
shape = MaterialTheme.shapes.medium,
onClick = { showDatePicker = true }) {
Text(
text = startDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
fontSize = 17.sp
)
}
CurrencyButton(
modifier = Modifier
.weight(1f)
.fillMaxWidth(1f),
onClick = { showCurrencyDialog = true }, text = currency
)
}
Button(
modifier = Modifier.fillMaxWidth(0.9f),
enabled = enableSave,
shape = MaterialTheme.shapes.medium,
onClick = {
val trip = 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))
onSave(if (tripToEdit == null) trip else trip.copy(id = tripToEdit.id))
}) {
Icon(
imageVector = Icons.Filled.Check,
@@ -166,4 +199,41 @@ fun NameInput(name: String, onTextChange: (String) -> Unit) {
onTextChange(text)
}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
@RequiresApi(Build.VERSION_CODES.O)
@SuppressLint("CoroutineCreationDuringComposition")
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
fun PreviewAddTripBottomSheet() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
CoroutineScope(Dispatchers.IO).launch {
sheetState.show()
}
TripMoneyTheme {
AddTripBottomSheet({}, {}, null, sheetState, defaultCurrency = Currencies.entries.random())
}
}
@RequiresApi(Build.VERSION_CODES.O)
@SuppressLint("CoroutineCreationDuringComposition")
@OptIn(ExperimentalMaterial3Api::class)
@AllPreviews
@Composable
fun PreviewAddTripBottomSheetEditTrip() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
CoroutineScope(Dispatchers.IO).launch {
sheetState.show()
}
TripMoneyTheme {
AddTripBottomSheet(
{},
{},
Trip(1, "Włochy", "2025-01-02", "PLN"),
sheetState,
defaultCurrency = Currencies.entries.random()
)
}
}

View File

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

View File

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

View File

@@ -18,12 +18,17 @@ import cc.n0th1ng.tripmoney.data.repository.TripRepository
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVPrinter
import java.io.File
import java.time.LocalDateTime
import javax.inject.Inject
@HiltViewModel
open class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository,
@@ -55,12 +60,39 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun generateCSVToFile(tripId: Int, file: File) {
file.writer().use { writer ->
CSVPrinter(
writer,
CSVFormat.DEFAULT.withHeader("date", "category", "currency", "amount")
).use { printer ->
expenseRepo.getExpenses(tripId).first().forEach { expenseDto ->
printer.printRecord(
expenseDto.expense.datetime,
expenseDto.category.name,
expenseDto.expense.currency,
expenseDto.expense.amount
)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSummaryAmount(tripId: Int): Flow<Double> {
return getExpensesWithConvertedAmounts(tripId).map { list ->
list.sumOf { it.convertedAmount }
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategory>> {
val tripCurrency = tripRepo.getTrip(tripId)?.currency ?: Currencies.default().name
return getExpensesWithConvertedAmounts(tripId)
.map { list ->
// Compute summary
val sumOfAll = list.sumOf { it.convertedAmount }
list.groupBy { it.expenseDto.category }
.map { (category, expenses) ->
@@ -84,7 +116,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
val convertedAmount =
if (expenseDto.expense.currency != expenseDto.trip.currency) {
runBlocking {
expenseDto.toExpenseDtoWithConvertedAmount()
expenseDto.convertedAmount()
}
} else {
expenseDto.expense.amount
@@ -102,7 +134,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
val convertedAmount =
if (expenseDto.expense.currency != expenseDto.trip.currency) {
runBlocking {
expenseDto.toExpenseDtoWithConvertedAmount()
expenseDto.convertedAmount()
}
} else {
expenseDto.expense.amount
@@ -120,7 +152,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun ExpenseDto.toExpenseDtoWithConvertedAmount(): Double {
suspend fun ExpenseDto.convertedAmount(): Double {
return exchangeRateRepository.getRate(
Currencies.valueOf(this.expense.currency),
Currencies.valueOf(this.trip.currency),

View File

@@ -21,4 +21,10 @@
<string name="edit_trip">Edytuj wycieczkę</string>
<string name="edit_expense">Edytuj wydatek</string>
<string name="default_currency">Domyślna waluta</string>
<string name="total_expenses">Suma wydatków</string>
<string name="settings">Ustawienia</string>
<string name="pick_trip">Wybierz wycieczkę</string>
<string name="list_of_expenses">Lista wydatków</string>
<string name="statistics">Statystyki</string>
<string name="export_to_csv">Eksport do CSV</string>
</resources>

View File

@@ -21,4 +21,10 @@
<string name="edit_trip">Edit trip</string>
<string name="edit_expense">Edit expense</string>
<string name="default_currency">Default currency</string>
<string name="total_expenses">Total expenses</string>
<string name="settings">Settings</string>
<string name="pick_trip">Pick trip</string>
<string name="list_of_expenses">List of expenses</string>
<string name="statistics">Statistics</string>
<string name="export_to_csv">Export to CSV</string>
</resources>

View File

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