WIP: Add support for Recurring Transactions

Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2023-01-31 20:07:57 -07:00
parent e86899b9ee
commit 7b1b088080
19 changed files with 1418 additions and 54 deletions

View file

@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@ -30,12 +31,15 @@ import com.wbrawner.budget.ui.auth.LoginScreen
import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.budget.ui.category.CategoriesScreen
import com.wbrawner.budget.ui.category.CategoryDetailsScreen
import com.wbrawner.budget.ui.recurringtransaction.RecurringTransactionDetailsScreen
import com.wbrawner.budget.ui.recurringtransaction.RecurringTransactionsScreen
import com.wbrawner.budget.ui.transaction.TransactionDetailsScreen
import com.wbrawner.budget.ui.transaction.TransactionsScreen
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@ -50,6 +54,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val state by store.state.collectAsState()
val navController = rememberNavController()
@ -89,9 +94,18 @@ class MainActivity : AppCompatActivity() {
) {
CategoryDetailsScreen(store = store)
}
// composable(Route.RECURRING_TRANSACTIONS.path) {
// RecurringTransactionsScreen(store = store)
// }
composable(Route.RecurringTransactions().path) {
RecurringTransactionsScreen(store = store)
}
composable(
Route.RecurringTransactions(selected = "{id}").path,
arguments = listOf(navArgument("id") {
type = NavType.StringType
nullable = false
})
) {
RecurringTransactionDetailsScreen(store = store)
}
}
}
}
@ -158,8 +172,8 @@ fun TwigsScaffold(
label = { Text(text = "Categories") }
)
NavigationBarItem(
selected = false,
onClick = { store.dispatch(BudgetAction.OverviewClicked) },
selected = state.route is Route.RecurringTransactions,
onClick = { store.dispatch(RecurringTransactionAction.RecurringTransactionsClicked) },
icon = { Icon(Icons.Default.Repeat, contentDescription = null) },
label = { Text(text = "Recurring") }
)

View file

@ -1,14 +0,0 @@
package com.wbrawner.budget.ui
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.wbrawner.twigs.shared.Store
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionsScreen(store: Store) {
TwigsScaffold(store = store, title = "Recurring Transactions") {
Text("Not yet implemented")
}
}

View file

@ -36,9 +36,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
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.input.key.type
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -145,12 +147,17 @@ fun LoginForm(
.fillMaxWidth()
.focusRequester(serverInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && !it.isShiftPressed) {
usernameInput.requestFocus()
true
} else {
false
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
if (it.key != Key.Tab) {
return@onPreviewKeyEvent false
}
if (it.isShiftPressed) {
return@onPreviewKeyEvent false
}
usernameInput.requestFocus()
true
},
value = server,
onValueChange = setServer,
@ -170,16 +177,18 @@ fun LoginForm(
.fillMaxWidth()
.focusRequester(usernameInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab) {
if (it.isShiftPressed) {
serverInput.requestFocus()
} else {
passwordInput.requestFocus()
}
true
} else {
false
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
if (it.key != Key.Tab) {
return@onPreviewKeyEvent false
}
if (it.isShiftPressed) {
serverInput.requestFocus()
} else {
passwordInput.requestFocus()
}
true
},
value = username,
onValueChange = setUsername,
@ -199,6 +208,9 @@ fun LoginForm(
.fillMaxWidth()
.focusRequester(passwordInput)
.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
when (it.key) {
Key.Tab -> {
if (it.isShiftPressed) {
@ -214,9 +226,7 @@ fun LoginForm(
true
}
else -> {
false
}
else -> false
}
},
value = password,

View file

@ -4,9 +4,12 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
@ -18,6 +21,9 @@ val lightColors = lightColorScheme(
primaryContainer = Green300,
secondary = Green700,
secondaryContainer = Green300,
background = Color.LightGray,
surface = Color.White,
surfaceVariant = Color.White
)
val darkColors = darkColorScheme(
@ -27,6 +33,7 @@ val darkColors = darkColorScheme(
secondaryContainer = Green700,
background = Color.Black,
surface = Color.Black,
surfaceVariant = Color.White.copy(alpha = 0.1f)
)
val ubuntu = FontFamily(
@ -41,7 +48,9 @@ val ubuntu = FontFamily(
@Composable
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (darkMode) darkColors else lightColors,
colorScheme = if (darkMode) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme(
LocalContext.current
),
typography = MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = ubuntu),

View file

@ -0,0 +1,155 @@
package com.wbrawner.budget.ui.recurringtransaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.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.budget.Budget
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.Time
import com.wbrawner.twigs.shared.user.User
import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val transaction =
remember { state.recurringTransactions!!.first { it.id == state.selectedRecurringTransaction } }
val createdBy = state.selectedRecurringTransactionCreatedBy ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val category = state.categories?.firstOrNull { it.id == transaction.categoryId }
val budget = state.budgets!!.first { it.id == transaction.budgetId }
TwigsScaffold(
store = store,
title = "Transaction Details",
navigationIcon = {
IconButton(onClick = { store.dispatch(Action.Back) }) {
Icon(Icons.Default.ArrowBack, "Go back")
}
},
actions = {
IconButton({
store.dispatch(
RecurringTransactionAction.EditRecurringTransaction(
requireNotNull(transaction.id)
)
)
}) {
Icon(Icons.Default.Edit, "Edit")
}
}
) { padding ->
RecurringTransactionDetails(
modifier = Modifier.padding(padding),
transaction = transaction,
category = category,
budget = budget,
createdBy = createdBy
)
if (state.editingTransaction) {
RecurringTransactionFormDialog(store = store)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionDetails(
modifier: Modifier = Modifier,
transaction: RecurringTransaction,
category: Category? = null,
budget: Budget,
createdBy: User
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxSize()
.scrollable(scrollState, Orientation.Vertical)
.padding(16.dp),
verticalArrangement = spacedBy(16.dp)
) {
Text(
text = transaction.title,
style = MaterialTheme.typography.headlineMedium
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall,
color = if (transaction.expense) Color.Red else Color.Green
)
}
LabeledField("Description", transaction.description ?: "")
LabeledField("Start", transaction.start.format(LocalContext.current))
transaction.finish?.let {
LabeledField("End", it.format(LocalContext.current))
}
LabeledField("Category", category?.title ?: "")
LabeledField("Created By", createdBy.username)
}
}
@Composable
fun LabeledField(label: String, field: String) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = spacedBy(4.dp)) {
Text(text = label, style = MaterialTheme.typography.bodySmall)
Text(text = field, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun TransactionDetails_Preview() {
TwigsApp {
RecurringTransactionDetails(
transaction = RecurringTransaction(
title = "DAZBOG",
description = "Chokolat Cappuccino",
frequency = Frequency.Daily(1, Time(9, 0, 0)),
start = Clock.System.now(),
amount = 550,
categoryId = "coffee",
budgetId = "budget",
createdBy = "user",
expense = true
),
category = Category(title = "Coffee", budgetId = "budget", amount = 1000),
budget = Budget(name = "Monthly Budget"),
createdBy = User(username = "user")
)
}
}

View file

@ -0,0 +1,359 @@
package com.wbrawner.budget.ui.recurringtransaction
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.budget.ui.util.DatePicker
import com.wbrawner.budget.ui.util.FrequencyPicker
import com.wbrawner.budget.ui.util.TimePicker
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.Time
import com.wbrawner.twigs.shared.recurringtransaction.time
import com.wbrawner.twigs.shared.transaction.TransactionAction
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.*
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun RecurringTransactionFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(TransactionAction.CancelEditTransaction) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
RecurringTransactionForm(store)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionForm(store: Store) {
val state by store.state.collectAsState()
val transaction = remember {
val defaultTransaction = RecurringTransaction(
title = "",
start = Clock.System.now(),
amount = 0L,
frequency = Frequency.Daily(1, Time(9, 0, 0)),
budgetId = state.selectedBudget!!,
categoryId = state.selectedCategory,
expense = true,
createdBy = state.user!!.id!!
)
if (state.selectedRecurringTransaction.isNullOrBlank()) {
defaultTransaction
} else {
state.recurringTransactions?.first { it.id == state.selectedRecurringTransaction }
?: defaultTransaction
}
}
val (title, setTitle) = remember { mutableStateOf(transaction.title) }
val (description, setDescription) = remember { mutableStateOf(transaction.description ?: "") }
val (frequency, setFrequency) = remember { mutableStateOf(transaction.frequency) }
val (start, setStart) = remember { mutableStateOf(transaction.start) }
val (end, setEnd) = remember { mutableStateOf(transaction.finish) }
val (amount, setAmount) = remember { mutableStateOf(transaction.amount.toDecimalString()) }
val (expense, setExpense) = remember { mutableStateOf(transaction.expense) }
val budget = remember { state.budgets!!.first { it.id == transaction.budgetId } }
val (category, setCategory) = remember { mutableStateOf(transaction.categoryId?.let { categoryId -> state.categories?.firstOrNull { it.id == categoryId } }) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { store.dispatch(TransactionAction.CancelEditTransaction) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
title = {
Text(if (transaction.id.isNullOrBlank()) "New Transaction" else "Edit Transaction")
}
)
}
) {
RecurringTransactionForm(
modifier = Modifier.padding(it),
title = title,
setTitle = setTitle,
description = description,
setDescription = setDescription,
frequency = frequency,
setFrequency = setFrequency,
start = start,
setStart = setStart,
end = end,
setEnd = setEnd,
amount = amount,
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
categories = state.categories?.filter { c -> c.expense == expense } ?: emptyList(),
category = category,
setCategory = setCategory
) {
store.dispatch(
transaction.id?.let { id ->
RecurringTransactionAction.UpdateRecurringTransaction(
id = id,
title = title,
amount = (amount.toDouble() * 100).toLong(),
frequency = frequency,
start = start,
end = end,
expense = expense,
category = category,
budget = budget
)
} ?: RecurringTransactionAction.CreateRecurringTransaction(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
frequency = frequency,
start = start,
end = end,
expense = expense,
category = category,
budget = budget
)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionForm(
modifier: Modifier,
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
frequency: Frequency,
setFrequency: (Frequency) -> Unit,
start: Instant,
setStart: (Instant) -> Unit,
end: Instant?,
setEnd: (Instant?) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
categories: List<Category>,
category: Category?,
setCategory: (Category?) -> 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()
}),
)
FrequencyPicker(frequency, setFrequency)
val (datePickerVisible, setDatePickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = start,
setDate = setStart,
dialogVisible = datePickerVisible,
setDialogVisible = setDatePickerVisible
)
val (timePickerVisible, setTimePickerVisible) = remember { mutableStateOf(false) }
TimePicker(
modifier = Modifier.fillMaxWidth(),
time = start.time(),
setTime = {
},
dialogVisible = timePickerVisible,
setDialogVisible = setTimePickerVisible
)
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")
}
}
val (categoriesExpanded, setCategoriesExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = categoriesExpanded,
onExpandedChange = setCategoriesExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = category?.title ?: "",
onValueChange = {},
readOnly = true,
label = {
Text("Category")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoriesExpanded)
}
)
ExposedDropdownMenu(expanded = categoriesExpanded, onDismissRequest = {
setCategoriesExpanded(false)
}) {
categories.forEach { c ->
DropdownMenuItem(
text = { Text(c.title) },
onClick = {
setCategory(c)
setCategoriesExpanded(false)
}
)
}
}
}
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 RecurringTransactionForm_Preview() {
TwigsApp {
RecurringTransactionForm(store = Store(reducers = emptyList()))
}
}

View file

@ -0,0 +1,138 @@
package com.wbrawner.budget.ui.recurringtransaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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.toCurrencyString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.groupByStatus
import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionsScreen(store: Store) {
TwigsScaffold(
store = store,
title = "Recurring Transactions",
// TODO: Implement RecurringTransaction creation/editing
// onClickFab = {
// store.dispatch(RecurringTransactionAction.NewRecurringTransactionClicked)
// }
) {
val state by store.state.collectAsState()
state.recurringTransactions?.let { transactions ->
val transactionGroups = remember { transactions.groupByStatus() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(horizontal = 8.dp)
) {
transactionGroups.forEach { (title, transactions) ->
item(title) {
Text(
modifier = Modifier.padding(8.dp),
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
item(transactions) {
Card {
transactions.forEach { transaction ->
RecurringTransactionListItem(transaction) {
store.dispatch(
RecurringTransactionAction.SelectRecurringTransaction(
transaction.id
)
)
}
}
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
// if (state.editingTransaction) {
// RecurringTransactionFormDialog(store = store)
// }
}
}
@Composable
fun RecurringTransactionListItem(
transaction: RecurringTransaction,
onClick: (RecurringTransaction) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(transaction) }
.padding(8.dp)
.heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = spacedBy(4.dp)
) {
Text(transaction.title, style = MaterialTheme.typography.bodyLarge)
if (!transaction.description.isNullOrBlank()) {
Text(
transaction.description!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
transaction.amount.toCurrencyString(),
color = if (transaction.expense) Color.Red else Color.Green,
)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun RecurringTransactionListItem_Preview() {
TwigsApp {
RecurringTransactionListItem(
transaction = RecurringTransaction(
title = "Google Store",
description = "Pixel 7 Pro",
frequency = Frequency.parse("Y;1;12-31;12:00:00"),
start = Clock.System.now(),
amount = 129999,
budgetId = "budgetId",
expense = true,
createdBy = "createdBy"
)
) {}
}
}

View file

@ -0,0 +1,9 @@
package com.wbrawner.budget.ui.util
import androidx.compose.runtime.Composable
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
@Composable
fun FrequencyPicker(frequency: Frequency, setFrequency: (Frequency) -> Unit) {
}

View file

@ -15,6 +15,8 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import com.wbrawner.twigs.shared.recurringtransaction.Time
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@ -87,5 +89,61 @@ fun TimePicker(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePicker(
modifier: Modifier,
time: Time,
setTime: (Time) -> Unit,
dialogVisible: Boolean,
setDialogVisible: (Boolean) -> Unit
) {
val context = LocalContext.current
OutlinedTextField(
modifier = modifier
.clickable {
setDialogVisible(true)
}
.focusRequester(FocusRequester())
.onFocusChanged {
setDialogVisible(it.hasFocus)
},
value = time.toString(),
onValueChange = {},
readOnly = true,
label = {
Text("Time")
}
)
val dialog = remember {
val localTime = Clock.System.now().toLocalDateTime(TimeZone.UTC)
TimePickerDialog(
context,
{ _, hour, minute -> setTime(Time(hour, minute, 0)) },
localTime.hour,
localTime.minute,
DateFormat.is24HourFormat(context)
).also { picker ->
picker.setOnDismissListener {
setDialogVisible(false)
}
}
}
DisposableEffect(key1 = dialogVisible) {
if (dialogVisible) {
context.fragmentManager?.let {
dialog.show()
}
} else if (dialog.isShowing) {
dialog.dismiss()
}
onDispose {
if (dialog.isShowing) {
dialog.dismiss()
}
}
}
}
fun Instant.formatTime(context: Context): String =
DateFormat.getTimeFormat(context).format(this.toEpochMilliseconds())

View file

@ -7,11 +7,9 @@
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="fontFamily">@font/ubuntu</item>
<item name="statusBarBackground">@color/colorBackgroundPrimary</item>
<item name="statusBarForeground">@color/colorTextPrimary</item>
<item name="android:statusBarColor">@color/colorBackgroundPrimary</item>
<item name="android:statusBarColor">#00000000</item>
<item name="android:windowBackground">@color/colorBackgroundPrimary</item>
<item name="android:navigationBarColor">@color/colorBackgroundPrimary</item>
<item name="android:navigationBarColor">#00000000</item>
<item name="android:timePickerDialogTheme">@style/DateTimePickerDialogTheme</item>
<item name="android:datePickerDialogTheme">@style/DateTimePickerDialogTheme</item>
<item name="android:dialogTheme">@style/DialogTheme</item>

View file

@ -9,7 +9,7 @@ compose-compiler = "1.3.2"
compose-material3 = "1.0.1"
espresso = "3.3.0"
hilt-android = "2.44"
kotlin = "1.8.0"
kotlin = "1.7.20"
kotlinx-serialization = "1.4.1"
kotlinx-coroutines = "1.6.4"
kotlinx-datetime = "0.4.0"

View file

@ -9,6 +9,8 @@ import com.wbrawner.twigs.shared.category.CategoryReducer
import com.wbrawner.twigs.shared.category.NetworkCategoryRepository
import com.wbrawner.twigs.shared.network.KtorAPIService
import com.wbrawner.twigs.shared.network.commonConfig
import com.wbrawner.twigs.shared.recurringtransaction.NetworkRecurringTransactionRepository
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionReducer
import com.wbrawner.twigs.shared.transaction.NetworkTransactionRepository
import com.wbrawner.twigs.shared.transaction.TransactionReducer
import com.wbrawner.twigs.shared.user.ConfigReducer
@ -25,6 +27,7 @@ val preferences = Settings()
val budgetRepository = NetworkBudgetRepository(apiService)
val categoryRepository = NetworkCategoryRepository(apiService)
val transactionRepository = NetworkTransactionRepository(apiService)
val recurringTransactionRepository = NetworkRecurringTransactionRepository(apiService)
val userRepository = NetworkUserRepository(apiService)
fun Store.Companion.create(
@ -32,6 +35,7 @@ fun Store.Companion.create(
BudgetReducer(budgetRepository, preferences),
CategoryReducer(categoryRepository),
ConfigReducer(apiService, preferences),
TransactionReducer(transactionRepository, userRepository)
TransactionReducer(transactionRepository, userRepository),
RecurringTransactionReducer(recurringTransactionRepository, userRepository)
),
) = Store(reducers)

View file

@ -2,6 +2,7 @@ package com.wbrawner.twigs.shared
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.User
import kotlinx.coroutines.CoroutineScope
@ -23,7 +24,9 @@ sealed class Route(val path: String) {
data class Categories(val selected: String? = null) :
Route(if (selected != null) "categories/${selected}" else "categories")
// data class RecurringTransactions(val selected: RecurringTransaction?): Route(if (selected != null) "transactions/${selected.id}" else "transactions")
data class RecurringTransactions(val selected: String? = null) :
Route(if (selected != null) "recurringtransactions/${selected}" else "recurringtransactions")
object Profile : Route("profile")
object Settings : Route("settings")
object About : Route("about")
@ -43,12 +46,16 @@ data class State(
val selectedTransaction: String? = null,
val selectedTransactionCreatedBy: User? = null,
val editingTransaction: Boolean = false,
val recurringTransactions: List<RecurringTransaction>? = null,
val selectedRecurringTransaction: String? = null,
val selectedRecurringTransactionCreatedBy: User? = null,
val editingRecurringTransaction: Boolean = false,
val loading: Boolean = false,
val route: Route = Route.Login,
val initialRoute: Route = Route.Login
) {
override fun toString(): String {
return "State(category=$selectedCategory, route=$route)"
return "State(recurringTransactionsSize=${recurringTransactions?.size}, route=$route)"
}
}

View file

@ -3,6 +3,7 @@ package com.wbrawner.twigs.shared.network
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.budget.NewBudgetRequest
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.transaction.BalanceResponse
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.LoginRequest
@ -21,7 +22,6 @@ interface APIService {
var baseUrl: String?
var authToken: String?
// Budgets
suspend fun getBudgets(
count: Int? = null,
page: Int? = null
@ -38,7 +38,6 @@ interface APIService {
suspend fun deleteBudget(id: String)
// Categories
suspend fun getCategories(
budgetIds: Array<String>? = null,
archived: Boolean? = false,
@ -57,7 +56,23 @@ interface APIService {
suspend fun deleteCategory(id: String)
// Transactions
suspend fun getRecurringTransactions(
budgetId: String,
count: Int? = null,
page: Int? = null
): List<RecurringTransaction>
suspend fun getRecurringTransaction(id: String): RecurringTransaction
suspend fun newRecurringTransaction(transaction: RecurringTransaction): RecurringTransaction
suspend fun updateRecurringTransaction(
id: String,
transaction: RecurringTransaction
): RecurringTransaction
suspend fun deleteRecurringTransaction(id: String)
suspend fun getTransactions(
budgetIds: List<String>? = null,
categoryIds: List<String>? = null,
@ -83,7 +98,6 @@ interface APIService {
suspend fun deleteTransaction(id: String)
// Users
suspend fun getUsers(
budgetId: String? = null,
count: Int? = null,

View file

@ -3,15 +3,24 @@ package com.wbrawner.twigs.shared.network
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.budget.NewBudgetRequest
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.transaction.BalanceResponse
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.LoginRequest
import com.wbrawner.twigs.shared.user.Session
import com.wbrawner.twigs.shared.user.User
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.headers
import io.ktor.client.request.parameter
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.URLBuilder
import io.ktor.http.contentType
import io.ktor.http.path
class KtorAPIService(
private val client: HttpClient
@ -122,6 +131,44 @@ class KtorAPIService(
httpMethod = HttpMethod.Delete
)
override suspend fun getRecurringTransactions(
budgetId: String,
count: Int?,
page: Int?
): List<RecurringTransaction> = request(
path = "recurringtransactions",
queryParams = listOf(
"budgetId" to budgetId,
"count" to count,
"page" to page,
)
)
override suspend fun getRecurringTransaction(id: String): RecurringTransaction =
request("recurringtransactions/$id")
override suspend fun newRecurringTransaction(transaction: RecurringTransaction): RecurringTransaction =
request(
path = "recurringtransactions",
body = transaction,
httpMethod = HttpMethod.Post
)
override suspend fun updateRecurringTransaction(
id: String,
transaction: RecurringTransaction
): RecurringTransaction =
request(
path = "recurringtransactions/$id",
body = transaction,
httpMethod = HttpMethod.Put
)
override suspend fun deleteRecurringTransaction(id: String) = request<Unit>(
path = "recurringtransactions/$id",
httpMethod = HttpMethod.Delete
)
override suspend fun getUsers(budgetId: String?, count: Int?, page: Int?): List<User> = request(
path = "users",
queryParams = listOf(
@ -177,7 +224,7 @@ class KtorAPIService(
}
queryParams?.forEach { (param, value) ->
value?.let {
when(it) {
when (it) {
is Array<*> -> parameter(param, it.joinToString(","))
is Iterable<*> -> parameter(param, it.joinToString(","))
else -> parameter(param, it)

View file

@ -0,0 +1,255 @@
package com.wbrawner.twigs.shared.recurringtransaction
import com.wbrawner.twigs.shared.startOfMonth
import kotlinx.datetime.Clock
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable
data class RecurringTransaction(
val id: String? = null,
val title: String,
val description: String? = null,
val frequency: Frequency,
val start: Instant,
val finish: Instant? = null,
val amount: Long,
val categoryId: String? = null,
val budgetId: String,
val expense: Boolean,
val createdBy: String
)
@Serializable(with = FrequencySerializer::class)
sealed class Frequency {
abstract val count: Int
abstract val time: Time
data class Daily(override val count: Int, override val time: Time) : Frequency() {
override fun toString(): String = "D;$count;$time"
companion object {
fun parse(s: String): Daily {
require(s[0] == 'D') { "Invalid format for Daily: $s" }
return with(s.split(';')) {
Daily(
get(1).toInt(),
Time.parse(get(2))
)
}
}
}
}
data class Weekly(
override val count: Int,
val daysOfWeek: Set<DayOfWeek>,
override val time: Time
) : Frequency() {
override fun toString(): String = "W;$count;${daysOfWeek.joinToString(",")};$time"
companion object {
fun parse(s: String): Weekly {
require(s[0] == 'W') { "Invalid format for Weekly: $s" }
return with(s.split(';')) {
Weekly(
get(1).toInt(),
get(2).split(',').map { DayOfWeek.valueOf(it) }.toSet(),
Time.parse(get(3))
)
}
}
}
}
data class Monthly(
override val count: Int,
val dayOfMonth: DayOfMonth,
override val time: Time
) : Frequency() {
override fun toString(): String = "M;$count;$dayOfMonth;$time"
companion object {
fun parse(s: String): Monthly {
require(s[0] == 'M') { "Invalid format for Monthly: $s" }
return with(s.split(';')) {
Monthly(
get(1).toInt(),
DayOfMonth.parse(get(2)),
Time.parse(get(3))
)
}
}
}
}
data class Yearly(override val count: Int, val dayOfYear: DayOfYear, override val time: Time) :
Frequency() {
override fun toString(): String =
"Y;$count;${dayOfYear.month.padStart(2, '0')}-${dayOfYear.day.padStart(2, '0')};$time"
companion object {
fun parse(s: String): Yearly {
require(s[0] == 'Y') { "Invalid format for Yearly: $s" }
return with(s.split(';')) {
Yearly(
get(1).toInt(),
DayOfYear.parse(get(2)),
Time.parse(get(3))
)
}
}
}
}
fun instant(now: Instant): Instant =
Instant.parse(now.toString().split("T")[0] + "T" + time.toString() + "Z")
companion object {
fun parse(s: String): Frequency = when (s[0]) {
'D' -> Daily.parse(s)
'W' -> Weekly.parse(s)
'M' -> Monthly.parse(s)
'Y' -> Yearly.parse(s)
else -> throw IllegalArgumentException("Invalid frequency format: $s")
}
}
}
sealed class DayOfMonth {
data class OrdinalDayOfMonth(val ordinal: Ordinal, val dayOfWeek: DayOfWeek) : DayOfMonth() {
override fun toString(): String = "${ordinal.name}-${dayOfWeek.name}"
}
data class FixedDayOfMonth(val day: Int) : DayOfMonth() {
override fun toString(): String = "DAY-$day"
}
companion object {
fun parse(s: String): DayOfMonth = with(s.split("-")) {
when (size) {
2 -> when (first()) {
"DAY" -> FixedDayOfMonth(get(1).toInt())
else -> OrdinalDayOfMonth(
Ordinal.valueOf(first()),
DayOfWeek.valueOf(get(1))
)
}
else -> throw IllegalArgumentException("Failed to parse DayOfMonth: $s")
}
}
}
}
enum class Ordinal {
FIRST,
SECOND,
THIRD,
FOURTH,
LAST
}
class DayOfYear private constructor(val month: Int, val day: Int) {
override fun toString(): String {
return "${month.padStart(2, '0')}-${day.padStart(2, '0')}"
}
companion object {
private fun maxDays(month: Int): Int = when (month) {
2 -> 29
4, 6, 9, 11 -> 30
else -> 31
}
fun of(month: Int, day: Int): DayOfYear {
require(month in 1..12) { "Invalid value for month: $month" }
require(day in 1..maxDays(month)) { "Invalid value for day: $day" }
return DayOfYear(month, day)
}
fun parse(s: String): DayOfYear {
val (month, day) = s.split("-").map { it.toInt() }
return of(month, day)
}
}
}
data class Time(val hours: Int, val minutes: Int, val seconds: Int) {
override fun toString(): String {
val s = StringBuilder()
if (hours < 10) {
s.append("0")
}
s.append(hours)
s.append(":")
if (minutes < 10) {
s.append("0")
}
s.append(minutes)
s.append(":")
if (seconds < 10) {
s.append("0")
}
s.append(seconds)
return s.toString()
}
companion object {
fun parse(s: String): Time {
require(s.length < 9) { "Invalid time format: $s. Time should be formatted as HH:mm:ss" }
require(s[2] == ':') { "Invalid time format: $s. Time should be formatted as HH:mm:ss" }
require(s[5] == ':') { "Invalid time format: $s. Time should be formatted as HH:mm:ss" }
return Time(
s.substring(0, 2).toInt(),
s.substring(3, 5).toInt(),
s.substring(7).toInt(),
)
}
}
}
fun Instant.time(): Time = with(toLocalDateTime(TimeZone.UTC).time) {
Time(hour, minute, second)
}
fun Int.padStart(length: Int, char: Char): String {
var stringValue = toString()
while (stringValue.length < length) stringValue = char + stringValue
return stringValue
}
object FrequencySerializer : KSerializer<Frequency> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Frequency", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Frequency) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Frequency = Frequency.parse(decoder.decodeString())
}
// TODO: This needs to take into account the last run date, which isn't currently returned by the
// server
val RecurringTransaction.isThisMonth: Boolean
get() = !isExpired && when (frequency) {
is Frequency.Daily -> true
is Frequency.Weekly -> true
is Frequency.Monthly -> true
is Frequency.Yearly -> Clock.System.now()
.toLocalDateTime(TimeZone.UTC).monthNumber == frequency.dayOfYear.month
}
val RecurringTransaction.isExpired: Boolean
get() = finish != null && startOfMonth() > finish

View file

@ -0,0 +1,268 @@
package com.wbrawner.twigs.shared.recurringtransaction
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Effect
import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Route
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.user.ConfigAction
import com.wbrawner.twigs.shared.user.User
import com.wbrawner.twigs.shared.user.UserPermission
import com.wbrawner.twigs.shared.user.UserRepository
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
sealed interface RecurringTransactionAction : Action {
object RecurringTransactionsClicked : RecurringTransactionAction
data class LoadRecurringTransactionsSuccess(val transactions: List<RecurringTransaction>) :
RecurringTransactionAction
data class LoadRecurringTransactionsFailed(val error: Exception) : RecurringTransactionAction
object NewRecurringTransactionClicked : RecurringTransactionAction
data class CreateRecurringTransaction(
val title: String,
val description: String? = null,
val amount: Long,
val frequency: Frequency,
val start: Instant,
val end: Instant? = null,
val expense: Boolean,
val category: Category? = null,
val budget: Budget,
) : RecurringTransactionAction
data class SaveRecurringTransactionSuccess(val transaction: RecurringTransaction) :
RecurringTransactionAction
data class SaveRecurringTransactionFailure(
val id: String? = null,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList(),
val error: Exception
) : RecurringTransactionAction
object CancelEditRecurringTransaction : RecurringTransactionAction
data class EditRecurringTransaction(val id: String) : RecurringTransactionAction
data class SelectRecurringTransaction(val id: String?) : RecurringTransactionAction
data class RecurringTransactionSelected(
val transaction: RecurringTransaction,
val createdBy: User
) :
RecurringTransactionAction
data class UpdateRecurringTransaction(
val id: String,
val title: String,
val description: String? = null,
val amount: Long,
val frequency: Frequency,
val start: Instant,
val end: Instant? = null,
val expense: Boolean,
val category: Category? = null,
val budget: Budget,
) : RecurringTransactionAction
data class DeleteRecurringTransaction(val id: String) : RecurringTransactionAction
}
class RecurringTransactionReducer(
private val recurringTransactionRepository: RecurringTransactionRepository,
private val userRepository: UserRepository,
) : Reducer() {
override fun reduce(action: Action, state: () -> State): State = when (action) {
is Action.Back -> {
val currentState = state()
currentState.copy(
editingTransaction = false,
selectedRecurringTransaction = if (currentState.editingRecurringTransaction) currentState.selectedRecurringTransaction else null,
route = if (currentState.route is Route.RecurringTransactions && !currentState.route.selected.isNullOrBlank() && !currentState.editingRecurringTransaction) {
Route.RecurringTransactions()
} else {
currentState.route
}
)
}
is RecurringTransactionAction.RecurringTransactionsClicked -> state().copy(
route = Route.RecurringTransactions(
null
)
)
is RecurringTransactionAction.LoadRecurringTransactionsSuccess -> state().copy(
recurringTransactions = action.transactions
)
is RecurringTransactionAction.LoadRecurringTransactionsFailed -> state().copy(loading = false)
.also {
emit(Effect.Error(action.error.message ?: "Failed to load recurring transactions"))
}
is RecurringTransactionAction.NewRecurringTransactionClicked -> state().copy(
editingRecurringTransaction = true
)
is RecurringTransactionAction.CancelEditRecurringTransaction -> {
val currentState = state()
currentState.copy(
editingTransaction = false,
selectedRecurringTransaction = if (currentState.route is Route.RecurringTransactions && !currentState.route.selected.isNullOrBlank()) {
currentState.selectedRecurringTransaction
} else {
null
}
)
}
is RecurringTransactionAction.CreateRecurringTransaction -> {
launch {
val transaction = recurringTransactionRepository.create(
RecurringTransaction(
title = action.title,
description = action.description,
amount = action.amount,
frequency = action.frequency,
start = action.start,
finish = action.end,
expense = action.expense,
categoryId = action.category?.id,
budgetId = action.budget.id!!,
createdBy = state().user!!.id!!
)
)
dispatch(RecurringTransactionAction.SaveRecurringTransactionSuccess(transaction))
}
state().copy(loading = true)
}
is RecurringTransactionAction.UpdateRecurringTransaction -> {
val createdBy = state().selectedRecurringTransactionCreatedBy!!
launch {
val transaction = recurringTransactionRepository.update(
RecurringTransaction(
id = action.id,
title = action.title,
description = action.description,
amount = action.amount,
frequency = action.frequency,
start = action.start,
finish = action.end,
expense = action.expense,
categoryId = action.category?.id,
budgetId = action.budget.id!!,
createdBy = createdBy.id!!
)
)
dispatch(RecurringTransactionAction.SaveRecurringTransactionSuccess(transaction))
}
state().copy(loading = true)
}
is RecurringTransactionAction.SaveRecurringTransactionSuccess -> {
val currentState = state()
val transactions =
currentState.recurringTransactions?.toMutableList() ?: mutableListOf()
transactions.removeAll { it.id == action.transaction.id }
transactions.add(action.transaction)
transactions.sortBy { it.title }
currentState.copy(
loading = false,
recurringTransactions = transactions.toList(),
selectedRecurringTransaction = action.transaction.id,
selectedRecurringTransactionCreatedBy = currentState.user,
editingTransaction = false
)
}
is BudgetAction.BudgetSelected -> {
launch {
try {
val transactions = recurringTransactionRepository.findAll(budgetId = action.id)
dispatch(
RecurringTransactionAction.LoadRecurringTransactionsSuccess(
transactions
)
)
} catch (e: Exception) {
e.printStackTrace()
dispatch(RecurringTransactionAction.LoadRecurringTransactionsFailed(e))
}
}
state().copy(recurringTransactions = null)
}
is ConfigAction.Logout -> state().copy(
transactions = null,
selectedRecurringTransaction = null,
editingTransaction = false
)
is RecurringTransactionAction.EditRecurringTransaction -> state().copy(
editingTransaction = true,
selectedRecurringTransaction = action.id
)
is RecurringTransactionAction.SelectRecurringTransaction -> {
launch {
val currentState = state()
val transaction = currentState.recurringTransactions!!.first { it.id == action.id }
val createdBy = userRepository.findById(transaction.createdBy)
dispatch(
RecurringTransactionAction.RecurringTransactionSelected(
transaction,
createdBy
)
)
}
state().copy(
loading = true,
selectedRecurringTransaction = action.id,
route = Route.RecurringTransactions(action.id)
)
}
is RecurringTransactionAction.RecurringTransactionSelected -> state().copy(
selectedRecurringTransaction = action.transaction.id,
selectedRecurringTransactionCreatedBy = action.createdBy
)
else -> state()
}
}
fun List<RecurringTransaction>.groupByStatus(): Map<String, List<RecurringTransaction>> {
val thisMonth = mutableListOf<RecurringTransaction>()
val future = mutableListOf<RecurringTransaction>()
val expired = mutableListOf<RecurringTransaction>()
forEach { transaction ->
if (transaction.isThisMonth) {
println("Adding ${transaction.title} to this month. end=${transaction.finish}")
thisMonth.add(transaction)
} else if (!transaction.isExpired) {
println("Adding ${transaction.title} to future. end=${transaction.finish}")
future.add(transaction)
} else {
println("Adding ${transaction.title} to expired. end=${transaction.finish}")
expired.add(transaction)
}
}
val groups = mutableMapOf<String, List<RecurringTransaction>>()
if (thisMonth.isNotEmpty()) {
groups["This Month"] = thisMonth.sortedBy { it.title }
}
if (future.isNotEmpty()) {
groups["Future"] = future.sortedBy { it.title }
}
if (expired.isNotEmpty()) {
groups["Expired"] = expired.sortedBy { it.title }
}
return groups
}

View file

@ -0,0 +1,32 @@
package com.wbrawner.twigs.shared.recurringtransaction
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.network.APIService
interface RecurringTransactionRepository : Repository<RecurringTransaction> {
suspend fun findAll(
budgetId: String,
): List<RecurringTransaction>
}
class NetworkRecurringTransactionRepository(private val apiService: APIService) :
RecurringTransactionRepository {
override suspend fun findAll(
budgetId: String,
): List<RecurringTransaction> = apiService.getRecurringTransactions(
budgetId,
)
override suspend fun findAll(): List<RecurringTransaction> = TODO("Not yet implemented")
override suspend fun create(newItem: RecurringTransaction): RecurringTransaction =
apiService.newRecurringTransaction(newItem)
override suspend fun findById(id: String): RecurringTransaction =
apiService.getRecurringTransaction(id)
override suspend fun update(updatedItem: RecurringTransaction): RecurringTransaction =
apiService.updateRecurringTransaction(updatedItem.id!!, updatedItem)
override suspend fun delete(id: String) = apiService.deleteTransaction(id)
}

View file

@ -144,6 +144,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.sortByDescending { it.date }
currentState.copy(