WIP: Migrate to Kotlin Multiplatform

Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2023-01-19 21:47:56 -07:00
parent 120d3dd70b
commit 95e7361907
69 changed files with 2172 additions and 3480 deletions

View file

@ -1,83 +0,0 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdkVersion 33
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
defaultConfig {
applicationId "com.wbrawner.twigs"
minSdkVersion 26
targetSdkVersion 33
versionCode 1
versionName "1.0"
vectorDrawables {
useSupportLibrary = true
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion "1.3.2"
}
}
dependencies {
implementation project(':shared')
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.media:media:1.6.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.android.material:material:1.7.0'
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
// Dagger
implementation "com.google.dagger:hilt-android:$dagger"
kapt "com.google.dagger:hilt-compiler:$dagger"
implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-splashscreen:1.0.0'
def navigation = '2.6.0-alpha04'
implementation "androidx.navigation:navigation-fragment-ktx:$navigation"
implementation "androidx.navigation:navigation-ui-ktx:$navigation"
implementation "androidx.navigation:navigation-compose:$navigation"
// implementation "androidx.compose.compiler:compiler:1.4.0-alpha02"
implementation "androidx.compose.ui:ui:$compose"
implementation "androidx.compose.ui:ui-tooling:$compose"
implementation "androidx.compose.foundation:foundation:$compose"
implementation "androidx.compose.material:material:$compose"
implementation "androidx.compose.material:material-icons-core:$compose"
implementation "androidx.compose.material:material-icons-extended:$compose"
implementation "androidx.compose.animation:animation:$compose"
implementation "androidx.compose.material3:material3:1.0.1"
implementation "androidx.compose.material3:material3-window-size-class:1.0.1"
implementation 'androidx.activity:activity-compose:1.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha03'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose"
}

91
android/build.gradle.kts Normal file
View file

@ -0,0 +1,91 @@
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
val keystoreProperties = Properties()
try {
val keystorePropertiesFile = rootProject.file("keystore.properties")
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
} catch (ignored: FileNotFoundException) {
logger.warn("Unable to load keystore properties. Using debug signing configuration instead")
keystoreProperties["keyAlias"] = "androiddebugkey"
keystoreProperties["keyPassword"] = "android"
keystoreProperties["storeFile"] =
File(System.getProperty("user.home"), ".android/debug.keystore").absolutePath
keystoreProperties["storePassword"] = "android"
}
android {
compileSdk = libs.versions.maxSdk.get().toInt()
defaultConfig {
applicationId = "com.wbrawner.twigs"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
signingConfig = signingConfigs["debug"]
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
storeFile = file(keystoreProperties["storeFile"].toString())
storePassword = keystoreProperties["storePassword"].toString()
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs["release"]
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
}
dependencies {
implementation(project(":shared"))
implementation(libs.bundles.coroutines)
implementation(libs.bundles.compose)
implementation(libs.hilt.android.core)
implementation(libs.hilt.navigation.compose)
kapt(libs.hilt.android.kapt)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.splash)
implementation(libs.material)
implementation("androidx.legacy:legacy-support-v4:1.0.0")
implementation(libs.preference)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestUtil(libs.androidx.test.orchestrator)
androidTestImplementation(libs.test.ext)
androidTestImplementation(libs.espresso)
androidTestImplementation(libs.hilt.android.testing)
kaptAndroidTest(libs.hilt.android.kapt)
androidTestImplementation(libs.compose.test.junit)
debugImplementation(libs.compose.test.manifest)
}

View file

@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

View file

@ -12,10 +12,9 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning"
tools:targetApi="n">
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name=".ui.auth.AuthActivity"
android:name=".ui.MainActivity"
android:exported="true"
android:resizeableActivity="true"
android:theme="@style/Theme.App.Starting"
@ -29,34 +28,6 @@
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".ui.MainActivity"
android:exported="false"
android:resizeableActivity="true"
android:theme="@style/AppTheme" />
<activity
android:name=".ui.transactions.TransactionFormActivity"
android:exported="true"
android:parentActivityName=".ui.MainActivity"
android:resizeableActivity="true"
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity" />
</activity>
<activity
android:name=".ui.categories.CategoryFormActivity"
android:exported="false"
android:parentActivityName=".ui.MainActivity"
android:resizeableActivity="true"
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity" />
</activity>
</application>
</manifest>

View file

@ -1,8 +1,9 @@
package com.wbrawner.budget
import android.app.Application
import android.util.Log
import com.wbrawner.pihelper.shared.create
import com.wbrawner.twigs.shared.ErrorHandler
import com.wbrawner.twigs.shared.Store
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -12,14 +13,12 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
fun provideErrorHandler(): com.wbrawner.twigs.shared.ErrorHandler = object :
com.wbrawner.twigs.shared.ErrorHandler {
override fun init(application: Application) {
// no-op
}
fun provideErrorHandler(): ErrorHandler = object : ErrorHandler {
override fun reportException(t: Throwable, message: String?) {
Log.e("ErrorHandler", "Report exception: $message", t)
}
}
@Provides
fun providesStore() = Store.create()
}

View file

@ -1,36 +0,0 @@
package com.wbrawner.budget
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
sealed class AsyncState<out T> {
object Loading : AsyncState<Nothing>()
class Success<T>(val data: T) : AsyncState<T>()
class Error(val exception: Exception) : AsyncState<Nothing>() {
constructor(message: String) : this(RuntimeException(message))
}
object Exit : AsyncState<Nothing>()
}
interface AsyncViewModel<T> {
val state: MutableStateFlow<AsyncState<T>>
}
fun <VM, T> VM.load(block: suspend () -> T): Job where VM : ViewModel, VM : AsyncViewModel<T> = viewModelScope.launch {
if (state.replayCache.firstOrNull() !is AsyncState.Success) {
state.emit(AsyncState.Loading)
}
try {
state.emit(AsyncState.Success(block()))
} catch (e: Exception) {
state.emit(AsyncState.Error(e))
Log.e("AsyncViewModel", "Failed to load data", e)
}
}

View file

@ -1,33 +1,7 @@
package com.wbrawner.budget
import android.app.Application
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.UserRepository
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
@HiltAndroidApp
class TwigsApplication : Application(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var userRepository: UserRepository
override fun onCreate() {
super.onCreate()
launch {
try {
userRepository.getProfile()
budgetRepository.prefetchData()
} catch (ignored: Exception) {
}
}
}
}
class TwigsApplication : Application()

View file

@ -0,0 +1,105 @@
package com.wbrawner.budget.ui
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.transaction.TransactionAction
import java.util.*
import kotlin.math.abs
import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoriesScreen(store: Store) {
val scrollState = rememberLazyListState()
TwigsScaffold(store = store, title = "Categories") {
val state by store.state.collectAsState()
state.categories?.let { categories ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(it),
state = scrollState
) {
itemsIndexed(categories, key = { _, t -> t.id!! }) { index, category ->
CategoryListItem(category, state.categoryBalances?.get(category.id!!)) {
store.dispatch(TransactionAction.SelectTransaction(category.id))
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
@Composable
fun CategoryListItem(category: Category, balance: Long?, onClick: (Category) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(category) }
.padding(8.dp)
.heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = spacedBy(4.dp)
) {
Text(category.title, style = MaterialTheme.typography.bodyLarge)
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(),
progress = progress,
color = if (category.expense) Color.Red else Color.Green
)
} ?: LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun CategoryListItem_Preview() {
TwigsApp {
CategoryListItem(
category = Category(
title = "Groceries",
amount = 150000,
budgetId = "budgetId",
expense = true,
),
balance = null
) {}
}
}

View file

@ -1,6 +0,0 @@
package com.wbrawner.budget.ui
const val EXTRA_BUDGET_ID = "budgetId"
const val EXTRA_CATEGORY_ID = "categoryId"
const val EXTRA_CATEGORY_NAME = "categoryName"
const val EXTRA_TRANSACTION_ID = "transactionId"

View file

@ -1,152 +1,178 @@
package com.wbrawner.budget.ui
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.DrawableRes
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
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.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.runtime.Composable
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.navigation.NavigationView
import androidx.navigation.navArgument
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.ui.auth.LoginScreen
import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.budget.ui.transaction.TransactionDetailsScreen
import com.wbrawner.budget.ui.transaction.TransactionsScreen
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val MENU_GROUP_BUDGETS = 50
private const val MENU_ITEM_ADD_BUDGET = 100
private const val MENU_ITEM_SETTINGS = 101
@OptIn(ExperimentalAnimationApi::class)
@AndroidEntryPoint
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
private lateinit var toggle: ActionBarDrawerToggle
private val viewModel: MainViewModel by viewModels()
class MainActivity : AppCompatActivity() {
@Inject
lateinit var store: Store
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
setContent {
}
setContentView(R.layout.activity_main)
setSupportActionBar(action_bar)
toggle =
ActionBarDrawerToggle(this, drawerLayout, R.string.action_open, R.string.action_close)
toggle.isDrawerIndicatorEnabled = true
toggle.isDrawerSlideAnimationEnabled = true
drawerLayout.addDrawerListener(toggle)
navigationView.setNavigationItemSelectedListener(this)
supportActionBar?.setHomeButtonEnabled(true)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val navController = findNavController(R.id.content_container)
menu_main.setupWithNavController(navController)
navController.addOnDestinationChangedListener { _, destination, _ ->
title = destination.label
val homeAsUpIndicator = when (destination.label) {
getString(R.string.title_overview) -> R.drawable.ic_menu
getString(R.string.title_transactions) -> R.drawable.ic_menu
getString(R.string.title_profile) -> R.drawable.ic_menu
getString(R.string.title_categories) -> R.drawable.ic_menu
else -> 0
val state by store.state.collectAsState()
val navController = rememberNavController()
LaunchedEffect(state.route) {
navController.navigate(state.route.path)
}
supportActionBar?.setHomeAsUpIndicator(homeAsUpIndicator)
}
lifecycleScope.launch {
viewModel.loadBudgets().collect { list ->
val menu = navigationView.menu
menu.clear()
val budgetsMenu = navigationView.menu.addSubMenu(0, 0, 0, "Budgets")
list.budgets.forEachIndexed { index, budget ->
budgetsMenu.add(MENU_GROUP_BUDGETS, index, index, budget.name)
.setIcon(R.drawable.ic_folder_selectable)
TwigsApp {
val authViewModel: AuthViewModel = hiltViewModel()
NavHost(navController, state.initialRoute.path) {
composable(Route.Login.path) {
LoginScreen(store = store, viewModel = authViewModel)
}
composable(Route.Overview.path) {
OverviewScreen(store = store)
}
composable(Route.Transactions(selected = null).path) {
TransactionsScreen(store = store)
}
composable(
Route.Transactions(selected = "{id}").path,
arguments = listOf(navArgument("id") {
type = NavType.StringType
nullable = false
})
) {
TransactionDetailsScreen(store = store)
}
composable(Route.Categories(selected = null).path) {
CategoriesScreen(store = store)
}
// composable(Route.RECURRING_TRANSACTIONS.path) {
// RecurringTransactionsScreen(store = store)
// }
}
budgetsMenu.setGroupCheckable(MENU_GROUP_BUDGETS, true, true)
list.selectedIndex?.let {
budgetsMenu.getItem(it).isChecked = true
}
menu.add(0, MENU_ITEM_ADD_BUDGET, list.budgets.size, R.string.title_add_budget)
.setIcon(R.drawable.ic_add_white_24dp)
menu.add(1, MENU_ITEM_SETTINGS, list.budgets.size + 1, "Settings")
.setIcon(R.drawable.ic_settings)
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId != android.R.id.home) return super.onOptionsItemSelected(item)
with(findNavController(R.id.content_container)) {
when (currentDestination?.label) {
getString(R.string.title_overview) -> drawerLayout.open()
getString(R.string.title_transactions) -> drawerLayout.open()
getString(R.string.title_profile) -> drawerLayout.open()
getString(R.string.title_categories) -> drawerLayout.open()
else -> navigateUp()
}
}
return true
}
companion object {
const val EXTRA_OPEN_FRAGMENT = "com.wbrawner.budget.MainActivity.EXTRA_OPEN_FRAGMENT"
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
MENU_ITEM_ADD_BUDGET -> findNavController(R.id.content_container).navigate(R.id.addEditBudget)
MENU_ITEM_SETTINGS -> findNavController(R.id.content_container).navigate(R.id.addEditBudget)
else -> viewModel.loadBudget(item.itemId)
}
drawerLayout.close()
return true
}
}
@Composable
fun MainScreen() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scaffoldState = rememberScaffoldState(drawerState)
Scaffold(
scaffoldState = scaffoldState,
drawerContent = {
}
) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TwigsDrawer(navController: NavController, budgets: List<Budget>, selectedBudgetId: String) {
Column(modifier = Modifier.fillMaxSize()) {
fun TwigsScaffold(
store: Store,
title: String,
onClickFab: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed),
navigationIcon: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
IconButton(onClick = {
coroutineScope.launch {
drawerState.open()
}
}) {
Icon(Icons.Default.Menu, "Main menu")
}
},
content: @Composable (padding: PaddingValues) -> Unit
) {
val state by store.state.collectAsState()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
TwigsDrawer(store = store, drawerState::close)
}
}
) {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = navigationIcon,
actions = actions,
title = {
Text(title)
}
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = state.route == Route.Overview,
onClick = { store.dispatch(BudgetAction.OverviewClicked) },
icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
label = { Text(text = "Overview") }
)
NavigationBarItem(
selected = state.route is Route.Transactions,
onClick = { store.dispatch(TransactionAction.TransactionsClicked) },
icon = { Icon(Icons.Default.AttachMoney, contentDescription = null) },
label = { Text(text = "Transactions") }
)
NavigationBarItem(
selected = state.route is Route.Categories,
onClick = { store.dispatch(CategoryAction.CategoriesClicked) },
icon = { Icon(Icons.Default.Category, contentDescription = null) },
label = { Text(text = "Categories") }
)
NavigationBarItem(
selected = false,
onClick = { store.dispatch(BudgetAction.OverviewClicked) },
icon = { Icon(Icons.Default.Repeat, contentDescription = null) },
label = { Text(text = "Recurring") }
)
}
},
floatingActionButton = {
onClickFab?.let { onClick ->
FloatingActionButton(onClick = onClick) {
Icon(imageVector = Icons.Default.Add, "Add")
}
}
}
) {
content(it)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TwigsDrawer(store: Store, close: suspend () -> Unit) {
val state by store.state.collectAsState()
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@ -156,60 +182,27 @@ fun TwigsDrawer(navController: NavController, budgets: List<Budget>, selectedBud
Image(painter = painterResource(id = image), null)
Text(
text = "twigs",
style = MaterialTheme.typography.h4
style = MaterialTheme.typography.titleLarge
)
}
NavigationDrawerItem(
selected = false,
onClick = { navController.navigate("overview") },
icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
label = { Text(text = "Overview") }
)
NavigationDrawerItem(
selected = false,
onClick = { navController.navigate("transactions") },
icon = { Icon(Icons.Default.AttachMoney, contentDescription = null) },
label = { Text(text = "Transactions") }
)
NavigationDrawerItem(
selected = false,
onClick = { navController.navigate("categories") },
icon = { Icon(Icons.Default.Category, contentDescription = null) },
label = { Text(text = "Categories") }
)
NavigationDrawerItem(
selected = false,
onClick = { navController.navigate("recurring") },
icon = { Icon(Icons.Default.Repeat, contentDescription = null) },
label = { Text(text = "Recurring Transactions") }
)
Divider()
LazyColumn(modifier = Modifier.fillMaxHeight()) {
items(budgets) { budget ->
val selected = budget.id == selectedBudgetId
val icon = if (selected) Icons.Filled.Folder else Icons.Outlined.Folder
NavigationDrawerItem(
icon = { Icon(icon, contentDescription = null) },
label = { Text(budget.name) },
selected = selected,
onClick = { navController.navigate("budgets/${budget.id}") }
)
state.budgets?.let { budgets ->
LazyColumn(modifier = Modifier.fillMaxHeight()) {
items(budgets) { budget ->
val selected = budget.id == state.selectedBudget
val icon = if (selected) Icons.Filled.Folder else Icons.Default.Folder
NavigationDrawerItem(
icon = { Icon(icon, contentDescription = null) },
label = { Text(budget.name) },
selected = selected,
onClick = {
store.dispatch(BudgetAction.SelectBudget(budget.id))
coroutineScope.launch {
close()
}
}
)
}
}
}
}
}
@Composable
@Preview
fun TwigsDrawer_Preview() {
val scaffoldState = rememberScaffoldState(rememberDrawerState(initialValue = DrawerValue.Open))
val navController = rememberNavController()
TwigsApp {
Scaffold(
scaffoldState = scaffoldState,
drawerContent = { TwigsDrawer(navController, emptyList(), "") }
) {
}
}
}
}

View file

@ -1,48 +0,0 @@
package com.wbrawner.budget.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
val budgetRepository: BudgetRepository,
val userRepository: UserRepository
) : ViewModel() {
private val budgets = MutableSharedFlow<BudgetList>(replay = 1)
fun loadBudgets(): SharedFlow<BudgetList> {
viewModelScope.launch {
val list = budgetRepository.findAll().sortedBy { it.name }
budgets.emit(
BudgetList(
list,
budgetRepository.currentBudget.replayCache.firstOrNull()?.let {
list.indexOf(it)
}
))
}
return budgets
}
fun loadBudget(index: Int) {
val list = budgets.replayCache.firstOrNull() ?: return
viewModelScope.launch {
budgetRepository.findById(list.budgets[index].id!!, true)
budgets.emit(list.copy(selectedIndex = index))
}
}
}
data class BudgetList(
val budgets: List<Budget> = emptyList(),
val selectedIndex: Int? = null
)

View file

@ -0,0 +1,39 @@
package com.wbrawner.budget.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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 com.wbrawner.budget.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store
@OptIn(ExperimentalMaterial3Api::class)
@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 } }
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
budget?.let { budget ->
Text(budget.name)
Text(budget.description ?: "")
}
Text("Cash Flow")
Text(state.budgetBalance?.toCurrencyString() ?: "-")
}
}
}

View file

@ -0,0 +1,14 @@
package com.wbrawner.budget.ui
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.wbrawner.twigs.shared.Store
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionsScreen(store: Store) {
TwigsScaffold(store = store, title = "Recurring Transactions") {
Text("Not yet implemented")
}
}

View file

@ -1,82 +0,0 @@
package com.wbrawner.budget.ui.auth
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.ViewTreeObserver
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.material.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.wbrawner.budget.ui.AuthViewModel
import com.wbrawner.budget.ui.MainActivity
import com.wbrawner.budget.ui.base.TwigsApp
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collect
@AndroidEntryPoint
class AuthActivity : AppCompatActivity() {
val viewModel: AuthViewModel by viewModels()
@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
val content: View = findViewById(android.R.id.content)
content.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
return if (viewModel.authenticated.value != null) {
Log.d("SplashScreen", "onPredraw Complete, removing listener")
content.viewTreeObserver.removeOnPreDrawListener(this)
true
} else {
Log.d("SplashScreen", "onPredraw active, ignoring draw")
false
}
}
}
)
setContent {
TwigsApp {
LaunchedEffect(key1 = viewModel) {
viewModel.checkForExistingCredentials()
}
LaunchedEffect(key1 = viewModel) {
viewModel.authenticated.collect {
if (it == true) {
// TODO: Replace with Compose Navigation
startActivity(Intent(this@AuthActivity, MainActivity::class.java))
finish()
}
}
}
val navController = rememberNavController()
NavHost(navController, AuthRoutes.LOGIN.name) {
composable(AuthRoutes.LOGIN.name) {
LoginScreen(navController = navController, viewModel = viewModel)
}
composable(AuthRoutes.REGISTER.name) {
Text("Not yet implemented")
}
composable(AuthRoutes.FORGOT_PASSWORD.name) {
Text("Not yet implemented")
}
}
}
}
}
}
enum class AuthRoutes {
LOGIN,
REGISTER,
FORGOT_PASSWORD,
}

View file

@ -1,100 +1,19 @@
package com.wbrawner.budget.ui
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.common.PREF_KEY_TOKEN
import com.wbrawner.budget.common.PREF_KEY_USER_ID
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.lib.network.PREF_KEY_BASE_URL
import com.wbrawner.twigs.shared.Store
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
val budgetRepository: BudgetRepository,
val userRepository: UserRepository,
val sharedPreferences: SharedPreferences,
val store: Store
) : ViewModel() {
val authenticated = MutableStateFlow<Boolean?>(null)
val loading = MutableStateFlow(false)
val error = MutableSharedFlow<String>(extraBufferCapacity = 1)
val server = MutableStateFlow("")
val username = MutableStateFlow("")
val email = MutableStateFlow("")
val password = MutableStateFlow("")
val confirmPassword = MutableStateFlow("")
val enableLogin = MutableStateFlow(false)
init {
viewModelScope.launch {
combine(server, username, password) { (server, username, password) ->
arrayOf(server.isNotBlank(), username.isNotBlank(), password.isNotBlank())
}.collect {
viewModelScope.launch {
enableLogin.emit(it.all { it })
}
}
}
}
fun checkForExistingCredentials() = viewModelScope.launch {
if (!sharedPreferences.contains(PREF_KEY_TOKEN)) {
authenticated.emit(false)
return@launch
}
loading.emit(true)
try {
userRepository.getProfile()
authenticated.emit(true)
} catch (ignored: Exception) {
authenticated.emit(false)
} finally {
loading.emit(false)
}
}
fun login() = viewModelScope.launch {
loading.emit(true)
try {
val correctServer = with(server.value) {
if (this.startsWith("http://") || this.startsWith("https://")) this
else "https://$this"
}.run{
if (this.endsWith("/api")) this else "$this/api"
}
userRepository.login(correctServer.trim(), username.value.trim(), password.value.trim())
.also {
sharedPreferences.edit {
putString(PREF_KEY_BASE_URL, correctServer)
putString(PREF_KEY_USER_ID, it.userId)
putString(PREF_KEY_TOKEN, it.token)
}
budgetRepository.prefetchData()
authenticated.emit(true)
}
} catch (e: Exception) {
loading.emit(false)
error.emit(e.localizedMessage ?: "Login failed")
}
}
fun setServer(server: String) = viewModelScope.launch {
this@AuthViewModel.server.emit(server)
}
fun setUsername(username: String) = viewModelScope.launch {
this@AuthViewModel.username.emit(username)
}
fun setPassword(password: String) = viewModelScope.launch {
this@AuthViewModel.password.emit(password)
}
val server = mutableStateOf("")
val username = mutableStateOf("")
val email = mutableStateOf("")
val password = mutableStateOf("")
val confirmPassword = mutableStateOf("")
val enableLogin = mutableStateOf(false)
}

View file

@ -7,123 +7,250 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
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.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.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.AuthViewModel
import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.twigs.shared.Effect
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.user.ConfigAction
@ExperimentalAnimationApi
@Composable
fun LoginScreen(
navController: NavController,
viewModel: AuthViewModel
store: Store,
viewModel: AuthViewModel
) {
val loading by viewModel.loading.collectAsState()
val server by viewModel.server.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val enableLogin by viewModel.enableLogin.collectAsState()
val state by store.state.collectAsState()
val effect by store.effects.collectAsState(initial = Effect.Empty)
val (error, setError) = remember { mutableStateOf("") }
(effect as? Effect.Error)?.let {
setError(it.message)
} ?: setError("")
val (server, setServer) = viewModel.server
val (username, setUsername) = viewModel.username
val (password, setPassword) = viewModel.password
AnimatedVisibility(
visible = !loading,
enter = fadeIn(),
exit = fadeOut()
visible = !state.loading,
enter = fadeIn(),
exit = fadeOut()
) {
LoginForm(
server,
viewModel::setServer,
username,
viewModel::setUsername,
password,
viewModel::setPassword,
enableLogin,
{ navController.navigate(AuthRoutes.FORGOT_PASSWORD.name) },
{ navController.navigate(AuthRoutes.REGISTER.name) },
viewModel::login,
error,
server,
setServer,
username,
setUsername,
password,
setPassword,
true,
{ store.dispatch(ConfigAction.ForgotPasswordClicked) },
{ store.dispatch(ConfigAction.RegisterClicked) },
{
store.dispatch(ConfigAction.SetServer(server))
store.dispatch(
ConfigAction.Login(
viewModel.username.value,
viewModel.password.value
)
)
},
)
}
AnimatedVisibility(
visible = state.loading,
enter = fadeIn(),
exit = fadeOut()
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun LoginForm(
server: String,
setServer: (String) -> Unit,
username: String,
setUsername: (String) -> Unit,
password: String,
setPassword: (String) -> Unit,
enableLogin: Boolean,
forgotPassword: () -> Unit,
register: () -> Unit,
login: () -> Unit,
error: String,
server: String,
setServer: (String) -> Unit,
username: String,
setUsername: (String) -> Unit,
password: String,
setPassword: (String) -> Unit,
enableLogin: Boolean,
forgotPassword: () -> Unit,
register: () -> Unit,
login: () -> Unit,
) {
val scrollState = rememberScrollState()
val (serverInput, usernameInput, passwordInput, loginButton) = FocusRequester.createRefs()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color),
contentDescription = null
painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color),
contentDescription = null
)
Text("Log in to manage your budgets")
if (error.isNotBlank()) {
Text(text = error, color = Color.Red)
}
TextField(
modifier = Modifier.fillMaxWidth(),
value = server,
onValueChange = setServer,
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, capitalization = KeyboardCapitalization.None),
placeholder = { Text("Server") }
modifier = Modifier
.fillMaxWidth()
.focusRequester(serverInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && !it.isShiftPressed) {
usernameInput.requestFocus()
true
} else {
false
}
},
value = server,
onValueChange = setServer,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next
),
placeholder = { Text("Server") },
keyboardActions = KeyboardActions(onNext = {
usernameInput.requestFocus()
}),
maxLines = 1
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = username,
onValueChange = setUsername,
placeholder = { Text("Username") }
modifier = Modifier
.fillMaxWidth()
.focusRequester(usernameInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab) {
if (it.isShiftPressed) {
serverInput.requestFocus()
} else {
passwordInput.requestFocus()
}
true
} else {
false
}
},
value = username,
onValueChange = setUsername,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next
),
placeholder = { Text("Username") },
keyboardActions = KeyboardActions(onNext = {
passwordInput.requestFocus()
}),
maxLines = 1
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = password,
onValueChange = setPassword,
placeholder = { Text("Password") },
visualTransformation = PasswordVisualTransformation()
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordInput)
.onPreviewKeyEvent {
when (it.key) {
Key.Tab -> {
if (it.isShiftPressed) {
usernameInput.requestFocus()
} else {
loginButton.requestFocus()
}
true
}
Key.Enter -> {
login()
true
}
else -> {
false
}
}
},
value = password,
onValueChange = setPassword,
placeholder = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
login()
}),
maxLines = 1
)
Button(
modifier = Modifier.fillMaxWidth(),
enabled = enableLogin,
onClick = login
modifier = Modifier
.fillMaxWidth()
.focusRequester(loginButton),
enabled = enableLogin,
onClick = login
) {
Text("Login")
}
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = forgotPassword
modifier = Modifier.fillMaxWidth(),
onClick = forgotPassword
) {
Text("Forgot password?")
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = register
modifier = Modifier.fillMaxWidth(),
onClick = register
) {
Text("Need an account?")
}
@ -132,24 +259,18 @@ fun LoginForm(
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun LoginScreen_Preview() {
TwigsApp {
LoginForm("", {}, "", {}, "", {}, false, {}, {}, { })
LoginForm("", "", {}, "", {}, "", {}, false, {}, {}, { })
}
}
@Composable
@Preview(showBackground = true, device = Devices.PIXEL_C)
@Preview(showBackground = true, device = Devices.PIXEL_C, uiMode = UI_MODE_NIGHT_YES)
fun LoginScreen_PreviewTablet() {
TwigsApp {
LoginForm("", {}, "", {}, "", {}, false, {}, {}, { })
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun LoginScreen_PreviewDark() {
TwigsApp {
LoginForm("", {}, "", {}, "", {}, false, {}, {}, { })
LoginForm("", "", {}, "", {}, "", {}, false, {}, {}, { })
}
}

View file

@ -1,87 +0,0 @@
package com.wbrawner.budget.ui.base
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.R
import com.wbrawner.budget.common.Identifiable
import com.wbrawner.budget.ui.hideFabOnScroll
import kotlinx.android.synthetic.main.fragment_list_with_add_button.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
abstract class ListWithAddButtonFragment<Data : Identifiable, ViewModel : AsyncViewModel<List<Data>>> : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_list_with_add_button, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.layoutManager = LinearLayoutManager(view.context)
recyclerView.hideFabOnScroll(addFab)
val adapter = BindableAdapter(constructors, diffUtilItemCallback)
recyclerView.adapter = adapter
addFab.setOnClickListener {
addItem()
}
noItemsTextView.setText(noItemsStringRes)
lifecycleScope.launch {
viewModel.state.collect { state ->
when (state) {
is AsyncState.Loading -> {
progressBar.visibility = View.VISIBLE
listContainer.visibility = View.GONE
noItemsTextView.visibility = View.GONE
}
is AsyncState.Success -> {
progressBar.visibility = View.GONE
listContainer.visibility = View.VISIBLE
noItemsTextView.visibility = View.GONE
adapter.submitList(state.data.map { bindData(it) })
}
is AsyncState.Error -> {
// TODO: Show an error message
progressBar.visibility = View.GONE
listContainer.visibility = View.GONE
noItemsTextView.visibility = View.VISIBLE
}
else -> { /* no-op */ }
}
}
}
}
override fun onStart() {
super.onStart()
reloadItems()
}
abstract val viewModel: ViewModel
abstract fun reloadItems()
@get:StringRes
abstract val noItemsStringRes: Int
abstract fun addItem()
abstract fun bindData(data: Data): BindableData<Data>
abstract val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Data>>
open val diffUtilItemCallback: DiffUtil.ItemCallback<BindableData<Data>> = object: DiffUtil.ItemCallback<BindableData<Data>>() {
override fun areItemsTheSame(oldItem: BindableData<Data>, newItem: BindableData<Data>): Boolean {
return oldItem.data.id === newItem.data.id
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: BindableData<Data>, newItem: BindableData<Data>): Boolean {
return oldItem.data == newItem.data
}
}
}

View file

@ -1,55 +0,0 @@
package com.wbrawner.budget.ui.base
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
class BindableAdapter<Data>(
private val constructors: Map<Int, (view: View) -> BindableViewHolder<Data>>,
diffUtilItemCallback: DiffUtil.ItemCallback<BindableData<Data>>
) : ListAdapter<BindableData<Data>, BindableAdapter.BindableViewHolder<Data>>(diffUtilItemCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
: BindableViewHolder<Data> = constructors[viewType]
?.invoke(LayoutInflater.from(parent.context).inflate(viewType, parent, false))
?: throw IllegalStateException("Attempted to create ViewHolder without proper constructor provided")
override fun onBindViewHolder(holder: BindableViewHolder<Data>, position: Int) {
holder.onBind(getItem(position))
}
override fun onViewRecycled(holder: BindableViewHolder<Data>) {
holder.onUnbind()
}
override fun getItemViewType(position: Int): Int = getItem(position).viewType
abstract class BindableViewHolder<T>(itemView: View) : RecyclerView.ViewHolder(itemView), Bindable<BindableData<T>>
abstract class CoroutineViewHolder<T>(itemView: View) : BindableViewHolder<T>(itemView), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
override fun onUnbind() {
coroutineContext[Job]?.cancel()
super.onUnbind()
}
}
}
interface Bindable<T> {
fun onBind(item: T) {}
fun onUnbind() {}
}
data class BindableData<T>(
val data: T,
@get:LayoutRes
val viewType: Int
)

View file

@ -1,44 +1,64 @@
package com.wbrawner.budget.ui.base
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.Surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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,
)
val darkColors = darkColorScheme(
primary = Green300,
primaryContainer = Green500,
secondary = Green500,
secondaryContainer = Green700,
background = Color.Black,
surface = Color.Black,
)
val ubuntu = FontFamily(
Font(R.font.ubuntu_bold, weight = FontWeight.Bold),
Font(R.font.ubuntu_regular, weight = FontWeight.Normal),
Font(R.font.ubuntu_light, weight = FontWeight.Light),
Font(R.font.ubuntu_bolditalic, weight = FontWeight.Bold, style = FontStyle.Italic),
Font(R.font.ubuntu_italic, weight = FontWeight.Normal, style = FontStyle.Italic),
Font(R.font.ubuntu_lightitalic, weight = FontWeight.Light, style = FontStyle.Italic),
)
@Composable
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (darkMode) darkColors else lightColors,
// typography = MaterialTheme.typography.copy(
// h1 = MaterialTheme.typography.h1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// h2 = MaterialTheme.typography.h2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// h3 = MaterialTheme.typography.h3.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// h4 = MaterialTheme.typography.h4.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// h5 = MaterialTheme.typography.h5.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// h6 = MaterialTheme.typography.h6.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// subtitle1 = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// subtitle2 = MaterialTheme.typography.subtitle2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// body1 = MaterialTheme.typography.body1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// body2 = MaterialTheme.typography.body2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// button = MaterialTheme.typography.button.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// caption = MaterialTheme.typography.caption.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// overline = MaterialTheme.typography.overline.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
// ),
typography = MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = ubuntu),
displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = ubuntu),
headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = ubuntu),
headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = ubuntu),
headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = ubuntu),
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = ubuntu),
titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = ubuntu),
titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = ubuntu),
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = ubuntu),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = ubuntu),
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = ubuntu),
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = ubuntu),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = ubuntu),
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = ubuntu),
),
content = content
)
}

View file

@ -1,101 +0,0 @@
package com.wbrawner.budget.ui.budgets
import android.content.Context
import android.os.Bundle
import android.view.*
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.fragment_add_edit_budget.*
@AndroidEntryPoint
class AddEditBudgetFragment : Fragment() {
private val viewModel: BudgetFormViewModel by viewModels()
var id: String? = null
var menu: Menu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onAttach(context: Context) {
super.onAttach(context)
viewModel.getBudget(arguments?.getString(EXTRA_BUDGET_ID))
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_add_edit_budget, container, false)
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_add_edit, menu)
if (id != null) {
menu.findItem(R.id.action_delete)?.isVisible = true
}
this.menu = menu
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val suggestionsAdapter = ArrayAdapter<User>(
requireContext(),
android.R.layout.simple_spinner_item,
mutableListOf()
)
usersSearch.setAdapter(suggestionsAdapter)
viewModel.userSuggestions.observe(viewLifecycleOwner, Observer {
suggestionsAdapter.clear()
suggestionsAdapter.addAll(it)
})
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is BudgetFormState.Loading -> {
progressBar.visibility = View.VISIBLE
budgetForm.visibility = View.GONE
}
is BudgetFormState.Success -> {
budgetForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
activity?.setTitle(state.titleRes)
menu?.findItem(R.id.action_delete)?.isVisible = state.showDeleteButton
id = state.budget.id
name.setText(state.budget.name)
description.setText(state.budget.description)
}
is BudgetFormState.Exit -> {
findNavController().navigateUp()
}
else -> { /* no-op */ }
}
})
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> findNavController().navigateUp()
R.id.action_save -> {
viewModel.saveBudget(Budget(
id = id,
name = name.text.toString(),
description = description.text.toString(),
users = emptyList()
))
}
R.id.action_delete -> {
viewModel.deleteBudget(this@AddEditBudgetFragment.id!!)
}
}
return true
}
}

View file

@ -1,99 +0,0 @@
package com.wbrawner.budget.ui.budgets
import androidx.annotation.StringRes
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.common.util.randomId
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class BudgetFormViewModel @Inject constructor(
val budgetRepository: BudgetRepository,
val userRepository: UserRepository
) : ViewModel() {
val state = MutableLiveData<BudgetFormState>(BudgetFormState.Loading)
val users = MutableLiveData<List<User>>()
val userSuggestions = MutableLiveData<List<User>>()
fun getBudget(id: String? = null) {
viewModelScope.launch {
state.postValue(BudgetFormState.Loading)
try {
val budget = id?.let {
budgetRepository.findById(it)
} ?: Budget(name = "")
state.postValue(BudgetFormState.Success(budget))
} catch (e: Exception) {
state.postValue(BudgetFormState.Failed(e))
}
}
}
fun saveBudget(budget: Budget) {
viewModelScope.launch {
state.postValue(BudgetFormState.Loading)
try {
if (budget.id != null) {
budgetRepository.update(budget)
} else {
budgetRepository.create(budget.copy(id = randomId()))
}
state.postValue(BudgetFormState.Exit)
} catch (e: Exception) {
state.postValue(BudgetFormState.Failed(e))
}
}
}
fun deleteBudget(budgetId: String) {
viewModelScope.launch {
state.postValue(BudgetFormState.Loading)
try {
budgetRepository.delete(budgetId)
state.postValue(BudgetFormState.Exit)
} catch (e: Exception) {
state.postValue(BudgetFormState.Failed(e))
}
}
}
fun searchUsers(query: String) {
if (query.isBlank()) {
userSuggestions.value = emptyList()
return
}
viewModelScope.launch {
userSuggestions.value = userRepository.findAllByNameLike(query).toList()
}
}
fun addUser(user: User) {
users.value
}
}
sealed class BudgetFormState {
object Loading: BudgetFormState()
class Success(
@StringRes val titleRes: Int,
val showDeleteButton: Boolean,
val budget: Budget
): BudgetFormState() {
constructor(budget: Budget): this(
budget.id?.let { R.string.title_edit_budget }?: R.string.title_add_budget,
budget.id != null,
budget
)
}
class Failed(val exception: Exception): BudgetFormState()
object Exit: BudgetFormState()
}

View file

@ -1,61 +0,0 @@
package com.wbrawner.budget.ui.budgets
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableData
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class BudgetListFragment : ListWithAddButtonFragment<Budget, BudgetListViewModel>() {
override val noItemsStringRes: Int = R.string.overview_no_data
override val viewModel: BudgetListViewModel by viewModels()
override fun reloadItems() {
viewModel.getBudgets()
}
override fun bindData(data: Budget): BindableData<Budget> = BindableData(data, BUDGET_VIEW)
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Budget>>
get() = mapOf(BUDGET_VIEW to { v -> BudgetViewHolder(v, findNavController()) })
override fun addItem() {
findNavController().navigate(R.id.addEditBudget)
}
}
const val BUDGET_VIEW = R.layout.list_item_budget
class BudgetViewHolder(itemView: View, val navController: NavController) : BindableAdapter.BindableViewHolder<Budget>(itemView) {
private val name: TextView = itemView.findViewById(R.id.budgetName)
private val description: TextView = itemView.findViewById(R.id.budgetDescription)
// private val balance: TextView = itemView.findViewById(R.id.budgetBalance)
override fun onBind(item: BindableData<Budget>) {
val budget = item.data
name.text = budget.name
if (budget.description.isNullOrBlank()) {
description.visibility = View.GONE
} else {
description.visibility = View.VISIBLE
description.text = budget.description
}
itemView.setOnClickListener {
val bundle = Bundle().apply {
putString(EXTRA_BUDGET_ID, budget.id)
}
navController.navigate(R.id.categoryListFragment, bundle)
}
}
}

View file

@ -1,25 +0,0 @@
package com.wbrawner.budget.ui.budgets
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.load
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@HiltViewModel
class BudgetListViewModel @Inject constructor(
val budgetRepository: BudgetRepository
) : ViewModel(), AsyncViewModel<List<Budget>> {
override val state: MutableStateFlow<AsyncState<List<Budget>>> =
MutableStateFlow(AsyncState.Loading)
fun getBudgets() {
load {
budgetRepository.findAll().toList()
}
}
}

View file

@ -1,123 +0,0 @@
package com.wbrawner.budget.ui.categories
import android.os.Build
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_NAME
import com.wbrawner.budget.ui.toAmountSpannable
import com.wbrawner.budget.ui.transactions.TransactionListFragment
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.fragment_category_details.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@AndroidEntryPoint
class CategoryDetailsFragment : Fragment() {
val viewModel: CategoryDetailsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? =
inflater.inflate(R.layout.fragment_category_details, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
activity?.title = arguments?.getString(EXTRA_CATEGORY_NAME)
lifecycleScope.launch {
viewModel.state.collect { state ->
when (state) {
is AsyncState.Loading -> {
categoryDetails.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is AsyncState.Success -> {
categoryDetails.visibility = View.VISIBLE
progressBar.visibility = View.GONE
val category = state.data.category
activity?.title = category.title
val tintColor =
if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
val colorStateList = with(view.context) {
android.content.res.ColorStateList.valueOf(getColor(tintColor))
}
categoryProgress.progressTintList = colorStateList
categoryProgress.max = category.amount.toInt()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
categoryProgress.setProgress(
state.data.balance.toInt(),
true
)
} else {
categoryProgress.progress = state.data.balance.toInt()
}
total.text = category.amount.toAmountSpannable()
balance.text = state.data.balance.toAmountSpannable()
remaining.text = state.data.remaining.toAmountSpannable()
if (category.description.isNullOrBlank()) {
categoryDescription.visibility = View.GONE
} else {
categoryDescription.visibility = View.VISIBLE
categoryDescription.text = category.description
}
childFragmentManager.fragments.firstOrNull()?.let {
if (it !is TransactionListFragment) return@let
it.reloadItems()
} ?: run {
val transactionsFragment = TransactionListFragment().apply {
arguments = Bundle().apply {
putString(EXTRA_BUDGET_ID, category.budgetId)
putString(EXTRA_CATEGORY_ID, category.id)
}
}
childFragmentManager.beginTransaction()
.replace(R.id.transactionsFragmentContainer, transactionsFragment)
.commit()
}
}
is AsyncState.Error -> {
categoryDetails.visibility = View.VISIBLE
progressBar.visibility = View.GONE
Toast.makeText(view.context, "Failed to load context", Toast.LENGTH_SHORT)
.show()
}
is AsyncState.Exit -> {
findNavController().navigateUp()
}
}
}
}
viewModel.getCategory(arguments?.getString(EXTRA_CATEGORY_ID))
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_edit) {
val bundle = Bundle().apply {
putString(EXTRA_CATEGORY_ID, arguments?.getString(EXTRA_CATEGORY_ID))
}
findNavController().navigate(R.id.addEditCategoryActivity, bundle)
} else if (item.itemId == android.R.id.home) {
return findNavController().navigateUp()
}
return super.onOptionsItemSelected(item)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_editable, menu)
}
}

View file

@ -1,46 +0,0 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.load
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CategoryDetailsViewModel @Inject constructor(
val categoryRepo: CategoryRepository
) : ViewModel(), AsyncViewModel<CategoryDetails> {
override val state: MutableStateFlow<AsyncState<CategoryDetails>> =
MutableStateFlow(AsyncState.Loading)
fun getCategory(id: String? = null) {
viewModelScope.launch {
if (id == null) {
state.emit(AsyncState.Error("Invalid category ID"))
return@launch
}
load {
val category = categoryRepo.findById(id)
val multiplier = if (category.expense) -1 else 1
val balance = categoryRepo.getBalance(id) * multiplier
CategoryDetails(
category,
balance,
category.amount - balance
)
}
}
}
}
data class CategoryDetails(
val category: Category,
val balance: Long,
val remaining: Long
)

View file

@ -1,148 +0,0 @@
package com.wbrawner.budget.ui.categories
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.lifecycleScope
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.transactions.toLong
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.activity_add_edit_category.*
import kotlinx.android.synthetic.main.activity_add_edit_category.progressBar
import kotlinx.android.synthetic.main.fragment_add_edit_budget.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@AndroidEntryPoint
class CategoryFormActivity : AppCompatActivity() {
val viewModel: CategoryFormViewModel by viewModels()
var id: String? = null
var menu: Menu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_edit_category)
setSupportActionBar(action_bar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
lifecycleScope.launch {
viewModel.state.collect { state ->
when (state) {
is AsyncState.Loading -> {
categoryForm.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is AsyncState.Success -> {
categoryForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
val category = state.data.category
id = category.id
setTitle(state.data.titleRes)
menu?.findItem(R.id.action_delete)?.isVisible = state.data.showDeleteButton
edit_category_name.setText(category.title)
edit_category_description.setText(category.description)
edit_category_amount.setText(
String.format(
"%.02f",
(category.amount.toBigDecimal() / 100.toBigDecimal()).toFloat()
)
)
expense.isChecked = category.expense
income.isChecked = !category.expense
archived.isChecked = category.archived
budgetSpinner.adapter = ArrayAdapter(
this@CategoryFormActivity,
android.R.layout.simple_list_item_1,
state.data.budgets
)
val budget = state.data.budgets.firstOrNull { it.id == category.budgetId }
?: viewModel.budgetRepository.currentBudget.replayCache.firstOrNull()
budgetSpinner.setSelection(state.data.budgets.indexOf(budget))
}
is AsyncState.Error -> {
// TODO: Show error message
categoryForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
Toast.makeText(this@CategoryFormActivity, "Failed to save Category", Toast.LENGTH_SHORT).show()
}
is AsyncState.Exit -> finish()
}
}
}
viewModel.loadCategory(intent?.extras?.getString(EXTRA_CATEGORY_ID))
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_add_edit, menu)
if (id != null) {
menu?.findItem(R.id.action_delete)?.isVisible = true
}
this.menu = menu
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
val upIntent: Intent? = NavUtils.getParentActivityIntent(this)
when {
upIntent == null -> throw IllegalStateException("No Parent Activity Intent")
NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot -> {
TaskStackBuilder.create(this)
.addNextIntentWithParentStack(upIntent)
.startActivities()
}
else -> {
NavUtils.navigateUpTo(this, upIntent)
}
}
}
R.id.action_save -> {
if (!validateFields()) return true
viewModel.saveCategory(
Category(
id = id,
title = edit_category_name.text.toString(),
description = edit_category_description.text.toString(),
amount = edit_category_amount.text.toLong(),
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
expense = expense.isChecked,
archived = archived.isChecked
)
)
}
R.id.action_delete -> {
viewModel.deleteCategoryById(this@CategoryFormActivity.id!!)
}
}
return true
}
private fun validateFields(): Boolean {
var errors = false
if (edit_category_name.text?.isEmpty() == true) {
edit_category_name.error = getString(R.string.required_field_name)
errors = true
}
if (edit_category_amount.text.toString().isEmpty()) {
edit_category_amount.error = getString(R.string.required_field_amount)
errors = true
}
return !errors
}
}

View file

@ -1,80 +0,0 @@
package com.wbrawner.budget.ui.categories
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.common.util.randomId
import com.wbrawner.budget.load
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CategoryFormViewModel @Inject constructor(
val categoryRepository: CategoryRepository,
val budgetRepository: BudgetRepository
) : ViewModel(), AsyncViewModel<CategoryFormState> {
override val state: MutableStateFlow<AsyncState<CategoryFormState>> =
MutableStateFlow(AsyncState.Loading)
fun loadCategory(categoryId: String? = null) {
load {
val category = categoryId?.let {
categoryRepository.findById(it)
} ?: Category("", title = "", amount = 0)
CategoryFormState(
category,
budgetRepository.findAll().toList()
)
}
}
fun saveCategory(category: Category) {
viewModelScope.launch {
state.emit(AsyncState.Loading)
try {
if (category.id == null)
categoryRepository.create(category.copy(id = randomId()))
else
categoryRepository.update(category)
state.emit(AsyncState.Exit)
} catch (e: Exception) {
state.emit(AsyncState.Error(e))
}
}
}
fun deleteCategoryById(id: String) {
viewModelScope.launch {
state.emit(AsyncState.Loading)
try {
categoryRepository.delete(id)
state.emit(AsyncState.Exit)
} catch (e: Exception) {
state.emit(AsyncState.Error(e))
}
}
}
}
data class CategoryFormState(
val category: Category,
val budgets: List<Budget>,
@StringRes val titleRes: Int,
val showDeleteButton: Boolean
) {
constructor(category: Category, budgets: List<Budget>) : this(
category,
budgets,
category.id?.let { R.string.title_edit_category } ?: R.string.title_add_category,
category.id != null
)
}

View file

@ -1,87 +0,0 @@
package com.wbrawner.budget.ui.categories
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.R
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_NAME
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableData
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class CategoryListFragment : ListWithAddButtonFragment<Category, CategoryListViewModel>() {
override val noItemsStringRes: Int = R.string.categories_no_data
override val viewModel: CategoryListViewModel by viewModels()
override fun reloadItems() {
viewModel.getCategories()
}
override fun bindData(data: Category): BindableData<Category> = BindableData(data, CATEGORY_VIEW)
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Category>> = mapOf(CATEGORY_VIEW to { v -> CategoryViewHolder(v, viewModel, findNavController()) })
override fun addItem() {
startActivity(Intent(activity, CategoryFormActivity::class.java))
}
companion object {
const val TAG_FRAGMENT = "categories"
}
}
const val CATEGORY_VIEW = R.layout.list_item_category
class CategoryViewHolder(
itemView: View,
private val viewModel: CategoryListViewModel,
private val navController: NavController
) : BindableAdapter.CoroutineViewHolder<Category>(itemView) {
private val name: TextView = itemView.findViewById(R.id.category_title)
private val amount: TextView = itemView.findViewById(R.id.category_amount)
private val progressBar: ProgressBar = itemView.findViewById(R.id.category_progress)
@SuppressLint("NewApi")
override fun onBind(item: BindableData<Category>) {
val category = item.data
name.text = category.title
// TODO: Format according to budget's currency
amount.text = String.format("${'$'}%.02f", category.amount / 100.0f)
val tintColor = if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
val colorStateList = with(itemView.context) {
android.content.res.ColorStateList.valueOf(getColor(tintColor))
}
progressBar.progressTintList = colorStateList
progressBar.indeterminateTintList = colorStateList
progressBar.max = category.amount.toInt()
launch {
val balance = viewModel.getBalance(category).toInt()
progressBar.isIndeterminate = false
progressBar.setProgress(
balance,
true
)
amount.text = itemView.context.getString(
R.string.balance_remaning,
(category.amount - balance) / 100.0f
)
}
itemView.setOnClickListener {
val bundle = Bundle().apply {
putString(EXTRA_CATEGORY_ID, category.id)
putString(EXTRA_CATEGORY_NAME, category.title)
}
navController.navigate(R.id.categoryFragment, bundle)
}
}
}

View file

@ -1,43 +0,0 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.*
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.load
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CategoryListViewModel @Inject constructor(
val budgetRepo: BudgetRepository,
val categoryRepo: CategoryRepository
) : ViewModel(), AsyncViewModel<List<Category>> {
override val state: MutableStateFlow<AsyncState<List<Category>>> =
MutableStateFlow(AsyncState.Loading)
fun getCategories() {
viewModelScope.launch {
budgetRepo.currentBudget.collect {
val budgetId = budgetRepo.currentBudget.replayCache.firstOrNull()?.id
if (budgetId == null) {
state.emit(AsyncState.Error("Invalid budget ID"))
return@collect
}
load {
categoryRepo.findAll(arrayOf(budgetId)).toList()
}
}
}
}
suspend fun getBalance(category: Category): Long {
val multiplier = if (category.expense) -1 else 1
return categoryRepo.getBalance(category.id!!) * multiplier
}
}

View file

@ -1,30 +0,0 @@
package com.wbrawner.budget.ui.overview
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
class AccountsAdapter() : RecyclerView.Adapter<AccountsAdapter.AccountViewHolder>() {
private val accounts = mutableListOf<Budget>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AccountViewHolder(
LayoutInflater.from(parent.context!!).inflate(R.layout.list_item_budget, parent, false)
)
override fun getItemCount(): Int = accounts.size
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
val account = accounts[holder.adapterPosition]
holder.title.text = account.name
holder.balance.text = account.name
}
class AccountViewHolder(
itemView: View,
val title: TextView = itemView.findViewById(R.id.budgetName),
val balance: TextView = itemView.findViewById(R.id.budgetBalance)
) : RecyclerView.ViewHolder(itemView)
}

View file

@ -1,61 +0,0 @@
package com.wbrawner.budget.ui.overview
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.toAmountSpannable
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.fragment_overview.*
@AndroidEntryPoint
class OverviewFragment : Fragment() {
val viewModel: OverviewViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_overview, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is AsyncState.Loading -> {
overviewContent.visibility = View.GONE
noData.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is AsyncState.Success -> {
overviewContent.visibility = View.VISIBLE
noData.visibility = View.GONE
progressBar.visibility = View.GONE
activity?.title = state.data.budget.name
balance.text = state.data.balance.toAmountSpannable(view.context)
}
is AsyncState.Error -> {
overviewContent.visibility = View.GONE
progressBar.visibility = View.GONE
noData.visibility = View.VISIBLE
Log.e("OverviewFragment", "Failed to load overview", state.exception)
}
else -> { /* no-op */ }
}
})
}
override fun onStart() {
super.onStart()
viewModel.loadOverview()
}
companion object {
const val EXTRA_BUDGET_ID = "budgetId"
}
}

View file

@ -1,51 +0,0 @@
package com.wbrawner.budget.ui.overview
import androidx.lifecycle.*
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.transaction.TransactionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class OverviewViewModel @Inject constructor(
val budgetRepo: BudgetRepository,
val transactionRepo: TransactionRepository
) : ViewModel() {
val state = MutableLiveData<AsyncState<OverviewState>>(AsyncState.Loading)
fun loadOverview() {
viewModelScope.launch {
budgetRepo.currentBudget.collect { budget ->
if (budget == null) {
state.postValue(AsyncState.Error("Invalid Budget ID"))
return@collect
}
viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
// TODO: Load expected and actual income/expense amounts as well
state.postValue(
AsyncState.Success(
OverviewState(
budget,
budgetRepo.getBalance(budget.id!!)
)
)
)
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
}
}
}
}
}
}
data class OverviewState(
val budget: Budget,
val balance: Long
)

View file

@ -1,23 +0,0 @@
package com.wbrawner.budget.ui.profile
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.wbrawner.budget.R
/**
* A simple [Fragment] subclass.
*/
class ProfileFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_profile, container, false)
}
}

View file

@ -0,0 +1,144 @@
package com.wbrawner.budget.ui.transaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.budget.ui.TwigsScaffold
import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.budget.ui.util.format
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.shared.user.User
import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val transaction = remember { state.transactions!!.first { it.id == state.selectedTransaction } }
val createdBy = state.selectedTransactionCreatedBy ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val category = state.categories?.firstOrNull { it.id == transaction.categoryId }
val budget = state.budgets!!.first { it.id == transaction.budgetId }
TwigsScaffold(
store = store,
title = "Transaction Details",
navigationIcon = {
IconButton(onClick = { store.dispatch(Action.Back) }) {
Icon(Icons.Default.ArrowBack, "Go back")
}
},
actions = {
IconButton({ store.dispatch(TransactionAction.EditTransaction(requireNotNull(transaction.id))) }) {
Icon(Icons.Default.Edit, "Edit")
}
}
) { padding ->
TransactionDetails(
modifier = Modifier.padding(padding),
transaction = transaction,
category = category,
budget = budget,
createdBy = createdBy
)
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionDetails(
modifier: Modifier = Modifier,
transaction: Transaction,
category: Category? = null,
budget: Budget,
createdBy: User
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxSize()
.scrollable(scrollState, Orientation.Vertical)
.padding(16.dp),
verticalArrangement = spacedBy(8.dp)
) {
Text(
text = transaction.title,
style = MaterialTheme.typography.headlineMedium
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = transaction.date.format(LocalContext.current),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall,
color = if (transaction.expense) Color.Red else Color.Green
)
}
LabeledField("Description", transaction.description ?: "")
LabeledField("Category", category?.title ?: "")
LabeledField("Created By", createdBy.username)
}
}
@Composable
fun LabeledField(label: String, field: String) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = label, style = MaterialTheme.typography.bodySmall)
Text(text = field, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun TransactionDetails_Preview() {
TwigsApp {
TransactionDetails(
transaction = Transaction(
title = "DAZBOG",
description = "Chokolat Cappuccino",
date = Clock.System.now(),
amount = 550,
categoryId = "coffee",
budgetId = "budget",
createdBy = "user",
expense = true
),
category = Category(title = "Coffee", budgetId = "budget", amount = 1000),
budget = Budget(name = "Monthly Budget"),
createdBy = User(username = "user")
)
}
}

View file

@ -0,0 +1,325 @@
package com.wbrawner.budget.ui.transaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.wbrawner.budget.ui.base.TwigsApp
import com.wbrawner.budget.ui.util.DatePicker
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.util.*
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TransactionFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(TransactionAction.CancelEditTransaction) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
TransactionForm(store)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionForm(store: Store) {
val state by store.state.collectAsState()
val transaction = remember {
val defaultTransaction = Transaction(
title = "",
date = Clock.System.now(),
amount = 0L,
budgetId = state.selectedBudget!!,
categoryId = state.selectedCategory,
expense = true,
createdBy = state.user!!.id!!
)
if (state.selectedTransaction.isNullOrBlank()) {
defaultTransaction
} else {
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 } }) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { store.dispatch(TransactionAction.CancelEditTransaction) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
title = {
Text(if (transaction.id.isNullOrBlank()) "New Transaction" else "Edit Transaction")
}
)
}
) {
TransactionForm(
modifier = Modifier.padding(it),
title = title,
setTitle = setTitle,
description = description,
setDescription = setDescription,
date = date,
setDate = setDate,
amount = amount,
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
categories = state.categories?.filter { c -> c.expense == expense } ?: emptyList(),
category = category,
setCategory = setCategory
) {
store.dispatch(
transaction.id?.let { id ->
TransactionAction.UpdateTransaction(
id = id,
title = title,
amount = (amount.toDouble() * 100).toLong(),
date = date,
expense = expense,
category = category,
budget = budget
)
} ?: TransactionAction.CreateTransaction(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
date = date,
expense = expense,
category = category,
budget = budget
)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun TransactionForm(
modifier: Modifier,
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
date: Instant,
setDate: (Instant) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
categories: List<Category>,
category: Category?,
setCategory: (Category?) -> Unit,
save: () -> Unit
) {
val scrollState = rememberScrollState()
val (titleInput, descriptionInput, amountInput, dateInput) = FocusRequester.createRefs()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = spacedBy(8.dp, Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// if (error.isNotBlank()) {
// Text(text = error, color = Color.Red)
// }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && !it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = title,
onValueChange = setTitle,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
label = { Text("Title") },
keyboardActions = KeyboardActions(onNext = {
descriptionInput.requestFocus()
}),
maxLines = 1
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(descriptionInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab) {
if (it.isShiftPressed) {
titleInput.requestFocus()
} else {
amountInput.requestFocus()
}
true
} else {
false
}
},
value = description,
onValueChange = setDescription,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Next
),
label = { Text("Description") },
keyboardActions = KeyboardActions(onNext = {
amountInput.requestFocus()
}),
)
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(amountInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = amount,
onValueChange = setAmount,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
),
label = { Text("Amount") },
keyboardActions = KeyboardActions(onNext = {
keyboardController?.hide()
}),
)
val (datePickerVisible, setDatePickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = date,
setDate = setDate,
dialogVisible = datePickerVisible,
setDialogVisible = setDatePickerVisible
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.clickable {
setExpense(true)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = expense, onClick = { setExpense(true) })
Text(text = "Expense")
}
Row(
modifier = Modifier.clickable {
setExpense(false)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = !expense, onClick = { setExpense(false) })
Text(text = "Income")
}
}
val (categoriesExpanded, setCategoriesExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = categoriesExpanded,
onExpandedChange = setCategoriesExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = category?.title ?: "",
onValueChange = {},
readOnly = true,
label = {
Text("Category")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoriesExpanded)
}
)
ExposedDropdownMenu(expanded = categoriesExpanded, onDismissRequest = {
setCategoriesExpanded(false)
}) {
categories.forEach { c ->
DropdownMenuItem(
text = { Text(c.title) },
onClick = {
setCategory(c)
setCategoriesExpanded(false)
}
)
}
}
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = save
) {
Text("Save")
}
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun TransactionForm_Preview() {
TwigsApp {
TransactionForm(store = Store(reducers = emptyList()))
}
}

View file

@ -0,0 +1,141 @@
package com.wbrawner.budget.ui.transaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.util.format
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.shared.transaction.groupByDate
import kotlinx.datetime.Clock
import kotlinx.datetime.toInstant
import java.text.NumberFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionsScreen(store: Store) {
TwigsScaffold(
store = store,
title = "Transactions",
onClickFab = {
store.dispatch(TransactionAction.NewTransactionClicked)
}
) {
val state by store.state.collectAsState()
state.transactions?.let { transactions ->
val transactionGroups = remember { transactions.groupByDate() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(it)
) {
transactionGroups.forEach { (timestamp, transactions) ->
item(timestamp) {
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = timestamp.toInstant().format(LocalContext.current),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
itemsIndexed(transactions) { i, transaction ->
TransactionListItem(transaction) {
store.dispatch(TransactionAction.SelectTransaction(transaction.id))
}
if (i != transactions.lastIndex) {
Divider(modifier = Modifier.padding(horizontal = 8.dp))
}
}
item {
Spacer(
Modifier
.fillMaxWidth()
.height(32.dp))
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
}
}
@Composable
fun TransactionListItem(transaction: Transaction, onClick: (Transaction) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(transaction) }
.padding(8.dp)
.heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = spacedBy(4.dp)
) {
Text(transaction.title, style = MaterialTheme.typography.bodyLarge)
if (!transaction.description.isNullOrBlank()) {
Text(
transaction.description!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
transaction.amount.toCurrencyString(),
color = if (transaction.expense) Color.Red else Color.Green,
)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun TransactionListItem_Preview() {
TwigsApp {
TransactionListItem(
transaction = Transaction(
title = "Google Store",
description = "Pixel 7 Pro",
date = Clock.System.now(),
amount = 129999,
budgetId = "budgetId",
expense = true,
createdBy = "createdBy"
)
) {}
}
}
fun Long.toCurrencyString(): String =
NumberFormat.getCurrencyInstance().format(this.toDouble() / 100.0)
fun Long.toDecimalString(): String = if (this > 0) (this.toDouble() / 100.0).toString() else ""

View file

@ -1,442 +0,0 @@
package com.wbrawner.budget.ui.transactions
import android.app.TimePickerDialog
import android.content.Intent
import android.os.Bundle
import android.text.Editable
import android.text.format.DateFormat
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.NavUtils
import androidx.core.app.TaskStackBuilder
import com.google.android.material.datepicker.MaterialDatePicker
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
import com.wbrawner.budget.ui.MainActivity
import com.wbrawner.budget.ui.base.TwigsApp
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.activity_add_edit_transaction.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.time.Instant
import java.util.*
import kotlin.coroutines.CoroutineContext
import kotlin.math.max
@AndroidEntryPoint
class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private val viewModel: TransactionFormViewModel by viewModels()
var id: String? = null
var menu: Menu? = null
var transaction: Transaction? = null
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_edit_transaction)
setSupportActionBar(action_bar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setTitle(R.string.title_add_transaction)
edit_transaction_type_expense.isChecked = true
launch {
val accounts = viewModel.getAccounts().toTypedArray()
setCategories()
budgetSpinner.adapter = ArrayAdapter<Budget>(
this@TransactionFormActivity,
android.R.layout.simple_list_item_1,
accounts
)
container_edit_transaction_type.setOnCheckedChangeListener { _, _ ->
this@TransactionFormActivity.launch {
val budget = budgetSpinner.selectedItem as Budget
setCategories(
viewModel.getCategories(
budget.id!!,
edit_transaction_type_expense.isChecked
)
)
}
}
budgetSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) {
}
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
this@TransactionFormActivity.launch {
val budget = budgetSpinner.selectedItem as Budget
setCategories(
viewModel.getCategories(
budget.id!!,
edit_transaction_type_expense.isChecked
)
)
}
}
}
val budgetIndex = if (transaction?.budgetId != null) {
accounts.indexOfFirst { it.id == transaction?.budgetId }
} else {
accounts.indexOfFirst { it.id == viewModel.budgetRepository.currentBudget.replayCache.firstOrNull()?.id }
}
budgetSpinner.setSelection(max(0, budgetIndex))
loadTransaction()
transactionDate.setOnClickListener {
val currentDate = DateFormat.getDateFormat(this@TransactionFormActivity)
.parse(transactionDate.text.toString()) ?: Date()
MaterialDatePicker.Builder.datePicker()
.setSelection(currentDate.time)
.setTheme(R.style.DateTimePickerDialogTheme)
.build()
.also { picker ->
picker.addOnPositiveButtonClickListener {
transactionDate.text =
DateFormat.getDateFormat(this@TransactionFormActivity)
.apply {
timeZone = TimeZone.getTimeZone("UTC")
}
.format(Date(it))
}
}
.show(supportFragmentManager, null)
}
transactionTime.setOnClickListener {
val currentDate = DateFormat.getTimeFormat(this@TransactionFormActivity)
.parse(transactionTime.text.toString()) ?: Date()
TimePickerDialog(
this@TransactionFormActivity,
{ _, hourOfDay, minute ->
val newTime = Date().apply {
hours = hourOfDay
minutes = minute
}
transactionTime.text =
DateFormat.getTimeFormat(this@TransactionFormActivity)
.format(newTime)
},
currentDate.hours,
currentDate.minutes,
DateFormat.is24HourFormat(this@TransactionFormActivity)
).show()
}
}
}
private suspend fun loadTransaction() {
transaction = try {
viewModel.getTransaction(intent!!.extras!!.getString(EXTRA_TRANSACTION_ID)!!)
} catch (e: Exception) {
menu?.findItem(R.id.action_delete)?.isVisible = false
val date = Date()
transactionDate.text = DateFormat.getDateFormat(this).format(date)
transactionTime.text = DateFormat.getTimeFormat(this).format(date)
return
}
setTitle(R.string.title_edit_transaction)
id = transaction?.id
menu?.findItem(R.id.action_delete)?.isVisible = true
edit_transaction_title.setText(transaction?.title)
edit_transaction_description.setText(transaction?.description)
edit_transaction_amount.setText(String.format("%.02f", transaction!!.amount / 100.0f))
if (transaction!!.expense) {
edit_transaction_type_expense.isChecked = true
} else {
edit_transaction_type_income.isChecked = true
}
transactionDate.text = DateFormat.getDateFormat(this).format(transaction!!.date)
transactionTime.text = DateFormat.getTimeFormat(this).format(transaction!!.date)
transaction?.categoryId?.let {
for (i in 0 until edit_transaction_category.adapter.count) {
if (it == (edit_transaction_category.adapter.getItem(i) as Category).id) {
edit_transaction_category.setSelection(i)
break
}
}
}
}
private fun setCategories(categories: List<Category> = emptyList()) {
val adapter = ArrayAdapter<Category>(
this@TransactionFormActivity,
android.R.layout.simple_list_item_1
)
adapter.add(
Category(
title = getString(R.string.uncategorized),
amount = 0, budgetId = ""
)
)
adapter.addAll(categories)
edit_transaction_category.adapter = adapter
transaction?.categoryId?.let {
for (i in 0 until edit_transaction_category.adapter.count) {
if (it == (edit_transaction_category.adapter.getItem(i) as Category).id) {
edit_transaction_category.setSelection(i)
break
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_add_edit, menu)
if (id != null) {
menu?.findItem(R.id.action_delete)?.isVisible = true
}
this.menu = menu
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> onNavigateUp()
R.id.action_save -> {
val date = GregorianCalendar.getInstance().apply {
DateFormat.getDateFormat(this@TransactionFormActivity)
.parse(transactionDate.text.toString())
?.let {
time = it
}
DateFormat.getTimeFormat(this@TransactionFormActivity)
.parse(transactionTime.text.toString())
?.let { GregorianCalendar.getInstance().apply { time = it } }
?.let {
set(Calendar.HOUR_OF_DAY, it.get(Calendar.HOUR_OF_DAY))
set(Calendar.MINUTE, it.get(Calendar.MINUTE))
}
}
val categoryId = (edit_transaction_category.selectedItem as? Category)?.id
launch {
viewModel.saveTransaction(
Transaction(
id = id,
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
title = edit_transaction_title.text.toString(),
date = date.time,
description = edit_transaction_description.text.toString(),
amount = (BigDecimal(edit_transaction_amount.text.toString()) * 100.toBigDecimal()).toLong(),
expense = edit_transaction_type_expense.isChecked,
categoryId = categoryId,
createdBy = viewModel.currentUserId!!
)
)
onNavigateUp()
}
}
R.id.action_delete -> {
launch {
viewModel.deleteTransaction(this@TransactionFormActivity.id!!)
onNavigateUp()
}
}
}
return true
}
override fun onNavigateUp(): Boolean {
val upIntent: Intent = NavUtils.getParentActivityIntent(this)
?: throw IllegalStateException("No Parent Activity Intent")
upIntent.putExtra(MainActivity.EXTRA_OPEN_FRAGMENT, TransactionListFragment.TAG_FRAGMENT)
when {
NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot -> {
TaskStackBuilder.create(this)
.addNextIntentWithParentStack(upIntent)
.startActivities()
}
else -> {
finish()
}
}
return true
}
}
fun Editable?.toLong(): Long = toString().toDouble().toLong() * 100
@Composable
fun TransactionForm(
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
date: Long,
setDate: (Long) -> Unit,
budget: Budget,
setBudget: (Budget) -> Unit,
budgets: List<Budget>,
category: Category,
setCategory: (Category) -> Unit,
categories: List<Category>,
) {
val scrollState = rememberScrollState()
val (budgetsExpanded, setBudgetsExpanded) = remember { mutableStateOf(false) }
val (categoriesExpanded, setCategoriesExpanded) = remember { mutableStateOf(false) }
val context = LocalContext.current
val formattedDate = remember(date) { DateFormat.getDateFormat(context).format(Date(date)) }
val formattedTime = remember(date) { DateFormat.getTimeFormat(context).format(Date(date)) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(scrollState),
verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically)
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = title,
onValueChange = setTitle,
placeholder = { Text("Title") },
maxLines = 1
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = description,
onValueChange = setDescription,
placeholder = { Text("Description") }
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = amount,
onValueChange = setAmount,
placeholder = { Text("Amount") },
maxLines = 1,
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number)
)
Row(modifier = Modifier.fillMaxWidth()) {
RadioButton(selected = expense, onClick = { setExpense(true) })
Text("Expense")
Spacer(modifier = Modifier.width(8.dp))
RadioButton(selected = !expense, onClick = { setExpense(false) })
Text("Income")
}
Text(text = "Date", style = MaterialTheme.typography.caption)
Row(modifier = Modifier.fillMaxWidth()) {
TextButton(
onClick = { /*TODO: Show date picker dialog */ },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.onSurface)
) {
Text(formattedDate)
}
TextButton(
onClick = { /*TODO: Show time picker dialog */ },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.onSurface)
) {
Text(formattedTime)
}
}
Spinner("Budget", budget.name, { it.name }, budgets, setBudget, budgetsExpanded, setBudgetsExpanded)
Spinner("Categories", category.title, { it.title }, categories, setCategory, categoriesExpanded, setCategoriesExpanded)
}
}
@Composable
fun <T> Spinner(
title: String,
selectedItemTitle: String,
itemTitle: (T) -> String,
items: List<T>,
selectItem: (T) -> Unit,
expanded: Boolean,
setExpanded: (Boolean) -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = title, style = MaterialTheme.typography.caption)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = selectedItemTitle,
onValueChange = {},
readOnly = true
)
DropdownMenu(
modifier = Modifier.fillMaxWidth(),
expanded = expanded,
onDismissRequest = { setExpanded(false) }
) {
items.forEach {
DropdownMenuItem(onClick = { selectItem(it) }) {
Text(itemTitle(it))
}
}
}
}
}
@Composable
@Preview
fun TransactionForm_Preview() {
TwigsApp {
TransactionForm(
title = "",
setTitle = { },
description = "",
setDescription = { },
amount = "",
setAmount = { },
expense = false,
setExpense = { },
date = System.currentTimeMillis(),
setDate = {},
budget = Budget(name = "Uncategorized"),
setBudget = {},
budgets = emptyList(),
category = Category("budget", title = "Uncategorized", amount = 0L),
setCategory = {},
categories = emptyList()
)
}
}
@Composable
@Preview(heightDp = 400)
fun Spinner_ExpandedPreview() {
TwigsApp {
Spinner(
"Items",
"Uncategorized",
{ it },
listOf("one", "two", "three"),
{},
true,
{}
)
}
}

View file

@ -1,48 +0,0 @@
package com.wbrawner.budget.ui.transactions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.common.user.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TransactionFormViewModel @Inject constructor(
val budgetRepository: BudgetRepository,
val categoryRepository: CategoryRepository,
val transactionRepository: TransactionRepository,
val userRepository: UserRepository,
) : ViewModel() {
var currentUserId: String? = null
private set
init {
viewModelScope.launch {
userRepository.currentUser.collect {
currentUserId = it?.id
}
}
}
suspend fun getCategories(budgetId: String, expense: Boolean) = categoryRepository.findAll(arrayOf(budgetId)).filter {
it.expense == expense
}
suspend fun getTransaction(id: String) = transactionRepository.findById(id)
suspend fun saveTransaction(transaction: Transaction) = if (transaction.id == null)
transactionRepository.create(transaction)
else
transactionRepository.update(transaction)
suspend fun deleteTransaction(id: String) = transactionRepository.delete(id)
suspend fun getAccounts() = budgetRepository.findAll()
}

View file

@ -1,107 +0,0 @@
package com.wbrawner.budget.ui.transactions
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.R
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableData
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import dagger.hilt.android.AndroidEntryPoint
import java.text.SimpleDateFormat
@AndroidEntryPoint
class TransactionListFragment : ListWithAddButtonFragment<Transaction, TransactionListViewModel>() {
override val noItemsStringRes: Int = R.string.transactions_no_data
override val viewModel: TransactionListViewModel by viewModels()
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Transaction>>
get() = mapOf(TRANSACTION_VIEW to { v -> TransactionViewHolder(v, findNavController()) })
override fun reloadItems() {
viewModel.getTransactions(categoryId = arguments?.getString(EXTRA_CATEGORY_ID))
}
override fun bindData(data: Transaction): BindableData<Transaction> = BindableData(data, TRANSACTION_VIEW)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_transaction_list, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId != R.id.filter) {
return super.onOptionsItemSelected(item)
}
// TODO: Launch a Google Drive-style search/filter screen
AlertDialog.Builder(requireContext(), R.style.DialogTheme)
.setTitle("Filter Transactions")
.setPositiveButton(R.string.action_submit) { _, _ ->
reloadItems()
}
.setNegativeButton(R.string.action_cancel) { _, _ ->
// Do nothing
}
.create()
.show()
return true
}
override fun addItem() {
startActivity(Intent(activity, TransactionFormActivity::class.java))
}
companion object {
const val TAG_FRAGMENT = "transactions"
}
}
const val TRANSACTION_VIEW = R.layout.list_item_transaction
class TransactionViewHolder(itemView: View, val navController: NavController) : BindableAdapter.CoroutineViewHolder<Transaction>(itemView) {
private val name: TextView = itemView.findViewById(R.id.transaction_title)
private val description: TextView = itemView.findViewById(R.id.transaction_description)
private val date: TextView = itemView.findViewById(R.id.transaction_date)
private val amount: TextView = itemView.findViewById(R.id.transaction_amount)
@SuppressLint("NewApi")
override fun onBind(item: BindableData<Transaction>) {
val transaction = item.data
name.text = transaction.title
if (transaction.description.isNullOrBlank()) {
description.visibility = View.GONE
} else {
description.visibility = View.VISIBLE
description.text = transaction.description
}
date.text = SimpleDateFormat.getDateInstance(SimpleDateFormat.SHORT).format(transaction.date)
amount.text = String.format("${'$'}%.02f", transaction.amount / 100.0f)
val context = itemView.context
val color = if (transaction.expense) R.color.colorTextRed else R.color.colorTextGreen
amount.setTextColor(ContextCompat.getColor(context, color))
itemView.setOnClickListener {
val bundle = Bundle().apply {
putString(EXTRA_BUDGET_ID, transaction.budgetId)
putString(EXTRA_TRANSACTION_ID, transaction.id)
}
navController.navigate(R.id.addEditTransactionActivity, bundle)
}
}
}

View file

@ -1,38 +0,0 @@
package com.wbrawner.budget.ui.transactions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.load
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TransactionListViewModel @Inject constructor(
val budgetRepository: BudgetRepository,
val transactionRepo: TransactionRepository,
) : ViewModel(), AsyncViewModel<List<Transaction>> {
override val state: MutableStateFlow<AsyncState<List<Transaction>>> =
MutableStateFlow(AsyncState.Loading)
fun getTransactions(
categoryId: String? = null,
) {
viewModelScope.launch {
budgetRepository.currentBudget.collect { budget ->
val budgets = budget?.id?.let { listOf(it) }
val categories = categoryId?.let { listOf(it) }
load {
transactionRepo.findAll(budgets, categories).toList()
}
}
}
}
}

View file

@ -0,0 +1,101 @@
package com.wbrawner.budget.ui.util
import android.content.Context
import android.content.ContextWrapper
import android.text.format.DateFormat
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentManager
import com.google.android.material.datepicker.MaterialDatePicker
import com.wbrawner.budget.R
import kotlinx.datetime.Instant
import java.util.TimeZone
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePicker(
modifier: Modifier,
date: Instant,
setDate: (Instant) -> Unit,
dialogVisible: Boolean,
setDialogVisible: (Boolean) -> Unit
) {
Log.d("DatePicker", "date input: ${date.toEpochMilliseconds()}")
val context = LocalContext.current
OutlinedTextField(
modifier = modifier
.clickable {
Log.d("DatePicker", "click!")
setDialogVisible(true)
}
.focusRequester(FocusRequester())
.onFocusChanged {
setDialogVisible(it.hasFocus)
},
value = date.format(context),
onValueChange = {},
readOnly = true,
label = {
Text("Date")
}
)
val dialog = remember {
MaterialDatePicker.Builder.datePicker()
.setSelection(date.toEpochMilliseconds())
.setTheme(R.style.DateTimePickerDialogTheme)
.build()
.also { picker ->
picker.addOnPositiveButtonClickListener {
setDate(Instant.fromEpochMilliseconds(it))
}
picker.addOnDismissListener {
setDialogVisible(false)
}
}
}
DisposableEffect(key1 = dialogVisible) {
if (dialogVisible) {
context.fragmentManager?.let {
dialog.show(it, null)
}
} else if (dialog.isVisible) {
dialog.dismiss()
}
onDispose {
if (dialog.isVisible) {
dialog.dismiss()
}
}
}
}
val Context.activity: AppCompatActivity?
get() = when (this) {
is AppCompatActivity -> this
is ContextWrapper -> baseContext.activity
else -> null
}
val Context.fragmentManager: FragmentManager?
get() = this.activity?.supportFragmentManager
fun Instant.format(context: Context): String =
DateFormat.getDateFormat(context)
.format(this.toEpochMilliseconds() - TimeZone.getDefault().rawOffset).also {
Log.d(
"DatePicker",
"offset: ${TimeZone.getDefault().rawOffset} adjusted time: ${this.toEpochMilliseconds() - TimeZone.getDefault().rawOffset}"
)
}

View file

@ -1,112 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/categoryForm"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_name"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_name"
style="@style/AppTheme.EditText"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_description"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_description"
style="@style/AppTheme.EditText"
android:inputType="textCapSentences" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_amount"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_amount">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_amount"
style="@style/AppTheme.EditText"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
android:id="@+id/categoryExpenseContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RadioButton
android:id="@+id/expense"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/type_expense" />
<RadioButton
android:id="@+id/income"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/type_income" />
</RadioGroup>
<CheckBox
android:id="@+id/archived"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/archived" />
<TextView
android:id="@+id/budgetHint"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/prompt_budget" />
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/budgetSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/scrollView2"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_title"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_title"
style="@style/AppTheme.EditText"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_description"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_description"
style="@style/AppTheme.EditText"
android:inputType="textCapSentences|textMultiLine"
android:scrollHorizontally="false" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_amount"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_amount"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_amount"
style="@style/AppTheme.EditText"
android:inputType="numberDecimal"
android:scrollHorizontally="false" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
android:id="@+id/container_edit_transaction_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_amount">
<RadioButton
android:id="@+id/edit_transaction_type_expense"
android:text="@string/type_expense"
android:checked="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<RadioButton
android:id="@+id/edit_transaction_type_income"
android:text="@string/type_income"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RadioGroup>
<TextView
android:id="@+id/container_edit_transaction_date"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_transaction_date"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_type" />
<TextView
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:id="@+id/transactionDate"
tools:text="9/23/2019"
android:padding="8dp"
android:textColor="@color/colorTextPrimary"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintEnd_toStartOf="@+id/transactionTime"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_date" />
<TextView
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:id="@+id/transactionTime"
android:padding="8dp"
android:textColor="@color/colorTextPrimary"
tools:text="10:23 pm"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/transactionDate"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_date" />
<TextView
android:id="@+id/budgetHint"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_budget"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transactionDate" />
<Spinner
android:id="@+id/budgetSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/budgetHint" />
<TextView
android:id="@+id/container_edit_transaction_category"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_transaction_category"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/budgetSpinner" />
<Spinner
android:id="@+id/edit_transaction_category"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_category" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,56 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/menu_main"
android:layout_width="0dp"
android:layout_height="56dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/bottom_navigation" />
<fragment
android:id="@+id/content_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@+id/menu_main"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
android:paddingTop="16dp"
app:headerLayout="@layout/header_navigation_menu"
app:menu="@menu/drawer_navigation" />
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.budgets.BudgetListFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>

View file

@ -1,71 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:fillViewport="true"
android:padding="16dp"
tools:context=".ui.budgets.AddEditBudgetFragment">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/budgetForm"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/descriptionContainer"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_account_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/nameContainer">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/description"
style="@style/AppTheme.EditText"
android:inputType="textMultiLine|textCapSentences" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/nameContainer"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_account_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/name"
style="@style/AppTheme.EditText"
android:inputType="text|textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<AutoCompleteTextView
android:id="@+id/usersSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_share_with"
app:layout_constraintTop_toBottomOf="@+id/descriptionContainer" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/usersContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/usersSearch" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
</ScrollView>

View file

@ -1,117 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/categoryDetails"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/categoryDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/totalLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_total"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toStartOf="@+id/balanceLabel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/balanceLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_balance"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toStartOf="@+id/remainingLabel"
app:layout_constraintStart_toEndOf="@+id/totalLabel"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/remainingLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_remaining"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/balanceLabel"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/total"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toStartOf="@+id/balance"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/totalLabel"
tools:text="$60.00" />
<TextView
android:id="@+id/balance"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toStartOf="@+id/remaining"
app:layout_constraintStart_toEndOf="@+id/total"
app:layout_constraintTop_toBottomOf="@+id/balanceLabel"
tools:text="$40.00" />
<TextView
android:id="@+id/remaining"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/balance"
app:layout_constraintTop_toBottomOf="@+id/remainingLabel"
tools:text="$20.00" />
<ProgressBar
android:id="@+id/categoryProgress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/total"
tools:progress="40" />
<FrameLayout
android:id="@+id/transactionsFragmentContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/categoryProgress" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>

View file

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/listContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill_vertical|center_horizontal" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:clickable="true"
android:focusable="true"
app:backgroundTint="@color/colorPrimary"
app:srcCompat="@drawable/ic_add_white_24dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<TextView
android:id="@+id/noItemsTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="24sp"
android:visibility="gone"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>

View file

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/overviewContent"
android:orientation="vertical"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/balanceLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/label_current_balance" />
<TextView
android:id="@+id/balance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textSize="36sp" />
</LinearLayout>
<TextView
android:id="@+id/noData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="8dp"
android:text="@string/overview_no_data"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="24sp"
android:visibility="gone"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.profile.ProfileFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>

View file

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_transactions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill_vertical|center_horizontal" />
<TextView
android:id="@+id/transaction_list_no_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textAlignment="center"
android:textSize="24sp"
android:layout_gravity="center"
android:textColor="@color/colorTextPrimary"
android:visibility="gone" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_transaction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
app:srcCompat="@drawable/ic_add_white_24dp"
android:focusable="true" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/twigsIcon"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_twigs_color"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
style="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@+id/twigsIcon"
app:layout_constraintTop_toTopOf="@+id/twigsIcon"
app:layout_constraintStart_toEndOf="@+id/twigsIcon" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?attr/listPreferredItemHeight"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/budgetName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
style="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@+id/budgetDescription"
app:layout_constraintEnd_toStartOf="@+id/budgetBalance"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="US Budget" />
<TextView
android:id="@+id/budgetDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/budgetBalance"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/budgetName"
tools:text="Monthly expenses in the US" />
<TextView
android:id="@+id/budgetBalance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="$1,000" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/category_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorTextPrimary"
android:textSize="18sp"
app:layout_constraintBottom_toTopOf="@id/barrier"
app:layout_constraintEnd_toStartOf="@+id/category_amount"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/category_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?android:attr/textAppearanceSmall"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintBottom_toTopOf="@id/barrier"
app:layout_constraintStart_toEndOf="@+id/category_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="category_title,category_amount" />
<ProgressBar
android:id="@+id/category_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintEnd_toStartOf="@+id/barrier"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/barrier" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/transaction_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorTextPrimary"
android:textSize="18sp"
app:layout_constraintBottom_toTopOf="@+id/transaction_date"
app:layout_constraintEnd_toStartOf="@+id/barrier"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Starbucks" />
<TextView
android:id="@+id/transaction_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintBottom_toTopOf="@+id/transaction_date"
app:layout_constraintEnd_toStartOf="@+id/transaction_amount"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transaction_title"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Grande decaf vanilla latte with a shot of peppermint" />
<TextView
android:id="@+id/transaction_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/transaction_amount"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transaction_description"
app:layout_constraintVertical_chainStyle="packed"
tools:text="12/12/12" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="start"
app:constraint_referenced_ids="transaction_amount" />
<TextView
android:id="@+id/transaction_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="$4.68" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -2,8 +2,11 @@
androidx-core = "1.9.0"
androidx-appcompat = "1.5.1"
androidx-splash = "1.0.0"
compose = "1.2.1"
androidx-test-runner = "1.5.1"
androidx-test-orchestrator = "1.4.2"
compose = "1.3.1"
compose-compiler = "1.3.2"
compose-material3 = "1.0.1"
espresso = "3.3.0"
hilt-android = "2.44"
kotlin = "1.7.20"
@ -25,15 +28,22 @@ android-gradle = { module = "com.android.tools.build:gradle", version = "7.3.1"
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" }
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
compose-material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" }
compose-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" }
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt-android" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
junit = { module = "junit:junit", version = "4.12" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
@ -60,7 +70,16 @@ preference = { module = "androidx.preference:preference-ktx", version = "1.1.1"
test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
[bundles]
compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"]
compose = [
"compose-activity",
"compose-material",
"compose-material-icons",
"compose-material3",
"compose-material3-window",
"compose-tooling",
"compose-ui",
"navigation-compose"
]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]

View file

@ -20,7 +20,7 @@ kotlin {
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
api(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
api(libs.multiplatform.settings)
}

View file

@ -1,22 +1,37 @@
package com.wbrawner.pihelper.shared
import com.russhwolf.settings.Settings
import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetReducer
import com.wbrawner.twigs.shared.budget.NetworkBudgetRepository
import com.wbrawner.twigs.shared.category.CategoryReducer
import com.wbrawner.twigs.shared.category.NetworkCategoryRepository
import com.wbrawner.twigs.shared.network.KtorAPIService
import com.wbrawner.twigs.shared.network.commonConfig
import io.ktor.client.*
import io.ktor.client.engine.android.*
import com.wbrawner.twigs.shared.transaction.NetworkTransactionRepository
import com.wbrawner.twigs.shared.transaction.TransactionReducer
import com.wbrawner.twigs.shared.user.ConfigReducer
import com.wbrawner.twigs.shared.user.NetworkUserRepository
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
val apiService = KtorAPIService(HttpClient(Android) {
commonConfig()
})
val preferences = Settings()
val budgetRepository = NetworkBudgetRepository(apiService)
val categoryRepository = NetworkCategoryRepository(apiService)
val transactionRepository = NetworkTransactionRepository(apiService)
val userRepository = NetworkUserRepository(apiService)
fun Store.Companion.create(
reducers: List<Reducer> = listOf(
BudgetReducer(budgetRepository)
BudgetReducer(budgetRepository, preferences),
CategoryReducer(categoryRepository),
ConfigReducer(apiService, preferences),
TransactionReducer(transactionRepository, userRepository)
),
) = Store(reducers)

View file

@ -1,75 +1,78 @@
package com.wbrawner.twigs.shared
import com.russhwolf.settings.Settings
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.network.APIService
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.User
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class Route(val path: String) {
WELCOME("welcome"),
LOGIN("login"),
REGISTER("register"),
OVERVIEW("overview"),
TRANSACTIONS("transactions"),
CATEGORIES("categories"),
RECURRING_TRANSACTIONS("recurringtransactions"),
PROFILE("profile"),
SETTINGS("settings"),
ABOUT("about"),
sealed class Route(val path: String) {
object Welcome : Route("welcome")
object Login : Route("login")
object Register : Route("register")
object Overview : Route("overview")
data class Transactions(val selected: String? = null) :
Route(if (selected != null) "transactions/${selected}" else "transactions")
data class Categories(val selected: String? = null) :
Route(if (selected != null) "categories/${selected}" else "categories")
// data class RecurringTransactions(val selected: RecurringTransaction?): Route(if (selected != null) "transactions/${selected.id}" else "transactions")
object Profile : Route("profile")
object Settings : Route("settings")
object About : Route("about")
}
data class State(
val host: String? = null,
val user: User? = null,
val budgets: List<Budget>? = null,
val budgetBalance: Long? = null,
val selectedBudget: String? = null,
val editingBudget: Boolean = false,
val categories: List<Category>? = null,
val categoryBalances: Map<String, Long>? = null,
val selectedCategory: String? = null,
val editingCategory: Boolean = false,
val transactions: List<Transaction>? = null,
val selectedTransaction: String? = null,
val selectedTransactionCreatedBy: User? = null,
val editingTransaction: Boolean = false,
val loading: Boolean = false,
val route: Route = Route.WELCOME,
val initialRoute: Route = Route.WELCOME
)
val route: Route = Route.Login,
val initialRoute: Route = Route.Login
) {
override fun toString(): String {
return "State(budget=$selectedBudget, selectedTransaction=$selectedTransaction, route=$route)"
}
}
interface Action {
object About : Action
object AboutClicked : Action
object Back : Action
}
interface AsyncAction : Action
interface Effect {
object Exit : Effect
data class Error(val message: String) : Effect
object Empty: Effect
}
abstract class Reducer {
abstract class Reducer : CoroutineScope by CoroutineScope(Dispatchers.Main) {
lateinit var dispatch: (Action) -> Unit
lateinit var emit: (Effect) -> Unit
internal val initialActions = ArrayDeque<Action>()
abstract fun reduce(action: Action, state: State): State
abstract fun reduce(action: Action, state: () -> State): State
}
abstract class AsyncReducer : Reducer() {
abstract suspend fun reduce(action: AsyncAction, state: State): State
}
const val KEY_HOST = "baseUrl"
class Store(
private val reducers: List<Reducer>,
private val settings: Settings = Settings(),
initialState: State = State()
) : CoroutineScope by CoroutineScope(Dispatchers.Main) {
private val _state = MutableStateFlow(initialState)
@ -80,22 +83,32 @@ class Store(
init {
reducers.forEach {
it.dispatch = this::dispatch
it.emit = {
launch {
_effects.emit(it)
}
}
var action = it.initialActions.removeFirstOrNull()
while (action != null) {
dispatch(action)
action = it.initialActions.removeFirstOrNull()
}
}
launch {
state.collect {
println(it)
}
}
}
fun dispatch(action: Action) {
println(action)
launch {
var state = _state.value
if (action is AsyncAction) {
reducers.filterIsInstance<AsyncReducer>().forEach {
state = it.reduce(action, state)
}
} else {
reducers.forEach {
state = it.reduce(action, state)
}
var newState = _state.value
reducers.forEach {
newState = it.reduce(action) { newState }
}
_state.emit(state)
_state.emit(newState)
}
}

View file

@ -1,121 +1,163 @@
package com.wbrawner.twigs.shared.budget
import com.wbrawner.twigs.shared.*
import com.wbrawner.twigs.shared.user.UserAction
import com.russhwolf.settings.Settings
import com.russhwolf.settings.set
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.user.ConfigAction
import com.wbrawner.twigs.shared.user.UserPermission
import kotlinx.coroutines.launch
sealed interface BudgetAction : Action {
object OverviewClicked : BudgetAction
data class LoadBudgetsSuccess(val budgets: List<Budget>) : BudgetAction
data class LoadBudgetsFailed(val error: Exception) : BudgetAction
data class CreateBudget(
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : BudgetAction {
fun async() = BudgetAsyncAction.CreateBudgetAsync(name, description, users)
}
) : BudgetAction
data class SaveBudgetSuccess(val budget: Budget) : BudgetAction
data class SaveBudgetFailure(
val id: String? = null,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList(),
val error: Exception
) : BudgetAction
data class EditBudget(val id: String) : BudgetAction
data class SelectBudget(val id: String) : BudgetAction
data class SelectBudget(val id: String?) : BudgetAction
data class BudgetSelected(val id: String) : BudgetAction
data class UpdateBudget(
val id: String,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : BudgetAction {
fun async() = BudgetAsyncAction.UpdateBudgetAsync(id, name, description, users)
}
) : BudgetAction
data class DeleteBudget(val id: String) : BudgetAction {
fun async() = BudgetAsyncAction.DeleteBudgetAsync(id)
}
data class DeleteBudget(val id: String) : BudgetAction
}
sealed interface BudgetAsyncAction : AsyncAction {
data class CreateBudgetAsync(
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : BudgetAsyncAction
const val KEY_LAST_BUDGET = "lastBudget"
data class UpdateBudgetAsync(
val id: String,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList()
) : BudgetAsyncAction
data class DeleteBudgetAsync(val id: String) : BudgetAsyncAction
}
class BudgetReducer(private val budgetRepository: BudgetRepository) : AsyncReducer() {
override fun reduce(action: Action, state: State): State = when (action) {
is Action.Back -> state.copy(
editingBudget = false,
selectedBudget = if (state.editingBudget) state.selectedBudget else null
)
is BudgetAction.CreateBudget -> state.copy(loading = true).also {
dispatch(action.async())
}
is BudgetAction.EditBudget -> state.copy(
editingBudget = true,
selectedBudget = action.id
)
is BudgetAction.SelectBudget -> state.copy(
selectedBudget = action.id
)
is BudgetAction.UpdateBudget -> state.copy(loading = true).also {
dispatch(action.async())
}
is BudgetAction.DeleteBudget -> state.copy(loading = true).also {
dispatch(action.async())
}
is UserAction.Logout -> state.copy(
editingBudget = false,
selectedBudget = null,
budgets = null
)
else -> state
}
override suspend fun reduce(action: AsyncAction, state: State): State = when (action) {
is BudgetAsyncAction.CreateBudgetAsync -> {
val budget = budgetRepository.create(
Budget(name = action.name, description = action.description, users = action.users)
class BudgetReducer(
private val budgetRepository: BudgetRepository,
private val settings: Settings
) : Reducer() {
override fun reduce(action: Action, state: () -> State): State = when (action) {
is Action.Back -> {
val currentState = state()
currentState.copy(
editingBudget = false
)
val budgets = state.budgets?.toMutableList() ?: mutableListOf()
budgets.add(budget)
}
is BudgetAction.OverviewClicked -> state().copy(route = Route.Overview)
is BudgetAction.LoadBudgetsSuccess -> state().copy(budgets = action.budgets).also {
dispatch(BudgetAction.SelectBudget(settings.getStringOrNull(KEY_LAST_BUDGET)))
}
is BudgetAction.CreateBudget -> {
launch {
val budget = budgetRepository.create(
Budget(
name = action.name,
description = action.description,
users = action.users
)
)
dispatch(BudgetAction.SaveBudgetSuccess(budget))
}
state().copy(loading = true)
}
is BudgetAction.SaveBudgetSuccess -> {
val currentState = state()
val budgets = currentState.budgets?.toMutableList() ?: mutableListOf()
budgets.add(action.budget)
budgets.sortBy { it.name }
state.copy(
currentState.copy(
loading = false,
budgets = budgets.toList(),
selectedBudget = budget.id
selectedBudget = action.budget.id,
editingBudget = false
)
}
is BudgetAsyncAction.UpdateBudgetAsync -> {
budgetRepository.update(
Budget(
id = action.id,
name = action.name,
description = action.description,
users = action.users
)
)
state.copy(
loading = false,
editingBudget = false,
)
is ConfigAction.LoginSuccess -> {
launch {
try {
val budgets = budgetRepository.findAll()
dispatch(BudgetAction.LoadBudgetsSuccess(budgets))
} catch (e: Exception) {
dispatch(BudgetAction.LoadBudgetsFailed(e))
}
}
state().copy(loading = true)
}
is BudgetAsyncAction.DeleteBudgetAsync -> {
budgetRepository.delete(action.id)
val budgets = state.budgets?.filterNot { it.id == action.id }
state.copy(
loading = false,
budgets = budgets,
editingBudget = false,
selectedBudget = null
)
is ConfigAction.Logout -> state().copy(
budgets = null,
selectedBudget = null,
editingBudget = false
)
// is BudgetAction.EditBudget -> state.copy(
// editingBudget = true,
// selectedBudget = action.id
// )
is BudgetAction.SelectBudget -> {
val currentState = state()
val budgetId = currentState.budgets
?.firstOrNull { it.id == action.id }
?.id
?: currentState.budgets?.firstOrNull()?.id
settings[KEY_LAST_BUDGET] = budgetId
dispatch(BudgetAction.BudgetSelected(budgetId!!))
state()
}
else -> state
is BudgetAction.BudgetSelected -> state().copy(selectedBudget = action.id)
// is BudgetAction.UpdateBudget -> state.copy(loading = true).also {
// dispatch(action.async())
// }
// is BudgetAction.DeleteBudget -> state.copy(loading = true).also {
// dispatch(action.async())
// }
//
// is BudgetAsyncAction.UpdateBudgetAsync -> {
// budgetRepository.update(
// Budget(
// id = action.id,
// name = action.name,
// description = action.description,
// users = action.users
// )
// )
// state().copy(
// loading = false,
// editingBudget = false,
// )
// }
// is BudgetAsyncAction.DeleteBudgetAsync -> {
// budgetRepository.delete(action.id)
// val currentState = state()
// val budgets = currentState.budgets?.filterNot { it.id == action.id }
// currentState.copy(
// loading = false,
// budgets = budgets,
// editingBudget = false,
// selectedBudget = null
// )
// }
else -> state()
}
}

View file

@ -0,0 +1,186 @@
package com.wbrawner.twigs.shared.category
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.budget.BudgetAction
import kotlinx.coroutines.launch
sealed interface CategoryAction : Action {
object CategoriesClicked : CategoryAction
data class BalancesCalculated(
val budgetBalance: Long,
val categoryBalances: Map<String, Long>
) : CategoryAction
data class LoadCategoriesSuccess(val categories: List<Category>) : CategoryAction
data class LoadCategoriesFailed(val error: Exception) : CategoryAction
data class CreateCategory(
val name: String,
val description: String? = null,
val amount: Long,
val expense: Boolean
) : CategoryAction
data class SaveCategorySuccess(val category: Category) : CategoryAction
data class SaveCategoryFailure(
val id: String? = null,
val name: String,
val description: String? = null,
val amount: Long,
val expense: Boolean,
val error: Exception
) : CategoryAction
data class EditCategory(val id: String) : CategoryAction
data class SelectCategory(val id: String?) : CategoryAction
data class CategorySelected(val id: String) : CategoryAction
data class UpdateCategory(
val id: String,
val name: String,
val description: String? = null,
val amount: Long,
val expense: Boolean
) : CategoryAction
data class DeleteCategory(val id: String) : CategoryAction
}
class CategoryReducer(private val categoryRepository: CategoryRepository) : Reducer() {
override fun reduce(action: Action, state: () -> State): State = when (action) {
is Action.Back -> {
val currentState = state()
currentState.copy(
editingCategory = false,
selectedCategory = if (currentState.editingCategory) currentState.selectedCategory else null
)
}
is CategoryAction.CategoriesClicked -> state().copy(route = Route.Categories())
is BudgetAction.BudgetSelected -> {
launch {
try {
val categories = categoryRepository.findAll(budgetIds = arrayOf(action.id))
dispatch(CategoryAction.LoadCategoriesSuccess(categories))
} catch (e: Exception) {
dispatch(CategoryAction.LoadCategoriesFailed(e))
}
}
state().copy(categories = null)
}
is CategoryAction.LoadCategoriesSuccess -> state().copy(categories = action.categories)
.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
}
dispatch(CategoryAction.BalancesCalculated(budgetBalance, categoryBalances))
}
}
is CategoryAction.BalancesCalculated -> state().copy(
budgetBalance = action.budgetBalance,
categoryBalances = action.categoryBalances
)
// is BudgetAction.CreateBudget -> {
// launch {
// val budget = budgetRepository.create(
// Budget(
// name = action.name,
// description = action.description,
// users = action.users
// )
// )
// dispatch(BudgetAction.SaveBudgetSuccess(budget))
// }
// state().copy(loading = true)
// }
// is BudgetAction.SaveBudgetSuccess -> {
// val currentState = state()
// val budgets = currentState.budgets?.toMutableList() ?: mutableListOf()
// budgets.add(action.budget)
// budgets.sortBy { it.name }
// currentState.copy(
// loading = false,
// budgets = budgets.toList(),
// selectedBudget = action.budget.id,
// editingBudget = false
// )
// }
// is ConfigAction.LoginSuccess -> {
// launch {
// try {
// val budgets = budgetRepository.findAll()
// dispatch(BudgetAction.LoadBudgetsSuccess(budgets))
// } catch (e: Exception) {
// dispatch(BudgetAction.LoadBudgetsFailed(e))
// }
// }
// state().copy(loading = true)
// }
// is ConfigAction.Logout -> state().copy(
// budgets = null,
// selectedBudget = null,
// editingBudget = false
// )
//// is BudgetAction.EditBudget -> state.copy(
//// editingBudget = true,
//// selectedBudget = action.id
//// )
// is BudgetAction.SelectBudget -> {
// val currentState = state()
// val budgetId = currentState.budgets
// ?.firstOrNull { it.id == action.id }
// ?.id
// ?: currentState.budgets?.firstOrNull()?.id
// settings[KEY_LAST_BUDGET] = budgetId
// dispatch(BudgetAction.BudgetSelected(budgetId!!))
// state()
// }
// is BudgetAction.BudgetSelected -> state().copy(selectedBudget = action.id)
//
//// is BudgetAction.UpdateBudget -> state.copy(loading = true).also {
//// dispatch(action.async())
//// }
//// is BudgetAction.DeleteBudget -> state.copy(loading = true).also {
//// dispatch(action.async())
//// }
////
//// is BudgetAsyncAction.UpdateBudgetAsync -> {
//// budgetRepository.update(
//// Budget(
//// id = action.id,
//// name = action.name,
//// description = action.description,
//// users = action.users
//// )
//// )
//// state().copy(
//// loading = false,
//// editingBudget = false,
//// )
//// }
//// is BudgetAsyncAction.DeleteBudgetAsync -> {
//// budgetRepository.delete(action.id)
//// val currentState = state()
//// val budgets = currentState.budgets?.filterNot { it.id == action.id }
//// currentState.copy(
//// loading = false,
//// budgets = budgets,
//// editingBudget = false,
//// selectedBudget = null
//// )
//// }
else -> state()
}
}

View file

@ -1,9 +1,28 @@
package com.wbrawner.twigs.shared.category
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.network.APIService
interface CategoryRepository : Repository<Category> {
suspend fun findAll(budgetIds: Array<String>? = null): List<Category>
suspend fun getBalance(id: String): Long
}
class NetworkCategoryRepository(private val apiService: APIService) : CategoryRepository {
override suspend fun findAll(budgetIds: Array<String>?): List<Category> =
apiService.getCategories(budgetIds = budgetIds)
override suspend fun findAll(): List<Category> = findAll(null)
override suspend fun getBalance(id: String): Long =
apiService.sumTransactions(categoryId = id).balance
override suspend fun create(newItem: Category): Category = apiService.newCategory(newItem)
override suspend fun findById(id: String): Category = apiService.getCategory(id)
override suspend fun update(updatedItem: Category): Category =
apiService.updateCategory(updatedItem.id!!, updatedItem)
override suspend fun delete(id: String) = apiService.deleteCategory(id)
}

View file

@ -8,11 +8,11 @@ import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.user.LoginRequest
import com.wbrawner.twigs.shared.user.Session
import com.wbrawner.twigs.shared.user.User
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.client.HttpClientConfig
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.serialization.json.Json
const val BASE_PATH = "/api"
@ -116,8 +116,8 @@ fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
})
}
install(HttpTimeout) {
requestTimeoutMillis = 1000
connectTimeoutMillis = 1000
socketTimeoutMillis = 1000
requestTimeoutMillis = 60_000
connectTimeoutMillis = 60_000
socketTimeoutMillis = 60_000
}
}

View file

@ -0,0 +1,191 @@
package com.wbrawner.twigs.shared.transaction
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.user.ConfigAction
import com.wbrawner.twigs.shared.user.User
import com.wbrawner.twigs.shared.user.UserPermission
import com.wbrawner.twigs.shared.user.UserRepository
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.nanoseconds
import kotlin.time.Duration.Companion.seconds
sealed interface TransactionAction : Action {
object TransactionsClicked : TransactionAction
data class LoadTransactionsSuccess(val transactions: List<Transaction>) : TransactionAction
data class LoadTransactionsFailed(val error: Exception) : TransactionAction
object NewTransactionClicked : TransactionAction
data class CreateTransaction(
val title: String,
val description: String? = null,
val amount: Long,
val date: Instant,
val expense: Boolean,
val category: Category? = null,
val budget: Budget,
) : TransactionAction
data class SaveTransactionSuccess(val transaction: Transaction) : TransactionAction
data class SaveTransactionFailure(
val id: String? = null,
val name: String,
val description: String? = null,
val users: List<UserPermission> = emptyList(),
val error: Exception
) : TransactionAction
object CancelEditTransaction : TransactionAction
data class EditTransaction(val id: String) : TransactionAction
data class SelectTransaction(val id: String?) : TransactionAction
data class TransactionSelected(val transaction: Transaction, val createdBy: User) :
TransactionAction
data class UpdateTransaction(
val id: String,
val title: String,
val description: String? = null,
val amount: Long,
val date: Instant,
val expense: Boolean,
val category: Category? = null,
val budget: Budget,
) : TransactionAction
data class DeleteTransaction(val id: String) : TransactionAction
}
class TransactionReducer(
private val transactionRepository: TransactionRepository,
private val userRepository: UserRepository,
) : Reducer() {
override fun reduce(action: Action, state: () -> State): State = when (action) {
is Action.Back -> {
val currentState = state()
currentState.copy(
editingTransaction = false,
selectedTransaction = if (currentState.editingTransaction) currentState.selectedTransaction else null,
route = if (currentState.route is Route.Transactions && !currentState.route.selected.isNullOrBlank() && !currentState.editingTransaction) {
Route.Transactions()
} else {
currentState.route
}
)
}
is TransactionAction.TransactionsClicked -> state().copy(route = Route.Transactions(null))
is TransactionAction.LoadTransactionsSuccess -> state().copy(transactions = action.transactions)
is TransactionAction.NewTransactionClicked -> state().copy(editingTransaction = true)
is TransactionAction.CancelEditTransaction -> state().copy(editingTransaction = false)
is TransactionAction.CreateTransaction -> {
launch {
val transaction = transactionRepository.create(
Transaction(
title = action.title,
description = action.description,
amount = action.amount,
date = action.date,
expense = action.expense,
categoryId = action.category?.id,
budgetId = action.budget.id!!,
createdBy = state().user!!.id!!
)
)
dispatch(TransactionAction.SaveTransactionSuccess(transaction))
}
state().copy(loading = true)
}
is TransactionAction.SaveTransactionSuccess -> {
val currentState = state()
val transactions = currentState.transactions?.toMutableList() ?: mutableListOf()
transactions.add(action.transaction)
transactions.sortByDescending { it.date }
currentState.copy(
loading = false,
transactions = transactions.toList(),
selectedTransaction = action.transaction.id,
selectedTransactionCreatedBy = currentState.user,
editingTransaction = false
)
}
is BudgetAction.BudgetSelected -> {
launch {
try {
val transactions = transactionRepository.findAll(budgetIds = listOf(action.id))
dispatch(TransactionAction.LoadTransactionsSuccess(transactions))
} catch (e: Exception) {
dispatch(TransactionAction.LoadTransactionsFailed(e))
}
}
state().copy(transactions = null)
}
is ConfigAction.Logout -> state().copy(
transactions = null,
selectedTransaction = null,
editingTransaction = false
)
is TransactionAction.EditTransaction -> state().copy(
editingTransaction = true,
selectedTransaction = action.id
)
is TransactionAction.SelectTransaction -> {
launch {
val currentState = state()
val transaction = currentState.transactions!!.first { it.id == action.id }
val createdBy = userRepository.findById(transaction.createdBy)
dispatch(TransactionAction.TransactionSelected(transaction, createdBy))
}
state().copy(
loading = true,
selectedTransaction = action.id,
route = Route.Transactions(action.id)
)
}
is TransactionAction.TransactionSelected -> state().copy(
selectedTransaction = action.transaction.id,
selectedTransactionCreatedBy = action.createdBy
)
else -> state()
}
}
fun Instant.stripTime(): Instant {
val localDateTime = toLocalDateTime(TimeZone.UTC)
return minus(localDateTime.hour.hours)
.minus(localDateTime.minute.minutes)
.minus(localDateTime.second.seconds)
.minus(localDateTime.nanosecond.nanoseconds)
}
fun List<Transaction>.groupByDate(): Map<String, List<Transaction>> {
val groups = mutableMapOf<String, List<Transaction>>()
forEach { transaction ->
println("Groups: ${groups.size}")
val key = transaction.date.stripTime().toString()
val list = groups[key]?.toMutableList() ?: mutableListOf()
list.add(transaction)
list.sortByDescending { t -> t.date }
groups[key] = list
}
return groups
}

View file

@ -2,14 +2,41 @@ package com.wbrawner.twigs.shared.transaction
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.endOfMonth
import com.wbrawner.twigs.shared.network.APIService
import com.wbrawner.twigs.shared.startOfMonth
import kotlinx.datetime.*
import kotlinx.datetime.Instant
interface TransactionRepository : Repository<Transaction> {
suspend fun findAll(
budgetIds: List<String>? = null,
categoryIds: List<String>? = null,
start: Instant? = startOfMonth(),
end: Instant? = endOfMonth()
budgetIds: List<String>? = null,
categoryIds: List<String>? = null,
start: Instant? = startOfMonth(),
end: Instant? = endOfMonth()
): List<Transaction>
}
class NetworkTransactionRepository(private val apiService: APIService) : TransactionRepository {
override suspend fun findAll(
budgetIds: List<String>?,
categoryIds: List<String>?,
start: Instant?,
end: Instant?
): List<Transaction> = apiService.getTransactions(
budgetIds,
categoryIds,
from = start.toString(),
to = end.toString()
)
override suspend fun findAll(): List<Transaction> = findAll(null, null)
override suspend fun create(newItem: Transaction): Transaction =
apiService.newTransaction(newItem)
override suspend fun findById(id: String): Transaction = apiService.getTransaction(id)
override suspend fun update(updatedItem: Transaction): Transaction =
apiService.updateTransaction(updatedItem.id!!, updatedItem)
override suspend fun delete(id: String) = apiService.deleteTransaction(id)
}

View file

@ -0,0 +1,116 @@
package com.wbrawner.twigs.shared.user
import com.russhwolf.settings.Settings
import com.russhwolf.settings.set
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Effect
import com.wbrawner.twigs.shared.Reducer
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.State
import com.wbrawner.twigs.shared.network.APIService
import kotlinx.coroutines.launch
const val KEY_AUTH_TOKEN = "authToken"
const val KEY_BASE_URL = "baseUrl"
const val KEY_USER_ID = "userId"
sealed interface ConfigAction : Action {
data class SetServer(val server: String) : ConfigAction
data class Login(val username: String, val password: String) : ConfigAction
data class TokenLogin(val baseUrl: String, val authToken: String, val userId: String) :
ConfigAction
data class TokenLoginFailed(val error: Exception) : ConfigAction
data class LoginSuccess(val user: User) : ConfigAction
data class LoginFailed(
val username: String,
val password: String,
val error: Exception
) : ConfigAction
data class Register(val username: String, val password: String, val confirmPassword: String) :
ConfigAction
data class RegistrationSuccess(val user: User) : ConfigAction
data class RegistrationFailed(val user: User) : ConfigAction
object Logout : ConfigAction
object ForgotPasswordClicked : Action
object RegisterClicked : Action
}
class ConfigReducer(private val apiService: APIService, private val settings: Settings) :
Reducer() {
init {
run {
val baseUrl = settings.getStringOrNull(KEY_BASE_URL) ?: return@run
val authToken = settings.getStringOrNull(KEY_AUTH_TOKEN) ?: return@run
val userId = settings.getStringOrNull(KEY_USER_ID) ?: return@run
initialActions.addLast(ConfigAction.TokenLogin(baseUrl, authToken, userId))
}
}
override fun reduce(action: Action, state: () -> State): State = when (action) {
is ConfigAction.SetServer -> {
var baseUrl = action.server
if (!baseUrl.startsWith("http")) {
baseUrl = "https://$baseUrl"
}
settings[KEY_BASE_URL] = baseUrl
apiService.baseUrl = baseUrl
state()
}
is ConfigAction.Login -> {
launch {
try {
val session = apiService.login(LoginRequest(action.username, action.password))
settings[KEY_AUTH_TOKEN] = session.token
apiService.authToken = session.token
val user = apiService.getUser(session.userId)
settings[KEY_USER_ID] = session.userId
dispatch(ConfigAction.LoginSuccess(user))
} catch (e: Exception) {
dispatch(ConfigAction.LoginFailed(action.username, action.password, e))
}
}
state().copy(loading = true)
}
is ConfigAction.TokenLogin -> {
launch {
try {
apiService.authToken = action.authToken
apiService.baseUrl = action.baseUrl
val user = apiService.getUser(action.userId)
dispatch(ConfigAction.LoginSuccess(user))
} catch (e: Exception) {
dispatch(ConfigAction.TokenLoginFailed(e))
}
}
state().copy(loading = true)
}
is ConfigAction.TokenLoginFailed -> {
emit(Effect.Error(action.error.message ?: "Invalid auth token"))
settings.remove(KEY_AUTH_TOKEN)
settings.remove(KEY_BASE_URL)
settings.remove(KEY_USER_ID)
state().copy(loading = false)
}
is ConfigAction.LoginSuccess -> state().copy(user = action.user, route = Route.Overview)
is ConfigAction.LoginFailed -> {
emit(Effect.Error(action.error.message ?: "Login failed"))
state().copy(loading = false)
}
is ConfigAction.Logout -> {
settings.remove(KEY_AUTH_TOKEN)
settings.remove(KEY_BASE_URL)
settings.remove(KEY_USER_ID)
state().copy(user = null, route = Route.Login)
}
else -> state()
}
}

View file

@ -1,13 +0,0 @@
package com.wbrawner.twigs.shared.user
import com.wbrawner.twigs.shared.Action
sealed interface UserAction: Action {
data class Login(val username: String, val password: String): UserAction
data class Register(val username: String, val password: String, val confirmPassword: String): UserAction
object Logout: UserAction
}
sealed interface UserEffect {
}

View file

@ -1,10 +1,37 @@
package com.wbrawner.twigs.shared.user
import com.wbrawner.twigs.shared.Repository
import com.wbrawner.twigs.shared.network.APIService
interface UserRepository : Repository<User> {
suspend fun login(username: String, password: String): User
suspend fun getProfile(): User
suspend fun findAll(budgetId: String? = null): List<User>
suspend fun findAllByNameLike(query: String): List<User>
}
class NetworkUserRepository(private val apiService: APIService) : UserRepository {
override suspend fun findAll(budgetId: String?): List<User> {
TODO("Not yet implemented")
}
override suspend fun findAll(): List<User> {
TODO("Not yet implemented")
}
override suspend fun findAllByNameLike(query: String): List<User> {
TODO("Not yet implemented")
}
override suspend fun create(newItem: User): User {
TODO("Not yet implemented")
}
override suspend fun findById(id: String): User = apiService.getUser(id)
override suspend fun update(updatedItem: User): User {
TODO("Not yet implemented")
}
override suspend fun delete(id: String) {
TODO("Not yet implemented")
}
}