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.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") }
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.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,
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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.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())
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 -> {
|
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(
|
||||||
|
|
Loading…
Reference in a new issue