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.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost 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.base.TwigsApp
import com.wbrawner.budget.ui.category.CategoriesScreen import com.wbrawner.budget.ui.category.CategoriesScreen
import com.wbrawner.budget.ui.category.CategoryDetailsScreen 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.TransactionDetailsScreen
import com.wbrawner.budget.ui.transaction.TransactionsScreen import com.wbrawner.budget.ui.transaction.TransactionsScreen
import com.wbrawner.twigs.shared.Route import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.Store import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetAction import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.category.CategoryAction import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.transaction.TransactionAction import com.wbrawner.twigs.shared.transaction.TransactionAction
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -50,6 +54,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
installSplashScreen() installSplashScreen()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
val state by store.state.collectAsState() val state by store.state.collectAsState()
val navController = rememberNavController() val navController = rememberNavController()
@ -89,9 +94,18 @@ class MainActivity : AppCompatActivity() {
) { ) {
CategoryDetailsScreen(store = store) CategoryDetailsScreen(store = store)
} }
// composable(Route.RECURRING_TRANSACTIONS.path) { composable(Route.RecurringTransactions().path) {
// RecurringTransactionsScreen(store = store) 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") } label = { Text(text = "Categories") }
) )
NavigationBarItem( NavigationBarItem(
selected = false, selected = state.route is Route.RecurringTransactions,
onClick = { store.dispatch(BudgetAction.OverviewClicked) }, onClick = { store.dispatch(RecurringTransactionAction.RecurringTransactionsClicked) },
icon = { Icon(Icons.Default.Repeat, contentDescription = null) }, icon = { Icon(Icons.Default.Repeat, contentDescription = null) },
label = { Text(text = "Recurring") } 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.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key 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.isShiftPressed
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@ -145,12 +147,17 @@ fun LoginForm(
.fillMaxWidth() .fillMaxWidth()
.focusRequester(serverInput) .focusRequester(serverInput)
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (it.key == Key.Tab && !it.isShiftPressed) { if (it.type != KeyEventType.KeyDown) {
usernameInput.requestFocus() return@onPreviewKeyEvent false
true
} else {
false
} }
if (it.key != Key.Tab) {
return@onPreviewKeyEvent false
}
if (it.isShiftPressed) {
return@onPreviewKeyEvent false
}
usernameInput.requestFocus()
true
}, },
value = server, value = server,
onValueChange = setServer, onValueChange = setServer,
@ -170,16 +177,18 @@ fun LoginForm(
.fillMaxWidth() .fillMaxWidth()
.focusRequester(usernameInput) .focusRequester(usernameInput)
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (it.key == Key.Tab) { if (it.type != KeyEventType.KeyDown) {
if (it.isShiftPressed) { return@onPreviewKeyEvent false
serverInput.requestFocus()
} else {
passwordInput.requestFocus()
}
true
} else {
false
} }
if (it.key != Key.Tab) {
return@onPreviewKeyEvent false
}
if (it.isShiftPressed) {
serverInput.requestFocus()
} else {
passwordInput.requestFocus()
}
true
}, },
value = username, value = username,
onValueChange = setUsername, onValueChange = setUsername,
@ -199,6 +208,9 @@ fun LoginForm(
.fillMaxWidth() .fillMaxWidth()
.focusRequester(passwordInput) .focusRequester(passwordInput)
.onPreviewKeyEvent { .onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
when (it.key) { when (it.key) {
Key.Tab -> { Key.Tab -> {
if (it.isShiftPressed) { if (it.isShiftPressed) {
@ -214,9 +226,7 @@ fun LoginForm(
true true
} }
else -> { else -> false
false
}
} }
}, },
value = password, value = password,

View file

@ -4,9 +4,12 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color 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.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
@ -18,6 +21,9 @@ val lightColors = lightColorScheme(
primaryContainer = Green300, primaryContainer = Green300,
secondary = Green700, secondary = Green700,
secondaryContainer = Green300, secondaryContainer = Green300,
background = Color.LightGray,
surface = Color.White,
surfaceVariant = Color.White
) )
val darkColors = darkColorScheme( val darkColors = darkColorScheme(
@ -27,6 +33,7 @@ val darkColors = darkColorScheme(
secondaryContainer = Green700, secondaryContainer = Green700,
background = Color.Black, background = Color.Black,
surface = Color.Black, surface = Color.Black,
surfaceVariant = Color.White.copy(alpha = 0.1f)
) )
val ubuntu = FontFamily( val ubuntu = FontFamily(
@ -41,7 +48,9 @@ val ubuntu = FontFamily(
@Composable @Composable
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme( MaterialTheme(
colorScheme = if (darkMode) darkColors else lightColors, colorScheme = if (darkMode) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme(
LocalContext.current
),
typography = MaterialTheme.typography.copy( typography = MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu), displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu),
displayMedium = MaterialTheme.typography.displayMedium.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.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.wbrawner.twigs.shared.recurringtransaction.Time
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime 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 = fun Instant.formatTime(context: Context): String =
DateFormat.getTimeFormat(context).format(this.toEpochMilliseconds()) DateFormat.getTimeFormat(context).format(this.toEpochMilliseconds())

View file

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

View file

@ -9,7 +9,7 @@ compose-compiler = "1.3.2"
compose-material3 = "1.0.1" compose-material3 = "1.0.1"
espresso = "3.3.0" espresso = "3.3.0"
hilt-android = "2.44" hilt-android = "2.44"
kotlin = "1.8.0" kotlin = "1.7.20"
kotlinx-serialization = "1.4.1" kotlinx-serialization = "1.4.1"
kotlinx-coroutines = "1.6.4" kotlinx-coroutines = "1.6.4"
kotlinx-datetime = "0.4.0" 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.category.NetworkCategoryRepository
import com.wbrawner.twigs.shared.network.KtorAPIService import com.wbrawner.twigs.shared.network.KtorAPIService
import com.wbrawner.twigs.shared.network.commonConfig 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.NetworkTransactionRepository
import com.wbrawner.twigs.shared.transaction.TransactionReducer import com.wbrawner.twigs.shared.transaction.TransactionReducer
import com.wbrawner.twigs.shared.user.ConfigReducer import com.wbrawner.twigs.shared.user.ConfigReducer
@ -25,6 +27,7 @@ val preferences = Settings()
val budgetRepository = NetworkBudgetRepository(apiService) val budgetRepository = NetworkBudgetRepository(apiService)
val categoryRepository = NetworkCategoryRepository(apiService) val categoryRepository = NetworkCategoryRepository(apiService)
val transactionRepository = NetworkTransactionRepository(apiService) val transactionRepository = NetworkTransactionRepository(apiService)
val recurringTransactionRepository = NetworkRecurringTransactionRepository(apiService)
val userRepository = NetworkUserRepository(apiService) val userRepository = NetworkUserRepository(apiService)
fun Store.Companion.create( fun Store.Companion.create(
@ -32,6 +35,7 @@ fun Store.Companion.create(
BudgetReducer(budgetRepository, preferences), BudgetReducer(budgetRepository, preferences),
CategoryReducer(categoryRepository), CategoryReducer(categoryRepository),
ConfigReducer(apiService, preferences), ConfigReducer(apiService, preferences),
TransactionReducer(transactionRepository, userRepository) TransactionReducer(transactionRepository, userRepository),
RecurringTransactionReducer(recurringTransactionRepository, userRepository)
), ),
) = Store(reducers) ) = 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.budget.Budget
import com.wbrawner.twigs.shared.category.Category 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.transaction.Transaction
import com.wbrawner.twigs.shared.user.User import com.wbrawner.twigs.shared.user.User
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -23,7 +24,9 @@ sealed class Route(val path: String) {
data class Categories(val selected: String? = null) : data class Categories(val selected: String? = null) :
Route(if (selected != null) "categories/${selected}" else "categories") 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 Profile : Route("profile")
object Settings : Route("settings") object Settings : Route("settings")
object About : Route("about") object About : Route("about")
@ -43,12 +46,16 @@ data class State(
val selectedTransaction: String? = null, val selectedTransaction: String? = null,
val selectedTransactionCreatedBy: User? = null, val selectedTransactionCreatedBy: User? = null,
val editingTransaction: Boolean = false, 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 loading: Boolean = false,
val route: Route = Route.Login, val route: Route = Route.Login,
val initialRoute: Route = Route.Login val initialRoute: Route = Route.Login
) { ) {
override fun toString(): String { 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.Budget
import com.wbrawner.twigs.shared.budget.NewBudgetRequest import com.wbrawner.twigs.shared.budget.NewBudgetRequest
import com.wbrawner.twigs.shared.category.Category 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.BalanceResponse
import com.wbrawner.twigs.shared.transaction.Transaction import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.LoginRequest import com.wbrawner.twigs.shared.user.LoginRequest
@ -21,7 +22,6 @@ interface APIService {
var baseUrl: String? var baseUrl: String?
var authToken: String? var authToken: String?
// Budgets
suspend fun getBudgets( suspend fun getBudgets(
count: Int? = null, count: Int? = null,
page: Int? = null page: Int? = null
@ -38,7 +38,6 @@ interface APIService {
suspend fun deleteBudget(id: String) suspend fun deleteBudget(id: String)
// Categories
suspend fun getCategories( suspend fun getCategories(
budgetIds: Array<String>? = null, budgetIds: Array<String>? = null,
archived: Boolean? = false, archived: Boolean? = false,
@ -57,7 +56,23 @@ interface APIService {
suspend fun deleteCategory(id: String) 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( suspend fun getTransactions(
budgetIds: List<String>? = null, budgetIds: List<String>? = null,
categoryIds: List<String>? = null, categoryIds: List<String>? = null,
@ -83,7 +98,6 @@ interface APIService {
suspend fun deleteTransaction(id: String) suspend fun deleteTransaction(id: String)
// Users
suspend fun getUsers( suspend fun getUsers(
budgetId: String? = null, budgetId: String? = null,
count: Int? = 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.Budget
import com.wbrawner.twigs.shared.budget.NewBudgetRequest import com.wbrawner.twigs.shared.budget.NewBudgetRequest
import com.wbrawner.twigs.shared.category.Category 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.BalanceResponse
import com.wbrawner.twigs.shared.transaction.Transaction import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.LoginRequest import com.wbrawner.twigs.shared.user.LoginRequest
import com.wbrawner.twigs.shared.user.Session import com.wbrawner.twigs.shared.user.Session
import com.wbrawner.twigs.shared.user.User import com.wbrawner.twigs.shared.user.User
import io.ktor.client.* import io.ktor.client.HttpClient
import io.ktor.client.call.* import io.ktor.client.call.body
import io.ktor.client.request.* import io.ktor.client.request.headers
import io.ktor.http.* 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( class KtorAPIService(
private val client: HttpClient private val client: HttpClient
@ -122,6 +131,44 @@ class KtorAPIService(
httpMethod = HttpMethod.Delete 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( override suspend fun getUsers(budgetId: String?, count: Int?, page: Int?): List<User> = request(
path = "users", path = "users",
queryParams = listOf( queryParams = listOf(
@ -177,7 +224,7 @@ class KtorAPIService(
} }
queryParams?.forEach { (param, value) -> queryParams?.forEach { (param, value) ->
value?.let { value?.let {
when(it) { when (it) {
is Array<*> -> parameter(param, it.joinToString(",")) is Array<*> -> parameter(param, it.joinToString(","))
is Iterable<*> -> parameter(param, it.joinToString(",")) is Iterable<*> -> parameter(param, it.joinToString(","))
else -> parameter(param, it) 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 -> { is TransactionAction.SaveTransactionSuccess -> {
val currentState = state() val currentState = state()
val transactions = currentState.transactions?.toMutableList() ?: mutableListOf() val transactions = currentState.transactions?.toMutableList() ?: mutableListOf()
transactions.removeAll { it.id == action.transaction.id }
transactions.add(action.transaction) transactions.add(action.transaction)
transactions.sortByDescending { it.date } transactions.sortByDescending { it.date }
currentState.copy( currentState.copy(