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

View file

@ -1,16 +1,35 @@
package com.wbrawner.budget.ui 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
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.budget.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store import com.wbrawner.twigs.shared.Store
@ -18,22 +37,173 @@ import com.wbrawner.twigs.shared.Store
@Composable @Composable
fun OverviewScreen(store: Store) { fun OverviewScreen(store: Store) {
val state by store.state.collectAsState() val state by store.state.collectAsState()
TwigsScaffold(store = store, title = "Overview") { padding -> val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } } TwigsScaffold(store = store, title = budget?.name ?: "Select a Budget") { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding)
horizontalAlignment = Alignment.CenterHorizontally, .scrollable(rememberScrollState(), orientation = Orientation.Vertical)
verticalArrangement = Arrangement.Center .padding(8.dp),
verticalArrangement = spacedBy(8.dp, alignment = Alignment.Top)
) { ) {
budget?.let { budget -> budget?.description?.let { description ->
Text(budget.name) if (description.isNotBlank()) {
Text(budget.description ?: "") Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
Text(description, style = MaterialTheme.typography.titleMedium)
}
}
}
} }
Text("Cash Flow") Card(modifier = Modifier.fillMaxWidth()) {
Text(state.budgetBalance?.toCurrencyString() ?: "-") 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -34,7 +35,6 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.isShiftPressed
@ -140,7 +140,7 @@ fun LoginForm(
) )
Text("Log in to manage your budgets") Text("Log in to manage your budgets")
if (error.isNotBlank()) { if (error.isNotBlank()) {
Text(text = error, color = Color.Red) Text(text = error, color = MaterialTheme.colorScheme.error)
} }
TextField( TextField(
modifier = Modifier modifier = Modifier

View file

@ -1,5 +1,7 @@
package com.wbrawner.budget.ui.base package com.wbrawner.budget.ui.base
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Green300 = Color(0xFF81C784) val Green300 = Color(0xFF81C784)
@ -11,3 +13,82 @@ val Red500 = Color(0xFFF44336)
val Red700 = Color(0xFFD32F2F) val Red700 = Color(0xFFD32F2F)
val Red900 = Color(0xFFB71C1C) 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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import com.wbrawner.budget.R import com.wbrawner.budget.R
val lightColors = lightColorScheme( private val LightColors = lightColorScheme(
primary = Green500, primary = md_theme_light_primary,
primaryContainer = Green300, onPrimary = md_theme_light_onPrimary,
secondary = Green700, primaryContainer = md_theme_light_primaryContainer,
secondaryContainer = Green300, onPrimaryContainer = md_theme_light_onPrimaryContainer,
background = Color.LightGray, secondary = md_theme_light_secondary,
surface = Color.White, onSecondary = md_theme_light_onSecondary,
surfaceVariant = Color.White 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, private val DarkColors = darkColorScheme(
primaryContainer = Green500, primary = md_theme_dark_primary,
secondary = Green500, onPrimary = md_theme_dark_onPrimary,
secondaryContainer = Green700, primaryContainer = md_theme_dark_primaryContainer,
background = Color.Black, onPrimaryContainer = md_theme_dark_onPrimaryContainer,
surface = Color.Black, secondary = md_theme_dark_secondary,
surfaceVariant = Color.White.copy(alpha = 0.1f) 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( val ubuntu = FontFamily(
@ -48,9 +89,7 @@ val ubuntu = FontFamily(
@Composable @Composable
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme( MaterialTheme(
colorScheme = if (darkMode) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme( colorScheme = if (darkMode) DarkColors else LightColors,
LocalContext.current
),
typography = MaterialTheme.typography.copy( typography = MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu), displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = ubuntu), displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = ubuntu),

View file

@ -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_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.util.Log
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -17,14 +16,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wbrawner.budget.ui.TwigsScaffold import com.wbrawner.budget.ui.TwigsScaffold
import com.wbrawner.budget.ui.base.TwigsApp 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.Store
import com.wbrawner.twigs.shared.category.Category import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction 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 java.util.*
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
@ -32,25 +36,37 @@ import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CategoriesScreen(store: Store) { 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( TwigsScaffold(
store = store, store = store,
title = "Categories", title = budget?.name ?: "Select a Budget",
onClickFab = { onClickFab = {
store.dispatch(CategoryAction.NewCategoryClicked) store.dispatch(CategoryAction.NewCategoryClicked)
} }
) { ) {
val state by store.state.collectAsState()
state.categories?.let { categories -> state.categories?.let { categories ->
LazyColumn( val categoryGroups = categories.groupByType()
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.padding(it), .padding(it)
state = scrollState .verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) { ) {
items(categories, key = { c -> c.id!! }) { category -> categoryGroups.toSortedMap().forEach { (group, c) ->
CategoryListItem(category, state.categoryBalances?.get(category.id!!)) { Text(
store.dispatch(CategoryAction.SelectCategory(category.id)) 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))
}
}
} }
} }
} }
@ -77,20 +93,31 @@ fun CategoryListItem(category: Category, balance: Long?, onClick: (Category) ->
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = spacedBy(4.dp) verticalArrangement = spacedBy(4.dp)
) { ) {
Text(category.title, style = MaterialTheme.typography.bodyLarge) 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)) Spacer(modifier = Modifier.height(8.dp))
balance?.let { balance?.let {
val denominator = remember { max(abs(it), abs(category.amount)).toFloat() } val denominator = remember { max(abs(it), abs(category.amount)).toFloat() }
val progress = val progress =
remember { if (denominator == 0f) 0f else abs(it).toFloat() / denominator } remember { if (denominator == 0f) 0f else abs(it).toFloat() / denominator }
Log.d(
"Twigs",
"Category ${category.title} amount: $denominator balance: $it progress: $progress"
)
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp)),
progress = progress, 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()) } ?: LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} }

View file

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

View file

@ -17,7 +17,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -109,7 +108,7 @@ fun RecurringTransactionDetails(
Text( Text(
text = transaction.amount.toCurrencyString(), text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall, 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 ?: "") 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy 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.material.CircularProgressIndicator
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -17,7 +18,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -34,41 +34,40 @@ import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RecurringTransactionsScreen(store: Store) { fun RecurringTransactionsScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold( TwigsScaffold(
store = store, store = store,
title = "Recurring Transactions", title = budget?.name ?: "Select a Budget",
onClickFab = { onClickFab = {
store.dispatch(RecurringTransactionAction.NewRecurringTransactionClicked) store.dispatch(RecurringTransactionAction.NewRecurringTransactionClicked)
} }
) { ) {
val state by store.state.collectAsState()
state.recurringTransactions?.let { transactions -> state.recurringTransactions?.let { transactions ->
val transactionGroups = remember { transactions.groupByStatus() } val transactionGroups =
LazyColumn( remember(state.editingRecurringTransaction) { transactions.groupByStatus() }
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(it) .padding(it)
.padding(horizontal = 8.dp) .verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) { ) {
transactionGroups.forEach { (title, transactions) -> transactionGroups.forEach { (title, transactions) ->
item(title) { Text(
Text( modifier = Modifier.padding(8.dp),
modifier = Modifier.padding(8.dp), text = title,
text = title, style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold )
) Card {
} transactions.forEach { transaction ->
item(transactions) { RecurringTransactionListItem(transaction) {
Card { store.dispatch(
transactions.forEach { transaction -> RecurringTransactionAction.SelectRecurringTransaction(
RecurringTransactionListItem(transaction) { transaction.id
store.dispatch(
RecurringTransactionAction.SelectRecurringTransaction(
transaction.id
)
) )
} )
} }
} }
} }
@ -111,7 +110,7 @@ fun RecurringTransactionListItem(
} }
Text( Text(
transaction.amount.toCurrencyString(), 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.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wbrawner.budget.ui.TwigsScaffold import com.wbrawner.budget.ui.TwigsScaffold
@ -46,6 +47,7 @@ fun TransactionDetailsScreen(store: Store) {
} }
val category = state.categories?.firstOrNull { it.id == transaction.categoryId } val category = state.categories?.firstOrNull { it.id == transaction.categoryId }
val budget = state.budgets!!.first { it.id == transaction.budgetId } val budget = state.budgets!!.first { it.id == transaction.budgetId }
val (confirmDeletionShown, setConfirmDeletionShown) = remember { mutableStateOf(false) }
TwigsScaffold( TwigsScaffold(
store = store, store = store,
@ -59,6 +61,9 @@ fun TransactionDetailsScreen(store: Store) {
IconButton({ store.dispatch(TransactionAction.EditTransaction(requireNotNull(transaction.id))) }) { IconButton({ store.dispatch(TransactionAction.EditTransaction(requireNotNull(transaction.id))) }) {
Icon(Icons.Default.Edit, "Edit") Icon(Icons.Default.Edit, "Edit")
} }
IconButton({ setConfirmDeletionShown(true) }) {
Icon(Icons.Default.Delete, "Delete")
}
} }
) { padding -> ) { padding ->
TransactionDetails( TransactionDetails(
@ -71,6 +76,35 @@ fun TransactionDetailsScreen(store: Store) {
if (state.editingTransaction) { if (state.editingTransaction) {
TransactionFormDialog(store = store) 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(
text = transaction.amount.toCurrencyString(), text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall, 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 ?: "") LabeledField("Description", transaction.description ?: "")

View file

@ -73,13 +73,18 @@ fun TransactionForm(store: Store) {
state.transactions?.first { it.id == state.selectedTransaction } ?: defaultTransaction state.transactions?.first { it.id == state.selectedTransaction } ?: defaultTransaction
} }
} }
val (title, setTitle) = remember { mutableStateOf(transaction.title) } val (title, setTitle) = remember(state.editingTransaction) { mutableStateOf(transaction.title) }
val (description, setDescription) = remember { mutableStateOf(transaction.description ?: "") } val (description, setDescription) = remember(state.editingTransaction) {
val (date, setDate) = remember { mutableStateOf(transaction.date) } mutableStateOf(
val (amount, setAmount) = remember { mutableStateOf(transaction.amount.toDecimalString()) } transaction.description ?: ""
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 (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( Scaffold(
topBar = { topBar = {
TopAppBar( 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy 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.material.CircularProgressIndicator
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -17,7 +18,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -36,37 +36,36 @@ import java.text.NumberFormat
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TransactionsScreen(store: Store) { fun TransactionsScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold( TwigsScaffold(
store = store, store = store,
title = "Transactions", title = budget?.name ?: "Select a Budget",
onClickFab = { onClickFab = {
store.dispatch(TransactionAction.NewTransactionClicked) store.dispatch(TransactionAction.NewTransactionClicked)
} }
) { ) {
val state by store.state.collectAsState()
state.transactions?.let { transactions -> state.transactions?.let { transactions ->
val transactionGroups = remember { transactions.groupByDate() } val transactionGroups =
LazyColumn( remember(state.editingTransaction) { transactions.groupByDate() }
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(it) .padding(it)
.padding(horizontal = 8.dp) .verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) { ) {
transactionGroups.forEach { (timestamp, transactions) -> transactionGroups.forEach { (timestamp, transactions) ->
item(timestamp) { Text(
Text( modifier = Modifier.padding(8.dp),
modifier = Modifier.padding(8.dp), text = timestamp.toInstant().format(LocalContext.current),
text = timestamp.toInstant().format(LocalContext.current), style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold )
) Card {
} transactions.forEach { transaction ->
item(transactions) { TransactionListItem(transaction) {
Card { store.dispatch(TransactionAction.SelectTransaction(transaction.id))
transactions.forEach { transaction ->
TransactionListItem(transaction) {
store.dispatch(TransactionAction.SelectTransaction(transaction.id))
}
} }
} }
} }
@ -106,7 +105,7 @@ fun TransactionListItem(transaction: Transaction, onClick: (Transaction) -> Unit
} }
Text( Text(
transaction.amount.toCurrencyString(), 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
sealed class Route(val path: String) { sealed class Route(val path: String) {
object Welcome : Route("welcome") object Welcome : Route("welcome")
@ -36,6 +37,10 @@ data class State(
val user: User? = null, val user: User? = null,
val budgets: List<Budget>? = null, val budgets: List<Budget>? = null,
val budgetBalance: Long? = 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 selectedBudget: String? = null,
val editingBudget: Boolean = false, val editingBudget: Boolean = false,
val categories: List<Category>? = null, val categories: List<Category>? = null,
@ -46,6 +51,8 @@ data class State(
val selectedTransaction: String? = null, val selectedTransaction: String? = null,
val selectedTransactionCreatedBy: User? = null, val selectedTransactionCreatedBy: User? = null,
val editingTransaction: Boolean = false, val editingTransaction: Boolean = false,
val from: Instant = startOfMonth(),
val to: Instant = endOfMonth(),
val recurringTransactions: List<RecurringTransaction>? = null, val recurringTransactions: List<RecurringTransaction>? = null,
val selectedRecurringTransaction: String? = null, val selectedRecurringTransaction: String? = null,
val selectedRecurringTransactionCreatedBy: User? = null, val selectedRecurringTransactionCreatedBy: User? = null,
@ -53,11 +60,7 @@ data class State(
val loading: Boolean = false, val loading: Boolean = false,
val route: Route = Route.Login, val route: Route = Route.Login,
val initialRoute: Route = Route.Login val initialRoute: Route = Route.Login
) { )
override fun toString(): String {
return "State(recurringTransactionsSize=${recurringTransactions?.size}, route=$route)"
}
}
interface Action { interface Action {
object AboutClicked : 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.Route
import com.wbrawner.twigs.shared.State import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.replace 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.ConfigAction
import com.wbrawner.twigs.shared.user.UserPermission import com.wbrawner.twigs.shared.user.UserPermission
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -127,6 +128,17 @@ class BudgetReducer(
is BudgetAction.BudgetSelected -> state().copy(selectedBudget = action.id) 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 { // is BudgetAction.UpdateBudget -> state.copy(loading = true).also {
// dispatch(action.async()) // 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.Repository
import com.wbrawner.twigs.shared.network.APIService import com.wbrawner.twigs.shared.network.APIService
import kotlinx.datetime.Instant
interface BudgetRepository : Repository<Budget> { 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 { 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 delete(id: String) = apiService.deleteBudget(id)
override suspend fun getBalance(id: String): Long = override suspend fun getBalance(id: String, from: Instant, to: Instant): Long =
apiService.sumTransactions(budgetId = id).balance 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.State
import com.wbrawner.twigs.shared.budget.BudgetAction import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.replace import com.wbrawner.twigs.shared.replace
import com.wbrawner.twigs.shared.transaction.TransactionAction
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
sealed interface CategoryAction : Action { sealed interface CategoryAction : Action {
object CategoriesClicked : CategoryAction object CategoriesClicked : CategoryAction
data class BalancesCalculated( data class BalancesCalculated(
val budgetBalance: Long, val categoryBalances: Map<String, Long>,
val categoryBalances: Map<String, Long> val actualIncome: Long,
val actualExpenses: Long
) : CategoryAction ) : CategoryAction
data class LoadCategoriesSuccess(val categories: List<Category>) : CategoryAction data class LoadCategoriesSuccess(val categories: List<Category>) : CategoryAction
@ -24,7 +27,8 @@ sealed interface CategoryAction : Action {
val title: String, val title: String,
val description: String? = null, val description: String? = null,
val amount: Long, val amount: Long,
val expense: Boolean val expense: Boolean,
val archived: Boolean,
) : CategoryAction ) : CategoryAction
data class SaveCategorySuccess(val category: Category) : CategoryAction data class SaveCategorySuccess(val category: Category) : CategoryAction
@ -35,6 +39,7 @@ sealed interface CategoryAction : Action {
val description: String? = null, val description: String? = null,
val amount: Long, val amount: Long,
val expense: Boolean, val expense: Boolean,
val archived: Boolean,
val error: Exception val error: Exception
) : CategoryAction ) : CategoryAction
@ -48,6 +53,7 @@ sealed interface CategoryAction : Action {
val description: String? = null, val description: String? = null,
val amount: Long, val amount: Long,
val expense: Boolean, val expense: Boolean,
val archived: Boolean,
) : CategoryAction ) : CategoryAction
data class DeleteCategory(val id: String) : CategoryAction data class DeleteCategory(val id: String) : CategoryAction
@ -78,7 +84,14 @@ class CategoryReducer(private val categoryRepository: CategoryRepository) : Redu
dispatch(CategoryAction.LoadCategoriesFailed(e)) 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( is CategoryAction.SelectCategory -> state().copy(
@ -86,23 +99,69 @@ class CategoryReducer(private val categoryRepository: CategoryRepository) : Redu
route = Route.Categories(action.id) route = Route.Categories(action.id)
).also { newState -> println("Category selected state update: $newState") } ).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 { .also {
launch { launch {
var budgetBalance = 0L val categoryBalances =
val categoryBalances = mutableMapOf<String, Long>() it.categories?.associate { it.id!! to 0L }?.toMutableMap()
action.categories.forEach { category -> ?: mutableMapOf()
val balance = categoryRepository.getBalance(category.id!!) var actualIncome = 0L
categoryBalances[category.id] = balance var actualExpenses = 0L
budgetBalance += balance 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
}
category?.let {
categoryBalances[it] = balance
}
} }
dispatch(CategoryAction.BalancesCalculated(budgetBalance, categoryBalances)) dispatch(
CategoryAction.BalancesCalculated(
categoryBalances,
actualIncome = actualIncome,
actualExpenses = actualExpenses
)
)
} }
} }
is CategoryAction.BalancesCalculated -> state().copy( 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) is CategoryAction.CancelEditCategory -> state().copy(editingCategory = false)
@ -162,4 +221,27 @@ class CategoryReducer(private val categoryRepository: CategoryRepository) : Redu
else -> state() 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.Repository
import com.wbrawner.twigs.shared.network.APIService import com.wbrawner.twigs.shared.network.APIService
import kotlinx.datetime.Instant
interface CategoryRepository : Repository<Category> { interface CategoryRepository : Repository<Category> {
suspend fun findAll(budgetIds: Array<String>? = null): List<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 { 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 findAll(): List<Category> = findAll(null)
override suspend fun getBalance(id: String): Long = override suspend fun getBalance(id: String, from: Instant, to: Instant): Long =
apiService.sumTransactions(categoryId = id).balance apiService.sumTransactions(categoryId = id, from = from, to = to).balance
override suspend fun create(newItem: Category): Category = apiService.newCategory(newItem) 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.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.datetime.Instant
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
const val BASE_PATH = "/api" const val BASE_PATH = "/api"
@ -40,7 +41,7 @@ interface APIService {
suspend fun getCategories( suspend fun getCategories(
budgetIds: Array<String>? = null, budgetIds: Array<String>? = null,
archived: Boolean? = false, archived: Boolean? = null,
count: Int? = null, count: Int? = null,
page: Int? = null, page: Int? = null,
): List<Category> ): List<Category>
@ -74,10 +75,10 @@ interface APIService {
suspend fun deleteRecurringTransaction(id: String) suspend fun deleteRecurringTransaction(id: String)
suspend fun getTransactions( suspend fun getTransactions(
from: Instant,
to: Instant,
budgetIds: List<String>? = null, budgetIds: List<String>? = null,
categoryIds: List<String>? = null, categoryIds: List<String>? = null,
from: String? = null,
to: String? = null,
count: Int? = null, count: Int? = null,
page: Int? = null page: Int? = null
): List<Transaction> ): List<Transaction>
@ -85,8 +86,10 @@ interface APIService {
suspend fun getTransaction(id: String): Transaction suspend fun getTransaction(id: String): Transaction
suspend fun sumTransactions( suspend fun sumTransactions(
from: Instant,
to: Instant,
budgetId: String? = null, budgetId: String? = null,
categoryId: String? = null categoryId: String? = null,
): BalanceResponse ): BalanceResponse
suspend fun newTransaction(transaction: Transaction): Transaction suspend fun newTransaction(transaction: Transaction): Transaction
@ -122,7 +125,7 @@ interface APIService {
companion object companion object
} }
fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() { fun <T : HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true

View file

@ -21,6 +21,7 @@ import io.ktor.http.HttpMethod
import io.ktor.http.URLBuilder import io.ktor.http.URLBuilder
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.http.path import io.ktor.http.path
import kotlinx.datetime.Instant
class KtorAPIService( class KtorAPIService(
private val client: HttpClient private val client: HttpClient
@ -84,10 +85,10 @@ class KtorAPIService(
) )
override suspend fun getTransactions( override suspend fun getTransactions(
from: Instant,
to: Instant,
budgetIds: List<String>?, budgetIds: List<String>?,
categoryIds: List<String>?, categoryIds: List<String>?,
from: String?,
to: String?,
count: Int?, count: Int?,
page: Int? page: Int?
): List<Transaction> = request( ): List<Transaction> = request(
@ -95,8 +96,8 @@ class KtorAPIService(
queryParams = listOf( queryParams = listOf(
"budgetIds" to budgetIds, "budgetIds" to budgetIds,
"categoryIds" to categoryIds, "categoryIds" to categoryIds,
"from" to from, "from" to from.toString(),
"to" to to, "to" to to.toString(),
"count" to count, "count" to count,
"page" to page, "page" to page,
) )
@ -104,12 +105,19 @@ class KtorAPIService(
override suspend fun getTransaction(id: String): Transaction = request("transactions/$id") 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( request(
path = "transactions/sum", path = "transactions/sum",
queryParams = listOf( queryParams = listOf(
"budgetId" to budgetId, "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 fun toString(): String = "M;$count;$dayOfMonth;$time"
override val name: String = "Monthly" override val name: String = "Monthly"
override val description: String 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}" else "Every $count months on ${dayOfMonth.description}"
companion object { companion object {
@ -163,8 +163,7 @@ sealed class DayOfMonth {
} }
data class FixedDayOfMonth(val day: Int) : DayOfMonth() { data class FixedDayOfMonth(val day: Int) : DayOfMonth() {
override val description: String override val description: String = day.ordinalString
get() = "$day"
override fun toString(): String = "DAY-$day" override fun toString(): String = "DAY-$day"
} }
@ -200,7 +199,7 @@ val Enum<*>.capitalizedName: String
class DayOfYear private constructor(val month: Int, val day: Int) { class DayOfYear private constructor(val month: Int, val day: Int) {
val description: String val description: String
get() = "${month.toMonth().capitalizedName} $day" get() = "${month.toMonth().capitalizedName} ${day.ordinalString}"
override fun toString(): String { override fun toString(): String {
return "${month.padStart(2, '0')}-${day.padStart(2, '0')}" 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) 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) { data class Time(val hours: Int, val minutes: Int, val seconds: Int) {
override fun toString(): String { override fun toString(): String {
val s = StringBuilder() val s = StringBuilder()

View file

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

View file

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