diff --git a/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt b/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt index 842d719..97be39e 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt @@ -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") } ) diff --git a/android/src/main/java/com/wbrawner/budget/ui/RecurringTransactionsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/RecurringTransactionsScreen.kt deleted file mode 100644 index ae4c2b8..0000000 --- a/android/src/main/java/com/wbrawner/budget/ui/RecurringTransactionsScreen.kt +++ /dev/null @@ -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") - } -} diff --git a/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt index 816d8aa..9dc259c 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt @@ -36,9 +36,11 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -145,12 +147,17 @@ fun LoginForm( .fillMaxWidth() .focusRequester(serverInput) .onPreviewKeyEvent { - if (it.key == Key.Tab && !it.isShiftPressed) { - usernameInput.requestFocus() - true - } else { - false + if (it.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false } + if (it.key != Key.Tab) { + return@onPreviewKeyEvent false + } + if (it.isShiftPressed) { + return@onPreviewKeyEvent false + } + usernameInput.requestFocus() + true }, value = server, onValueChange = setServer, @@ -170,16 +177,18 @@ fun LoginForm( .fillMaxWidth() .focusRequester(usernameInput) .onPreviewKeyEvent { - if (it.key == Key.Tab) { - if (it.isShiftPressed) { - serverInput.requestFocus() - } else { - passwordInput.requestFocus() - } - true - } else { - false + if (it.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false } + if (it.key != Key.Tab) { + return@onPreviewKeyEvent false + } + if (it.isShiftPressed) { + serverInput.requestFocus() + } else { + passwordInput.requestFocus() + } + true }, value = username, onValueChange = setUsername, @@ -199,6 +208,9 @@ fun LoginForm( .fillMaxWidth() .focusRequester(passwordInput) .onPreviewKeyEvent { + if (it.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } when (it.key) { Key.Tab -> { if (it.isShiftPressed) { @@ -214,9 +226,7 @@ fun LoginForm( true } - else -> { - false - } + else -> false } }, value = password, diff --git a/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt b/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt index b0e101f..1476be8 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt @@ -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), diff --git a/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionDetailsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionDetailsScreen.kt new file mode 100644 index 0000000..4d0d312 --- /dev/null +++ b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionDetailsScreen.kt @@ -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") + ) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionForm.kt b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionForm.kt new file mode 100644 index 0000000..98afeef --- /dev/null +++ b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionForm.kt @@ -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?, + 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())) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionsScreen.kt new file mode 100644 index 0000000..21c53d8 --- /dev/null +++ b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionsScreen.kt @@ -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" + ) + ) {} + } +} \ No newline at end of file diff --git a/android/src/main/java/com/wbrawner/budget/ui/util/FrequencyPicker.kt b/android/src/main/java/com/wbrawner/budget/ui/util/FrequencyPicker.kt new file mode 100644 index 0000000..40f148d --- /dev/null +++ b/android/src/main/java/com/wbrawner/budget/ui/util/FrequencyPicker.kt @@ -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) { + +} \ No newline at end of file diff --git a/android/src/main/java/com/wbrawner/budget/ui/util/TimePicker.kt b/android/src/main/java/com/wbrawner/budget/ui/util/TimePicker.kt index 3bc0b30..1b404ff 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/util/TimePicker.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/util/TimePicker.kt @@ -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()) diff --git a/android/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml index 32a6bb5..ebd348e 100644 --- a/android/src/main/res/values/styles.xml +++ b/android/src/main/res/values/styles.xml @@ -7,11 +7,9 @@ @color/colorPrimaryDark @color/colorAccent @font/ubuntu - @color/colorBackgroundPrimary - @color/colorTextPrimary - @color/colorBackgroundPrimary + #00000000 @color/colorBackgroundPrimary - @color/colorBackgroundPrimary + #00000000 @style/DateTimePickerDialogTheme @style/DateTimePickerDialogTheme @style/DialogTheme diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dbefda5..0a99109 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt index dc452e5..088035d 100644 --- a/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt +++ b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt @@ -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) diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt index a2759ac..0ef20c9 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt @@ -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? = 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)" } } diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt index 3418148..409dfe7 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt @@ -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? = 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 + + 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? = null, categoryIds: List? = null, @@ -83,7 +98,6 @@ interface APIService { suspend fun deleteTransaction(id: String) - // Users suspend fun getUsers( budgetId: String? = null, count: Int? = null, diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt index cf57428..ed18d44 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt @@ -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 = 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( + path = "recurringtransactions/$id", + httpMethod = HttpMethod.Delete + ) + override suspend fun getUsers(budgetId: String?, count: Int?, page: Int?): List = 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) diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransaction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransaction.kt new file mode 100644 index 0000000..88248d9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransaction.kt @@ -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, + 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 { + 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 \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransactionAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransactionAction.kt new file mode 100644 index 0000000..0aa164a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransactionAction.kt @@ -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) : + 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 = 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.groupByStatus(): Map> { + val thisMonth = mutableListOf() + val future = mutableListOf() + val expired = mutableListOf() + 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>() + 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 +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransactionRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransactionRepository.kt new file mode 100644 index 0000000..ba057ce --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransactionRepository.kt @@ -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 { + suspend fun findAll( + budgetId: String, + ): List +} + +class NetworkRecurringTransactionRepository(private val apiService: APIService) : + RecurringTransactionRepository { + override suspend fun findAll( + budgetId: String, + ): List = apiService.getRecurringTransactions( + budgetId, + ) + + override suspend fun findAll(): List = 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) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt index 26e5595..240fae6 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt @@ -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(