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.
|
# Add project specific ProGuard rules here.
|
||||||
# You can control the set of applied configuration files using the
|
# 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
|
# For more details, see
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
|
@ -12,10 +12,9 @@
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:ignore="GoogleAppIndexingWarning"
|
tools:ignore="GoogleAppIndexingWarning">
|
||||||
tools:targetApi="n">
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.auth.AuthActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:theme="@style/Theme.App.Starting"
|
android:theme="@style/Theme.App.Starting"
|
||||||
|
@ -29,34 +28,6 @@
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
|
@ -1,8 +1,9 @@
|
||||||
package com.wbrawner.budget
|
package com.wbrawner.budget
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.wbrawner.pihelper.shared.create
|
||||||
import com.wbrawner.twigs.shared.ErrorHandler
|
import com.wbrawner.twigs.shared.ErrorHandler
|
||||||
|
import com.wbrawner.twigs.shared.Store
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
@ -12,14 +13,12 @@ import dagger.hilt.components.SingletonComponent
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
class AppModule {
|
class AppModule {
|
||||||
@Provides
|
@Provides
|
||||||
fun provideErrorHandler(): com.wbrawner.twigs.shared.ErrorHandler = object :
|
fun provideErrorHandler(): ErrorHandler = object : ErrorHandler {
|
||||||
com.wbrawner.twigs.shared.ErrorHandler {
|
|
||||||
override fun init(application: Application) {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reportException(t: Throwable, message: String?) {
|
override fun reportException(t: Throwable, message: String?) {
|
||||||
Log.e("ErrorHandler", "Report exception: $message", t)
|
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
|
package com.wbrawner.budget
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.wbrawner.budget.common.budget.BudgetRepository
|
|
||||||
import com.wbrawner.budget.common.user.UserRepository
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
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
|
@HiltAndroidApp
|
||||||
class TwigsApplication : Application(), CoroutineScope {
|
class TwigsApplication : Application()
|
||||||
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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
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
|
package com.wbrawner.budget.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.activity.compose.setContent
|
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.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.*
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.outlined.Folder
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.navigation.NavController
|
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.compose.rememberNavController
|
||||||
import androidx.navigation.findNavController
|
import androidx.navigation.navArgument
|
||||||
import androidx.navigation.ui.setupWithNavController
|
|
||||||
import com.google.android.material.navigation.NavigationView
|
|
||||||
import com.wbrawner.budget.R
|
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.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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.android.synthetic.main.activity_main.*
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val MENU_GROUP_BUDGETS = 50
|
@OptIn(ExperimentalAnimationApi::class)
|
||||||
private const val MENU_ITEM_ADD_BUDGET = 100
|
|
||||||
private const val MENU_ITEM_SETTINGS = 101
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
|
class MainActivity : AppCompatActivity() {
|
||||||
private lateinit var toggle: ActionBarDrawerToggle
|
@Inject
|
||||||
private val viewModel: MainViewModel by viewModels()
|
lateinit var store: Store
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
installSplashScreen()
|
||||||
setContent {
|
setContent {
|
||||||
|
val state by store.state.collectAsState()
|
||||||
|
val navController = rememberNavController()
|
||||||
|
LaunchedEffect(state.route) {
|
||||||
|
navController.navigate(state.route.path)
|
||||||
}
|
}
|
||||||
setContentView(R.layout.activity_main)
|
TwigsApp {
|
||||||
setSupportActionBar(action_bar)
|
val authViewModel: AuthViewModel = hiltViewModel()
|
||||||
toggle =
|
NavHost(navController, state.initialRoute.path) {
|
||||||
ActionBarDrawerToggle(this, drawerLayout, R.string.action_open, R.string.action_close)
|
composable(Route.Login.path) {
|
||||||
toggle.isDrawerIndicatorEnabled = true
|
LoginScreen(store = store, viewModel = authViewModel)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
supportActionBar?.setHomeAsUpIndicator(homeAsUpIndicator)
|
composable(Route.Overview.path) {
|
||||||
|
OverviewScreen(store = store)
|
||||||
}
|
}
|
||||||
lifecycleScope.launch {
|
composable(Route.Transactions(selected = null).path) {
|
||||||
viewModel.loadBudgets().collect { list ->
|
TransactionsScreen(store = store)
|
||||||
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)
|
|
||||||
}
|
|
||||||
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 = {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TwigsDrawer(navController: NavController, budgets: List<Budget>, selectedBudgetId: String) {
|
fun TwigsScaffold(
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
@ -156,60 +182,27 @@ fun TwigsDrawer(navController: NavController, budgets: List<Budget>, selectedBud
|
||||||
Image(painter = painterResource(id = image), null)
|
Image(painter = painterResource(id = image), null)
|
||||||
Text(
|
Text(
|
||||||
text = "twigs",
|
text = "twigs",
|
||||||
style = MaterialTheme.typography.h4
|
style = MaterialTheme.typography.titleLarge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
NavigationDrawerItem(
|
state.budgets?.let { budgets ->
|
||||||
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()) {
|
LazyColumn(modifier = Modifier.fillMaxHeight()) {
|
||||||
items(budgets) { budget ->
|
items(budgets) { budget ->
|
||||||
val selected = budget.id == selectedBudgetId
|
val selected = budget.id == state.selectedBudget
|
||||||
val icon = if (selected) Icons.Filled.Folder else Icons.Outlined.Folder
|
val icon = if (selected) Icons.Filled.Folder else Icons.Default.Folder
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(icon, contentDescription = null) },
|
icon = { Icon(icon, contentDescription = null) },
|
||||||
label = { Text(budget.name) },
|
label = { Text(budget.name) },
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onClick = { navController.navigate("budgets/${budget.id}") }
|
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
|
package com.wbrawner.budget.ui
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import com.wbrawner.twigs.shared.Store
|
||||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthViewModel @Inject constructor(
|
class AuthViewModel @Inject constructor(
|
||||||
val budgetRepository: BudgetRepository,
|
val store: Store
|
||||||
val userRepository: UserRepository,
|
|
||||||
val sharedPreferences: SharedPreferences,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val authenticated = MutableStateFlow<Boolean?>(null)
|
val server = mutableStateOf("")
|
||||||
val loading = MutableStateFlow(false)
|
val username = mutableStateOf("")
|
||||||
val error = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
val email = mutableStateOf("")
|
||||||
val server = MutableStateFlow("")
|
val password = mutableStateOf("")
|
||||||
val username = MutableStateFlow("")
|
val confirmPassword = mutableStateOf("")
|
||||||
val email = MutableStateFlow("")
|
val enableLogin = mutableStateOf(false)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,62 +7,110 @@ import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||||
|
import androidx.compose.foundation.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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.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.res.painterResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.tooling.preview.Devices
|
import androidx.compose.ui.tooling.preview.Devices
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.wbrawner.budget.R
|
import com.wbrawner.budget.R
|
||||||
import com.wbrawner.budget.ui.AuthViewModel
|
import com.wbrawner.budget.ui.AuthViewModel
|
||||||
import com.wbrawner.budget.ui.base.TwigsApp
|
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
|
@ExperimentalAnimationApi
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
navController: NavController,
|
store: Store,
|
||||||
viewModel: AuthViewModel
|
viewModel: AuthViewModel
|
||||||
) {
|
) {
|
||||||
val loading by viewModel.loading.collectAsState()
|
val state by store.state.collectAsState()
|
||||||
val server by viewModel.server.collectAsState()
|
val effect by store.effects.collectAsState(initial = Effect.Empty)
|
||||||
val username by viewModel.username.collectAsState()
|
val (error, setError) = remember { mutableStateOf("") }
|
||||||
val password by viewModel.password.collectAsState()
|
(effect as? Effect.Error)?.let {
|
||||||
val enableLogin by viewModel.enableLogin.collectAsState()
|
setError(it.message)
|
||||||
|
} ?: setError("")
|
||||||
|
val (server, setServer) = viewModel.server
|
||||||
|
val (username, setUsername) = viewModel.username
|
||||||
|
val (password, setPassword) = viewModel.password
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !loading,
|
visible = !state.loading,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
LoginForm(
|
LoginForm(
|
||||||
|
error,
|
||||||
server,
|
server,
|
||||||
viewModel::setServer,
|
setServer,
|
||||||
username,
|
username,
|
||||||
viewModel::setUsername,
|
setUsername,
|
||||||
password,
|
password,
|
||||||
viewModel::setPassword,
|
setPassword,
|
||||||
enableLogin,
|
true,
|
||||||
{ navController.navigate(AuthRoutes.FORGOT_PASSWORD.name) },
|
{ store.dispatch(ConfigAction.ForgotPasswordClicked) },
|
||||||
{ navController.navigate(AuthRoutes.REGISTER.name) },
|
{ store.dispatch(ConfigAction.RegisterClicked) },
|
||||||
viewModel::login,
|
{
|
||||||
|
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
|
@Composable
|
||||||
fun LoginForm(
|
fun LoginForm(
|
||||||
|
error: String,
|
||||||
server: String,
|
server: String,
|
||||||
setServer: (String) -> Unit,
|
setServer: (String) -> Unit,
|
||||||
username: String,
|
username: String,
|
||||||
|
@ -75,6 +123,7 @@ fun LoginForm(
|
||||||
login: () -> Unit,
|
login: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
val (serverInput, usernameInput, passwordInput, loginButton) = FocusRequester.createRefs()
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
@ -88,28 +137,106 @@ fun LoginForm(
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
Text("Log in to manage your budgets")
|
Text("Log in to manage your budgets")
|
||||||
|
if (error.isNotBlank()) {
|
||||||
|
Text(text = error, color = Color.Red)
|
||||||
|
}
|
||||||
TextField(
|
TextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(serverInput)
|
||||||
|
.onPreviewKeyEvent {
|
||||||
|
if (it.key == Key.Tab && !it.isShiftPressed) {
|
||||||
|
usernameInput.requestFocus()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
value = server,
|
value = server,
|
||||||
onValueChange = setServer,
|
onValueChange = setServer,
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, capitalization = KeyboardCapitalization.None),
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
placeholder = { Text("Server") }
|
keyboardType = KeyboardType.Uri,
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
placeholder = { Text("Server") },
|
||||||
|
keyboardActions = KeyboardActions(onNext = {
|
||||||
|
usernameInput.requestFocus()
|
||||||
|
}),
|
||||||
|
maxLines = 1
|
||||||
)
|
)
|
||||||
TextField(
|
TextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(usernameInput)
|
||||||
|
.onPreviewKeyEvent {
|
||||||
|
if (it.key == Key.Tab) {
|
||||||
|
if (it.isShiftPressed) {
|
||||||
|
serverInput.requestFocus()
|
||||||
|
} else {
|
||||||
|
passwordInput.requestFocus()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
value = username,
|
value = username,
|
||||||
onValueChange = setUsername,
|
onValueChange = setUsername,
|
||||||
placeholder = { Text("Username") }
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
placeholder = { Text("Username") },
|
||||||
|
keyboardActions = KeyboardActions(onNext = {
|
||||||
|
passwordInput.requestFocus()
|
||||||
|
}),
|
||||||
|
maxLines = 1
|
||||||
)
|
)
|
||||||
TextField(
|
TextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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,
|
value = password,
|
||||||
onValueChange = setPassword,
|
onValueChange = setPassword,
|
||||||
placeholder = { Text("Password") },
|
placeholder = { Text("Password") },
|
||||||
visualTransformation = PasswordVisualTransformation()
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
login()
|
||||||
|
}),
|
||||||
|
maxLines = 1
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(loginButton),
|
||||||
enabled = enableLogin,
|
enabled = enableLogin,
|
||||||
onClick = login
|
onClick = login
|
||||||
) {
|
) {
|
||||||
|
@ -132,24 +259,18 @@ fun LoginForm(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
|
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
|
||||||
fun LoginScreen_Preview() {
|
fun LoginScreen_Preview() {
|
||||||
TwigsApp {
|
TwigsApp {
|
||||||
LoginForm("", {}, "", {}, "", {}, false, {}, {}, { })
|
LoginForm("", "", {}, "", {}, "", {}, false, {}, {}, { })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview(showBackground = true, device = Devices.PIXEL_C)
|
@Preview(showBackground = true, device = Devices.PIXEL_C)
|
||||||
|
@Preview(showBackground = true, device = Devices.PIXEL_C, uiMode = UI_MODE_NIGHT_YES)
|
||||||
fun LoginScreen_PreviewTablet() {
|
fun LoginScreen_PreviewTablet() {
|
||||||
TwigsApp {
|
TwigsApp {
|
||||||
LoginForm("", {}, "", {}, "", {}, false, {}, {}, { })
|
LoginForm("", "", {}, "", {}, "", {}, false, {}, {}, { })
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
|
|
||||||
fun LoginScreen_PreviewDark() {
|
|
||||||
TwigsApp {
|
|
||||||
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
|
package com.wbrawner.budget.ui.base
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material.Surface
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.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(
|
val lightColors = lightColorScheme(
|
||||||
primary = Green500,
|
primary = Green500,
|
||||||
|
primaryContainer = Green300,
|
||||||
secondary = Green700,
|
secondary = Green700,
|
||||||
|
secondaryContainer = Green300,
|
||||||
)
|
)
|
||||||
|
|
||||||
val darkColors = darkColorScheme(
|
val darkColors = darkColorScheme(
|
||||||
primary = Green300,
|
primary = Green300,
|
||||||
|
primaryContainer = Green500,
|
||||||
secondary = Green500,
|
secondary = Green500,
|
||||||
|
secondaryContainer = Green700,
|
||||||
background = Color.Black,
|
background = Color.Black,
|
||||||
surface = 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
|
@Composable
|
||||||
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = if (darkMode) darkColors else lightColors,
|
colorScheme = if (darkMode) darkColors else lightColors,
|
||||||
// typography = MaterialTheme.typography.copy(
|
typography = MaterialTheme.typography.copy(
|
||||||
// h1 = MaterialTheme.typography.h1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu),
|
||||||
// h2 = MaterialTheme.typography.h2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = ubuntu),
|
||||||
// h3 = MaterialTheme.typography.h3.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = ubuntu),
|
||||||
// h4 = MaterialTheme.typography.h4.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = ubuntu),
|
||||||
// h5 = MaterialTheme.typography.h5.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = ubuntu),
|
||||||
// h6 = MaterialTheme.typography.h6.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = ubuntu),
|
||||||
// subtitle1 = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = ubuntu),
|
||||||
// subtitle2 = MaterialTheme.typography.subtitle2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = ubuntu),
|
||||||
// body1 = MaterialTheme.typography.body1.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = ubuntu),
|
||||||
// body2 = MaterialTheme.typography.body2.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = ubuntu),
|
||||||
// button = MaterialTheme.typography.button.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = ubuntu),
|
||||||
// caption = MaterialTheme.typography.caption.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = ubuntu),
|
||||||
// overline = MaterialTheme.typography.overline.copy(fontFamily = FontFamily(Font(R.font.ubuntu))),
|
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = ubuntu),
|
||||||
// ),
|
labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = ubuntu),
|
||||||
|
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = ubuntu),
|
||||||
|
),
|
||||||
content = content
|
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-core = "1.9.0"
|
||||||
androidx-appcompat = "1.5.1"
|
androidx-appcompat = "1.5.1"
|
||||||
androidx-splash = "1.0.0"
|
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-compiler = "1.3.2"
|
||||||
|
compose-material3 = "1.0.1"
|
||||||
espresso = "3.3.0"
|
espresso = "3.3.0"
|
||||||
hilt-android = "2.44"
|
hilt-android = "2.44"
|
||||||
kotlin = "1.7.20"
|
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-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||||
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
|
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-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" }
|
||||||
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
|
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-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
|
||||||
compose-ui = { module = "androidx.compose.ui:ui", 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" }
|
dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
|
||||||
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
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-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-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" }
|
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
|
||||||
junit = { module = "junit:junit", version = "4.12" }
|
junit = { module = "junit:junit", version = "4.12" }
|
||||||
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
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" }
|
test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
|
||||||
|
|
||||||
[bundles]
|
[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"]
|
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
|
||||||
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
|
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ kotlin {
|
||||||
implementation(libs.ktor.client.serialization)
|
implementation(libs.ktor.client.serialization)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.datetime)
|
api(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
api(libs.multiplatform.settings)
|
api(libs.multiplatform.settings)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,37 @@
|
||||||
package com.wbrawner.pihelper.shared
|
package com.wbrawner.pihelper.shared
|
||||||
|
|
||||||
|
import com.russhwolf.settings.Settings
|
||||||
import com.wbrawner.twigs.shared.Reducer
|
import com.wbrawner.twigs.shared.Reducer
|
||||||
import com.wbrawner.twigs.shared.Store
|
import com.wbrawner.twigs.shared.Store
|
||||||
import com.wbrawner.twigs.shared.budget.BudgetReducer
|
import com.wbrawner.twigs.shared.budget.BudgetReducer
|
||||||
import com.wbrawner.twigs.shared.budget.NetworkBudgetRepository
|
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.KtorAPIService
|
||||||
import com.wbrawner.twigs.shared.network.commonConfig
|
import com.wbrawner.twigs.shared.network.commonConfig
|
||||||
import io.ktor.client.*
|
import com.wbrawner.twigs.shared.transaction.NetworkTransactionRepository
|
||||||
import io.ktor.client.engine.android.*
|
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) {
|
val apiService = KtorAPIService(HttpClient(Android) {
|
||||||
commonConfig()
|
commonConfig()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
val preferences = Settings()
|
||||||
|
|
||||||
val budgetRepository = NetworkBudgetRepository(apiService)
|
val budgetRepository = NetworkBudgetRepository(apiService)
|
||||||
|
val categoryRepository = NetworkCategoryRepository(apiService)
|
||||||
|
val transactionRepository = NetworkTransactionRepository(apiService)
|
||||||
|
val userRepository = NetworkUserRepository(apiService)
|
||||||
|
|
||||||
fun Store.Companion.create(
|
fun Store.Companion.create(
|
||||||
reducers: List<Reducer> = listOf(
|
reducers: List<Reducer> = listOf(
|
||||||
BudgetReducer(budgetRepository)
|
BudgetReducer(budgetRepository, preferences),
|
||||||
|
CategoryReducer(categoryRepository),
|
||||||
|
ConfigReducer(apiService, preferences),
|
||||||
|
TransactionReducer(transactionRepository, userRepository)
|
||||||
),
|
),
|
||||||
) = Store(reducers)
|
) = Store(reducers)
|
||||||
|
|
|
@ -1,75 +1,78 @@
|
||||||
package com.wbrawner.twigs.shared
|
package com.wbrawner.twigs.shared
|
||||||
|
|
||||||
import com.russhwolf.settings.Settings
|
|
||||||
import com.wbrawner.twigs.shared.budget.Budget
|
import com.wbrawner.twigs.shared.budget.Budget
|
||||||
import com.wbrawner.twigs.shared.category.Category
|
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.transaction.Transaction
|
||||||
import com.wbrawner.twigs.shared.user.User
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
enum class Route(val path: String) {
|
sealed class Route(val path: String) {
|
||||||
WELCOME("welcome"),
|
object Welcome : Route("welcome")
|
||||||
LOGIN("login"),
|
object Login : Route("login")
|
||||||
REGISTER("register"),
|
object Register : Route("register")
|
||||||
OVERVIEW("overview"),
|
object Overview : Route("overview")
|
||||||
TRANSACTIONS("transactions"),
|
data class Transactions(val selected: String? = null) :
|
||||||
CATEGORIES("categories"),
|
Route(if (selected != null) "transactions/${selected}" else "transactions")
|
||||||
RECURRING_TRANSACTIONS("recurringtransactions"),
|
|
||||||
PROFILE("profile"),
|
data class Categories(val selected: String? = null) :
|
||||||
SETTINGS("settings"),
|
Route(if (selected != null) "categories/${selected}" else "categories")
|
||||||
ABOUT("about"),
|
|
||||||
|
// 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(
|
data class State(
|
||||||
val host: String? = null,
|
|
||||||
val user: User? = null,
|
val user: User? = null,
|
||||||
val budgets: List<Budget>? = null,
|
val budgets: List<Budget>? = null,
|
||||||
|
val budgetBalance: Long? = null,
|
||||||
val selectedBudget: String? = null,
|
val selectedBudget: String? = null,
|
||||||
val editingBudget: Boolean = false,
|
val editingBudget: Boolean = false,
|
||||||
val categories: List<Category>? = null,
|
val categories: List<Category>? = null,
|
||||||
|
val categoryBalances: Map<String, Long>? = null,
|
||||||
val selectedCategory: String? = null,
|
val selectedCategory: String? = null,
|
||||||
val editingCategory: Boolean = false,
|
val editingCategory: Boolean = false,
|
||||||
val transactions: List<Transaction>? = null,
|
val transactions: List<Transaction>? = null,
|
||||||
val selectedTransaction: String? = null,
|
val selectedTransaction: String? = null,
|
||||||
|
val selectedTransactionCreatedBy: User? = null,
|
||||||
val editingTransaction: Boolean = false,
|
val editingTransaction: Boolean = false,
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val route: Route = Route.WELCOME,
|
val route: Route = Route.Login,
|
||||||
val initialRoute: Route = Route.WELCOME
|
val initialRoute: Route = Route.Login
|
||||||
)
|
) {
|
||||||
|
override fun toString(): String {
|
||||||
|
return "State(budget=$selectedBudget, selectedTransaction=$selectedTransaction, route=$route)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Action {
|
interface Action {
|
||||||
object About : Action
|
object AboutClicked : Action
|
||||||
object Back : Action
|
object Back : Action
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AsyncAction : Action
|
|
||||||
|
|
||||||
interface Effect {
|
interface Effect {
|
||||||
object Exit : Effect
|
object Exit : Effect
|
||||||
|
data class Error(val message: String) : Effect
|
||||||
object Empty: Effect
|
object Empty: Effect
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class Reducer {
|
abstract class Reducer : CoroutineScope by CoroutineScope(Dispatchers.Main) {
|
||||||
lateinit var dispatch: (Action) -> Unit
|
lateinit var dispatch: (Action) -> Unit
|
||||||
lateinit var emit: (Effect) -> 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(
|
class Store(
|
||||||
private val reducers: List<Reducer>,
|
private val reducers: List<Reducer>,
|
||||||
private val settings: Settings = Settings(),
|
|
||||||
initialState: State = State()
|
initialState: State = State()
|
||||||
) : CoroutineScope by CoroutineScope(Dispatchers.Main) {
|
) : CoroutineScope by CoroutineScope(Dispatchers.Main) {
|
||||||
private val _state = MutableStateFlow(initialState)
|
private val _state = MutableStateFlow(initialState)
|
||||||
|
@ -80,22 +83,32 @@ class Store(
|
||||||
init {
|
init {
|
||||||
reducers.forEach {
|
reducers.forEach {
|
||||||
it.dispatch = this::dispatch
|
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) {
|
fun dispatch(action: Action) {
|
||||||
|
println(action)
|
||||||
launch {
|
launch {
|
||||||
var state = _state.value
|
var newState = _state.value
|
||||||
if (action is AsyncAction) {
|
|
||||||
reducers.filterIsInstance<AsyncReducer>().forEach {
|
|
||||||
state = it.reduce(action, state)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reducers.forEach {
|
reducers.forEach {
|
||||||
state = it.reduce(action, state)
|
newState = it.reduce(action) { newState }
|
||||||
}
|
}
|
||||||
}
|
_state.emit(newState)
|
||||||
_state.emit(state)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,121 +1,163 @@
|
||||||
package com.wbrawner.twigs.shared.budget
|
package com.wbrawner.twigs.shared.budget
|
||||||
|
|
||||||
import com.wbrawner.twigs.shared.*
|
import com.russhwolf.settings.Settings
|
||||||
import com.wbrawner.twigs.shared.user.UserAction
|
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 com.wbrawner.twigs.shared.user.UserPermission
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
sealed interface BudgetAction : Action {
|
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(
|
data class CreateBudget(
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val users: List<UserPermission> = emptyList()
|
val users: List<UserPermission> = emptyList()
|
||||||
) : BudgetAction {
|
) : BudgetAction
|
||||||
fun async() = BudgetAsyncAction.CreateBudgetAsync(name, description, users)
|
|
||||||
}
|
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 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(
|
data class UpdateBudget(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val users: List<UserPermission> = emptyList()
|
val users: List<UserPermission> = emptyList()
|
||||||
) : BudgetAction {
|
) : BudgetAction
|
||||||
fun async() = BudgetAsyncAction.UpdateBudgetAsync(id, name, description, users)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class DeleteBudget(val id: String) : BudgetAction {
|
data class DeleteBudget(val id: String) : BudgetAction
|
||||||
fun async() = BudgetAsyncAction.DeleteBudgetAsync(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface BudgetAsyncAction : AsyncAction {
|
const val KEY_LAST_BUDGET = "lastBudget"
|
||||||
data class CreateBudgetAsync(
|
|
||||||
val name: String,
|
|
||||||
val description: String? = null,
|
|
||||||
val users: List<UserPermission> = emptyList()
|
|
||||||
) : BudgetAsyncAction
|
|
||||||
|
|
||||||
data class UpdateBudgetAsync(
|
class BudgetReducer(
|
||||||
val id: String,
|
private val budgetRepository: BudgetRepository,
|
||||||
val name: String,
|
private val settings: Settings
|
||||||
val description: String? = null,
|
) : Reducer() {
|
||||||
val users: List<UserPermission> = emptyList()
|
override fun reduce(action: Action, state: () -> State): State = when (action) {
|
||||||
) : BudgetAsyncAction
|
is Action.Back -> {
|
||||||
|
val currentState = state()
|
||||||
data class DeleteBudgetAsync(val id: String) : BudgetAsyncAction
|
currentState.copy(
|
||||||
}
|
editingBudget = false
|
||||||
|
|
||||||
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 BudgetAction.OverviewClicked -> state().copy(route = Route.Overview)
|
||||||
is BudgetAsyncAction.CreateBudgetAsync -> {
|
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(
|
val budget = budgetRepository.create(
|
||||||
Budget(name = action.name, description = action.description, users = action.users)
|
|
||||||
)
|
|
||||||
val budgets = state.budgets?.toMutableList() ?: mutableListOf()
|
|
||||||
budgets.add(budget)
|
|
||||||
budgets.sortBy { it.name }
|
|
||||||
state.copy(
|
|
||||||
loading = false,
|
|
||||||
budgets = budgets.toList(),
|
|
||||||
selectedBudget = budget.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is BudgetAsyncAction.UpdateBudgetAsync -> {
|
|
||||||
budgetRepository.update(
|
|
||||||
Budget(
|
Budget(
|
||||||
id = action.id,
|
|
||||||
name = action.name,
|
name = action.name,
|
||||||
description = action.description,
|
description = action.description,
|
||||||
users = action.users
|
users = action.users
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
state.copy(
|
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,
|
loading = false,
|
||||||
editingBudget = false,
|
budgets = budgets.toList(),
|
||||||
|
selectedBudget = action.budget.id,
|
||||||
|
editingBudget = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is BudgetAsyncAction.DeleteBudgetAsync -> {
|
|
||||||
budgetRepository.delete(action.id)
|
is ConfigAction.LoginSuccess -> {
|
||||||
val budgets = state.budgets?.filterNot { it.id == action.id }
|
launch {
|
||||||
state.copy(
|
try {
|
||||||
loading = false,
|
val budgets = budgetRepository.findAll()
|
||||||
budgets = budgets,
|
dispatch(BudgetAction.LoadBudgetsSuccess(budgets))
|
||||||
editingBudget = false,
|
} catch (e: Exception) {
|
||||||
selectedBudget = null
|
dispatch(BudgetAction.LoadBudgetsFailed(e))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
else -> state
|
}
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
package com.wbrawner.twigs.shared.category
|
||||||
|
|
||||||
import com.wbrawner.twigs.shared.Repository
|
import com.wbrawner.twigs.shared.Repository
|
||||||
import com.wbrawner.twigs.shared.category.Category
|
import com.wbrawner.twigs.shared.network.APIService
|
||||||
|
|
||||||
interface CategoryRepository : Repository<Category> {
|
interface CategoryRepository : Repository<Category> {
|
||||||
suspend fun findAll(budgetIds: Array<String>? = null): List<Category>
|
suspend fun findAll(budgetIds: Array<String>? = null): List<Category>
|
||||||
suspend fun getBalance(id: String): Long
|
suspend fun getBalance(id: String): 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.LoginRequest
|
||||||
import com.wbrawner.twigs.shared.user.Session
|
import com.wbrawner.twigs.shared.user.Session
|
||||||
import com.wbrawner.twigs.shared.user.User
|
import com.wbrawner.twigs.shared.user.User
|
||||||
import io.ktor.client.*
|
import io.ktor.client.HttpClientConfig
|
||||||
import io.ktor.client.engine.*
|
import io.ktor.client.engine.HttpClientEngineConfig
|
||||||
import io.ktor.client.plugins.*
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
const val BASE_PATH = "/api"
|
const val BASE_PATH = "/api"
|
||||||
|
@ -116,8 +116,8 @@ fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
install(HttpTimeout) {
|
install(HttpTimeout) {
|
||||||
requestTimeoutMillis = 1000
|
requestTimeoutMillis = 60_000
|
||||||
connectTimeoutMillis = 1000
|
connectTimeoutMillis = 60_000
|
||||||
socketTimeoutMillis = 1000
|
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,8 +2,9 @@ package com.wbrawner.twigs.shared.transaction
|
||||||
|
|
||||||
import com.wbrawner.twigs.shared.Repository
|
import com.wbrawner.twigs.shared.Repository
|
||||||
import com.wbrawner.twigs.shared.endOfMonth
|
import com.wbrawner.twigs.shared.endOfMonth
|
||||||
|
import com.wbrawner.twigs.shared.network.APIService
|
||||||
import com.wbrawner.twigs.shared.startOfMonth
|
import com.wbrawner.twigs.shared.startOfMonth
|
||||||
import kotlinx.datetime.*
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
interface TransactionRepository : Repository<Transaction> {
|
interface TransactionRepository : Repository<Transaction> {
|
||||||
suspend fun findAll(
|
suspend fun findAll(
|
||||||
|
@ -13,3 +14,29 @@ interface TransactionRepository : Repository<Transaction> {
|
||||||
end: Instant? = endOfMonth()
|
end: Instant? = endOfMonth()
|
||||||
): List<Transaction>
|
): 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
|
package com.wbrawner.twigs.shared.user
|
||||||
|
|
||||||
import com.wbrawner.twigs.shared.Repository
|
import com.wbrawner.twigs.shared.Repository
|
||||||
|
import com.wbrawner.twigs.shared.network.APIService
|
||||||
|
|
||||||
interface UserRepository : Repository<User> {
|
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 findAll(budgetId: String? = null): List<User>
|
||||||
suspend fun findAllByNameLike(query: String): 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