Too much to split into individual commits:

- updated theme
- changed title to budget name on all main tab pages
- various fixes to state handling

Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2023-03-05 10:33:13 -07:00
parent c23c16ab40
commit ed4fd514f1
22 changed files with 717 additions and 166 deletions

View file

@ -1,6 +1,7 @@
package com.wbrawner.budget.ui
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
@ -35,6 +36,7 @@ import com.wbrawner.budget.ui.recurringtransaction.RecurringTransactionDetailsSc
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.Action
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetAction
@ -63,6 +65,9 @@ class MainActivity : AppCompatActivity() {
}
TwigsApp {
val authViewModel: AuthViewModel = hiltViewModel()
BackHandler {
store.dispatch(Action.Back)
}
NavHost(navController, state.initialRoute.path) {
composable(Route.Login.path) {
LoginScreen(store = store, viewModel = authViewModel)

View file

@ -1,16 +1,35 @@
package com.wbrawner.budget.ui
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.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.budget.ui.base.TwigsColors
import com.wbrawner.budget.ui.base.TwigsTheme
import com.wbrawner.budget.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store
@ -18,22 +37,173 @@ import com.wbrawner.twigs.shared.Store
@Composable
fun OverviewScreen(store: Store) {
val state by store.state.collectAsState()
TwigsScaffold(store = store, title = "Overview") { padding ->
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(store = store, title = budget?.name ?: "Select a Budget") { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.padding(padding)
.scrollable(rememberScrollState(), orientation = Orientation.Vertical)
.padding(8.dp),
verticalArrangement = spacedBy(8.dp, alignment = Alignment.Top)
) {
budget?.let { budget ->
Text(budget.name)
Text(budget.description ?: "")
budget?.description?.let { description ->
if (description.isNotBlank()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
Text(description, style = MaterialTheme.typography.titleMedium)
}
Text("Cash Flow")
Text(state.budgetBalance?.toCurrencyString() ?: "-")
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
LabeledField(
label = "Cash Flow",
value = state.budgetBalance?.toCurrencyString() ?: "-"
)
LabeledField(
label = "Transactions",
value = state.transactions?.size?.toString() ?: "-"
)
}
}
CashFlowChart(
expectedIncome = state.expectedIncome,
actualIncome = state.actualIncome,
expectedExpenses = state.expectedExpenses,
actualExpenses = state.actualExpenses
)
}
}
}
@Composable
fun CashFlowChart(
expectedIncome: Long?,
actualIncome: Long?,
expectedExpenses: Long?,
actualExpenses: Long?,
) {
val maxValue = listOfNotNull(expectedIncome, expectedExpenses, actualIncome, actualExpenses)
.maxOrNull()
?.toFloat()
?: 0f
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
CashFlowProgressBar(
label = "Expected Income",
value = expectedIncome,
maxValue = maxValue,
color = TwigsColors.DarkGreen,
trackColor = MaterialTheme.colorScheme.outline
)
CashFlowProgressBar(
label = "Actual Income",
value = actualIncome,
maxValue = maxValue,
color = TwigsColors.Green,
trackColor = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(4.dp))
CashFlowProgressBar(
label = "Expected Expenses",
value = expectedExpenses,
maxValue = maxValue,
color = TwigsColors.DarkRed,
trackColor = MaterialTheme.colorScheme.outline
)
CashFlowProgressBar(
label = "Actual Expenses",
value = actualExpenses,
maxValue = maxValue,
color = TwigsColors.Red,
trackColor = MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
fun CashFlowProgressBar(
label: String,
value: Long?,
maxValue: Float,
color: Color,
trackColor: Color
) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = spacedBy(4.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label)
Text(value?.toCurrencyString() ?: "-")
}
value?.let {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
progress = it.toFloat() / maxValue,
color = color,
trackColor = trackColor,
)
} ?: LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = color,
trackColor = trackColor,
)
}
}
@Composable
fun LabeledField(label: String, value: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(4.dp)
) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = value, style = MaterialTheme.typography.bodyLarge)
}
}
@Preview
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun CashFlowChart_Preview() {
TwigsTheme {
CashFlowChart(
expectedIncome = 100,
actualIncome = 50,
expectedExpenses = 80,
actualExpenses = 95
)
}
}
@Preview
@Composable
fun LabeledField_Preview() {
TwigsTheme {
LabeledField(
label = "Transactions",
value = "250"
)
}
}

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -34,7 +35,6 @@ 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.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
@ -140,7 +140,7 @@ fun LoginForm(
)
Text("Log in to manage your budgets")
if (error.isNotBlank()) {
Text(text = error, color = Color.Red)
Text(text = error, color = MaterialTheme.colorScheme.error)
}
TextField(
modifier = Modifier

View file

@ -1,5 +1,7 @@
package com.wbrawner.budget.ui.base
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val Green300 = Color(0xFF81C784)
@ -11,3 +13,82 @@ val Red500 = Color(0xFFF44336)
val Red700 = Color(0xFFD32F2F)
val Red900 = Color(0xFFB71C1C)
object TwigsColors {
val Green
@Composable
get() = if (isSystemInDarkTheme()) Green300 else Green700
val DarkGreen
@Composable
get() = if (isSystemInDarkTheme()) Green500 else Green900
val Red
@Composable
get() = if (isSystemInDarkTheme()) Red300 else Red700
val DarkRed
@Composable
get() = if (isSystemInDarkTheme()) Red500 else Red900
}
val md_theme_light_primary = Color(0xFF006E26)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFF6CFF82)
val md_theme_light_onPrimaryContainer = Color(0xFF002106)
val md_theme_light_secondary = Color(0xFF526350)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFD5E8D0)
val md_theme_light_onSecondaryContainer = Color(0xFF101F10)
val md_theme_light_tertiary = Color(0xFF39656B)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFBCEBF2)
val md_theme_light_onTertiaryContainer = Color(0xFF001F23)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFCFDF7)
val md_theme_light_onBackground = Color(0xFF1A1C19)
val md_theme_light_surface = Color(0xFFFCFDF7)
val md_theme_light_onSurface = Color(0xFF1A1C19)
val md_theme_light_surfaceVariant = Color(0xFFDEE5D9)
val md_theme_light_onSurfaceVariant = Color(0xFF424940)
val md_theme_light_outline = Color(0xFF72796F)
val md_theme_light_inverseOnSurface = Color(0xFFF0F1EB)
val md_theme_light_inverseSurface = Color(0xFF2F312D)
val md_theme_light_inversePrimary = Color(0xFF47E266)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF006E26)
val md_theme_light_outlineVariant = Color(0xFFC2C9BD)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF47E266)
val md_theme_dark_onPrimary = Color(0xFF003910)
val md_theme_dark_primaryContainer = Color(0xFF00531A)
val md_theme_dark_onPrimaryContainer = Color(0xFF6CFF82)
val md_theme_dark_secondary = Color(0xFFB9CCB4)
val md_theme_dark_onSecondary = Color(0xFF243424)
val md_theme_dark_secondaryContainer = Color(0xFF3A4B39)
val md_theme_dark_onSecondaryContainer = Color(0xFFD5E8D0)
val md_theme_dark_tertiary = Color(0xFFA1CED5)
val md_theme_dark_onTertiary = Color(0xFF00363C)
val md_theme_dark_tertiaryContainer = Color(0xFF1F4D53)
val md_theme_dark_onTertiaryContainer = Color(0xFFBCEBF2)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1A1C19)
val md_theme_dark_onBackground = Color(0xFFE2E3DD)
val md_theme_dark_surface = Color(0xFF1A1C19)
val md_theme_dark_onSurface = Color(0xFFE2E3DD)
val md_theme_dark_surfaceVariant = Color(0xFF424940)
val md_theme_dark_onSurfaceVariant = Color(0xFFC2C9BD)
val md_theme_dark_outline = Color(0xFF8C9388)
val md_theme_dark_inverseOnSurface = Color(0xFF1A1C19)
val md_theme_dark_inverseSurface = Color(0xFFE2E3DD)
val md_theme_dark_inversePrimary = Color(0xFF006E26)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF47E266)
val md_theme_dark_outlineVariant = Color(0xFF424940)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF30D158)

View file

@ -4,36 +4,77 @@ 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
import androidx.compose.ui.text.font.FontWeight
import com.wbrawner.budget.R
val lightColors = lightColorScheme(
primary = Green500,
primaryContainer = Green300,
secondary = Green700,
secondaryContainer = Green300,
background = Color.LightGray,
surface = Color.White,
surfaceVariant = Color.White
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
val darkColors = darkColorScheme(
primary = Green300,
primaryContainer = Green500,
secondary = Green500,
secondaryContainer = Green700,
background = Color.Black,
surface = Color.Black,
surfaceVariant = Color.White.copy(alpha = 0.1f)
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
val ubuntu = FontFamily(
@ -48,9 +89,7 @@ val ubuntu = FontFamily(
@Composable
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (darkMode) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme(
LocalContext.current
),
colorScheme = if (darkMode) DarkColors else LightColors,
typography = MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = ubuntu),

View file

@ -2,13 +2,12 @@ package com.wbrawner.budget.ui.category
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.util.Log
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.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@ -17,14 +16,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.category.groupByType
import com.wbrawner.twigs.shared.recurringtransaction.capitalizedName
import java.util.*
import kotlin.math.abs
import kotlin.math.max
@ -32,28 +36,40 @@ import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoriesScreen(store: Store) {
val scrollState = rememberLazyListState()
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = "Categories",
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(CategoryAction.NewCategoryClicked)
}
) {
val state by store.state.collectAsState()
state.categories?.let { categories ->
LazyColumn(
val categoryGroups = categories.groupByType()
Column(
modifier = Modifier
.fillMaxSize()
.padding(it),
state = scrollState
.fillMaxWidth()
.padding(it)
.verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
items(categories, key = { c -> c.id!! }) { category ->
categoryGroups.toSortedMap().forEach { (group, c) ->
Text(
modifier = Modifier.padding(8.dp),
text = group.capitalizedName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Card {
c.forEach { category ->
CategoryListItem(category, state.categoryBalances?.get(category.id!!)) {
store.dispatch(CategoryAction.SelectCategory(category.id))
}
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@ -76,21 +92,32 @@ fun CategoryListItem(category: Category, balance: Long?, onClick: (Category) ->
Column(
modifier = Modifier.weight(1f),
verticalArrangement = spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(category.title, style = MaterialTheme.typography.bodyLarge)
balance?.let {
Text(
(category.amount - abs(it)).toCurrencyString() + " remaining",
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(8.dp))
balance?.let {
val denominator = remember { max(abs(it), abs(category.amount)).toFloat() }
val progress =
remember { if (denominator == 0f) 0f else abs(it).toFloat() / denominator }
Log.d(
"Twigs",
"Category ${category.title} amount: $denominator balance: $it progress: $progress"
)
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp)),
progress = progress,
color = if (category.expense) Color.Red else Color.Green
color = if (category.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
trackColor = Color.LightGray
)
} ?: LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}

View file

@ -70,6 +70,7 @@ fun CategoryForm(store: Store) {
val (description, setDescription) = remember { mutableStateOf(category.description ?: "") }
val (amount, setAmount) = remember { mutableStateOf(category.amount.toDecimalString()) }
val (expense, setExpense) = remember { mutableStateOf(category.expense) }
val (archived, setArchived) = remember { mutableStateOf(category.archived) }
Scaffold(
topBar = {
TopAppBar(
@ -94,6 +95,8 @@ fun CategoryForm(store: Store) {
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
archived = archived,
setArchived = setArchived
) {
store.dispatch(
category.id?.let { id ->
@ -103,12 +106,14 @@ fun CategoryForm(store: Store) {
description = description,
amount = (amount.toDouble() * 100).toLong(),
expense = expense,
archived = archived
)
} ?: CategoryAction.CreateCategory(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
expense = expense,
archived = archived
)
)
}
@ -127,6 +132,8 @@ fun CategoryForm(
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
archived: Boolean,
setArchived: (Boolean) -> Unit,
save: () -> Unit
) {
val scrollState = rememberScrollState()
@ -239,6 +246,15 @@ fun CategoryForm(
Text(text = "Income")
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { setArchived(!archived) },
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = archived, onCheckedChange = { setArchived(!archived) })
Text("Archived")
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = save

View file

@ -17,7 +17,6 @@ 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
@ -109,7 +108,7 @@ fun RecurringTransactionDetails(
Text(
text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall,
color = if (transaction.expense) Color.Red else Color.Green
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
LabeledField("Description", transaction.description ?: "")

View file

@ -5,7 +5,8 @@ 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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
@ -17,7 +18,6 @@ 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
@ -34,32 +34,32 @@ import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionsScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = "Recurring Transactions",
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(RecurringTransactionAction.NewRecurringTransactionClicked)
}
) {
val state by store.state.collectAsState()
state.recurringTransactions?.let { transactions ->
val transactionGroups = remember { transactions.groupByStatus() }
LazyColumn(
val transactionGroups =
remember(state.editingRecurringTransaction) { transactions.groupByStatus() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(horizontal = 8.dp)
.verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
transactionGroups.forEach { (title, transactions) ->
item(title) {
Text(
modifier = Modifier.padding(8.dp),
text = title,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
item(transactions) {
Card {
transactions.forEach { transaction ->
RecurringTransactionListItem(transaction) {
@ -73,7 +73,6 @@ fun RecurringTransactionsScreen(store: Store) {
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@ -111,7 +110,7 @@ fun RecurringTransactionListItem(
}
Text(
transaction.amount.toCurrencyString(),
color = if (transaction.expense) Color.Red else Color.Green,
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
}

View file

@ -9,15 +9,16 @@ 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.Delete
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.mutableStateOf
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.budget.ui.TwigsScaffold
@ -46,6 +47,7 @@ fun TransactionDetailsScreen(store: Store) {
}
val category = state.categories?.firstOrNull { it.id == transaction.categoryId }
val budget = state.budgets!!.first { it.id == transaction.budgetId }
val (confirmDeletionShown, setConfirmDeletionShown) = remember { mutableStateOf(false) }
TwigsScaffold(
store = store,
@ -59,6 +61,9 @@ fun TransactionDetailsScreen(store: Store) {
IconButton({ store.dispatch(TransactionAction.EditTransaction(requireNotNull(transaction.id))) }) {
Icon(Icons.Default.Edit, "Edit")
}
IconButton({ setConfirmDeletionShown(true) }) {
Icon(Icons.Default.Delete, "Delete")
}
}
) { padding ->
TransactionDetails(
@ -71,6 +76,35 @@ fun TransactionDetailsScreen(store: Store) {
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
if (confirmDeletionShown) {
AlertDialog(
text = {
Text("Are you sure you want to delete this transaction?")
},
onDismissRequest = { setConfirmDeletionShown(false) },
confirmButton = {
TextButton(onClick = {
setConfirmDeletionShown(false)
store.dispatch(
TransactionAction.DeleteTransaction(
requireNotNull(
transaction.id
)
)
)
}) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = {
setConfirmDeletionShown(false)
}) {
Text("Cancel")
}
}
)
}
}
}
@ -107,7 +141,7 @@ fun TransactionDetails(
Text(
text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall,
color = if (transaction.expense) Color.Red else Color.Green
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
}
LabeledField("Description", transaction.description ?: "")

View file

@ -73,13 +73,18 @@ fun TransactionForm(store: Store) {
state.transactions?.first { it.id == state.selectedTransaction } ?: defaultTransaction
}
}
val (title, setTitle) = remember { mutableStateOf(transaction.title) }
val (description, setDescription) = remember { mutableStateOf(transaction.description ?: "") }
val (date, setDate) = remember { mutableStateOf(transaction.date) }
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 } }) }
val (title, setTitle) = remember(state.editingTransaction) { mutableStateOf(transaction.title) }
val (description, setDescription) = remember(state.editingTransaction) {
mutableStateOf(
transaction.description ?: ""
)
}
val (date, setDate) = remember(state.editingTransaction) { mutableStateOf(transaction.date) }
val (amount, setAmount) = remember(state.editingTransaction) { mutableStateOf(transaction.amount.toDecimalString()) }
val (expense, setExpense) = remember(state.editingTransaction) { mutableStateOf(transaction.expense) }
val budget =
remember(state.editingTransaction) { state.budgets!!.first { it.id == transaction.budgetId } }
val (category, setCategory) = remember(state.editingTransaction) { mutableStateOf(transaction.categoryId?.let { categoryId -> state.categories?.firstOrNull { it.id == categoryId } }) }
Scaffold(
topBar = {
TopAppBar(

View file

@ -5,7 +5,8 @@ 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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
@ -17,7 +18,6 @@ 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.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@ -36,32 +36,32 @@ import java.text.NumberFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionsScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = "Transactions",
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(TransactionAction.NewTransactionClicked)
}
) {
val state by store.state.collectAsState()
state.transactions?.let { transactions ->
val transactionGroups = remember { transactions.groupByDate() }
LazyColumn(
val transactionGroups =
remember(state.editingTransaction) { transactions.groupByDate() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(horizontal = 8.dp)
.verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
transactionGroups.forEach { (timestamp, transactions) ->
item(timestamp) {
Text(
modifier = Modifier.padding(8.dp),
text = timestamp.toInstant().format(LocalContext.current),
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
item(transactions) {
Card {
transactions.forEach { transaction ->
TransactionListItem(transaction) {
@ -71,7 +71,6 @@ fun TransactionsScreen(store: Store) {
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@ -106,7 +105,7 @@ fun TransactionListItem(transaction: Transaction, onClick: (Transaction) -> Unit
}
Text(
transaction.amount.toCurrencyString(),
color = if (transaction.expense) Color.Red else Color.Green,
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
}

View file

@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
sealed class Route(val path: String) {
object Welcome : Route("welcome")
@ -36,6 +37,10 @@ data class State(
val user: User? = null,
val budgets: List<Budget>? = null,
val budgetBalance: Long? = null,
val actualIncome: Long? = null,
val actualExpenses: Long? = null,
val expectedIncome: Long? = null,
val expectedExpenses: Long? = null,
val selectedBudget: String? = null,
val editingBudget: Boolean = false,
val categories: List<Category>? = null,
@ -46,6 +51,8 @@ data class State(
val selectedTransaction: String? = null,
val selectedTransactionCreatedBy: User? = null,
val editingTransaction: Boolean = false,
val from: Instant = startOfMonth(),
val to: Instant = endOfMonth(),
val recurringTransactions: List<RecurringTransaction>? = null,
val selectedRecurringTransaction: String? = null,
val selectedRecurringTransactionCreatedBy: User? = null,
@ -53,11 +60,7 @@ data class State(
val loading: Boolean = false,
val route: Route = Route.Login,
val initialRoute: Route = Route.Login
) {
override fun toString(): String {
return "State(recurringTransactionsSize=${recurringTransactions?.size}, route=$route)"
}
}
)
interface Action {
object AboutClicked : Action

View file

@ -7,6 +7,7 @@ import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.replace
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.shared.user.ConfigAction
import com.wbrawner.twigs.shared.user.UserPermission
import kotlinx.coroutines.launch
@ -127,6 +128,17 @@ class BudgetReducer(
is BudgetAction.BudgetSelected -> state().copy(selectedBudget = action.id)
is TransactionAction.LoadTransactionsSuccess -> {
val balance = action.transactions.sumOf {
if (it.expense) {
it.amount * -1
} else {
it.amount
}
}
state().copy(budgetBalance = balance)
}
// is BudgetAction.UpdateBudget -> state.copy(loading = true).also {
// dispatch(action.async())
// }

View file

@ -2,9 +2,10 @@ package com.wbrawner.twigs.shared.budget
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.network.APIService
import kotlinx.datetime.Instant
interface BudgetRepository : Repository<Budget> {
suspend fun getBalance(id: String): Long
suspend fun getBalance(id: String, from: Instant, to: Instant): Long
}
class NetworkBudgetRepository(private val apiService: APIService) : BudgetRepository {
@ -19,6 +20,6 @@ class NetworkBudgetRepository(private val apiService: APIService) : BudgetReposi
override suspend fun delete(id: String) = apiService.deleteBudget(id)
override suspend fun getBalance(id: String): Long =
apiService.sumTransactions(budgetId = id).balance
override suspend fun getBalance(id: String, from: Instant, to: Instant): Long =
apiService.sumTransactions(budgetId = id, from = from, to = to).balance
}

View file

@ -6,13 +6,16 @@ import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.replace
import com.wbrawner.twigs.shared.transaction.TransactionAction
import kotlinx.coroutines.launch
import kotlin.math.abs
sealed interface CategoryAction : Action {
object CategoriesClicked : CategoryAction
data class BalancesCalculated(
val budgetBalance: Long,
val categoryBalances: Map<String, Long>
val categoryBalances: Map<String, Long>,
val actualIncome: Long,
val actualExpenses: Long
) : CategoryAction
data class LoadCategoriesSuccess(val categories: List<Category>) : CategoryAction
@ -24,7 +27,8 @@ sealed interface CategoryAction : Action {
val title: String,
val description: String? = null,
val amount: Long,
val expense: Boolean
val expense: Boolean,
val archived: Boolean,
) : CategoryAction
data class SaveCategorySuccess(val category: Category) : CategoryAction
@ -35,6 +39,7 @@ sealed interface CategoryAction : Action {
val description: String? = null,
val amount: Long,
val expense: Boolean,
val archived: Boolean,
val error: Exception
) : CategoryAction
@ -48,6 +53,7 @@ sealed interface CategoryAction : Action {
val description: String? = null,
val amount: Long,
val expense: Boolean,
val archived: Boolean,
) : CategoryAction
data class DeleteCategory(val id: String) : CategoryAction
@ -78,7 +84,14 @@ class CategoryReducer(private val categoryRepository: CategoryRepository) : Redu
dispatch(CategoryAction.LoadCategoriesFailed(e))
}
}
state().copy(categories = null)
state().copy(
categories = null,
selectedCategory = null,
editingCategory = false,
categoryBalances = null,
actualIncome = null,
actualExpenses = null
)
}
is CategoryAction.SelectCategory -> state().copy(
@ -86,23 +99,69 @@ class CategoryReducer(private val categoryRepository: CategoryRepository) : Redu
route = Route.Categories(action.id)
).also { newState -> println("Category selected state update: $newState") }
is CategoryAction.LoadCategoriesSuccess -> state().copy(categories = action.categories)
is CategoryAction.LoadCategoriesSuccess -> {
var expectedIncome = 0L
var expectedExpenses = 0L
action.categories.forEach { category ->
if (category.archived) return@forEach
if (category.expense) {
expectedExpenses += category.amount
} else {
expectedIncome += category.amount
}
}
val currentState = state()
val defaultCategoryBalances =
action.categories.associate { it.id!! to 0L }.toMutableMap()
val categoryBalances: Map<String, Long>? = currentState.categoryBalances?.let {
defaultCategoryBalances.apply {
putAll(it)
}
}
state().copy(
categories = action.categories,
categoryBalances = categoryBalances,
expectedExpenses = expectedExpenses,
expectedIncome = expectedIncome
)
}
is TransactionAction.LoadTransactionsSuccess -> state()
.also {
launch {
var budgetBalance = 0L
val categoryBalances = mutableMapOf<String, Long>()
action.categories.forEach { category ->
val balance = categoryRepository.getBalance(category.id!!)
categoryBalances[category.id] = balance
budgetBalance += balance
val categoryBalances =
it.categories?.associate { it.id!! to 0L }?.toMutableMap()
?: mutableMapOf()
var actualIncome = 0L
var actualExpenses = 0L
action.transactions.forEach { transaction ->
val category = transaction.categoryId
var balance = category?.let { categoryBalances[it] ?: 0L } ?: 0L
if (transaction.expense) {
balance -= transaction.amount
actualExpenses += abs(transaction.amount)
} else {
balance += transaction.amount
actualIncome += transaction.amount
}
dispatch(CategoryAction.BalancesCalculated(budgetBalance, categoryBalances))
category?.let {
categoryBalances[it] = balance
}
}
dispatch(
CategoryAction.BalancesCalculated(
categoryBalances,
actualIncome = actualIncome,
actualExpenses = actualExpenses
)
)
}
}
is CategoryAction.BalancesCalculated -> state().copy(
budgetBalance = action.budgetBalance,
categoryBalances = action.categoryBalances
categoryBalances = action.categoryBalances,
actualExpenses = action.actualExpenses,
actualIncome = action.actualIncome
)
is CategoryAction.CancelEditCategory -> state().copy(editingCategory = false)
@ -163,3 +222,26 @@ class CategoryReducer(private val categoryRepository: CategoryRepository) : Redu
else -> state()
}
}
enum class CategoryGroup {
INCOME,
EXPENSE,
ARCHIVED
}
val Category.group: CategoryGroup
get() = when {
archived -> CategoryGroup.ARCHIVED
expense -> CategoryGroup.EXPENSE
else -> CategoryGroup.INCOME
}
fun List<Category>.groupByType(): Map<CategoryGroup, List<Category>> {
val groups = mutableMapOf<CategoryGroup, List<Category>>()
forEach { category ->
val list = groups[category.group]?.toMutableList() ?: mutableListOf()
list.add(category)
groups[category.group] = list.sortedBy { it.title }
}
return groups
}

View file

@ -2,10 +2,11 @@ package com.wbrawner.twigs.shared.category
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.network.APIService
import kotlinx.datetime.Instant
interface CategoryRepository : Repository<Category> {
suspend fun findAll(budgetIds: Array<String>? = null): List<Category>
suspend fun getBalance(id: String): Long
suspend fun getBalance(id: String, from: Instant, to: Instant): Long
}
class NetworkCategoryRepository(private val apiService: APIService) : CategoryRepository {
@ -14,8 +15,8 @@ class NetworkCategoryRepository(private val apiService: APIService) : CategoryRe
override suspend fun findAll(): List<Category> = findAll(null)
override suspend fun getBalance(id: String): Long =
apiService.sumTransactions(categoryId = id).balance
override suspend fun getBalance(id: String, from: Instant, to: Instant): Long =
apiService.sumTransactions(categoryId = id, from = from, to = to).balance
override suspend fun create(newItem: Category): Category = apiService.newCategory(newItem)

View file

@ -14,6 +14,7 @@ import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.datetime.Instant
import kotlinx.serialization.json.Json
const val BASE_PATH = "/api"
@ -40,7 +41,7 @@ interface APIService {
suspend fun getCategories(
budgetIds: Array<String>? = null,
archived: Boolean? = false,
archived: Boolean? = null,
count: Int? = null,
page: Int? = null,
): List<Category>
@ -74,10 +75,10 @@ interface APIService {
suspend fun deleteRecurringTransaction(id: String)
suspend fun getTransactions(
from: Instant,
to: Instant,
budgetIds: List<String>? = null,
categoryIds: List<String>? = null,
from: String? = null,
to: String? = null,
count: Int? = null,
page: Int? = null
): List<Transaction>
@ -85,8 +86,10 @@ interface APIService {
suspend fun getTransaction(id: String): Transaction
suspend fun sumTransactions(
from: Instant,
to: Instant,
budgetId: String? = null,
categoryId: String? = null
categoryId: String? = null,
): BalanceResponse
suspend fun newTransaction(transaction: Transaction): Transaction
@ -122,7 +125,7 @@ interface APIService {
companion object
}
fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
fun <T : HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true

View file

@ -21,6 +21,7 @@ import io.ktor.http.HttpMethod
import io.ktor.http.URLBuilder
import io.ktor.http.contentType
import io.ktor.http.path
import kotlinx.datetime.Instant
class KtorAPIService(
private val client: HttpClient
@ -84,10 +85,10 @@ class KtorAPIService(
)
override suspend fun getTransactions(
from: Instant,
to: Instant,
budgetIds: List<String>?,
categoryIds: List<String>?,
from: String?,
to: String?,
count: Int?,
page: Int?
): List<Transaction> = request(
@ -95,8 +96,8 @@ class KtorAPIService(
queryParams = listOf(
"budgetIds" to budgetIds,
"categoryIds" to categoryIds,
"from" to from,
"to" to to,
"from" to from.toString(),
"to" to to.toString(),
"count" to count,
"page" to page,
)
@ -104,12 +105,19 @@ class KtorAPIService(
override suspend fun getTransaction(id: String): Transaction = request("transactions/$id")
override suspend fun sumTransactions(budgetId: String?, categoryId: String?): BalanceResponse =
override suspend fun sumTransactions(
from: Instant,
to: Instant,
budgetId: String?,
categoryId: String?
): BalanceResponse =
request(
path = "transactions/sum",
queryParams = listOf(
"budgetId" to budgetId,
"categoryId" to categoryId
"categoryId" to categoryId,
"from" to from.toString(),
"to" to to.toString(),
)
)

View file

@ -89,7 +89,7 @@ sealed class Frequency {
override fun toString(): String = "M;$count;$dayOfMonth;$time"
override val name: String = "Monthly"
override val description: String
get() = if (count == 1) "Every month on ${dayOfMonth.description}"
get() = if (count == 1) "Every month on the ${dayOfMonth.description}"
else "Every $count months on ${dayOfMonth.description}"
companion object {
@ -163,8 +163,7 @@ sealed class DayOfMonth {
}
data class FixedDayOfMonth(val day: Int) : DayOfMonth() {
override val description: String
get() = "$day"
override val description: String = day.ordinalString
override fun toString(): String = "DAY-$day"
}
@ -200,7 +199,7 @@ val Enum<*>.capitalizedName: String
class DayOfYear private constructor(val month: Int, val day: Int) {
val description: String
get() = "${month.toMonth().capitalizedName} $day"
get() = "${month.toMonth().capitalizedName} ${day.ordinalString}"
override fun toString(): String {
return "${month.padStart(2, '0')}-${day.padStart(2, '0')}"
@ -228,6 +227,14 @@ class DayOfYear private constructor(val month: Int, val day: Int) {
fun Int.toMonth(): Month = Month(this)
val Int.ordinalString: String
get() = when {
mod(1) == 0 -> "${this}st"
mod(2) == 0 -> "${this}nd"
mod(3) == 0 -> "${this}rd"
else -> "${this}th"
}
data class Time(val hours: Int, val minutes: Int, val seconds: Int) {
override fun toString(): String {
val s = StringBuilder()

View file

@ -1,6 +1,7 @@
package com.wbrawner.twigs.shared.transaction
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
@ -25,6 +26,7 @@ sealed interface TransactionAction : Action {
object TransactionsClicked : TransactionAction
data class LoadTransactionsSuccess(val transactions: List<Transaction>) : TransactionAction
data class LoadTransactionsFailed(val error: Exception) : TransactionAction
data class ChangeDateRange(val from: Instant, val to: Instant) : TransactionAction
object NewTransactionClicked : TransactionAction
data class CreateTransaction(
val title: String,
@ -67,6 +69,10 @@ sealed interface TransactionAction : Action {
) : TransactionAction
data class DeleteTransaction(val id: String) : TransactionAction
data class TransactionDeleted(val id: String) : TransactionAction
data class TransactionDeletedFailure(val id: String) : TransactionAction
}
class TransactionReducer(
@ -89,6 +95,24 @@ class TransactionReducer(
is TransactionAction.TransactionsClicked -> state().copy(route = Route.Transactions(null))
is TransactionAction.LoadTransactionsSuccess -> state().copy(transactions = action.transactions)
is TransactionAction.ChangeDateRange -> state().copy(
from = action.from,
to = action.to
).also {
launch {
try {
val transactions = transactionRepository.findAll(
budgetIds = listOf(it.selectedBudget!!),
start = it.from,
end = it.to,
)
dispatch(TransactionAction.LoadTransactionsSuccess(transactions))
} catch (e: Exception) {
dispatch(TransactionAction.LoadTransactionsFailed(e))
}
}
}
is TransactionAction.NewTransactionClicked -> state().copy(editingTransaction = true)
is TransactionAction.CancelEditTransaction -> {
val currentState = state()
@ -147,25 +171,30 @@ class TransactionReducer(
val transactions = currentState.transactions?.toMutableList() ?: mutableListOf()
transactions.replace(action.transaction)
transactions.sortByDescending { it.date }
dispatch(TransactionAction.LoadTransactionsSuccess(transactions))
currentState.copy(
loading = false,
transactions = transactions.toList(),
selectedTransaction = action.transaction.id,
selectedTransactionCreatedBy = currentState.user,
route = Route.Transactions(action.transaction.id),
editingTransaction = false
)
}
is BudgetAction.BudgetSelected -> {
is BudgetAction.BudgetSelected -> state().copy(transactions = null).also {
launch {
try {
val transactions = transactionRepository.findAll(budgetIds = listOf(action.id))
val transactions = transactionRepository.findAll(
start = it.from,
end = it.to,
budgetIds = listOf(action.id)
)
dispatch(TransactionAction.LoadTransactionsSuccess(transactions))
} catch (e: Exception) {
dispatch(TransactionAction.LoadTransactionsFailed(e))
}
}
state().copy(transactions = null)
}
is ConfigAction.Logout -> state().copy(
@ -198,6 +227,32 @@ class TransactionReducer(
selectedTransactionCreatedBy = action.createdBy
)
is TransactionAction.DeleteTransaction -> state().copy(loading = true).also {
launch {
try {
transactionRepository.delete(action.id)
dispatch(TransactionAction.TransactionDeleted(action.id))
} catch (e: Exception) {
e.printStackTrace()
dispatch(TransactionAction.TransactionDeletedFailure(action.id))
}
}
}
is TransactionAction.TransactionDeleted -> {
val currentState = state()
currentState.copy(
transactions = currentState.transactions?.filter { it.id != action.id },
selectedTransaction = if (currentState.selectedTransaction == action.id) null else currentState.selectedTransaction,
editingTransaction = if (currentState.selectedTransaction == action.id) false else currentState.editingTransaction,
route = if (currentState.selectedTransaction == action.id) Route.Transactions() else currentState.route,
)
}
is TransactionAction.TransactionDeletedFailure -> state().also {
emit(Effect.Error("Failed to delete transaction"))
}
else -> state()
}
}

View file

@ -10,8 +10,8 @@ interface TransactionRepository : Repository<Transaction> {
suspend fun findAll(
budgetIds: List<String>? = null,
categoryIds: List<String>? = null,
start: Instant? = startOfMonth(),
end: Instant? = endOfMonth()
start: Instant,
end: Instant
): List<Transaction>
}
@ -19,16 +19,21 @@ class NetworkTransactionRepository(private val apiService: APIService) : Transac
override suspend fun findAll(
budgetIds: List<String>?,
categoryIds: List<String>?,
start: Instant?,
end: Instant?
start: Instant,
end: Instant
): List<Transaction> = apiService.getTransactions(
budgetIds,
categoryIds,
from = start.toString(),
to = end.toString()
budgetIds = budgetIds,
categoryIds = categoryIds,
from = start,
to = end
)
override suspend fun findAll(): List<Transaction> = findAll(null, null)
override suspend fun findAll(): List<Transaction> = findAll(
start = startOfMonth(),
end = endOfMonth(),
budgetIds = null,
categoryIds = null
)
override suspend fun create(newItem: Transaction): Transaction =
apiService.newTransaction(newItem)