This commit is contained in:
Rafal Wisniewski
2026-03-19 15:32:51 +01:00
commit 20370e3906
68 changed files with 3267 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
package cc.n0th1ng.tripmoney.screens
import android.graphics.drawable.Icon
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.aspectRatio
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.Colors
import cc.n0th1ng.tripmoney.utils.Icons
@Composable
fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
var name by remember { mutableStateOf("") }
var icon by remember { mutableStateOf(Icons.entries[0]) }
var color by remember { mutableStateOf(Colors.entries[0].hexString) }
AlertDialog(
onDismissRequest = onDismiss, title = { Text("Add new category") }, text = {
AlertDialogFill(
onTextChange = { newText ->
name = newText
},
onIconChange = { newIcon -> icon = newIcon },
onColorChange = {newColor -> color = newColor}
)
}, confirmButton = {
Button(
enabled = !name.isEmpty(),
onClick = {
onSave(
Category(
name = name,
icon = icon,
color = color
)
)
}) { Text("Save") }
},
dismissButton = {
Button(
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error),
onClick = onDismiss
) { Text("close") }
})
}
@Composable
fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Unit, onColorChange: (String) -> Unit) {
var text by remember { mutableStateOf("") }
var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) }
var colorHex by remember { mutableStateOf(Colors.entries[0].hexString) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
modifier = Modifier.size(30.dp),
painter = painterResource(iconId), contentDescription = null,
tint = Color(colorHex.toColorInt())
)
OutlinedTextField(label = { Text("Name") }, value = text, onValueChange = { newText ->
text = newText
onTextChange(text)
})
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.horizontalScroll(
rememberScrollState()
)
) {
Icons.entries.forEach { icon ->
Icon(
modifier = Modifier
.size(30.dp)
.clickable(onClick = {
iconId = icon.resource
onIconChange(icon)
}),
painter = painterResource(icon.resource),
contentDescription = null,
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.horizontalScroll(
rememberScrollState()
)
) {
Colors.entries.forEach { color ->
Box(
modifier = Modifier
.clickable(onClick = {
colorHex = color.hexString
onColorChange(colorHex)
})
.size(30.dp)
.aspectRatio(1f)
.background(Color(color.hexString.toColorInt()))
) {}
}
}
}
}

View File

@@ -0,0 +1,369 @@
package cc.n0th1ng.tripmoney.screens.addexpense
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.aspectRatio
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
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.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.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import java.time.LocalDateTime
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit,
onDismiss: () -> Unit,
settingsViewModel: SettingsViewModel,
categories: List<Category>,
expenseAndCategoryViewModel: ExpenseAndCategoryViewModel,
expenseDtoToEdit: ExpenseDto?
) {
val currentTripId by settingsViewModel.currentTrip.collectAsState()
var amount by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.amount?.toString() ?: "0.00"
)
}
var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoryDialog by remember { mutableStateOf(false) }
var currency by remember { mutableStateOf(expenseDtoToEdit?.expense?.currency ?: "PLN") }
var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }
var datetime by remember {
mutableStateOf(
LocalDateTime.parse(expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now().toString())
)
}
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,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp)
) {
Text(
text = amount.ifEmpty { "0.00" },
fontSize = 25.sp,
fontWeight = FontWeight.Bold
)
CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency)
}
Spacer(Modifier.height(14.dp))
DateTimePicker(
dateTime = datetime,
onChange = { datetime = it }
)
Spacer(Modifier.height(14.dp))
CategoryButton(onClick = { showCategoryDialog = true }, category = category)
Spacer(Modifier.height(14.dp))
Row(
modifier = Modifier.height(50.dp),
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)
)
}
)
}
Spacer(Modifier.height(14.dp))
NumberKeyboard(
onNumberClick = { number ->
val newText = (if (amount == "0.00") "" else amount) + number
if (newText.isDoubleTwoDigitsAboveZero()) {
amount = newText
enableSave = true
} else if (amount == "0.00") {
enableSave = false
}
},
onBackspaceClick = {
if (amount == "0.00") return@NumberKeyboard
amount = amount.safeSubstring(0, amount.length - 1)
enableSave = amount.isDoubleTwoDigitsAboveZero()
})
}
}
if (showCurrencyDialog) {
CurrencySelectionDialog(
onDismiss = { showCurrencyDialog = false },
onCurrencySelected = { selectedCurrency ->
showCurrencyDialog = false
currency = selectedCurrency
},
selected = currency,
listOfCurrencies = listOf("PLN", "EUR", "USD")
)
}
if (showCategoryDialog) {
CategorySelectionDialog(
onDismiss = { showCategoryDialog = false },
onCategorySelected = { selectedCategory ->
showCategoryDialog = false
category = selectedCategory
},
selected = category,
categories = categories,
settingsAndCategoryViewModel = expenseAndCategoryViewModel
)
}
}
fun String.safeSubstring(start: Int, end: Int): String {
return try {
this.substring(start, end)
} catch (e: Exception) {
"0.00"
}
}
fun String.isDoubleTwoDigitsAboveZero(): Boolean {
return this.toDoubleOrNull() != null && this.matches(Regex("^\\d*(\\.\\d{0,2})?$")) && this.toDouble() > 0
}
@Composable
fun NoteInput(note: String, onTextChange: (String) -> Unit) {
var text by remember { mutableStateOf(note) }
OutlinedTextField(
label = { Text(stringResource(R.string.note)) }, value = note, onValueChange = { newText ->
text = newText
onTextChange(text)
}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
@Composable
fun CurrencyButton(onClick: () -> Unit, text: String) {
OutlinedButton(onClick = onClick) {
Text(text)
}
}
@Composable
fun CategoryButton(onClick: () -> Unit, category: Category) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth(0.5f)
) {
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()))
}
}
@Composable
fun SaveButton(enabled: Boolean, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.save)
)
}
}
@Preview
@Composable
fun Preview() {
TripMoneyTheme(darkTheme = true) {
NumberKeyboard(onNumberClick = {}, onBackspaceClick = {})
}
}
@Composable
fun NumberKeyboard(
modifier: Modifier = Modifier,
onNumberClick: (String) -> Unit,
onBackspaceClick: () -> 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)
) {
OutlinedButton(
onClick = { onNumberClick("1") },
modifier = buttonModifier.weight(1f)
) {
Text("1", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("2") },
modifier = buttonModifier.weight(1f)
) {
Text("2", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("3") },
modifier = buttonModifier.weight(1f)
) {
Text("3", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
onClick = { onNumberClick("4") },
modifier = buttonModifier.weight(1f)
) {
Text("4", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("5") },
modifier = buttonModifier.weight(1f)
) {
Text("5", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("6") },
modifier = buttonModifier.weight(1f)
) {
Text("6", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
onClick = { onNumberClick("7") },
modifier = buttonModifier.weight(1f)
) {
Text("7", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("8") },
modifier = buttonModifier.weight(1f)
) {
Text("8", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("9") },
modifier = buttonModifier.weight(1f)
) {
Text("9", fontSize = 20.sp)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
OutlinedButton(
onClick = { onNumberClick(".") },
modifier = buttonModifier.weight(1f)
) {
Text(".", fontSize = 20.sp)
}
OutlinedButton(
onClick = { onNumberClick("0") },
modifier = buttonModifier.weight(1f)
) {
Text("0", fontSize = 20.sp)
}
OutlinedButton(
onClick = onBackspaceClick,
modifier = buttonModifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.backspace)
)
}
}
}
}

View File

@@ -0,0 +1,106 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import com.composables.icons.materialsymbols.outlined.R
@Composable
fun CategorySelectionDialog(
onDismiss: () -> Unit,
onCategorySelected: (Category) -> Unit,
selected: Category,
categories: List<Category>,
settingsAndCategoryViewModel: ExpenseAndCategoryViewModel
) {
val listState = rememberLazyListState()
var showAddCategoryDialog by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss, title = { Text(stringResource(string.pick_category)) }, text = {
Column {
LazyColumn(
modifier = Modifier.heightIn(max = 300.dp),
state = listState,
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(
count = categories.size,
key = { index -> categories[index].id }) { index ->
val category = categories[index]
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onCategorySelected(category)
}
.padding(vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selected == category, onClick = {
onCategorySelected(category)
})
Icon(
painter = painterResource(category.icon.resource),
contentDescription = stringResource(string.category),
tint = Color(category.color.toColorInt())
)
Text(
text = category.name, modifier = Modifier.padding(start = 8.dp),
color = Color(category.color.toColorInt())
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showAddCategoryDialog = true
}
.padding(top = 15.dp),
verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(R.drawable.materialsymbols_ic_add_outlined),
contentDescription = stringResource(string.category)
)
Text(
text = stringResource(string.add_new_category), modifier = Modifier.padding(start = 8.dp),
)
}
}
}, confirmButton = {})
if (showAddCategoryDialog) {
AddCategoryDialog(onDismiss = {
showAddCategoryDialog = false
}, onSave = { category ->
settingsAndCategoryViewModel.save(category)
showAddCategoryDialog = false
})
}
}

View File

@@ -0,0 +1,51 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cc.n0th1ng.tripmoney.R
@Composable
fun CurrencySelectionDialog(
onDismiss: () -> Unit,
onCurrencySelected: (String) -> Unit,
selected: String,
listOfCurrencies: List<String>
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pick_currency)) },
text = {
Column {
listOfCurrencies.forEach { currency ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onCurrencySelected(currency)
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selected == currency, onClick = {
onCurrencySelected(currency)
})
Text(
text = currency, modifier = Modifier.padding(start = 8.dp)
)
}
}
}
},
confirmButton = {})
}

View File

@@ -0,0 +1,108 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import cc.n0th1ng.tripmoney.R.*
import java.time.Instant
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
@RequiresApi(Build.VERSION_CODES.O)
@OptIn(ExperimentalMaterial3Api::class)
fun DateTimePicker(
dateTime: LocalDateTime = LocalDateTime.now(),
onChange: (LocalDateTime) -> Unit
) {
val datePickerState =
rememberDatePickerState(initialSelectedDateMillis = dateTime.toEpochMilli())
val timePickerState = rememberTimePickerState(
initialHour = dateTime.hour,
initialMinute = dateTime.minute
)
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
val formatter = DateTimeFormatter.ofPattern("dd.MM HH:mm")
OutlinedButton(onClick = { showDatePicker = true }) {
Text(text = dateTime.format(formatter), fontSize = 17.sp)
}
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
showDatePicker = false
val selectedMillis = datePickerState.selectedDateMillis
if (selectedMillis != null) {
val selectedDate = Instant.ofEpochMilli(selectedMillis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
// open time picker next
showTimePicker = true
onChange(
LocalDateTime.of(
selectedDate,
dateTime.toLocalTime()
)
)
}
}) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = {
showDatePicker = false
}) { Text(stringResource(string.cancel)) }
}
) {
DatePicker(state = datePickerState)
}
}
if (showTimePicker) {
AlertDialog(
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(onClick = {
showTimePicker = false
val newTime = LocalTime.of(timePickerState.hour, timePickerState.minute)
onChange(LocalDateTime.of(dateTime.toLocalDate(), newTime))
}) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = { showTimePicker = false }) { Text(stringResource(string.cancel)) }
},
text = { TimePicker(state = timePickerState) }
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun LocalDateTime.toEpochMilli(): Long =
this.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()

View File

@@ -0,0 +1,328 @@
package cc.n0th1ng.tripmoney.screens.listexpense
import android.annotation.SuppressLint
import android.os.Build
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.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.compose.collectAsLazyPagingItems
import cc.n0th1ng.tripmoney.R.*
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.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.getValue
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
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) }
var expenseDtoToEdit: ExpenseDto? = null
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showBottomSheet = true },
icon = { Icon(Icons.Filled.Add, stringResource(string.add_expense)) },
text = { Text(text = stringResource(string.add_expense)) },
)
})
{
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
state = listState
) {
items(
count = expenses.itemCount,
key = { index -> expenses[index]?.expense?.id ?: index }
) { index ->
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))
}
}
Spacer(Modifier.height(5.dp))
SwipeToDeleteExpenseCard(
expenseDto = expenseDto,
onDelete = { expense -> expenseAndCategoryViewModel.delete(expense) },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
})
}
}
}
if (showBottomSheet) {
AddExpenseBottomSheet(
onSave = { expense ->
expenseAndCategoryViewModel.save(expense)
showBottomSheet = false
expenseDtoToEdit = null
},
onDismiss = {
expenseDtoToEdit = null
showBottomSheet = false
},
settingsViewModel = settingsViewModel,
categories = categories,
expenseAndCategoryViewModel = expenseAndCategoryViewModel,
expenseDtoToEdit = expenseDtoToEdit
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun SwipeToDeleteExpenseCard(
expenseDto: ExpenseDto,
onDelete: (Expense) -> Unit,
onClick: (ExpenseDto) -> Unit
) {
var dismissed by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
if (!dismissed) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart
) {
showDialog = true
false
} else {
false
}
}
)
if (showDialog) {
DeleteConfirmationDialog(
onConfirm = {
showDialog = false
dismissed = true
onDelete(expenseDto.expense)
},
onCancel = { showDialog = false }
)
}
SwipeToDismissBox(
modifier = Modifier,
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
Box(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete))
}
}
) {
ExpenseCard(expenseDto, onClick = onClick)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeleteConfirmationDialog(
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
BasicAlertDialog(
onDismissRequest = { onCancel() }
) {
Column(
Modifier
.background(
MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium
)
.padding(24.dp)
) {
Text(
stringResource(string.delete_confirmation),
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp)
) {
Text(
text = stringResource(string.cancel),
modifier = Modifier
.padding(end = 24.dp)
.clickable { onCancel() }
)
Text(
text = stringResource(string.delete),
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable { onConfirm() }
)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth(0.9f)
.height(70.dp)
.clickable { onClick(expenseDto) },
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp),
modifier = Modifier.fillMaxHeight()
) {
Icon(
painter = painterResource(expenseDto.category.icon.resource),
contentDescription = "Category",
tint = Color(expenseDto.category.color.toColorInt())
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 8.dp)
) {
Column(
) {
Text(
text = expenseDto.category.name,
fontWeight = FontWeight.Bold,
lineHeight = 5.sp
)
Text(
modifier = Modifier.padding(0.dp),
text = expenseDto.expense.note,
fontSize = 11.sp,
lineHeight = 5.sp
)
}
Text(
text = LocalDateTime.parse(expenseDto.expense.datetime).format(
DateTimeFormatter.ofPattern("dd MMM HH:mm")
),
fontSize = 12.sp,
)
}
}
Column {
Text(
text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount),
fontWeight = FontWeight.Bold
)
if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) {
Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.amount),
fontSize = 12.sp
)
}
}
}
}
}

View File

@@ -0,0 +1,148 @@
package cc.n0th1ng.tripmoney.screens.settings
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun SettingsScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState()
var showDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Card {
SettingsListItem(onClick = { showDialog = true }, stringResource(string.theme)) {
Text(
if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme
)
)
}
}
if (showDialog) {
ThemeSelectionDialog(
onDismiss = { showDialog = false },
onThemeSelected = { theme ->
settingsViewModel.setTheme(theme)
showDialog = false
},
selected = currentTheme
)
}
}
}
@Composable
fun SettingsCard(@StringRes title: Int = -1, content: @Composable () -> Unit) {
Card {
if (title != -1) {
Text(
text = stringResource(title),
fontSize = 13.sp,
modifier = Modifier
.padding(start = 15.dp, top = 15.dp, end = 15.dp)
.alpha(0.6f)
)
}
content()
}
}
@Composable
fun SettingsListItem(
onClick: () -> Unit,
headlineText: String,
trailingContent: @Composable () -> Unit = {},
supportingContent: @Composable () -> Unit
) {
ListItem(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
headlineContent = { Text(headlineText) },
supportingContent = supportingContent,
trailingContent = trailingContent,
modifier = Modifier
.clickable(true, onClick = onClick)
)
}
@Composable
fun ThemeSelectionDialog(
onDismiss: () -> Unit,
onThemeSelected: (AppTheme) -> Unit,
selected: AppTheme
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(string.pick_theme)) },
text = {
Column {
AppTheme.entries.forEach { theme ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onThemeSelected(theme)
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected == theme,
onClick = {
onThemeSelected(theme)
}
)
Text(
text = when (theme) {
AppTheme.LIGHT -> stringResource(string.light_theme)
AppTheme.DARK -> stringResource(string.dark_theme)
AppTheme.SYSTEM -> stringResource(string.system_settings)
},
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
},
confirmButton = {}
)
}

View File

@@ -0,0 +1,9 @@
package cc.n0th1ng.tripmoney.screens.statistics
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun StatisticsScreen() {
Text("TODO")
}

View File

@@ -0,0 +1,101 @@
package cc.n0th1ng.tripmoney.screens.trippicker
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
@Composable
fun TripPickerScreen(
navController: NavController
) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
LazyColumn(
modifier = Modifier
.padding(horizontal = 15.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.Center
) {
items(trips.itemCount, trips.itemKey { it.id }) { i ->
Spacer(Modifier.height(10.dp))
val trip = trips[i]
if (trip != null) {
TripCard(trip, currentTripId == trip.id, onClick = {
settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
})
}
Spacer(Modifier.height(10.dp))
}
}
}
@Composable
fun TripCard(trip: Trip, isSelected: Boolean, onClick: () -> Unit) {
ElevatedCard(
modifier = Modifier
.height(100.dp)
.clickable(true, onClick = onClick)
.alpha(if (isSelected) 1.0f else 0.7f),
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.padding(16.dp)
) {
Text(fontSize = 25.sp, fontWeight = FontWeight.SemiBold, text = trip.name)
Text(trip.startDate)
}
Text(
trip.currency.uppercase(),
modifier = Modifier.padding(20.dp),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}