WIP: Migrate to Kotlin Multiplatform
Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
parent
120d3dd70b
commit
95e7361907
69 changed files with 2172 additions and 3480 deletions
|
@ -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
91
android/build.gradle.kts
Normal 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)
|
||||
}
|
2
android/proguard-rules.pro
vendored
2
android/proguard-rules.pro
vendored
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
105
android/src/main/java/com/wbrawner/budget/ui/CategoriesScreen.kt
Normal file
105
android/src/main/java/com/wbrawner/budget/ui/CategoriesScreen.kt
Normal 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
|
||||
) {}
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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(), "") }
|
||||
) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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() ?: "-")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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, {}, {}, { })
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
|
@ -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 ""
|
|
@ -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,
|
||||
{}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
101
android/src/main/java/com/wbrawner/budget/ui/util/DatePicker.kt
Normal file
101
android/src/main/java/com/wbrawner/budget/ui/util/DatePicker.kt
Normal 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}"
|
||||
)
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue