Add ability to create/edit categories

Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2023-01-31 20:52:39 -07:00
parent 7b1b088080
commit badafaffc7
12 changed files with 395 additions and 128 deletions

View file

@ -33,7 +33,13 @@ import kotlin.math.max
@Composable
fun CategoriesScreen(store: Store) {
val scrollState = rememberLazyListState()
TwigsScaffold(store = store, title = "Categories") {
TwigsScaffold(
store = store,
title = "Categories",
onClickFab = {
store.dispatch(CategoryAction.NewCategoryClicked)
}
) {
val state by store.state.collectAsState()
state.categories?.let { categories ->
LazyColumn(
@ -51,6 +57,9 @@ fun CategoriesScreen(store: Store) {
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
if (state.editingCategory) {
CategoryFormDialog(store = store)
}
}
}

View file

@ -21,12 +21,14 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.budget.ui.TwigsScaffold
import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.budget.ui.transaction.TransactionFormDialog
import com.wbrawner.budget.ui.transaction.TransactionListItem
import com.wbrawner.budget.ui.transaction.toCurrencyString
import com.wbrawner.budget.ui.util.format
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.shared.transaction.groupByDate
@ -38,7 +40,8 @@ import kotlin.math.abs
@Composable
fun CategoryDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val category = remember { state.categories!!.first { it.id == state.selectedCategory } }
val category =
remember(state.editingCategory) { state.categories!!.first { it.id == state.selectedCategory } }
TwigsScaffold(
store = store,
title = category.title,
@ -48,21 +51,27 @@ fun CategoryDetailsScreen(store: Store) {
}
},
actions = {
IconButton({ store.dispatch(TransactionAction.EditTransaction(requireNotNull(category.id))) }) {
IconButton({ store.dispatch(CategoryAction.EditCategory(requireNotNull(category.id))) }) {
Icon(Icons.Default.Edit, "Edit")
}
},
onClickFab = {
store.dispatch(TransactionAction.NewTransactionClicked)
}
) { padding ->
CategoryDetails(
modifier = Modifier.padding(padding),
category = category,
balance = state.categoryBalances!![category.id!!]!!,
balance = state.categoryBalances!![category.id!!] ?: 0,
transactions = state.transactions!!.filter { it.categoryId == category.id },
onTransactionClicked = { store.dispatch(TransactionAction.SelectTransaction(it.id)) }
)
// if (state.editingCategory) {
// CategoryFormDialog(store = store)
// }
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
if (state.editingCategory) {
CategoryFormDialog(store = store)
}
}
}

View file

@ -0,0 +1,258 @@
package com.wbrawner.budget.ui.category
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.budget.ui.transaction.toDecimalString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
import java.util.*
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CategoryFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(CategoryAction.CancelEditCategory) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
CategoryForm(store)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryForm(store: Store) {
val state by store.state.collectAsState()
val category = remember {
val defaultCategory = Category(
title = "",
amount = 0L,
expense = true,
budgetId = state.selectedBudget!!,
)
if (state.selectedCategory.isNullOrBlank()) {
defaultCategory
} else {
state.categories?.first { it.id == state.selectedCategory } ?: defaultCategory
}
}
val (title, setTitle) = remember { mutableStateOf(category.title) }
val (description, setDescription) = remember { mutableStateOf(category.description ?: "") }
val (amount, setAmount) = remember { mutableStateOf(category.amount.toDecimalString()) }
val (expense, setExpense) = remember { mutableStateOf(category.expense) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { store.dispatch(CategoryAction.CancelEditCategory) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
title = {
Text(if (category.id.isNullOrBlank()) "New Category" else "Edit Category")
}
)
}
) {
CategoryForm(
modifier = Modifier.padding(it),
title = title,
setTitle = setTitle,
description = description,
setDescription = setDescription,
amount = amount,
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
) {
store.dispatch(
category.id?.let { id ->
CategoryAction.UpdateCategory(
id = id,
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
expense = expense,
)
} ?: CategoryAction.CreateCategory(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
expense = expense,
)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun CategoryForm(
modifier: Modifier,
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
save: () -> Unit
) {
val scrollState = rememberScrollState()
val (titleInput, descriptionInput, amountInput) = FocusRequester.createRefs()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = spacedBy(8.dp, Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// if (error.isNotBlank()) {
// Text(text = error, color = Color.Red)
// }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && !it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = title,
onValueChange = setTitle,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
label = { Text("Title") },
keyboardActions = KeyboardActions(onNext = {
descriptionInput.requestFocus()
}),
maxLines = 1
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(descriptionInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab) {
if (it.isShiftPressed) {
titleInput.requestFocus()
} else {
amountInput.requestFocus()
}
true
} else {
false
}
},
value = description,
onValueChange = setDescription,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Next
),
label = { Text("Description") },
keyboardActions = KeyboardActions(onNext = {
amountInput.requestFocus()
}),
)
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(amountInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = amount,
onValueChange = setAmount,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
),
label = { Text("Amount") },
keyboardActions = KeyboardActions(onNext = {
keyboardController?.hide()
}),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.clickable {
setExpense(true)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = expense, onClick = { setExpense(true) })
Text(text = "Expense")
}
Row(
modifier = Modifier.clickable {
setExpense(false)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = !expense, onClick = { setExpense(false) })
Text(text = "Income")
}
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = save
) {
Text("Save")
}
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun CategoryForm_Preview() {
TwigsApp {
CategoryForm(store = Store(reducers = emptyList()))
}
}

View file

@ -37,7 +37,8 @@ import kotlinx.datetime.Clock
@Composable
fun TransactionDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val transaction = remember { state.transactions!!.first { it.id == state.selectedTransaction } }
val transaction =
remember(state.editingTransaction) { state.transactions!!.first { it.id == state.selectedTransaction } }
val createdBy = state.selectedTransactionCreatedBy ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()

View file

@ -133,4 +133,13 @@ fun TransactionListItem_Preview() {
fun Long.toCurrencyString(): String =
NumberFormat.getCurrencyInstance().format(this.toDouble() / 100.0)
fun Long.toDecimalString(): String = if (this > 0) (this.toDouble() / 100.0).toString() else ""
fun Long.toDecimalString(): String = if (this > 0) {
val decimal = (this.toDouble() / 100.0).toString()
if (decimal.length - decimal.lastIndexOf('.') == 2) {
decimal + '0'
} else {
decimal
}
} else {
""
}

View file

@ -16,3 +16,11 @@ interface Repository<T> {
interface Identifiable {
val id: String?
}
inline fun <T : Identifiable> MutableList<T>.replace(item: T) {
val index = indexOf(item)
if (index > -1) {
removeAt(index)
}
add(index.coerceAtLeast(0), item)
}

View file

@ -1,15 +1,16 @@
package com.wbrawner.twigs.shared.budget
import com.wbrawner.twigs.shared.Identifiable
import com.wbrawner.twigs.shared.user.UserPermission
import kotlinx.serialization.Serializable
@Serializable
data class Budget(
val id: String? = null,
override val id: String? = null,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
)
) : Identifiable
data class NewBudgetRequest(
val name: String,

View file

@ -6,6 +6,7 @@ import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.replace
import com.wbrawner.twigs.shared.user.ConfigAction
import com.wbrawner.twigs.shared.user.UserPermission
import kotlinx.coroutines.launch
@ -82,7 +83,7 @@ class BudgetReducer(
is BudgetAction.SaveBudgetSuccess -> {
val currentState = state()
val budgets = currentState.budgets?.toMutableList() ?: mutableListOf()
budgets.add(action.budget)
budgets.replace(action.budget)
budgets.sortBy { it.name }
currentState.copy(
loading = false,

View file

@ -1,14 +1,15 @@
package com.wbrawner.twigs.shared.category
import com.wbrawner.twigs.shared.Identifiable
import kotlinx.serialization.Serializable
@Serializable
data class Category(
val budgetId: String,
val id: String? = null,
val title: String,
val description: String? = null,
val amount: Long,
val expense: Boolean = true,
val archived: Boolean = false
)
val budgetId: String,
override val id: String? = null,
val title: String,
val description: String? = null,
val amount: Long,
val expense: Boolean = true,
val archived: Boolean = false
) : Identifiable

View file

@ -5,6 +5,7 @@ import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.replace
import kotlinx.coroutines.launch
sealed interface CategoryAction : Action {
@ -16,8 +17,11 @@ sealed interface CategoryAction : Action {
data class LoadCategoriesSuccess(val categories: List<Category>) : CategoryAction
data class LoadCategoriesFailed(val error: Exception) : CategoryAction
object NewCategoryClicked : CategoryAction
object CancelEditCategory : CategoryAction
data class CreateCategory(
val name: String,
val title: String,
val description: String? = null,
val amount: Long,
val expense: Boolean
@ -27,7 +31,7 @@ sealed interface CategoryAction : Action {
data class SaveCategoryFailure(
val id: String? = null,
val name: String,
val title: String,
val description: String? = null,
val amount: Long,
val expense: Boolean,
@ -38,14 +42,12 @@ sealed interface CategoryAction : Action {
data class SelectCategory(val id: String?) : CategoryAction
data class CategorySelected(val id: String) : CategoryAction
data class UpdateCategory(
val id: String,
val name: String,
val title: String,
val description: String? = null,
val amount: Long,
val expense: Boolean
val expense: Boolean,
) : CategoryAction
data class DeleteCategory(val id: String) : CategoryAction
@ -102,95 +104,62 @@ class CategoryReducer(private val categoryRepository: CategoryRepository) : Redu
budgetBalance = action.budgetBalance,
categoryBalances = action.categoryBalances
)
// is BudgetAction.CreateBudget -> {
// launch {
// val budget = budgetRepository.create(
// Budget(
// name = action.name,
// description = action.description,
// users = action.users
// )
// )
// dispatch(BudgetAction.SaveBudgetSuccess(budget))
// }
// state().copy(loading = true)
// }
// is BudgetAction.SaveBudgetSuccess -> {
// val currentState = state()
// val budgets = currentState.budgets?.toMutableList() ?: mutableListOf()
// budgets.add(action.budget)
// budgets.sortBy { it.name }
// currentState.copy(
// loading = false,
// budgets = budgets.toList(),
// selectedBudget = action.budget.id,
// editingBudget = false
// )
// }
// is ConfigAction.LoginSuccess -> {
// launch {
// try {
// val budgets = budgetRepository.findAll()
// dispatch(BudgetAction.LoadBudgetsSuccess(budgets))
// } catch (e: Exception) {
// dispatch(BudgetAction.LoadBudgetsFailed(e))
// }
// }
// state().copy(loading = true)
// }
// is ConfigAction.Logout -> state().copy(
// budgets = null,
// selectedBudget = null,
// editingBudget = false
// )
//// is BudgetAction.EditBudget -> state.copy(
//// editingBudget = true,
//// selectedBudget = action.id
//// )
// is BudgetAction.SelectBudget -> {
// val currentState = state()
// val budgetId = currentState.budgets
// ?.firstOrNull { it.id == action.id }
// ?.id
// ?: currentState.budgets?.firstOrNull()?.id
// settings[KEY_LAST_BUDGET] = budgetId
// dispatch(BudgetAction.BudgetSelected(budgetId!!))
// state()
// }
// is BudgetAction.BudgetSelected -> state().copy(selectedBudget = action.id)
//
//// is BudgetAction.UpdateBudget -> state.copy(loading = true).also {
//// dispatch(action.async())
//// }
//// is BudgetAction.DeleteBudget -> state.copy(loading = true).also {
//// dispatch(action.async())
//// }
////
//// is BudgetAsyncAction.UpdateBudgetAsync -> {
//// budgetRepository.update(
//// Budget(
//// id = action.id,
//// name = action.name,
//// description = action.description,
//// users = action.users
//// )
//// )
//// state().copy(
//// loading = false,
//// editingBudget = false,
//// )
//// }
//// is BudgetAsyncAction.DeleteBudgetAsync -> {
//// budgetRepository.delete(action.id)
//// val currentState = state()
//// val budgets = currentState.budgets?.filterNot { it.id == action.id }
//// currentState.copy(
//// loading = false,
//// budgets = budgets,
//// editingBudget = false,
//// selectedBudget = null
//// )
//// }
is CategoryAction.CancelEditCategory -> state().copy(editingCategory = false)
is CategoryAction.NewCategoryClicked -> state().copy(editingCategory = true)
is CategoryAction.CreateCategory -> {
val currentState = state()
val budgetId = requireNotNull(currentState.selectedBudget)
launch {
val category = categoryRepository.create(
Category(
title = action.title,
description = action.description,
amount = action.amount,
budgetId = budgetId
)
)
dispatch(CategoryAction.SaveCategorySuccess(category))
}
currentState.copy(loading = true)
}
is CategoryAction.SaveCategorySuccess -> {
val currentState = state()
val categories = currentState.categories?.toMutableList() ?: mutableListOf()
categories.replace(action.category)
categories.sortBy { it.title }
currentState.copy(
loading = false,
categories = categories.toList(),
selectedCategory = action.category.id,
editingCategory = false,
route = Route.Categories(action.category.id)
)
}
is CategoryAction.EditCategory -> state().copy(
editingCategory = true,
selectedCategory = action.id
)
is CategoryAction.UpdateCategory -> {
launch {
val oldCategory = categoryRepository.findById(action.id)
val category = categoryRepository.update(
oldCategory.copy(
title = action.title,
description = action.description,
amount = action.amount
)
)
dispatch(CategoryAction.SaveCategorySuccess(category))
}
state().copy(loading = true)
}
else -> state()
}
}

View file

@ -1,5 +1,6 @@
package com.wbrawner.twigs.shared.transaction
import com.wbrawner.twigs.shared.Identifiable
import kotlinx.datetime.Instant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
@ -11,17 +12,17 @@ import kotlinx.serialization.encoding.Encoder
@Serializable
data class Transaction(
val id: String? = null,
val title: String,
@Serializable(with = DateSerializer::class)
val date: Instant,
val description: String? = null,
val amount: Long,
val categoryId: String? = null,
val budgetId: String,
val expense: Boolean,
val createdBy: String
)
override val id: String? = null,
val title: String,
@Serializable(with = DateSerializer::class)
val date: Instant,
val description: String? = null,
val amount: Long,
val categoryId: String? = null,
val budgetId: String,
val expense: Boolean,
val createdBy: String
) : Identifiable
@Serializable
data class BalanceResponse(val balance: Long)

View file

@ -7,6 +7,7 @@ import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.replace
import com.wbrawner.twigs.shared.user.ConfigAction
import com.wbrawner.twigs.shared.user.User
import com.wbrawner.twigs.shared.user.UserPermission
@ -144,8 +145,7 @@ class TransactionReducer(
is TransactionAction.SaveTransactionSuccess -> {
val currentState = state()
val transactions = currentState.transactions?.toMutableList() ?: mutableListOf()
transactions.removeAll { it.id == action.transaction.id }
transactions.add(action.transaction)
transactions.replace(action.transaction)
transactions.sortByDescending { it.date }
currentState.copy(
loading = false,