diff --git a/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt b/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt index 97be39e..962d7fa 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/MainActivity.kt @@ -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) diff --git a/android/src/main/java/com/wbrawner/budget/ui/OverviewScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/OverviewScreen.kt index 6d3aca4..b65bbea 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/OverviewScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/OverviewScreen.kt @@ -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 } } + 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" + ) + } +} diff --git a/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt index 9dc259c..2afab3b 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/auth/LoginScreen.kt @@ -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 diff --git a/android/src/main/java/com/wbrawner/budget/ui/base/Colors.kt b/android/src/main/java/com/wbrawner/budget/ui/base/Colors.kt index 254e4b8..1a224a1 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/base/Colors.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/base/Colors.kt @@ -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) diff --git a/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt b/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt index 1476be8..dd13dea 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/base/Theme.kt @@ -4,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), diff --git a/android/src/main/java/com/wbrawner/budget/ui/category/CategoriesScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/category/CategoriesScreen.kt index 282c8fd..f41a73d 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/category/CategoriesScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/category/CategoriesScreen.kt @@ -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,25 +36,37 @@ 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 -> - CategoryListItem(category, state.categoryBalances?.get(category.id!!)) { - store.dispatch(CategoryAction.SelectCategory(category.id)) + 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)) + } + } } } } @@ -77,20 +93,31 @@ fun CategoryListItem(category: Category, balance: Long?, onClick: (Category) -> modifier = Modifier.weight(1f), 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)) 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()) } diff --git a/android/src/main/java/com/wbrawner/budget/ui/category/CategoryForm.kt b/android/src/main/java/com/wbrawner/budget/ui/category/CategoryForm.kt index c328bd7..7b6e2d4 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/category/CategoryForm.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/category/CategoryForm.kt @@ -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 diff --git a/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionDetailsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionDetailsScreen.kt index ef0dc73..9fe88c7 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionDetailsScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionDetailsScreen.kt @@ -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 ?: "") diff --git a/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionsScreen.kt index b8f64ed..3206b9f 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionsScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/recurringtransaction/RecurringTransactionsScreen.kt @@ -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,41 +34,40 @@ 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, - fontWeight = FontWeight.Bold - ) - } - item(transactions) { - Card { - transactions.forEach { transaction -> - RecurringTransactionListItem(transaction) { - store.dispatch( - RecurringTransactionAction.SelectRecurringTransaction( - transaction.id - ) + Text( + modifier = Modifier.padding(8.dp), + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Card { + transactions.forEach { transaction -> + RecurringTransactionListItem(transaction) { + store.dispatch( + RecurringTransactionAction.SelectRecurringTransaction( + transaction.id ) - } + ) } } } @@ -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, ) } } diff --git a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionDetailsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionDetailsScreen.kt index f2d5322..0218207 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionDetailsScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionDetailsScreen.kt @@ -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 ?: "") diff --git a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionForm.kt b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionForm.kt index e178dc6..d4c0f9a 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionForm.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionForm.kt @@ -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( diff --git a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionsScreen.kt b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionsScreen.kt index 31a2a96..fa2b67a 100644 --- a/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionsScreen.kt +++ b/android/src/main/java/com/wbrawner/budget/ui/transaction/TransactionsScreen.kt @@ -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,37 +36,36 @@ 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, - fontWeight = FontWeight.Bold - ) - } - item(transactions) { - Card { - transactions.forEach { transaction -> - TransactionListItem(transaction) { - store.dispatch(TransactionAction.SelectTransaction(transaction.id)) - } + Text( + modifier = Modifier.padding(8.dp), + text = timestamp.toInstant().format(LocalContext.current), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Card { + transactions.forEach { transaction -> + TransactionListItem(transaction) { + store.dispatch(TransactionAction.SelectTransaction(transaction.id)) } } } @@ -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, ) } } diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt index 0ef20c9..3ab54e6 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/Store.kt @@ -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? = 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? = 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? = 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 diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt index 1b3db09..b81b4de 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetAction.kt @@ -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()) // } diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetRepository.kt index 8c34931..46e8ebb 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetRepository.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/budget/BudgetRepository.kt @@ -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 { - 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 } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryAction.kt index 93d1893..9139dc1 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryAction.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryAction.kt @@ -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 + val categoryBalances: Map, + val actualIncome: Long, + val actualExpenses: Long ) : CategoryAction data class LoadCategoriesSuccess(val categories: List) : 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? = 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() - 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 + } + category?.let { + categoryBalances[it] = balance + } } - dispatch(CategoryAction.BalancesCalculated(budgetBalance, categoryBalances)) + 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) @@ -162,4 +221,27 @@ 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.groupByType(): Map> { + val groups = mutableMapOf>() + forEach { category -> + val list = groups[category.group]?.toMutableList() ?: mutableListOf() + list.add(category) + groups[category.group] = list.sortedBy { it.title } + } + return groups } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt index b54bc2e..680b560 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/category/CategoryRepository.kt @@ -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 { suspend fun findAll(budgetIds: Array? = null): List - 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 = 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) diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt index 409dfe7..577e8dc 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/APIService.kt @@ -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? = null, - archived: Boolean? = false, + archived: Boolean? = null, count: Int? = null, page: Int? = null, ): List @@ -74,10 +75,10 @@ interface APIService { suspend fun deleteRecurringTransaction(id: String) suspend fun getTransactions( + from: Instant, + to: Instant, budgetIds: List? = null, categoryIds: List? = null, - from: String? = null, - to: String? = null, count: Int? = null, page: Int? = null ): List @@ -85,8 +86,10 @@ interface APIService { suspend fun getTransaction(id: String): Transaction suspend fun sumTransactions( + from: Instant, + to: Instant, budgetId: String? = null, - categoryId: String? = null + categoryId: String? = null, ): BalanceResponse suspend fun newTransaction(transaction: Transaction): Transaction @@ -122,7 +125,7 @@ interface APIService { companion object } -fun HttpClientConfig.commonConfig() { +fun HttpClientConfig.commonConfig() { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt index ed18d44..9fcd538 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/network/KtorAPIService.kt @@ -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?, categoryIds: List?, - from: String?, - to: String?, count: Int?, page: Int? ): List = 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(), ) ) diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransaction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransaction.kt index 2a91e8e..e4203bf 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransaction.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/recurringtransaction/RecurringTransaction.kt @@ -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() diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt index e0520ad..3fa6d75 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionAction.kt @@ -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) : 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() } } diff --git a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt index 34a0286..e162751 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/twigs/shared/transaction/TransactionRepository.kt @@ -10,8 +10,8 @@ interface TransactionRepository : Repository { suspend fun findAll( budgetIds: List? = null, categoryIds: List? = null, - start: Instant? = startOfMonth(), - end: Instant? = endOfMonth() + start: Instant, + end: Instant ): List } @@ -19,16 +19,21 @@ class NetworkTransactionRepository(private val apiService: APIService) : Transac override suspend fun findAll( budgetIds: List?, categoryIds: List?, - start: Instant?, - end: Instant? + start: Instant, + end: Instant ): List = apiService.getTransactions( - budgetIds, - categoryIds, - from = start.toString(), - to = end.toString() + budgetIds = budgetIds, + categoryIds = categoryIds, + from = start, + to = end ) - override suspend fun findAll(): List = findAll(null, null) + override suspend fun findAll(): List = findAll( + start = startOfMonth(), + end = endOfMonth(), + budgetIds = null, + categoryIds = null + ) override suspend fun create(newItem: Transaction): Transaction = apiService.newTransaction(newItem)