WIP: Add support for Recurring Transactions
Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
parent
e86899b9ee
commit
7b1b088080
19 changed files with 1418 additions and 54 deletions
|
@ -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") }
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
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
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
value = server,
|
||||
onValueChange = setServer,
|
||||
|
@ -170,16 +177,18 @@ fun LoginForm(
|
|||
.fillMaxWidth()
|
||||
.focusRequester(usernameInput)
|
||||
.onPreviewKeyEvent {
|
||||
if (it.key == Key.Tab) {
|
||||
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
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
) {}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue