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:
parent
c23c16ab40
commit
ed4fd514f1
22 changed files with 717 additions and 166 deletions
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ?: "")
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ?: "")
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
// }
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue