Basically rewrite most of the app

A lot of stuff was moved around and some of the API connections were improved upon. It's still in a pretty broken state but it's minimally functional enough for day-to-day use for myself
This commit is contained in:
Billy Brawner 2019-09-24 18:34:15 -07:00
parent 3dd98b1e08
commit 6f24feed7b
81 changed files with 2033 additions and 972 deletions

View file

@ -20,17 +20,14 @@ android {
useSupportLibrary = true
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
buildConfigField "String", "API_URL", "\"https://budget-api.projects.wbrawner.com/\""
buildConfigField "String", "API_URL", "\"http://192.168.86.92:8080/\""
// buildConfigField "String", "API_URL", "\"http://10.0.2.2:8080/\""
}
buildTypes {
release {
minifyEnabled false
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "String", "API_URL", "\"https://budget-api.intra.wbrawner.com/\""
}
}
sourceSets {
@ -45,17 +42,16 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.core:core:1.0.2'
implementation 'androidx.media:media:1.0.1'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core:1.1.0'
implementation 'androidx.media:media:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'com.google.android.material:material:1.0.0'
implementation 'com.google.android.material:material:1.1.0-alpha10'
implementation 'androidx.emoji:emoji-bundled:1.0.0'
implementation 'com.github.BlacKCaT27:CurrencyEditText:2.0.2'
def lifecycle_version = "1.1.1"
// ViewModel and LiveData
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
// Dagger
implementation "com.google.dagger:dagger:$rootProject.ext.dagger"
kapt "com.google.dagger:dagger-compiler:$rootProject.ext.dagger"
@ -64,8 +60,23 @@ dependencies {
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation 'com.google.firebase:firebase-core:16.0.9'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
implementation 'com.google.firebase:firebase-core:17.2.0'
implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.1.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.1.0'
debugImplementation "com.willowtreeapps.hyperion:hyperion-core:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-attr:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-build-config:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-crash:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-disk:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-geiger-counter:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-measurement:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-phoenix:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-recorder:$rootProject.ext.hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-shared-preferences:$rootProject.ext.hyperion"
}
apply plugin: 'com.google.gms.google-services'

View file

@ -11,6 +11,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name=".ui.SplashActivity"

View file

@ -6,10 +6,7 @@ import com.wbrawner.budget.di.AppComponent
import com.wbrawner.budget.di.DaggerAppComponent
class AllowanceApplication : Application() {
var currentUser: User? = User(
id = 1,
username = "wbrawner"
)
var currentUser: User? = null
lateinit var appComponent: AppComponent
private set

View file

@ -6,7 +6,14 @@ import com.wbrawner.budget.lib.network.NetworkModule
import com.wbrawner.budget.ui.MainActivity
import com.wbrawner.budget.ui.SplashActivity
import com.wbrawner.budget.ui.SplashViewModelMapper
import com.wbrawner.budget.ui.auth.LoginFragment
import com.wbrawner.budget.ui.auth.RegisterFragment
import com.wbrawner.budget.ui.budgets.AddEditAccountsViewModelMapper
import com.wbrawner.budget.ui.budgets.AddEditBudgetFragment
import com.wbrawner.budget.ui.budgets.BudgetListFragment
import com.wbrawner.budget.ui.budgets.BudgetViewModelMapper
import com.wbrawner.budget.ui.categories.AddEditCategoryActivity
import com.wbrawner.budget.ui.categories.CategoryFragment
import com.wbrawner.budget.ui.categories.CategoryListFragment
import com.wbrawner.budget.ui.categories.CategoryViewModelMapper
import com.wbrawner.budget.ui.overview.AccountOverviewViewModelMapper
@ -25,6 +32,11 @@ import javax.inject.Singleton
@Component(modules = [AuthModule::class, AppModule::class, NetworkModule::class])
interface AppComponent {
fun inject(fragment: OverviewFragment)
fun inject(fragment: LoginFragment)
fun inject(fragment: BudgetListFragment)
fun inject(fragment: RegisterFragment)
fun inject(fragment: AddEditBudgetFragment)
fun inject(fragment: CategoryFragment)
fun inject(fragment: TransactionListFragment)
fun inject(fragment: CategoryListFragment)
fun inject(activity: MainActivity)
@ -46,7 +58,9 @@ interface AppComponent {
@Module(
includes = [
AccountOverviewViewModelMapper::class,
AddEditAccountsViewModelMapper::class,
AddEditTransactionViewModelMapper::class,
BudgetViewModelMapper::class,
CategoryViewModelMapper::class,
SplashViewModelMapper::class,
TransactionListViewModelMapper::class,

View file

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

View file

@ -1,76 +1,46 @@
package com.wbrawner.budget.ui
import android.os.Bundle
import androidx.annotation.StringRes
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.emoji.text.EmojiCompat
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController
import com.wbrawner.budget.R
import com.wbrawner.budget.common.account.Account
import com.wbrawner.budget.ui.categories.CategoryListFragment
import com.wbrawner.budget.ui.overview.OverviewFragment
import com.wbrawner.budget.ui.transactions.TransactionListFragment
import kotlinx.android.synthetic.main.activity_transaction_list.*
class MainActivity : AppCompatActivity() {
private val account = Account(
id = 2,
name = "Wells Fargo Checking",
currencyCode = "USD"
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EmojiCompat.init(androidx.emoji.bundled.BundledEmojiCompatConfig(this))
setContentView(R.layout.activity_transaction_list)
setSupportActionBar(action_bar)
menu_main.setOnNavigationItemSelectedListener { item ->
when (item.itemId) {
R.id.action_transactions -> updateFragment(
TransactionListFragment.TAG_FRAGMENT,
TransactionListFragment.TITLE_FRAGMENT
)
R.id.action_categories -> updateFragment(
CategoryListFragment.TAG_FRAGMENT,
CategoryListFragment.TITLE_FRAGMENT
)
else ->
updateFragment(OverviewFragment.TAG_FRAGMENT, OverviewFragment.TITLE_FRAGMENT)
val navController = findNavController(R.id.content_container)
menu_main.setupWithNavController(navController)
navController.addOnDestinationChangedListener { _, destination, _ ->
title = destination.label
val showHomeAsUp = when (destination.label) {
getString(R.string.title_transactions) -> false
getString(R.string.title_profile) -> false
getString(R.string.title_budgets) -> false
else -> true
}
true
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
}
val openFragmentId = when (intent?.getStringExtra(EXTRA_OPEN_FRAGMENT)) {
TransactionListFragment.TAG_FRAGMENT -> R.id.action_transactions
CategoryListFragment.TAG_FRAGMENT -> R.id.action_categories
else -> R.id.action_overview
CategoryListFragment.TAG_FRAGMENT -> R.id.categoryListFragment
else -> R.id.transactionListFragment
}
menu_main.selectedItemId = openFragmentId
}
private fun updateFragment(tag: String, @StringRes title: Int) {
setTitle(title)
var fragment = supportFragmentManager.findFragmentByTag(tag)
val ft = supportFragmentManager.beginTransaction()
if (fragment == null) {
fragment = when (tag) {
OverviewFragment.TAG_FRAGMENT -> OverviewFragment().apply { account = this@MainActivity.account }
CategoryListFragment.TAG_FRAGMENT -> CategoryListFragment().apply { account = this@MainActivity.account }
else -> {
TransactionListFragment().apply { account = this@MainActivity.account }
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
if (item?.itemId == android.R.id.home) {
findNavController(R.id.content_container).navigateUp()
return true
}
}
ft.add(R.id.content_container, fragment, tag)
}
for (fmFragment in supportFragmentManager.fragments) {
if (fmFragment == fragment) {
ft.show(fmFragment)
} else {
ft.hide(fmFragment)
}
}
ft.commit()
return super.onOptionsItemSelected(item)
}
companion object {

View file

@ -1,19 +1,21 @@
package com.wbrawner.budget.ui
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.di.BudgetViewModelFactory
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.activity_splash.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class SplashActivity : AppCompatActivity() {
private val disposables = CompositeDisposable()
class SplashActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private lateinit var viewModel: SplashViewModel
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
@ -30,54 +32,20 @@ class SplashActivity : AppCompatActivity() {
}
(application as AllowanceApplication).appComponent.inject(this)
viewModel = ViewModelProviders.of(this, viewModelFactory).get(SplashViewModel::class.java)
viewModel.checkForExistingCredentials()
.fromBackgroundToMain()
.subscribe { hasExistingCredentials, err ->
if (hasExistingCredentials) {
startActivity(Intent(applicationContext, MainActivity::class.java))
finish()
val navController = findNavController(R.id.auth_content)
launch {
val navId = try {
val user = viewModel.checkForExistingCredentials()
if (user != null) {
(application as AllowanceApplication).currentUser = user
R.id.mainActivity
} else {
showLogin()
R.id.loginFragment
}
} catch (e: Exception) {
R.id.loginFragment
}
navController.navigate(navId)
}
}
.autoDispose(disposables)
}
private fun showLogin() {
loginContainer.show()
viewModel.isLoading
.fromBackgroundToMain()
.subscribe { isLoading ->
formPrompt.show(!isLoading)
usernameContainer.show(!isLoading)
passwordContainer.show(!isLoading)
submit.show(!isLoading)
progressBar.show(isLoading)
}
.autoDispose(disposables)
submit.setOnClickListener {
if (!username.ensureNotEmpty() || !password.ensureNotEmpty()) {
return@setOnClickListener
}
viewModel.login(username.text.toString(), password.text.toString())
.fromBackgroundToMain()
.subscribe { success, error ->
if (error != null || !success) {
username.error = "Invalid username/password"
password.error = "Invalid username/password"
return@subscribe
}
startActivity(Intent(applicationContext, MainActivity::class.java))
}
.autoDispose(disposables)
}
}
override fun onDestroy() {
disposables.dispose()
disposables.clear()
super.onDestroy()
}
}

View file

@ -1,38 +1,38 @@
package com.wbrawner.budget.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.auth.CredentialsProvider
import com.wbrawner.budget.common.account.AccountRepository
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.di.ViewModelKey
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import io.reactivex.Single
import io.reactivex.subjects.BehaviorSubject
import javax.inject.Inject
class SplashViewModel @Inject constructor(
private val credentialsProvider: CredentialsProvider,
private val accountRepository: AccountRepository
private val budgetRepository: BudgetRepository
) : ViewModel() {
val isLoading: BehaviorSubject<Boolean> = BehaviorSubject.createDefault(false)
val isLoading: MutableLiveData<Boolean> = MutableLiveData(false)
fun checkForExistingCredentials(): Single<Boolean> = Single.create { subscriber ->
login(credentialsProvider.username(), credentialsProvider.password()).subscribe { _, err ->
subscriber.onSuccess(err == null)
suspend fun checkForExistingCredentials(): User? {
return try {
login(credentialsProvider.username, credentialsProvider.password)
} catch (ignored: Exception) {
null
}
}
fun login(username: String, password: String): Single<Boolean> = Single.create { subscriber ->
isLoading.onNext(true)
suspend fun login(username: String, password: String): User {
isLoading.value = true
credentialsProvider.saveCredentials(username, password)
accountRepository.findAll().subscribe { _, err ->
isLoading.onNext(false)
if (err != null) {
subscriber.onError(err)
} else {
subscriber.onSuccess(true)
}
return try {
budgetRepository.login(username, password)
} catch (e: java.lang.Exception) {
isLoading.value = false
throw e
}
}
}

View file

@ -1,23 +1,15 @@
package com.wbrawner.budget.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.res.Resources
import android.content.Context
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.view.View
import android.widget.EditText
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.wbrawner.budget.R
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
fun Disposable.autoDispose(compositeDisposable: CompositeDisposable) {
compositeDisposable.add(this)
}
fun RecyclerView.hideFabOnScroll(fab: FloatingActionButton) {
addOnScrollListener(object : RecyclerView.OnScrollListener() {
@ -27,35 +19,12 @@ fun RecyclerView.hideFabOnScroll(fab: FloatingActionButton) {
})
}
fun <T> Single<T>.fromBackgroundToMain(): Single<T> {
return this.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun <T> Observable<T>.fromBackgroundToMain(): Observable<T> {
return this.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun View.hide() {
show(false)
}
fun View.show(show: Boolean = true) {
val shortAnimTime = Resources.getSystem().getInteger(android.R.integer.config_shortAnimTime).toLong()
if (show) {
// Put the view back on the screen before animating its visibility
alpha = 0f
visibility = View.VISIBLE
}
animate()
.setDuration(shortAnimTime)
.alpha((if (show) 1 else 0).toFloat())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
this@show.visibility = if (show) View.VISIBLE else View.GONE
}
})
visibility = if (show) View.VISIBLE else View.GONE
}
fun EditText.ensureNotEmpty(): Boolean {
@ -67,3 +36,23 @@ fun EditText.ensureNotEmpty(): Boolean {
true
}
}
fun Long.toAmountSpannable(context: Context? = null): Spannable {
val spannableStringBuilder = SpannableStringBuilder()
spannableStringBuilder.append(String.format("${'$'}%.02f", this / 100.0f))
if (context == null) {
return spannableStringBuilder
}
val color = when {
this > 0 -> R.color.colorTextGreen
this == 0L -> R.color.colorTextPrimary
else -> R.color.colorTextRed
}
spannableStringBuilder.setSpan(
ForegroundColorSpan(ContextCompat.getColor(context, color)),
0,
spannableStringBuilder.length,
0
)
return spannableStringBuilder
}

View file

@ -0,0 +1,93 @@
package com.wbrawner.budget.ui.auth
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.MainActivity
import com.wbrawner.budget.ui.SplashViewModel
import com.wbrawner.budget.ui.ensureNotEmpty
import com.wbrawner.budget.ui.show
import kotlinx.android.synthetic.main.fragment_login.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/**
* A simple [Fragment] subclass.
*/
class LoginFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private lateinit var viewModel: SplashViewModel
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
(requireActivity().application as AllowanceApplication)
.appComponent
.inject(this)
viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)
.get(SplashViewModel::class.java)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_login, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.isLoading.observe(this, Observer { isLoading ->
formPrompt.show(!isLoading)
usernameContainer.show(!isLoading)
passwordContainer.show(!isLoading)
submit.show(!isLoading)
registerButton.show(!isLoading)
forgotPasswordLink.show(!isLoading)
progressBar.show(isLoading)
})
password.setOnEditorActionListener { _, _, _ ->
submit.performClick()
}
submit.setOnClickListener {
if (!username.ensureNotEmpty() || !password.ensureNotEmpty()) {
return@setOnClickListener
}
launch {
try {
val user = viewModel.login(username.text.toString(), password.text.toString())
(requireActivity().application as AllowanceApplication).currentUser = user
startActivity(Intent(requireContext().applicationContext, MainActivity::class.java))
} catch (e: Exception) {
username.error = "Invalid username/password"
password.error = "Invalid username/password"
e.printStackTrace()
}
}
}
val usernameString = arguments?.getString(EXTRA_USERNAME)
val passwordString = arguments?.getString(EXTRA_PASSWORD)
if (!usernameString.isNullOrBlank() && !passwordString.isNullOrBlank()) {
username.setText(usernameString)
password.setText(passwordString)
submit.performClick()
}
}
companion object {
const val EXTRA_USERNAME = "username"
const val EXTRA_PASSWORD = "password"
}
}

View file

@ -0,0 +1,23 @@
package com.wbrawner.budget.ui.auth
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 RegisterFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_login, container, false)
}
}

View file

@ -0,0 +1,102 @@
package com.wbrawner.budget.ui.base
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.lifecycle.*
import androidx.recyclerview.widget.LinearLayoutManager
import com.wbrawner.budget.R
import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.hideFabOnScroll
import com.wbrawner.budget.ui.show
import kotlinx.android.synthetic.main.fragment_list_with_add_button.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
abstract class ListWithAddButtonFragment<T : LoadingViewModel> : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
lateinit var viewModel: T
private var viewCreated = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProviders.of(this, viewModelFactory).get(viewModelClass)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_list_with_add_button, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.isLoading.observe(this, Observer {
view.findViewById<ProgressBar?>(R.id.progressBar)?.show(it)
})
recyclerView.layoutManager = LinearLayoutManager(view.context)
recyclerView.hideFabOnScroll(addFab)
addFab.setOnClickListener {
addItem()
}
reloadItems()
}
override fun onStart() {
super.onStart()
reloadItems()
}
private fun reloadItems() {
launch {
if (view == null) return@launch
val (items, constructors) = loadItems()
if (items.isEmpty()) {
recyclerView.adapter = null
recyclerView.visibility = View.GONE
noItemsTextView.setText(noItemsStringRes)
noItemsTextView.visibility = View.VISIBLE
} else {
recyclerView.adapter = BindableAdapter(items, constructors)
recyclerView.visibility = View.VISIBLE
noItemsTextView.visibility = View.GONE
}
}
}
override fun onDestroyView() {
try {
coroutineContext.cancel()
} catch (ignored: Exception) {
}
super.onDestroyView()
}
abstract val viewModelClass: Class<T>
@get:StringRes
abstract val noItemsStringRes: Int
abstract fun addItem()
abstract suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>>
}
abstract class LoadingViewModel : ViewModel() {
val isLoading: LiveData<Boolean> = MutableLiveData(true)
suspend fun <T> showLoader(block: suspend () -> T): T {
(isLoading as MutableLiveData).postValue(true)
val result = block.invoke()
isLoading.postValue(false)
return result
}
}

View file

@ -0,0 +1,58 @@
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.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext
class BindableAdapter(
private val items: List<BindableState>,
private val constructors: Map<Int, (view: View) -> BindableViewHolder<in BindableState>>
) : RecyclerView.Adapter<BindableAdapter.BindableViewHolder<in BindableState>>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
: BindableViewHolder<in BindableState> = constructors[viewType]
?.invoke(LayoutInflater.from(parent.context).inflate(viewType, parent, false))
?: throw IllegalStateException("Attempted to create ViewHolder without proper constructor provided")
override fun getItemCount(): Int = items.size
override fun getItemViewType(position: Int): Int = items[position].viewType
override fun onBindViewHolder(holder: BindableViewHolder<in BindableState>, position: Int) {
holder.onBind(items[position])
}
override fun onViewRecycled(holder: BindableViewHolder<in BindableState>) {
holder.onUnbind()
}
abstract class BindableViewHolder<T>(itemView: View)
: RecyclerView.ViewHolder(itemView), Bindable<T>
abstract class CoroutineViewHolder<T>(itemView: View) : BindableViewHolder<T>(itemView), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
override fun onUnbind() {
try {
coroutineContext.cancel()
} catch (ignored: Exception) {
}
super.onUnbind()
}
}
}
interface Bindable<T> {
fun onBind(item: T) {}
fun onUnbind() {}
}
interface BindableState {
@get:LayoutRes
val viewType: Int
}

View file

@ -0,0 +1,93 @@
package com.wbrawner.budget.ui.budgets
import android.os.Bundle
import android.view.*
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import kotlinx.android.synthetic.main.fragment_add_edit_budget.*
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
class AddEditBudgetFragment : Fragment() {
lateinit var viewModel: AddEditBudgetViewModel
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
var id: Long? = null
var menu: Menu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
(requireActivity().application as AllowanceApplication)
.appComponent
.inject(this)
viewModel = ViewModelProviders.of(requireActivity(), viewModelFactory)
.get(AddEditBudgetViewModel::class.java)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return 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?) {
usersSearch.setAdapter(viewModel.suggestionsAdapter)
launch {
val budget = try {
viewModel.getBudget(arguments!!.getLong(EXTRA_BUDGET_ID))
} catch (e: Exception) {
return@launch
}
activity?.setTitle(R.string.title_edit_budget)
menu?.findItem(R.id.action_delete)?.isVisible = true
id = budget.id
name.setText(budget.name)
description.setText(budget.description)
budget.users.forEach {
viewModel.addUser(it)
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> findNavController().navigateUp()
R.id.action_save -> {
launch {
viewModel.saveBudget(Budget(
id = id,
name = name.text.toString(),
description = description.text.toString(),
users = viewModel.users.value ?: emptyList()
))
findNavController().navigateUp()
}
}
R.id.action_delete -> {
launch {
viewModel.deleteBudget(this@AddEditBudgetFragment.id!!)
findNavController().navigateUp()
}
}
}
return true
}
}
fun AddEditBudgetFragment.launch(block: suspend CoroutineScope.() -> Unit) = viewModel.launch(block)

View file

@ -0,0 +1,64 @@
package com.wbrawner.budget.ui.budgets
import android.content.Context
import android.widget.ArrayAdapter
import androidx.lifecycle.MutableLiveData
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.User
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.di.ViewModelKey
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class AddEditBudgetViewModel @Inject constructor(
context: Context,
private val userRepository: UserRepository,
private val budgetRepository: BudgetRepository
) : ViewModel() {
val suggestionsAdapter = ArrayAdapter<String>(
context,
android.R.layout.simple_spinner_item,
mutableListOf()
)
val users = MutableLiveData<List<User>>()
suspend fun getBudget(id: Long) = budgetRepository.findById(id)
suspend fun saveBudget(budget: Budget): Budget = if (budget.id != null) {
budgetRepository.update(budget)
} else {
budgetRepository.create(budget)
}
suspend fun deleteBudget(accountId: Long) = budgetRepository.delete(accountId)
suspend fun searchUsers(query: String) {
suggestionsAdapter.clear()
if (query.isNotBlank()) {
suggestionsAdapter.addAll(userRepository.findAllByNameLike(query).map { it.username })
}
}
fun addUser(user: User) {
users.value
}
}
@Module
abstract class AddEditAccountsViewModelMapper {
@Binds
@IntoMap
@ViewModelKey(AddEditBudgetViewModel::class)
abstract fun bindViewModel(viewModel: AddEditBudgetViewModel): ViewModel
}
fun ViewModel.launch(block: suspend CoroutineScope.() -> Unit) {
viewModelScope.launch(block = block)
}

View file

@ -0,0 +1,72 @@
package com.wbrawner.budget.ui.budgets
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
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.BindableState
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import kotlinx.coroutines.CoroutineScope
class BudgetListFragment : ListWithAddButtonFragment<BudgetViewModel>(), CoroutineScope {
override val noItemsStringRes: Int = R.string.overview_no_data
override val viewModelClass: Class<BudgetViewModel> = BudgetViewModel::class.java
override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(this)
super.onCreate(savedInstanceState)
}
override suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> {
val budgetItems = viewModel.getBudgets().map { BudgetState(it) }
val navController = findNavController()
return Pair(
budgetItems,
mapOf(BUDGET_VIEW to { v -> BudgetViewHolder(v, navController) as BindableAdapter.BindableViewHolder<in BindableState> })
)
}
override fun addItem() {
findNavController().navigate(R.id.addEditBudget)
}
}
const val BUDGET_VIEW = R.layout.list_item_budget
class BudgetState(val budget: Budget) : BindableState {
override val viewType: Int = BUDGET_VIEW
}
class BudgetViewHolder(
itemView: View,
private val navController: NavController
) : BindableAdapter.BindableViewHolder<BudgetState>(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: BudgetState) {
with(item) {
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 {
putLong(EXTRA_BUDGET_ID, budget.id!!)
}
navController.navigate(R.id.categoryListFragment, bundle)
}
}
}
}

View file

@ -0,0 +1,44 @@
package com.wbrawner.budget.ui.budgets
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.di.ViewModelKey
import com.wbrawner.budget.ui.base.LoadingViewModel
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import javax.inject.Inject
class BudgetViewModel @Inject constructor(private val budgetRepo: BudgetRepository) : LoadingViewModel() {
suspend fun getBudget(id: Long): Budget = showLoader {
budgetRepo.findById(id)
}
suspend fun getBudgets(): Collection<Budget> = showLoader {
budgetRepo.findAll()
}
suspend fun saveBudget(budget: Budget) = showLoader {
if (budget.id == null)
budgetRepo.create(budget)
else
budgetRepo.update(budget)
}
suspend fun deleteBudgetById(id: Long) = showLoader {
budgetRepo.delete(id)
}
suspend fun getBalance(id: Long) = showLoader {
budgetRepo.getBalance(id)
}
}
@Module
abstract class BudgetViewModelMapper {
@Binds
@IntoMap
@ViewModelKey(BudgetViewModel::class)
abstract fun bindViewModel(viewModel: BudgetViewModel): ViewModel
}

View file

@ -4,26 +4,27 @@ import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.ViewModelProviders
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.account.Account
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.autoDispose
import com.wbrawner.budget.ui.fromBackgroundToMain
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.transactions.toLong
import kotlinx.android.synthetic.main.activity_add_edit_category.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class AddEditCategoryActivity : AppCompatActivity() {
private val disposables = CompositeDisposable()
private lateinit var account: Account
class AddEditCategoryActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
lateinit var viewModel: CategoryViewModel
@ -37,40 +38,36 @@ class AddEditCategoryActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
(application as AllowanceApplication).appComponent.inject(this)
viewModel = ViewModelProviders.of(this, viewModelFactory).get(CategoryViewModel::class.java)
viewModel.getAccount(intent!!.extras!!.getLong(EXTRA_ACCOUNT_ID))
.fromBackgroundToMain()
.subscribe { account, err ->
if (err != null) {
finish()
// TODO: Hide a progress spinner and show error message
return@subscribe
}
this@AddEditCategoryActivity.account = account
launch {
val budgets = viewModel.getBudgets().toTypedArray()
budgetSpinner.adapter = ArrayAdapter<Budget>(
this@AddEditCategoryActivity,
android.R.layout.simple_list_item_1,
budgets
)
loadCategory()
}
.autoDispose(disposables)
}
private fun loadCategory() {
if (intent?.hasExtra(EXTRA_CATEGORY_ID) == false) {
val categoryId = intent?.extras?.getLong(EXTRA_CATEGORY_ID)
if (categoryId == null) {
setTitle(R.string.title_add_category)
return
}
viewModel.getCategory(intent!!.extras!!.getLong(EXTRA_CATEGORY_ID))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { category, err ->
if (err != null) {
launch {
val category = try {
viewModel.getCategory(categoryId)
} catch (e: Exception) {
menu?.findItem(R.id.action_delete)?.isVisible = false
return@subscribe
}
null
} ?: return@launch
id = category.id
setTitle(R.string.title_edit_category)
menu?.findItem(R.id.action_delete)?.isVisible = true
edit_category_name.setText(category.title)
edit_category_amount.setText(String.format("%.02f", category.amount / 100.0f))
}
.autoDispose(disposables)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
@ -101,21 +98,19 @@ class AddEditCategoryActivity : AppCompatActivity() {
}
R.id.action_save -> {
if (!validateFields()) return true
launch {
viewModel.saveCategory(Category(
id = id,
title = edit_category_name.text.toString(),
amount = edit_category_amount.rawValue,
accountId = account.id!!
amount = edit_category_amount.text.toLong(),
budgetId = (budgetSpinner.selectedItem as Budget).id!!
))
.fromBackgroundToMain()
.subscribe { _, err ->
finish()
}
}
R.id.action_delete -> {
launch {
viewModel.deleteCategoryById(this@AddEditCategoryActivity.id!!)
.fromBackgroundToMain()
.subscribe { _, _ ->
finish()
}
}
@ -123,7 +118,6 @@ class AddEditCategoryActivity : AppCompatActivity() {
return true
}
private fun validateFields(): Boolean {
var errors = false
if (edit_category_name.text?.isEmpty() == true) {
@ -131,21 +125,11 @@ class AddEditCategoryActivity : AppCompatActivity() {
errors = true
}
if (edit_category_amount.text.isEmpty()) {
if (edit_category_amount.text.toString().isEmpty()) {
edit_category_amount.error = getString(R.string.required_field_amount)
errors = true
}
return !errors
}
override fun onDestroy() {
disposables.dispose()
super.onDestroy()
}
companion object {
const val EXTRA_ACCOUNT_ID = "EXTRA_ACCOUNT_ID"
const val EXTRA_CATEGORY_ID = "EXTRA_CATEGORY_ID"
}
}

View file

@ -8,22 +8,21 @@ import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat.startActivity
import androidx.recyclerview.widget.RecyclerView
import com.wbrawner.budget.R
import com.wbrawner.budget.common.account.Account
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.ui.autoDispose
import com.wbrawner.budget.ui.categories.AddEditCategoryActivity.Companion.EXTRA_ACCOUNT_ID
import com.wbrawner.budget.ui.categories.AddEditCategoryActivity.Companion.EXTRA_CATEGORY_ID
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class CategoryAdapter(
private val activity: Activity,
private val account: Account,
private val data: List<Category>,
private val viewModel: CategoryViewModel
) : androidx.recyclerview.widget.RecyclerView.Adapter<CategoryAdapter.ViewHolder>() {
) : RecyclerView.Adapter<CategoryAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_category, parent, false)
@ -35,13 +34,11 @@ class CategoryAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val category = data[position]
holder.title?.text = category.title
// TODO: Format according to account's currency
// TODO: Format according to budget's currency
holder.amount?.text = String.format("${'$'}%.02f", category.amount / 100.0f)
holder.progress?.max = category.amount.toInt()
viewModel.getBalance(category.id!!)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { balance, _ ->
holder.launch {
val balance = viewModel.getBalance(category.id!!)
holder.progress?.isIndeterminate = false
holder.progress?.setProgress(
(-1 * balance).toInt(),
@ -52,13 +49,11 @@ class CategoryAdapter(
(category.amount + balance) / 100.0f
)
}
.autoDispose(holder.disposables)
holder.itemView.setOnClickListener {
startActivity(
activity,
Intent(it.context.applicationContext, AddEditCategoryActivity::class.java)
.apply {
putExtra(EXTRA_ACCOUNT_ID, account.id!!)
putExtra(EXTRA_CATEGORY_ID, category.id!!)
},
null
@ -67,15 +62,14 @@ class CategoryAdapter(
}
override fun onViewRecycled(holder: ViewHolder) {
holder.disposables.dispose()
holder.disposables.clear()
holder.coroutineContext.cancel()
super.onViewRecycled(holder)
}
inner class ViewHolder(itemView: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(itemView) {
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
val title: TextView? = itemView.findViewById(R.id.category_title)
val amount: TextView? = itemView.findViewById(R.id.category_amount)
val progress: ProgressBar? = itemView.findViewById(R.id.category_progress)
val disposables = CompositeDisposable()
}
}

View file

@ -0,0 +1,71 @@
package com.wbrawner.budget.ui.categories
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableState
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import com.wbrawner.budget.ui.transactions.TRANSACTION_VIEW
import com.wbrawner.budget.ui.transactions.TransactionState
import com.wbrawner.budget.ui.transactions.TransactionViewHolder
/**
* A simple [Fragment] subclass.
*/
class CategoryFragment : ListWithAddButtonFragment<CategoryViewModel>() {
override val noItemsStringRes: Int = R.string.transactions_no_data
override val viewModelClass: Class<CategoryViewModel> = CategoryViewModel::class.java
override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(this)
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> {
val categoryId = arguments?.getLong(EXTRA_CATEGORY_ID)
if (categoryId == null) {
findNavController().navigateUp()
return Pair(emptyList(), emptyMap())
}
val category = viewModel.getCategory(categoryId)
activity?.title = category.title
// TODO: Add category details here as well
val items = ArrayList<BindableState>()
items.addAll(viewModel.getTransactions(categoryId).map { TransactionState(it) })
return Pair(
items,
mapOf(TRANSACTION_VIEW to { v ->
TransactionViewHolder(v, findNavController()) as BindableAdapter.BindableViewHolder<in BindableState>
})
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_edit) {
val bundle = Bundle().apply {
putLong(EXTRA_CATEGORY_ID, arguments?.getLong(EXTRA_CATEGORY_ID) ?: -1)
}
findNavController().navigate(R.id.addEditCategoryActivity, bundle)
}
return super.onOptionsItemSelected(item)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_editable, menu)
}
override fun addItem() {
// TODO: Open new transaction flow with budget and category pre-filled
findNavController().navigate(R.id.addEditTransactionActivity)
}
}

View file

@ -1,79 +1,97 @@
package com.wbrawner.budget.ui.categories
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.emoji.widget.EmojiTextView
import androidx.lifecycle.ViewModelProviders
import com.google.android.material.floatingactionbutton.FloatingActionButton
import android.widget.ProgressBar
import android.widget.TextView
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.account.Account
import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.autoDispose
import com.wbrawner.budget.ui.categories.AddEditCategoryActivity.Companion.EXTRA_ACCOUNT_ID
import com.wbrawner.budget.ui.hideFabOnScroll
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import javax.inject.Inject
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableState
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import kotlinx.coroutines.launch
class CategoryListFragment : androidx.fragment.app.Fragment() {
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
lateinit var viewModel: CategoryViewModel
lateinit var account: Account
private val disposables = CompositeDisposable()
var recyclerView: androidx.recyclerview.widget.RecyclerView? = null
var noDataView: EmojiTextView? = null
class CategoryListFragment : ListWithAddButtonFragment<CategoryViewModel>() {
override val noItemsStringRes: Int = R.string.categories_no_data
override val viewModelClass: Class<CategoryViewModel> = CategoryViewModel::class.java
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_transaction_list, container, false)
recyclerView = view.findViewById(R.id.list_transactions)
val fab = view.findViewById<FloatingActionButton>(R.id.fab_add_transaction)
recyclerView?.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(activity)
(activity!!.application as AllowanceApplication).appComponent.inject(this)
viewModel = ViewModelProviders.of(activity!!, viewModelFactory).get(CategoryViewModel::class.java)
noDataView = view.findViewById(R.id.transaction_list_no_data)
recyclerView?.hideFabOnScroll(fab)
fab.setOnClickListener {
startActivity(Intent(activity, AddEditCategoryActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT_ID, account.id)
override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(this)
super.onCreate(savedInstanceState)
}
override suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> {
val budgetId = arguments?.getLong(EXTRA_BUDGET_ID)
if (budgetId == null) {
findNavController().navigateUp()
return Pair(emptyList(), emptyMap())
}
val budget = viewModel.getBudget(budgetId)
activity?.title = budget.name
return Pair(
viewModel.getCategories(budgetId).map { CategoryState(it) },
mapOf(CATEGORY_VIEW to { v ->
CategoryViewHolder(v, viewModel, findNavController()) as BindableAdapter.BindableViewHolder<in BindableState>
})
}
return view
)
}
override fun onDestroyView() {
disposables.dispose()
super.onDestroyView()
}
override fun onResume() {
super.onResume()
viewModel.getCategories(account.id!!)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { data ->
if (data.isEmpty()) {
recyclerView?.adapter = null
noDataView?.setText(R.string.categories_no_data)
recyclerView?.visibility = View.GONE
noDataView?.visibility = View.VISIBLE
} else {
recyclerView?.adapter = CategoryAdapter(activity!!, account, data.toList(),
viewModel)
recyclerView?.visibility = View.VISIBLE
noDataView?.visibility = View.GONE
}
}
.autoDispose(disposables)
override fun addItem() {
startActivity(Intent(activity, AddEditCategoryActivity::class.java))
}
companion object {
const val TAG_FRAGMENT = "categories"
const val TITLE_FRAGMENT = R.string.title_categories
}
}
const val CATEGORY_VIEW = R.layout.list_item_category
class CategoryState(val category: Category) : BindableState {
override val viewType: Int = CATEGORY_VIEW
}
class CategoryViewHolder(
itemView: View,
private val viewModel: CategoryViewModel,
private val navController: NavController
) : BindableAdapter.CoroutineViewHolder<CategoryState>(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: CategoryState) {
with(item) {
name.text = category.title
// TODO: Format according to budget's currency
amount.text = String.format("${'$'}%.02f", category.amount / 100.0f)
progressBar.max = category.amount.toInt()
launch {
val balance = viewModel.getBalance(category.id!!)
progressBar.isIndeterminate = false
progressBar.setProgress(
(-1 * balance).toInt(),
true
)
amount.text = itemView.context.getString(
R.string.balance_remaning,
(category.amount + balance) / 100.0f
)
}
itemView.setOnClickListener {
val bundle = Bundle().apply {
putLong(EXTRA_CATEGORY_ID, category.id ?: -1)
}
navController.navigate(R.id.categoryFragment, bundle)
}
}
}
}

View file

@ -0,0 +1,36 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.ViewModel
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.di.ViewModelKey
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import javax.inject.Inject
class CategoryListViewModel @Inject constructor(
private val budgetRepo: BudgetRepository,
private val categoryRepo: CategoryRepository
) : ViewModel() {
suspend fun getCategory(id: Long): Category = categoryRepo.findById(id)
suspend fun getCategories(budgetId: Long? = null): Collection<Category> =
categoryRepo.findAll(budgetId)
suspend fun saveCategory(category: Category) = if (category.id == null) categoryRepo.create(category)
else categoryRepo.update(category)
suspend fun deleteCategoryById(id: Long) = categoryRepo.delete(id)
suspend fun getBalance(id: Long) = categoryRepo.getBalance(id)
}
@Module
abstract class CategoryListViewModelMapper {
@Binds
@IntoMap
@ViewModelKey(CategoryListViewModel::class)
abstract fun bindViewModel(viewModel: CategoryListViewModel): ViewModel
}

View file

@ -1,30 +1,57 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.common.account.AccountRepository
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.transaction.TransactionRepository
import com.wbrawner.budget.di.ViewModelKey
import com.wbrawner.budget.ui.base.LoadingViewModel
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import io.reactivex.Single
import javax.inject.Inject
class CategoryViewModel @Inject constructor(private val accountRepo: AccountRepository, private val categoryRepo:
CategoryRepository) : ViewModel() {
fun getAccount(id: Long) = accountRepo.findById(id)
class CategoryViewModel @Inject constructor(
private val transactionRepo: TransactionRepository,
private val categoryRepo: CategoryRepository,
private val budgetRepo: BudgetRepository
) : LoadingViewModel() {
suspend fun getBudget(budgetId: Long): Budget = showLoader {
budgetRepo.findById(budgetId)
}
fun getCategory(id: Long): Single<Category> = categoryRepo.findById(id)
suspend fun getBudgets() = showLoader {
budgetRepo.findAll()
}
fun getCategories(accountId: Long): Single<Collection<Category>> = categoryRepo.findAll(accountId)
suspend fun getTransactions(categoryId: Long) = showLoader {
transactionRepo.findAll(categoryId = categoryId)
}
fun saveCategory(category: Category) = if (category.id == null) categoryRepo.create(category)
else categoryRepo.update(category)
suspend fun getCategory(id: Long): Category = showLoader {
categoryRepo.findById(id)
}
fun deleteCategoryById(id: Long) = categoryRepo.delete(id)
suspend fun getCategories(budgetId: Long? = null): Collection<Category> = showLoader {
categoryRepo.findAll(budgetId)
}
fun getBalance(id: Long) = categoryRepo.getBalance(id)
suspend fun saveCategory(category: Category) = showLoader {
if (category.id == null)
categoryRepo.create(category)
else
categoryRepo.update(category)
}
suspend fun deleteCategoryById(id: Long) = showLoader {
categoryRepo.delete(id)
}
suspend fun getBalance(id: Long) = showLoader {
categoryRepo.getBalance(id)
}
}
@Module

View file

@ -1,16 +1,15 @@
package com.wbrawner.budget.ui.overview
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.common.account.AccountRepository
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.di.ViewModelKey
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import io.reactivex.Single
import javax.inject.Inject
class AccountOverviewViewModel @Inject constructor(private val accountRepo: AccountRepository) : ViewModel() {
fun getBalance(id: Long): Single<Long> = accountRepo.getBalance(id)
class AccountOverviewViewModel @Inject constructor(private val budgetRepo: BudgetRepository) : ViewModel() {
suspend fun getBalance(id: Long): Long = budgetRepo.getBalance(id)
}
@Module

View file

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

View file

@ -5,23 +5,25 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.account.Account
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.autoDispose
import com.wbrawner.budget.ui.fromBackgroundToMain
import io.reactivex.disposables.CompositeDisposable
import com.wbrawner.budget.ui.toAmountSpannable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class OverviewFragment : androidx.fragment.app.Fragment() {
private val disposables = CompositeDisposable()
class OverviewFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
lateinit var viewModel: AccountOverviewViewModel
lateinit var account: Account
lateinit var budget: Budget
private var inflatedView: View? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -33,21 +35,14 @@ class OverviewFragment : androidx.fragment.app.Fragment() {
return view
}
override fun onResume() {
super.onResume()
viewModel.getBalance(account.id!!)
.fromBackgroundToMain()
.subscribe { balance ->
val balanceView = view!!.findViewById<TextView>(R.id.overview_current_balance)
val color = when {
balance > 0 -> R.color.colorTextGreen
balance == 0L -> R.color.colorTextPrimary
else -> R.color.colorTextRed
}
balanceView.setTextColor(ContextCompat.getColor(context!!, color))
balanceView.text = String.format("${'$'}%.02f", balance / 100.0f)
override fun onStart() {
super.onStart()
launch {
val balance = viewModel.getBalance(budget.id!!)
view?.findViewById<TextView>(R.id.overview_current_balance)
?.text = balance.toAmountSpannable(view?.context)
showData(inflatedView, true)
}.autoDispose(disposables)
}
}
private fun showData(view: View?, show: Boolean) {

View file

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

View file

@ -1,9 +1,15 @@
package com.wbrawner.budget.ui.transactions
import android.app.DatePickerDialog
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.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
@ -11,36 +17,31 @@ import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.ViewModelProviders
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.account.Account
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.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
import com.wbrawner.budget.ui.MainActivity
import com.wbrawner.budget.ui.autoDispose
import com.wbrawner.budget.ui.fromBackgroundToMain
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.activity_add_edit_transaction.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
const val EXTRA_ACCOUNT_ID = "EXTRA_ACCOUNT_ID"
const val EXTRA_TRANSACTION_ID = "EXTRA_TRANSACTION_ID"
class AddEditTransactionActivity : AppCompatActivity() {
private val disposables = CompositeDisposable()
private lateinit var account: Account
class AddEditTransactionActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
private lateinit var viewModel: AddEditTransactionViewModel
var id: Long? = null
var menu: Menu? = null
var transaction: Transaction? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent?.hasExtra(EXTRA_ACCOUNT_ID) != true) {
finish()
return
}
setContentView(R.layout.activity_add_edit_transaction)
setSupportActionBar(action_bar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
@ -48,69 +49,86 @@ class AddEditTransactionActivity : AppCompatActivity() {
edit_transaction_type_expense.isChecked = true
(application as AllowanceApplication).appComponent.inject(this)
viewModel = ViewModelProviders.of(this, viewModelFactory).get(AddEditTransactionViewModel::class.java)
viewModel.getAccount(intent!!.extras!!.getLong(EXTRA_ACCOUNT_ID))
.fromBackgroundToMain()
.subscribe { account, err ->
if (err != null) {
finish()
// TODO: Hide a progress spinner and show error message
return@subscribe
}
this@AddEditTransactionActivity.account = account
loadCategories()
}
.autoDispose(disposables)
}
private fun loadCategories() {
viewModel.getCategories(account.id!!)
.fromBackgroundToMain()
.subscribe { categories ->
val adapter = ArrayAdapter<Category>(
launch {
val accounts = viewModel.getAccounts().toTypedArray()
setCategories()
budgetSpinner.adapter = ArrayAdapter<Budget>(
this@AddEditTransactionActivity,
android.R.layout.simple_list_item_1
android.R.layout.simple_list_item_1,
accounts
)
adapter.add(Category(id = 0, title = getString(R.string.uncategorized),
amount = 0, accountId = 0))
// TODO: Add option to create new category on-the-fly
if (!categories.isEmpty()) {
adapter.addAll(categories)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
edit_transaction_category.adapter = adapter
budgetSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) {
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
this@AddEditTransactionActivity.launch {
val account = budgetSpinner.selectedItem as Budget
setCategories(viewModel.getCategories(account.id!!))
}
}
}
if (intent?.hasExtra(EXTRA_TRANSACTION_ID) == true) {
loadTransaction()
transactionDate.setOnClickListener {
val currentDate = DateFormat.getDateFormat(this@AddEditTransactionActivity)
.parse(transactionDate.text.toString())
DatePickerDialog(
this@AddEditTransactionActivity,
{ _, year, month, dayOfMonth ->
transactionDate.text = DateFormat.getDateFormat(this@AddEditTransactionActivity)
.format(Date(year, month, dayOfMonth))
},
currentDate.year + 1900,
currentDate.month,
currentDate.date
).show()
}
transactionTime.setOnClickListener {
val currentDate = DateFormat.getTimeFormat(this@AddEditTransactionActivity)
.parse(transactionTime.text.toString())
TimePickerDialog(
this@AddEditTransactionActivity,
TimePickerDialog.OnTimeSetListener { _, hourOfDay, minute ->
val newTime = Date().apply {
hours = hourOfDay
minutes = minute
}
transactionTime.text = DateFormat.getTimeFormat(this@AddEditTransactionActivity)
.format(newTime)
},
currentDate.hours,
currentDate.minutes,
DateFormat.is24HourFormat(this@AddEditTransactionActivity)
).show()
}
}
.autoDispose(disposables)
}
private fun loadTransaction() {
private suspend fun loadTransaction() {
transaction = try {
viewModel.getTransaction(intent!!.extras!!.getLong(EXTRA_TRANSACTION_ID))
.fromBackgroundToMain()
.subscribe { transaction, err ->
if (err != null) {
} catch (e: Exception) {
menu?.findItem(R.id.action_delete)?.isVisible = false
return@subscribe
val date = Date()
transactionDate.text = DateFormat.getDateFormat(this).format(date)
transactionTime.text = DateFormat.getTimeFormat(this).format(date)
return
}
id = transaction.id
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_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
}
val field = Calendar.getInstance()
field.time = transaction.date
val year = field.get(Calendar.YEAR)
val month = field.get(Calendar.MONTH)
val day = field.get(Calendar.DAY_OF_MONTH)
edit_transaction_date.updateDate(year, month, day)
transaction.categoryId?.let {
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)
@ -119,7 +137,24 @@ class AddEditTransactionActivity : AppCompatActivity() {
}
}
}
.autoDispose(disposables)
private fun setCategories(categories: Collection<Category> = emptyList()) {
val adapter = ArrayAdapter<Category>(
this@AddEditTransactionActivity,
android.R.layout.simple_list_item_1
)
adapter.add(Category(id = 0, title = getString(R.string.uncategorized),
amount = 0, budgetId = 0))
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 {
@ -135,36 +170,33 @@ class AddEditTransactionActivity : AppCompatActivity() {
when (item?.itemId) {
android.R.id.home -> onNavigateUp()
R.id.action_save -> {
val field = edit_transaction_date
val cal = Calendar.getInstance()
cal.set(field.year, field.month, field.dayOfMonth)
var categoryId = (edit_transaction_category.selectedItem as? Category)?.id
val date = DateFormat.getDateFormat(this).parse(transactionDate.text.toString())
val time = DateFormat.getTimeFormat(this).parse(transactionTime.text.toString())
date.hours = time.hours
date.minutes = time.minutes
val categoryId = (edit_transaction_category.selectedItem as? Category)?.id
?.let {
if (it > 0) it
else null
}
launch {
viewModel.saveTransaction(Transaction(
id = id,
accountId = account.id!!,
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
title = edit_transaction_title.text.toString(),
date = cal.time,
date = date,
description = edit_transaction_description.text.toString(),
amount = edit_transaction_amount.rawValue,
amount = edit_transaction_amount.text.toLong(),
expense = edit_transaction_type_expense.isChecked,
categoryId = categoryId,
createdBy = (application as AllowanceApplication).currentUser!!.id!!
))
.fromBackgroundToMain()
.subscribe { transaction, err ->
onNavigateUp()
}
.autoDispose(disposables)
}
R.id.action_delete -> {
launch {
viewModel.deleteTransaction(this@AddEditTransactionActivity.id!!)
.fromBackgroundToMain()
.subscribe { _, err ->
err?.printStackTrace()
onNavigateUp()
}
}
@ -190,9 +222,6 @@ class AddEditTransactionActivity : AppCompatActivity() {
return true
}
}
override fun onDestroy() {
disposables.dispose()
super.onDestroy()
}
}
fun Editable?.toLong(): Long = toString().toDouble().toLong() * 100

View file

@ -1,7 +1,7 @@
package com.wbrawner.budget.ui.transactions
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.common.account.AccountRepository
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
@ -12,22 +12,22 @@ import dagger.multibindings.IntoMap
import javax.inject.Inject
class AddEditTransactionViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val budgetRepository: BudgetRepository,
private val categoryRepository: CategoryRepository,
private val transactionRepository: TransactionRepository
) : ViewModel() {
fun getCategories(accountId: Long) = categoryRepository.findAll(accountId)
suspend fun getCategories(accountId: Long) = categoryRepository.findAll(accountId)
fun getTransaction(id: Long) = transactionRepository.findById(id)
suspend fun getTransaction(id: Long) = transactionRepository.findById(id)
fun saveTransaction(transaction: Transaction) = if (transaction.id == null)
suspend fun saveTransaction(transaction: Transaction) = if (transaction.id == null)
transactionRepository.create(transaction)
else
transactionRepository.update(transaction)
fun deleteTransaction(id: Long) = transactionRepository.delete(id)
suspend fun deleteTransaction(id: Long) = transactionRepository.delete(id)
fun getAccount(id: Long) = accountRepository.findById(id)
suspend fun getAccounts() = budgetRepository.findAll()
}
@Module

View file

@ -1,52 +0,0 @@
package com.wbrawner.budget.ui.transactions
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.startActivity
import com.wbrawner.budget.R
import com.wbrawner.budget.common.transaction.Transaction
import java.text.SimpleDateFormat
class TransactionAdapter(private val activity: Activity, private val data: List<Transaction>)
: androidx.recyclerview.widget.RecyclerView.Adapter<TransactionAdapter.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_transaction, parent, false)
return ViewHolder(view)
}
override fun getItemCount(): Int = data.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val transaction: Transaction = data[position]
holder.title?.text = transaction.title
holder.date?.text = SimpleDateFormat.getDateInstance(SimpleDateFormat.SHORT).format(transaction.date)
holder.amount?.text = String.format("${'$'}%.02f", transaction.amount / 100.0f)
val context = holder.itemView.context
val color = if (transaction.expense) R.color.colorTextRed else R.color.colorTextGreen
holder.amount?.setTextColor(ContextCompat.getColor(context, color))
holder.itemView.setOnClickListener {
startActivity(
activity,
Intent(it.context.applicationContext, AddEditTransactionActivity::class.java)
.apply {
putExtra(EXTRA_ACCOUNT_ID, transaction.accountId)
putExtra(EXTRA_TRANSACTION_ID, transaction.id)
},
null
)
}
}
inner class ViewHolder(itemView: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(itemView) {
val title: TextView? = itemView.findViewById(R.id.transaction_title)
val date: TextView? = itemView.findViewById(R.id.transaction_date)
val amount: TextView? = itemView.findViewById(R.id.transaction_amount)
}
}

View file

@ -1,83 +1,84 @@
package com.wbrawner.budget.ui.transactions
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.emoji.widget.EmojiTextView
import androidx.lifecycle.ViewModelProviders
import com.google.android.material.floatingactionbutton.FloatingActionButton
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.account.Account
import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import javax.inject.Inject
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.BindableState
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import java.text.SimpleDateFormat
class TransactionListFragment : androidx.fragment.app.Fragment() {
private val disposables = CompositeDisposable()
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
lateinit var listViewModel: TransactionListViewModel
lateinit var account: Account
lateinit var recyclerView: androidx.recyclerview.widget.RecyclerView
lateinit var noDataView: EmojiTextView
class TransactionListFragment : ListWithAddButtonFragment<TransactionListViewModel>() {
override val viewModelClass: Class<TransactionListViewModel> = TransactionListViewModel::class.java
override val noItemsStringRes: Int = R.string.transactions_no_data
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_transaction_list, container, false)
recyclerView = view.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.list_transactions)
noDataView = view.findViewById<EmojiTextView>(R.id.transaction_list_no_data)
val fab = view.findViewById<FloatingActionButton>(R.id.fab_add_transaction)
recyclerView.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(activity)
(activity!!.application as AllowanceApplication).appComponent.inject(this)
listViewModel = ViewModelProviders.of(activity!!, viewModelFactory).get(TransactionListViewModel::class.java)
recyclerView.addOnScrollListener(object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: androidx.recyclerview.widget.RecyclerView, dx: Int, dy: Int) {
if (dy > 0) fab.hide() else fab.show()
override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(this)
super.onCreate(savedInstanceState)
}
override fun addItem() {
startActivity(Intent(activity, AddEditTransactionActivity::class.java))
}
override suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> {
return Pair(
viewModel.getTransactions(
arguments?.getLong(EXTRA_BUDGET_ID),
arguments?.getLong(EXTRA_CATEGORY_ID)
).map { TransactionState(it) },
mapOf(TRANSACTION_VIEW to { v ->
TransactionViewHolder(v, findNavController()) as BindableAdapter.BindableViewHolder<in BindableState>
})
fab.setOnClickListener {
startActivity(
Intent(activity, AddEditTransactionActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT_ID, account.id!!)
}
)
}
return view
}
override fun onResume() {
super.onResume()
listViewModel.getTransactions(account.id!!)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { data ->
if (data.isEmpty()) {
recyclerView.adapter = null
noDataView.setText(R.string.transactions_no_data)
recyclerView.visibility = View.GONE
noDataView.visibility = View.VISIBLE
} else {
recyclerView.adapter = TransactionAdapter(activity!!, data.toList())
recyclerView.visibility = View.VISIBLE
noDataView.visibility = View.GONE
}
}
.autoDispose(disposables)
}
override fun onDestroyView() {
disposables.dispose()
super.onDestroyView()
}
companion object {
const val TAG_FRAGMENT = "transactions"
const val TITLE_FRAGMENT = R.string.title_transactions
}
}
const val TRANSACTION_VIEW = R.layout.list_item_transaction
class TransactionState(val transaction: Transaction) : BindableState {
override val viewType: Int = TRANSACTION_VIEW
}
class TransactionViewHolder(
itemView: View,
private val navController: NavController
) : BindableAdapter.CoroutineViewHolder<TransactionState>(itemView) {
private val name: TextView = itemView.findViewById(R.id.transaction_title)
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: TransactionState) {
with(item) {
name.text = transaction.title
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 {
putLong(EXTRA_BUDGET_ID, transaction.budgetId)
putLong(EXTRA_TRANSACTION_ID, transaction.id ?: -1)
}
navController.navigate(R.id.addEditTransactionActivity, bundle)
}
}
}
}

View file

@ -1,20 +1,19 @@
package com.wbrawner.budget.ui.transactions
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.di.ViewModelKey
import com.wbrawner.budget.ui.base.LoadingViewModel
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import io.reactivex.Single
import javax.inject.Inject
class TransactionListViewModel @Inject constructor(private val transactionRepo: TransactionRepository) :
ViewModel
() {
fun getTransactions(accountId: Long): Single<Collection<Transaction>> =
transactionRepo.findAll(accountId)
LoadingViewModel() {
suspend fun getTransactions(budgetId: Long? = null, categoryId: Long? = null) = showLoader {
transactionRepo.findAll(budgetId = budgetId, categoryId = categoryId)
}
}
@Module

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M4,10v7h3v-7L4,10zM10,10v7h3v-7h-3zM2,22h19v-3L2,19v3zM16,10v7h3v-7h-3zM11.5,1L2,6v2h19L21,6l-9.5,-5z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
</vector>

View file

@ -1,7 +1,6 @@
<?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="match_parent">
@ -32,7 +31,6 @@
android:id="@+id/container_edit_category_name"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_name"
app:layout_constraintBottom_toTopOf="@+id/container_edit_category_amount"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@ -47,38 +45,30 @@
android:id="@+id/container_edit_category_amount"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_amount"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_category_name">
<com.blackcat.currencyedittext.CurrencyEditText
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_amount"
style="@style/AppTheme.EditText"
android:text="0.00"
android:inputType="number"
tools:ignore="HardcodedText" />
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
<!--<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_constraintBottom_toTopOf="@+id/edit_transaction_date"-->
<!--app:layout_constraintEnd_toEndOf="parent"-->
<!--app:layout_constraintStart_toStartOf="parent"-->
<!--app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_amount" />-->
<TextView
android:id="@+id/budgetHint"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/prompt_account"
app:layout_constraintTop_toBottomOf="@+id/container_edit_category_amount"
app:layout_constraintStart_toStartOf="parent" />
<!--<DatePicker-->
<!--android:id="@+id/edit_transaction_date"-->
<!--style="@style/AppTheme.DatePicker"-->
<!--app:layout_constraintBottom_toBottomOf="parent"-->
<!--app:layout_constraintEnd_toEndOf="parent"-->
<!--app:layout_constraintStart_toStartOf="parent"-->
<!--app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_date" />-->
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/budgetSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/budgetHint" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -2,7 +2,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">
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
@ -64,10 +65,11 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_description">
<com.blackcat.currencyedittext.CurrencyEditText
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_amount"
style="@style/AppTheme.EditText"
android:inputType="numberDecimal" />
android:inputType="numberDecimal"
android:scrollHorizontally="false" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
@ -81,6 +83,7 @@
<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
@ -101,13 +104,49 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_type" />
<DatePicker
android:id="@+id/edit_transaction_date"
style="@style/AppTheme.DatePicker"
app:layout_constraintEnd_toEndOf="parent"
<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"
@ -117,7 +156,7 @@
android:text="@string/prompt_transaction_category"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/edit_transaction_date" />
app:layout_constraintTop_toBottomOf="@+id/budgetSpinner" />
<Spinner
android:id="@+id/edit_transaction_category"

View file

@ -1,107 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<fragment 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/auth_content"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.SplashActivity">
<ScrollView
android:id="@+id/loginContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_rounded"
android:clipChildren="false"
android:clipToPadding="false"
android:fitsSystemWindows="true"
android:visibility="gone"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:fitsSystemWindows="true">
<TextView
android:id="@+id/formPrompt"
android:text="Login to manage your budget"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:hint="@string/prompt_username"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/formPrompt">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:hint="@string/prompt_password"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/usernameContainer">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/submit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:backgroundTint="@color/colorAccent"
android:text="@string/action_submit"
android:textColor="@color/colorTextPrimaryInverted"
app:cornerRadius="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/passwordContainer" />
<ProgressBar
android:id="@+id/progressBar"
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_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
app:defaultNavHost="true"
app:navGraph="@navigation/auth_graph"
tools:context=".ui.SplashActivity" />

View file

@ -8,21 +8,12 @@
<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"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
<FrameLayout
android:id="@+id/content_container"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@+id/action_bar"
app:layout_constraintBottom_toTopOf="@+id/menu_main"
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"
@ -33,4 +24,16 @@
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/main_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>

View file

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

View file

@ -0,0 +1,57 @@
<?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:padding="16dp"
tools:context=".ui.budgets.AddEditBudgetFragment">
<androidx.constraintlayout.widget.ConstraintLayout
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>
</ScrollView>

View file

@ -0,0 +1,41 @@
<?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"
android:animateLayoutChanges="true">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<androidx.emoji.widget.EmojiTextView
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" />
<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:srcCompat="@drawable/ic_add_white_24dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,129 @@
<?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"
android:id="@+id/loginContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:animateLayoutChanges="true"
android:background="@drawable/bg_rounded"
android:clipChildren="false"
android:clipToPadding="false"
android:fitsSystemWindows="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:fitsSystemWindows="true">
<ProgressBar
android:id="@+id/progressBar"
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_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/formPrompt"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/info_login"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/submit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:backgroundTint="@color/colorAccent"
android:text="@string/action_login"
android:textColor="@color/colorTextPrimaryInverted"
app:cornerRadius="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/passwordContainer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/forgotPasswordLink"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/title_forgot_password"
app:cornerRadius="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/submit" />
<com.google.android.material.button.MaterialButton
android:id="@+id/registerButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/title_register"
app:cornerRadius="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/forgotPasswordLink" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:hint="@string/prompt_password"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/usernameContainer">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:nextFocusLeft="@+id/username"
android:nextFocusUp="@+id/username" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:hint="@string/prompt_username"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/formPrompt">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1"
android:nextFocusRight="@+id/password"
android:nextFocusDown="@+id/password"
android:nextFocusForward="@+id/password" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View file

@ -22,11 +22,20 @@
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textSize="36sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/accountsList"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/overview_current_balance_label" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/accountsList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/overview_current_balance" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/overview_no_data"
android:layout_width="0dp"

View file

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

View file

@ -0,0 +1,14 @@
<?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.auth.LoginFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>

View file

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

View file

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

View file

@ -1,15 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_overview"
android:icon="@drawable/ic_baseline_dashboard_24dp"
android:title="@string/title_dashboard" />
<item
android:id="@+id/action_transactions"
android:id="@+id/transactionListFragment"
android:icon="@drawable/ic_attach_money_black_24dp"
android:title="@string/title_transactions" />
<item
android:id="@+id/action_categories"
android:id="@+id/budgetsFragment"
android:icon="@drawable/ic_baseline_category_24px"
android:title="@string/title_categories" />
android:title="@string/title_budgets" />
<item
android:id="@+id/profileFragment"
android:icon="@drawable/ic_person_black_24dp"
android:title="@string/title_profile" />
</menu>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_edit"
android:icon="@drawable/ic_edit_black_24dp"
app:showAsAction="ifRoom"
android:title="@string/title_edit" />
</menu>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation 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/nav_graph"
app:startDestination="@id/placeholder">
<fragment
android:id="@+id/loginFragment"
android:name="com.wbrawner.budget.ui.auth.LoginFragment"
android:label="LoginFragment">
<argument
android:name="username"
app:argType="string"
app:nullable="true" />
<argument
android:name="password"
app:argType="string"
app:nullable="true" />
</fragment>
<fragment
android:id="@+id/registerFragment"
android:name="com.wbrawner.budget.ui.auth.RegisterFragment"
android:label="RegisterFragment" />
<fragment
android:id="@+id/placeholder"
android:name="androidx.fragment.app.Fragment" />
<activity
android:id="@+id/mainActivity"
android:name="com.wbrawner.budget.ui.MainActivity"
android:label="activity_transaction_list"
tools:layout="@layout/activity_transaction_list" />
</navigation>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation 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/nav_graph"
app:startDestination="@id/categoryListFragment">
<fragment
android:id="@+id/categoryListFragment"
android:name="com.wbrawner.budget.ui.categories.CategoryListFragment"
android:label="Categories"
tools:layout="@layout/fragment_transaction_list">
<action
android:id="@+id/action_categoryListFragment_to_categoryFragment"
app:destination="@id/categoryFragment" />
<action
android:id="@+id/action_categoryListFragment_to_addEditBudget"
app:destination="@id/addEditBudget" />
</fragment>
<fragment
android:id="@+id/transactionListFragment"
android:name="com.wbrawner.budget.ui.transactions.TransactionListFragment"
android:label="@string/title_transactions"
tools:layout="@layout/fragment_transaction_list" />
<fragment
android:id="@+id/budgetsFragment"
android:name="com.wbrawner.budget.ui.budgets.BudgetListFragment"
android:label="@string/title_budgets"
tools:layout="@layout/fragment_list_with_add_button">
<action
android:id="@+id/action_budgetsFragment_to_categoryListFragment"
app:destination="@id/categoryListFragment" />
</fragment>
<fragment
android:id="@+id/addEditBudget"
android:name="com.wbrawner.budget.ui.budgets.AddEditBudgetFragment"
android:label="@string/title_add_budget"
tools:layout="@layout/fragment_add_edit_budget" />
<fragment
android:id="@+id/profileFragment"
android:name="com.wbrawner.budget.ui.profile.ProfileFragment"
android:label="@string/title_profile"
tools:layout="@layout/fragment_profile" />
<fragment
android:id="@+id/categoryFragment"
android:name="com.wbrawner.budget.ui.categories.CategoryFragment"
android:label="fragment_category"
tools:layout="@layout/fragment_list_with_add_button" />
<activity
android:id="@+id/addEditTransactionActivity"
android:name="com.wbrawner.budget.ui.transactions.AddEditTransactionActivity"
android:label="AddEditTransactionActivity" />
<activity
android:id="@+id/addEditCategoryActivity"
android:name="com.wbrawner.budget.ui.categories.AddEditCategoryActivity"
android:label="@string/title_add_category" />
</navigation>

View file

@ -31,4 +31,22 @@
<string name="prompt_password">Password</string>
<string name="action_submit">Submit</string>
<string name="error_required_field">This is a required field</string>
<!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string>
<string name="title_accounts">Accounts</string>
<string name="title_profile">Profile</string>
<string name="info_login">Login to manage your budget</string>
<string name="action_login">Login</string>
<string name="title_register">Register</string>
<string name="title_forgot_password">Forgot password?</string>
<string name="prompt_account">Account</string>
<string name="prompt_account_name">Name</string>
<string name="prompt_account_description">Description</string>
<string name="prompt_share_with">Share with...</string>
<string name="title_budgets">Budgets</string>
<string name="prompt_budget">Budget</string>
<string name="title_add_budget">Add Budget</string>
<string name="title_edit_budget">Edit Budget</string>
<string name="title_edit">Edit</string>
</resources>

View file

@ -1,7 +1,7 @@
<resources>
<!-- Base application theme. -->
<style name="BaseTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<style name="BaseTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
@ -10,11 +10,17 @@
<item name="android:statusBarColor">?android:attr/windowBackground</item>
<item name="android:windowBackground">@color/colorBackgroundPrimary</item>
<item name="android:navigationBarColor">@color/colorBackgroundPrimary</item>
<item name="android:timePickerDialogTheme">
@style/Theme.MaterialComponents.DayNight.Dialog.Alert
</item>
<item name="android:datePickerDialogTheme">
@style/Theme.MaterialComponents.DayNight.Dialog.Alert
</item>
</style>
<style name="AppTheme" parent="BaseTheme" />
<style name="SplashTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<style name="SplashTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">@drawable/bg_splash</item>
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:windowTranslucentStatus">true</item>
@ -25,24 +31,15 @@
<item name="android:layout_width">match_parent</item>
</style>
<style name="AppTheme.EditText.Container" parent="AppTheme.EditText">
<style name="AppTheme.EditText.Container" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_width">match_parent</item>
</style>
<style name="AppTheme.EditText.Hint" parent="TextAppearance.Design.Hint">
</style>
<style name="BaseTheme.DatePicker">
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_width">match_parent</item>
<item name="android:startYear">2000</item>
<item name="android:calendarViewShown">false</item>
<item name="android:datePickerMode">spinner</item>
</style>
<style name="AppTheme.DatePicker" parent="BaseTheme.DatePicker">
</style>
<style name="AppTheme.AlertDialog" parent="Theme.AppCompat.DayNight.Dialog.Alert">
<style name="AppTheme.AlertDialog" parent="Theme.MaterialComponents.DayNight.Dialog.Alert">
<item name="android:background">@drawable/bg_rounded</item>
<item name="android:gravity">bottom</item>
</style>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Used to allow connections to http://localhost on developer machine -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>

View file

@ -17,6 +17,7 @@ class AuthModule {
SharedPreferencesCredentialsProvider(sharedPreferences)
@Provides
@Singleton
fun provideSharedPreferences(context: Context): SharedPreferences =
EncryptedSharedPreferences.create(
ENCRYPTED_SHARED_PREFS_FILE_NAME,

View file

@ -3,8 +3,8 @@ package com.wbrawner.budget.auth
import android.content.SharedPreferences
interface CredentialsProvider {
fun username(): String
fun password(): String
val username: String
val password: String
fun saveCredentials(username: String, password: String)
}
@ -12,9 +12,9 @@ private const val PREF_KEY_USERNAME = "PREF_KEY_USERNAME"
private const val PREF_KEY_PASSWORD = "PREF_KEY_PASSWORD"
class SharedPreferencesCredentialsProvider(private val sharedPreferences: SharedPreferences) : CredentialsProvider {
override fun username(): String = sharedPreferences.getString(PREF_KEY_USERNAME, null) ?: ""
override val username: String = sharedPreferences.getString(PREF_KEY_USERNAME, null) ?: ""
override fun password(): String = sharedPreferences.getString(PREF_KEY_PASSWORD, null) ?: ""
override val password: String = sharedPreferences.getString(PREF_KEY_PASSWORD, null) ?: ""
override fun saveCredentials(username: String, password: String) {
sharedPreferences.edit()

View file

@ -28,8 +28,8 @@ dependencies {
implementation project(':auth')
// Retrofit
api "com.squareup.retrofit2:retrofit:$rootProject.ext.retrofit"
implementation "com.squareup.retrofit2:adapter-rxjava2:$rootProject.ext.retrofit"
implementation "com.squareup.retrofit2:converter-moshi:$rootProject.ext.retrofit"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
api "com.squareup.moshi:moshi:$rootProject.ext.moshi"
implementation "com.squareup.moshi:moshi-adapters:$rootProject.ext.moshi"
// Dagger

View file

@ -1,3 +0,0 @@
package com.wbrawner.budget.lib.network
data class AccountBalanceResponse(val id: Long, val balance: Long)

View file

@ -0,0 +1,3 @@
package com.wbrawner.budget.lib.network
data class BudgetBalanceResponse(val id: Long, val balance: Long)

View file

@ -1,107 +1,115 @@
package com.wbrawner.budget.lib.network
import com.wbrawner.budget.common.account.Account
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.common.user.LoginRequest
import com.wbrawner.budget.common.user.User
import io.reactivex.Single
import com.wbrawner.budget.lib.repository.NewBudgetRequest
import retrofit2.http.*
interface BudgetApiService {
// Accounts
@GET("accounts")
fun getAccounts(
// Budgets
@GET("budgets")
suspend fun getBudgets(
@Query("count") count: Int? = null,
@Query("page") page: Int? = null
): Single<Collection<Account>>
): Collection<Budget>
@GET("accounts/{id}")
fun getAccount(@Path("id") id: Long): Single<Account>
@GET("budgets/{id}")
suspend fun getBudget(@Path("id") id: Long): Budget
@GET("accounts/{id}/balance")
fun getAccountBalance(@Path("id") id: Long): Single<AccountBalanceResponse>
@GET("budgets/{id}/balance")
suspend fun getBudgetBalance(@Path("id") id: Long): BudgetBalanceResponse
@POST("accounts/new")
fun newAccount(@Body account: Account): Single<Account>
@POST("budgets/new")
suspend fun newBudget(@Body budget: NewBudgetRequest): Budget
@PUT("accounts/{id}")
fun updateAccount(
@PUT("budgets/{id}")
suspend fun updateBudget(
@Path("id") id: Long,
@Body account: Account
): Single<Account>
@Body budget: Budget
): Budget
@DELETE("accounts/{id}")
fun deleteAccount(@Path("id") id: Long): Single<Void>
@DELETE("budgets/{id}")
suspend fun deleteBudget(@Path("id") id: Long)
// Categories
@GET("categories")
fun getCategories(
@Query("accountId") accountId: Long,
suspend fun getCategories(
@Query("budgetId") budgetId: Long? = null,
@Query("count") count: Int? = null,
@Query("page") page: Int? = null
): Single<Collection<Category>>
): Collection<Category>
@GET("categories/{id}")
fun getCategory(@Path("id") id: Long): Single<Category>
suspend fun getCategory(@Path("id") id: Long): Category
@GET("categories/{id}/balance")
fun getCategoryBalance(@Path("id") id: Long): Single<CategoryBalanceResponse>
suspend fun getCategoryBalance(@Path("id") id: Long): CategoryBalanceResponse
@POST("categories/new")
fun newCategory(@Body category: Category): Single<Category>
suspend fun newCategory(@Body category: Category): Category
@PUT("categories/{id}")
fun updateCategory(
suspend fun updateCategory(
@Path("id") id: Long,
@Body category: Category
): Single<Category>
): Category
@DELETE("categories/{id}")
fun deleteCategory(@Path("id") id: Long): Single<Void>
suspend fun deleteCategory(@Path("id") id: Long)
// Transactions
@GET("transactions")
fun getTransactions(
@Query("accountId") accountId: Long,
suspend fun getTransactions(
@Query("budgetId") budgetId: Long? = null,
@Query("categoryId") categoryId: Long? = null,
@Query("count") count: Int? = null,
@Query("page") page: Int? = null
): Single<Collection<Transaction>>
): Collection<Transaction>
@GET("transactions/{id}")
fun getTransaction(@Path("id") id: Long): Single<Transaction>
suspend fun getTransaction(@Path("id") id: Long): Transaction
@POST("transactions/new")
fun newTransaction(@Body transaction: Transaction): Single<Transaction>
suspend fun newTransaction(@Body transaction: Transaction): Transaction
@PUT("transactions/{id}")
fun updateTransaction(
suspend fun updateTransaction(
@Path("id") id: Long,
@Body transaction: Transaction
): Single<Transaction>
): Transaction
@DELETE("transactions/{id}")
fun deleteTransaction(@Path("id") id: Long): Single<Void>
suspend fun deleteTransaction(@Path("id") id: Long)
// Users
@GET("users")
fun getUsers(
@Query("accountId") accountId: Long,
suspend fun getUsers(
@Query("budgetId") budgetId: Long? = null,
@Query("count") count: Int? = null,
@Query("page") page: Int? = null
): Single<Collection<User>>
): Collection<User>
@POST("users/login")
suspend fun login(@Body request: LoginRequest): User
@GET("users/search")
suspend fun searchUsers(@Query("query") query: String): List<User>
@GET("users/{id}")
fun getUser(@Path("id") id: Long): Single<User>
suspend fun getUser(@Path("id") id: Long): User
@POST("users/new")
fun newUser(@Body user: User): Single<User>
suspend fun newUser(@Body user: User): User
@PUT("users/{id}")
fun updateUser(
suspend fun updateUser(
@Path("id") id: Long,
@Body user: User
): Single<User>
): User
@DELETE("users/{id}")
fun deleteUser(@Path("id") id: Long): Single<Void>
suspend fun deleteUser(@Path("id") id: Long)
}

View file

@ -3,11 +3,11 @@ package com.wbrawner.budget.lib.network
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import com.wbrawner.budget.auth.CredentialsProvider
import com.wbrawner.budget.common.account.AccountRepository
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.lib.repository.NetworkAccountRepository
import com.wbrawner.budget.lib.repository.NetworkBudgetRepository
import com.wbrawner.budget.lib.repository.NetworkCategoryRepository
import com.wbrawner.budget.lib.repository.NetworkTransactionRepository
import com.wbrawner.budget.lib.repository.NetworkUserRepository
@ -16,7 +16,6 @@ import dagger.Provides
import okhttp3.Credentials
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.*
import javax.inject.Named
@ -34,8 +33,8 @@ class NetworkModule {
if (it.request().headers().get("Authorization") != null)
return@addInterceptor it.proceed(it.request())
val credentials = Credentials.basic(
credentialsProvider.username(),
credentialsProvider.password()
credentialsProvider.username,
credentialsProvider.password
)
val newHeaders = it.request()
.headers()
@ -57,7 +56,6 @@ class NetworkModule {
client: OkHttpClient
): Retrofit = Retrofit.Builder()
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.baseUrl(baseUrl)
.client(client)
.build()
@ -67,8 +65,8 @@ class NetworkModule {
retrofit.create(BudgetApiService::class.java)
@Provides
fun provideAccountRepository(apiService: BudgetApiService): AccountRepository =
NetworkAccountRepository(apiService)
fun provideAccountRepository(apiService: BudgetApiService): BudgetRepository =
NetworkBudgetRepository(apiService)
@Provides
fun provideCategoryRepository(apiService: BudgetApiService): CategoryRepository =

View file

@ -1,31 +0,0 @@
package com.wbrawner.budget.lib.repository
import com.wbrawner.budget.common.account.Account
import com.wbrawner.budget.common.account.AccountRepository
import com.wbrawner.budget.lib.network.BudgetApiService
import io.reactivex.Single
import javax.inject.Inject
class NetworkAccountRepository @Inject constructor(private val apiService: BudgetApiService) :
AccountRepository {
override fun create(newItem: Account): Single<Account> = apiService.newAccount(newItem)
override fun findAll(): Single<Collection<Account>> = apiService.getAccounts()
override fun findById(id: Long): Single<Account> = apiService.getAccount(id)
override fun update(updatedItem: Account): Single<Account> =
apiService.updateAccount(updatedItem.id!!, updatedItem)
override fun delete(id: Long): Single<Void> = apiService.deleteAccount(id)
override fun getBalance(id: Long): Single<Long> = Single.create {
apiService.getAccountBalance(id).subscribe { res, err ->
if (err != null) {
it.onError(err)
} else {
it.onSuccess(res.balance)
}
}
}
}

View file

@ -0,0 +1,36 @@
package com.wbrawner.budget.lib.repository
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.LoginRequest
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.lib.network.BudgetApiService
import javax.inject.Inject
class NetworkBudgetRepository @Inject constructor(private val apiService: BudgetApiService) :
BudgetRepository {
override suspend fun create(newItem: Budget): Budget =
apiService.newBudget(NewBudgetRequest(newItem))
override suspend fun findAll(): Collection<Budget> = apiService.getBudgets().sortedBy { it.name }
override suspend fun findById(id: Long): Budget = apiService.getBudget(id)
override suspend fun update(updatedItem: Budget): Budget =
apiService.updateBudget(updatedItem.id!!, updatedItem)
override suspend fun delete(id: Long) = apiService.deleteBudget(id)
override suspend fun login(username: String, password: String): User =
apiService.login(LoginRequest(username, password))
override suspend fun getBalance(id: Long): Long = apiService.getBudgetBalance(id).balance
}
data class NewBudgetRequest(
val name: String,
val description: String? = null,
val userIds: List<Long>
) {
constructor(budget: Budget) : this(budget.name, budget.description, budget.users.map { it.id!! })
}

View file

@ -3,45 +3,22 @@ package com.wbrawner.budget.lib.repository
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.lib.network.BudgetApiService
import io.reactivex.Single
import javax.inject.Inject
class NetworkCategoryRepository @Inject constructor(private val apiService: BudgetApiService) : CategoryRepository {
override fun create(newItem: Category): Single<Category> = apiService.newCategory(newItem)
override suspend fun create(newItem: Category): Category = apiService.newCategory(newItem)
override fun findAll(accountId: Long): Single<Collection<Category>> = Single.create { subscriber ->
apiService.getCategories(accountId).subscribe { categories, error ->
if (error != null) {
subscriber.onError(error)
} else {
subscriber.onSuccess(categories.sortedBy { it.title })
}
}
override suspend fun findAll(accountId: Long?): Collection<Category> = apiService.getCategories(accountId).sortedBy { it.title }
}
override suspend fun findAll(): Collection<Category> = findAll(null)
/**
* This will only return an empty list, since an accountId is required to get categories.
* Pass a [Long] as the first (and only) parameter to denote the
* [account ID][com.wbrawner.budget.common.account.Account.id] instead
*/
override fun findAll(): Single<Collection<Category>> = Single.just(ArrayList())
override suspend fun findById(id: Long): Category = apiService.getCategory(id)
override fun findById(id: Long): Single<Category> = apiService.getCategory(id)
override fun update(updatedItem: Category): Single<Category> =
override suspend fun update(updatedItem: Category): Category =
apiService.updateCategory(updatedItem.id!!, updatedItem)
override fun delete(id: Long): Single<Void> = apiService.deleteCategory(id)
override suspend fun delete(id: Long) = apiService.deleteCategory(id)
// TODO: Implement this method server-side and then here
override fun getBalance(id: Long): Single<Long> = Single.create { subscriber ->
apiService.getCategoryBalance(id).subscribe { res, err ->
if (err != null) {
subscriber.onError(err)
} else {
subscriber.onSuccess(res.balance)
}
}
}
override suspend fun getBalance(id: Long): Long = apiService.getCategoryBalance(id).balance
}

View file

@ -3,33 +3,20 @@ package com.wbrawner.budget.lib.repository
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.lib.network.BudgetApiService
import io.reactivex.Single
import javax.inject.Inject
class NetworkTransactionRepository @Inject constructor(private val apiService: BudgetApiService) : TransactionRepository {
override fun create(newItem: Transaction): Single<Transaction> = apiService.newTransaction(newItem)
override suspend fun create(newItem: Transaction): Transaction = apiService.newTransaction(newItem)
override fun findAll(accountId: Long): Single<Collection<Transaction>> = Single.create { subscriber ->
apiService.getTransactions(accountId).subscribe { transactions, error ->
if (error != null) {
subscriber.onError(error)
} else {
subscriber.onSuccess(transactions.sortedByDescending { it.date })
}
}
}
override suspend fun findAll(budgetId: Long?, categoryId: Long?): Collection<Transaction> =
apiService.getTransactions(budgetId, categoryId).sortedByDescending { it.date }
/**
* This will only return an empty list, since an accountId is required to get transactions.
* Pass a [Long] as the first (and only) parameter to denote the
* [account ID][com.wbrawner.budget.common.account.Account.id] instead
*/
override fun findAll(): Single<Collection<Transaction>> = Single.just(ArrayList())
override suspend fun findAll(): Collection<Transaction> = findAll(null)
override fun findById(id: Long): Single<Transaction> = apiService.getTransaction(id)
override suspend fun findById(id: Long): Transaction = apiService.getTransaction(id)
override fun update(updatedItem: Transaction): Single<Transaction> =
override suspend fun update(updatedItem: Transaction): Transaction =
apiService.updateTransaction(updatedItem.id!!, updatedItem)
override fun delete(id: Long): Single<Void> = apiService.deleteTransaction(id)
override suspend fun delete(id: Long) = apiService.deleteTransaction(id)
}

View file

@ -3,25 +3,22 @@ package com.wbrawner.budget.lib.repository
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.lib.network.BudgetApiService
import io.reactivex.Single
import javax.inject.Inject
class NetworkUserRepository @Inject constructor(private val apiService: BudgetApiService) : UserRepository {
override fun create(newItem: User): Single<User> = apiService.newUser(newItem)
override suspend fun create(newItem: User): User = apiService.newUser(newItem)
override fun findAll(accountId: Long): Single<Collection<User>> = apiService.getUsers(accountId)
override suspend fun findAll(accountId: Long?): Collection<User> = apiService.getUsers(accountId)
/**
* This will only return an empty list, since an accountId is required to get users.
* Pass a [Long] as the first (and only) parameter to denote the
* [account ID][com.wbrawner.budget.common.account.Account.id] instead
*/
override fun findAll(): Single<Collection<User>> = Single.just(ArrayList())
override suspend fun findAll(): Collection<User> = findAll(null)
override fun findById(id: Long): Single<User> = apiService.getUser(id)
override suspend fun findById(id: Long): User = apiService.getUser(id)
override fun update(updatedItem: User): Single<User> =
override suspend fun findAllByNameLike(query: String): Collection<User> =
apiService.searchUsers(query)
override suspend fun update(updatedItem: User): User =
apiService.updateUser(updatedItem.id!!, updatedItem)
override fun delete(id: Long): Single<Void> = apiService.deleteUser(id)
override suspend fun delete(id: Long) = apiService.deleteUser(id)
}

View file

@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.31'
ext.kotlin_version = '1.3.50'
repositories {
google()
jcenter()
@ -10,10 +10,10 @@ buildscript {
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.4.1'
classpath 'com.android.tools.build:gradle:3.5.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.2.0'
classpath 'io.fabric.tools:gradle:1.26.1'
classpath 'com.google.gms:google-services:4.3.2'
classpath 'io.fabric.tools:gradle:1.31.0'
}
}
@ -31,8 +31,8 @@ allprojects {
ext {
dagger = '2.23.1'
moshi = '1.8.0'
retrofit = '2.5.0'
rxjava = '2.2.8'
retrofit = '2.6.0'
hyperion = '0.9.27'
}
task clean(type: Delete) {

View file

@ -27,11 +27,10 @@ android {
}
dependencies {
api "io.reactivex.rxjava2:rxjava:$rootProject.ext.rxjava"
implementation 'androidx.appcompat:appcompat:1.0.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
repositories {

View file

@ -1,7 +1,5 @@
package com.wbrawner.budget.common
import io.reactivex.Single
/**
* Base interface for an entity repository that provides basic CRUD methods
*
@ -9,9 +7,9 @@ import io.reactivex.Single
* @param K The type of the primary identifier for the object supported by this repository
*/
interface Repository<T, K> {
fun create(newItem: T): Single<T>
fun findAll(): Single<Collection<T>>
fun findById(id: K): Single<T>
fun update(updatedItem: T): Single<T>
fun delete(id: K): Single<Void>
suspend fun create(newItem: T): T
suspend fun findAll(): Collection<T>
suspend fun findById(id: K): T
suspend fun update(updatedItem: T): T
suspend fun delete(id: K)
}

View file

@ -1,8 +0,0 @@
package com.wbrawner.budget.common.account
data class Account(
val id: Long? = null,
val name: String,
val description: String? = null,
val currencyCode: String
)

View file

@ -1,8 +0,0 @@
package com.wbrawner.budget.common.account
import com.wbrawner.budget.common.Repository
import io.reactivex.Single
interface AccountRepository : Repository<Account, Long> {
fun getBalance(id: Long): Single<Long>
}

View file

@ -0,0 +1,14 @@
package com.wbrawner.budget.common.budget
import com.wbrawner.budget.common.user.User
data class Budget(
val id: Long? = null,
val name: String,
val description: String? = null,
val users: List<User> = emptyList()
) {
override fun toString(): String {
return name
}
}

View file

@ -0,0 +1,9 @@
package com.wbrawner.budget.common.budget
import com.wbrawner.budget.common.Repository
import com.wbrawner.budget.common.user.User
interface BudgetRepository : Repository<Budget, Long> {
suspend fun login(username: String, password: String): User
suspend fun getBalance(id: Long): Long
}

View file

@ -1,7 +1,7 @@
package com.wbrawner.budget.common.category
data class Category(
val accountId: Long,
val budgetId: Long,
val id: Long? = null,
val title: String,
val description: String? = null,

View file

@ -1,9 +1,8 @@
package com.wbrawner.budget.common.category
import com.wbrawner.budget.common.Repository
import io.reactivex.Single
interface CategoryRepository : Repository<Category, Long> {
fun findAll(accountId: Long): Single<Collection<Category>>
fun getBalance(id: Long): Single<Long>
suspend fun findAll(accountId: Long? = null): Collection<Category>
suspend fun getBalance(id: Long): Long
}

View file

@ -9,7 +9,7 @@ data class Transaction(
val description: String,
val amount: Long,
val categoryId: Long? = null,
val accountId: Long,
val budgetId: Long,
val expense: Boolean,
val createdBy: Long
)

View file

@ -1,8 +1,7 @@
package com.wbrawner.budget.common.transaction
import com.wbrawner.budget.common.Repository
import io.reactivex.Single
interface TransactionRepository : Repository<Transaction, Long> {
fun findAll(accountId: Long): Single<Collection<Transaction>>
suspend fun findAll(budgetId: Long? = null, categoryId: Long? = null): Collection<Transaction>
}

View file

@ -6,3 +6,8 @@ data class User(
val email: String? = null,
val avatar: String? = null
)
data class LoginRequest(
val username: String,
val password: String
)

View file

@ -1,8 +1,8 @@
package com.wbrawner.budget.common.user
import com.wbrawner.budget.common.Repository
import io.reactivex.Single
interface UserRepository : Repository<User, Long> {
fun findAll(accountId: Long): Single<Collection<User>>
suspend fun findAll(accountId: Long? = null): Collection<User>
suspend fun findAllByNameLike(query: String): Collection<User>
}

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip