Another massive rewrite, this time geared towards making the code more reactive

This commit is contained in:
William Brawner 2020-08-07 12:40:14 -07:00
parent 2210222b22
commit 7109764e09
40 changed files with 531 additions and 445 deletions

View file

@ -2,7 +2,6 @@ package com.wbrawner.budget
import android.app.Application
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.di.DaggerAppComponent
class AllowanceApplication : Application() {
var currentUser: User? = null

View file

@ -5,8 +5,10 @@ import com.wbrawner.budget.lib.network.NetworkModule
import com.wbrawner.budget.storage.StorageModule
import com.wbrawner.budget.ui.SplashViewModel
import com.wbrawner.budget.ui.budgets.BudgetFormViewModel
import com.wbrawner.budget.ui.budgets.BudgetViewModel
import com.wbrawner.budget.ui.categories.CategoryViewModel
import com.wbrawner.budget.ui.budgets.BudgetListViewModel
import com.wbrawner.budget.ui.categories.CategoryDetailsViewModel
import com.wbrawner.budget.ui.categories.CategoryFormViewModel
import com.wbrawner.budget.ui.categories.CategoryListViewModel
import com.wbrawner.budget.ui.overview.OverviewViewModel
import com.wbrawner.budget.ui.transactions.TransactionFormViewModel
import com.wbrawner.budget.ui.transactions.TransactionListViewModel
@ -20,9 +22,11 @@ import javax.inject.Singleton
interface AppComponent {
fun inject(viewModel: OverviewViewModel)
fun inject(viewModel: SplashViewModel)
fun inject(viewMode: BudgetViewModel)
fun inject(viewMode: BudgetListViewModel)
fun inject(viewModel: BudgetFormViewModel)
fun inject(viewModel: CategoryViewModel)
fun inject(viewModel: CategoryListViewModel)
fun inject(viewModel: CategoryDetailsViewModel)
fun inject(viewModel: CategoryFormViewModel)
fun inject(viewModel: TransactionListViewModel)
fun inject(viewModel: TransactionFormViewModel)

View file

@ -1,5 +1,6 @@
package com.wbrawner.budget
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -9,7 +10,11 @@ import kotlinx.coroutines.launch
sealed class AsyncState<out T> {
object Loading : AsyncState<Nothing>()
class Success<T>(val data: T) : AsyncState<T>()
class Error(val exception: Exception) : AsyncState<Nothing>()
class Error(val exception: Exception) : AsyncState<Nothing>() {
constructor(message: String) : this(RuntimeException(message))
}
object Exit : AsyncState<Nothing>()
}
interface AsyncViewModel<T> {
@ -22,6 +27,7 @@ fun <VM, T> VM.launch(block: suspend () -> T): Job where VM : ViewModel, VM : As
state.postValue(AsyncState.Success(block()))
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
Log.e("AsyncViewModel", "Failed to load data", e)
}
}

View file

@ -7,7 +7,6 @@ import androidx.emoji.text.EmojiCompat
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.categories.CategoryListFragment
import kotlinx.android.synthetic.main.activity_transaction_list.*
class MainActivity : AppCompatActivity() {
@ -21,6 +20,7 @@ class MainActivity : AppCompatActivity() {
navController.addOnDestinationChangedListener { _, destination, _ ->
title = destination.label
val showHomeAsUp = when (destination.label) {
getString(R.string.title_overview) -> false
getString(R.string.title_transactions) -> false
getString(R.string.title_profile) -> false
getString(R.string.title_budgets) -> false
@ -28,15 +28,10 @@ class MainActivity : AppCompatActivity() {
}
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
}
val openFragmentId = when (intent?.getStringExtra(EXTRA_OPEN_FRAGMENT)) {
CategoryListFragment.TAG_FRAGMENT -> R.id.categoryListFragment
else -> R.id.transactionListFragment
}
menu_main.selectedItemId = openFragmentId
}
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
if (item?.itemId == android.R.id.home) {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
findNavController(R.id.content_container).navigateUp()
return true
}

View file

@ -2,25 +2,22 @@ package com.wbrawner.budget.ui
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
class SplashActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private lateinit var viewModel: SplashViewModel
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
private val viewModel: SplashViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
(application as AllowanceApplication).appComponent.inject(viewModel)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
window.decorView.apply {
@ -30,8 +27,6 @@ class SplashActivity : AppCompatActivity(), CoroutineScope {
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
}
(application as AllowanceApplication).appComponent.inject(this)
viewModel = ViewModelProviders.of(this, viewModelFactory).get(SplashViewModel::class.java)
val navController = findNavController(R.id.auth_content)
launch {
val navId = try {

View file

@ -6,12 +6,11 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.SplashViewModel
import com.wbrawner.budget.ui.ensureNotEmpty
import com.wbrawner.budget.ui.show
@ -19,7 +18,6 @@ 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
/**
@ -27,24 +25,13 @@ import kotlin.coroutines.CoroutineContext
*/
class LoginFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private lateinit var viewModel: SplashViewModel
@Inject
lateinit var viewModelFactory: BudgetViewModelFactory
private val viewModel: SplashViewModel by activityViewModels()
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 onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_login, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View file

@ -29,7 +29,8 @@ abstract class ListWithAddButtonFragment<Data : Identifiable, ViewModel : AsyncV
super.onViewCreated(view, savedInstanceState)
recyclerView.layoutManager = LinearLayoutManager(view.context)
recyclerView.hideFabOnScroll(addFab)
val adapter = BindableAdapter<Data>(constructors, diffUtilItemCallback)
val adapter = BindableAdapter(constructors, diffUtilItemCallback)
recyclerView.adapter = adapter
addFab.setOnClickListener {
addItem()
}

View file

@ -29,6 +29,8 @@ class BindableAdapter<Data>(
holder.onUnbind()
}
override fun getItemViewType(position: Int): Int = getItem(position).viewType
abstract class BindableViewHolder<T>(itemView: View) : RecyclerView.ViewHolder(itemView), Bindable<BindableData<T>>
abstract class CoroutineViewHolder<T>(itemView: View) : BindableViewHolder<T>(itemView), CoroutineScope {
@ -46,8 +48,8 @@ interface Bindable<T> {
fun onUnbind() {}
}
interface BindableData<T> {
val data: T
@get:LayoutRes
val viewType: Int
}
data class BindableData<T>(
val data: T,
@get:LayoutRes
val viewType: Int
)

View file

@ -1,9 +1,12 @@
package com.wbrawner.budget.ui.budgets
import android.content.Context
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
@ -12,33 +15,26 @@ import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableData
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import kotlinx.coroutines.CoroutineScope
class BudgetListFragment : ListWithAddButtonFragment<BudgetViewModel, BudgetData>(), CoroutineScope {
class BudgetListFragment : ListWithAddButtonFragment<Budget, BudgetListViewModel>() {
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 fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
override suspend fun loadItems(): Pair<List<BudgetData>, Map<Int, (view: View) -> BudgetViewHolder>> {
val budgetItems = viewModel.getBudgets().map { BudgetData(it) }
override val viewModel: BudgetListViewModel by viewModels()
return Pair(
budgetItems,
mapOf(BUDGET_VIEW to { v ->
BudgetViewHolder(v) { _, budget ->
val bundle = Bundle().apply {
putLong(EXTRA_BUDGET_ID, budget.id!!)
}
findNavController().navigate(R.id.categoryListFragment, bundle)
}
})
)
override fun reloadItems() {
viewModel.getBudgets()
}
override fun bindData(data: Budget): BindableData<Budget> = BindableData(data, BUDGET_VIEW)
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Budget>>
get() = mapOf(BUDGET_VIEW to { v -> BudgetViewHolder(v, findNavController()) })
override fun addItem() {
findNavController().navigate(R.id.addEditBudget)
}
@ -46,30 +42,25 @@ class BudgetListFragment : ListWithAddButtonFragment<BudgetViewModel, BudgetData
const val BUDGET_VIEW = R.layout.list_item_budget
class BudgetData(val budget: Budget) : BindableData {
override val viewType: Int = BUDGET_VIEW
}
class BudgetViewHolder(
itemView: View,
private val budgetClickListener: (View, Budget) -> Unit
) : BindableAdapter.BindableViewHolder<BudgetData>(itemView) {
class BudgetViewHolder(itemView: View, val navController: NavController) : BindableAdapter.BindableViewHolder<Budget>(itemView) {
private val name: TextView = itemView.findViewById(R.id.budgetName)
private val description: TextView = itemView.findViewById(R.id.budgetDescription)
// private val balance: TextView = itemView.findViewById(R.id.budgetBalance)
override fun onBind(item: BudgetData) {
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 {
budgetClickListener(it, budget)
override fun onBind(item: BindableData<Budget>) {
val budget = item.data
name.text = budget.name
if (budget.description.isNullOrBlank()) {
description.visibility = View.GONE
} else {
description.visibility = View.VISIBLE
description.text = budget.description
}
itemView.setOnClickListener {
val bundle = Bundle().apply {
putLong(EXTRA_BUDGET_ID, budget.id!!)
}
navController.navigate(R.id.categoryListFragment, bundle)
}
}
}

View file

@ -0,0 +1,23 @@
package com.wbrawner.budget.ui.budgets
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.launch
import javax.inject.Inject
class BudgetListViewModel : ViewModel(), AsyncViewModel<List<Budget>> {
override val state: MutableLiveData<AsyncState<List<Budget>>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var budgetRepo: BudgetRepository
fun getBudgets() {
launch {
budgetRepo.findAll().toList()
}
}
}

View file

@ -1,33 +0,0 @@
package com.wbrawner.budget.ui.budgets
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.ui.base.LoadingViewModel
import javax.inject.Inject
class BudgetViewModel : LoadingViewModel() {
@Inject lateinit var budgetRepo: BudgetRepository
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)
}
}

View file

@ -0,0 +1,95 @@
package com.wbrawner.budget.ui.categories
import android.content.Context
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.transactions.TransactionListFragment
import kotlinx.android.synthetic.main.fragment_category_details.*
/**
* A simple [Fragment] subclass.
*/
class CategoryDetailsFragment : Fragment() {
val viewModel: CategoryDetailsViewModel by viewModels()
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_category_details, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is AsyncState.Loading -> {
categoryDetails.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is AsyncState.Success -> {
categoryDetails.visibility = View.VISIBLE
progressBar.visibility = View.GONE
val category = state.data.category
activity?.title = category.title
categoryDescription.text = category.description
childFragmentManager.fragments.firstOrNull()?.let {
if (it !is TransactionListFragment) return@let
it.reloadItems()
} ?: run {
val transactionsFragment = TransactionListFragment().apply {
arguments = Bundle().apply {
putLong(EXTRA_BUDGET_ID, category.budgetId)
putLong(EXTRA_CATEGORY_ID, category.id!!)
}
}
childFragmentManager.beginTransaction()
.replace(R.id.transactionsFragmentContainer, transactionsFragment)
.commit()
}
}
is AsyncState.Error -> {
categoryDetails.visibility = View.VISIBLE
progressBar.visibility = View.GONE
Toast.makeText(view.context, "Failed to load context", Toast.LENGTH_SHORT).show()
}
is AsyncState.Exit -> {
findNavController().navigateUp()
}
}
})
viewModel.getCategory(arguments?.getLong(EXTRA_CATEGORY_ID))
}
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)
} else if (item.itemId == android.R.id.home) {
return findNavController().navigateUp()
}
return super.onOptionsItemSelected(item)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_editable, menu)
}
}

View file

@ -0,0 +1,38 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.launch
import javax.inject.Inject
class CategoryDetailsViewModel : ViewModel(), AsyncViewModel<CategoryDetails> {
override val state: MutableLiveData<AsyncState<CategoryDetails>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var categoryRepo: CategoryRepository
fun getCategory(id: Long? = null) {
if (id == null) {
state.postValue(AsyncState.Error("Invalid category ID"))
return
}
launch {
val category = categoryRepo.findById(id)
val multiplier = if (category.expense) -1 else 1
val balance = categoryRepo.getBalance(category.id!!).toInt() * multiplier
CategoryDetails(
category,
balance
)
}
}
}
data class CategoryDetails(
val category: Category,
val balance: Int
)

View file

@ -4,26 +4,24 @@ import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.Observer
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.di.BudgetViewModelFactory
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 kotlin.coroutines.CoroutineContext
class CategoryFormActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
lateinit var viewModel: CategoryViewModel
class CategoryFormActivity : AppCompatActivity() {
lateinit var viewModel: CategoryFormViewModel
var id: Long? = null
var menu: Menu? = null
@ -32,40 +30,41 @@ class CategoryFormActivity : AppCompatActivity(), CoroutineScope {
setContentView(R.layout.activity_add_edit_category)
setSupportActionBar(action_bar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
(application as AllowanceApplication).appComponent.inject(this)
launch {
val budgets = viewModel.getBudgets().toTypedArray()
budgetSpinner.adapter = ArrayAdapter<Budget>(
this@CategoryFormActivity,
android.R.layout.simple_list_item_1,
budgets
)
loadCategory()
}
}
private fun loadCategory() {
val categoryId = intent?.extras?.getLong(EXTRA_CATEGORY_ID)
if (categoryId == null) {
setTitle(R.string.title_add_category)
return
}
launch {
val category = try {
viewModel.getCategory(categoryId)
} catch (e: Exception) {
menu?.findItem(R.id.action_delete)?.isVisible = false
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))
expense.isChecked = category.expense
income.isChecked = !category.expense
archived.isChecked = category.archived
}
(application as AllowanceApplication).appComponent.inject(viewModel)
viewModel.state.observe(this, Observer { state ->
when (state) {
is AsyncState.Loading -> {
categoryForm.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is AsyncState.Success -> {
categoryForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
val category = state.data.category
id = category.id
setTitle(state.data.titleRes)
menu?.findItem(R.id.action_delete)?.isVisible = state.data.showDeleteButton
edit_category_name.setText(category.title)
edit_category_amount.setText(String.format("%.02f", (category.amount.toBigDecimal() / 100.toBigDecimal()).toFloat()))
expense.isChecked = category.expense
income.isChecked = !category.expense
archived.isChecked = category.archived
budgetSpinner.adapter = ArrayAdapter<Budget>(
this@CategoryFormActivity,
android.R.layout.simple_list_item_1,
state.data.budgets
)
}
is AsyncState.Error -> {
// TODO: Show error message
categoryForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
Toast.makeText(this, "Failed to save Category", Toast.LENGTH_SHORT).show()
}
is AsyncState.Exit -> finish()
}
})
viewModel.loadCategory(intent?.extras?.getLong(EXTRA_CATEGORY_ID))
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
@ -96,23 +95,17 @@ class CategoryFormActivity : AppCompatActivity(), CoroutineScope {
}
R.id.action_save -> {
if (!validateFields()) return true
launch {
viewModel.saveCategory(Category(
id = id,
title = edit_category_name.text.toString(),
amount = edit_category_amount.text.toLong(),
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
expense = expense.isChecked,
archived = archived.isChecked
))
finish()
}
viewModel.saveCategory(Category(
id = id,
title = edit_category_name.text.toString(),
amount = edit_category_amount.text.toLong(),
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
expense = expense.isChecked,
archived = archived.isChecked
))
}
R.id.action_delete -> {
launch {
viewModel.deleteCategoryById(this@CategoryFormActivity.id!!)
finish()
}
viewModel.deleteCategoryById(this@CategoryFormActivity.id!!)
}
}
return true

View file

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

View file

@ -1,81 +0,0 @@
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.fragment.app.viewModels
import androidx.recyclerview.widget.DiffUtil
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import com.wbrawner.budget.ui.transactions.TRANSACTION_VIEW
import com.wbrawner.budget.ui.transactions.TransactionData
import com.wbrawner.budget.ui.transactions.TransactionViewHolder
/**
* A simple [Fragment] subclass.
*/
class CategoryFragment : ListWithAddButtonFragment<Category, CategoryViewModel>() {
override val viewModel: CategoryViewModel by viewModels()
override val noItemsStringRes: Int = R.string.transactions_no_data
override fun reloadItems() {
}
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Category>>
get() = TODO("Not yet implemented")
override val diffUtilItemCallback: DiffUtil.ItemCallback<Category>
get() = TODO("Not yet implemented")
override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override suspend fun loadItems(): Pair<List<TransactionData>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<TransactionData>>> {
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<TransactionData>()
items.addAll(viewModel.getTransactions(categoryId).map { TransactionData(it) })
return Pair(
items,
mapOf(TRANSACTION_VIEW to { v ->
TransactionViewHolder(v, findNavController())
})
)
}
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

@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
@ -18,29 +19,20 @@ import com.wbrawner.budget.ui.base.BindableData
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import kotlinx.coroutines.launch
class CategoryListFragment : ListWithAddButtonFragment<Category, CategoryViewModel>() {
class CategoryListFragment : ListWithAddButtonFragment<Category, CategoryListViewModel>() {
override val noItemsStringRes: Int = R.string.categories_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)
override val viewModel: CategoryListViewModel by viewModels()
override fun reloadItems() {
viewModel.getCategories(arguments?.getLong(EXTRA_BUDGET_ID))
}
override suspend fun loadItems(): Pair<List<CategoryData>, Map<Int, (view: View) -> CategoryViewHolder>> {
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 { CategoryData(it) },
mapOf(CATEGORY_VIEW to { v ->
CategoryViewHolder(v, viewModel, findNavController())
})
)
override fun bindData(data: Category): BindableData<Category> = BindableData(data, CATEGORY_VIEW)
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Category>> = mapOf(CATEGORY_VIEW to { v -> CategoryViewHolder(v, viewModel, findNavController()) })
override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onCreate(savedInstanceState)
}
override fun addItem() {
@ -54,50 +46,45 @@ class CategoryListFragment : ListWithAddButtonFragment<Category, CategoryViewMod
const val CATEGORY_VIEW = R.layout.list_item_category
class CategoryData(val category: Category) : BindableData {
override val viewType: Int = CATEGORY_VIEW
}
class CategoryViewHolder(
itemView: View,
private val viewModel: CategoryViewModel,
private val viewModel: CategoryListViewModel,
private val navController: NavController
) : BindableAdapter.CoroutineViewHolder<CategoryData>(itemView) {
) : BindableAdapter.CoroutineViewHolder<Category>(itemView) {
private val name: TextView = itemView.findViewById(R.id.category_title)
private val amount: TextView = itemView.findViewById(R.id.category_amount)
private val progressBar: ProgressBar = itemView.findViewById(R.id.category_progress)
@SuppressLint("NewApi")
override fun onBind(item: CategoryData) {
with(item) {
name.text = category.title
// TODO: Format according to budget's currency
amount.text = String.format("${'$'}%.02f", category.amount / 100.0f)
val tintColor = if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
val colorStateList = with(itemView.context) {
android.content.res.ColorStateList.valueOf(getColor(tintColor))
}
progressBar.progressTintList = colorStateList
progressBar.indeterminateTintList = colorStateList
progressBar.max = category.amount.toInt()
launch {
val balance = viewModel.getBalance(category)
progressBar.isIndeterminate = false
progressBar.setProgress(
balance,
true
)
amount.text = itemView.context.getString(
R.string.balance_remaning,
(category.amount - balance) / 100.0f
)
}
itemView.setOnClickListener {
val bundle = Bundle().apply {
putLong(EXTRA_CATEGORY_ID, category.id ?: -1)
}
navController.navigate(R.id.categoryFragment, bundle)
override fun onBind(item: BindableData<Category>) {
val category = item.data
name.text = category.title
// TODO: Format according to budget's currency
amount.text = String.format("${'$'}%.02f", category.amount / 100.0f)
val tintColor = if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
val colorStateList = with(itemView.context) {
android.content.res.ColorStateList.valueOf(getColor(tintColor))
}
progressBar.progressTintList = colorStateList
progressBar.indeterminateTintList = colorStateList
progressBar.max = category.amount.toInt()
launch {
val balance = viewModel.getBalance(category).toInt()
progressBar.isIndeterminate = false
progressBar.setProgress(
balance,
true
)
amount.text = itemView.context.getString(
R.string.balance_remaning,
(category.amount - balance) / 100.0f
)
}
itemView.setOnClickListener {
val bundle = Bundle().apply {
putLong(EXTRA_CATEGORY_ID, category.id ?: -1)
}
navController.navigate(R.id.categoryFragment, bundle)
}
}
}

View file

@ -1,24 +1,36 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.launch
import javax.inject.Inject
class CategoryListViewModel : ViewModel() {
@Inject lateinit var budgetRepo: BudgetRepository
@Inject lateinit var categoryRepo: CategoryRepository
class CategoryListViewModel : ViewModel(), AsyncViewModel<List<Category>> {
override val state: MutableLiveData<AsyncState<List<Category>>> = MutableLiveData(AsyncState.Loading)
suspend fun getCategory(id: Long): Category = categoryRepo.findById(id)
@Inject
lateinit var budgetRepo: BudgetRepository
suspend fun getCategories(budgetId: Long? = null): Collection<Category> =
categoryRepo.findAll(budgetId?.let { arrayOf(it) })
@Inject
lateinit var categoryRepo: CategoryRepository
suspend fun saveCategory(category: Category) = if (category.id == null) categoryRepo.create(category)
else categoryRepo.update(category)
fun getCategories(budgetId: Long? = null) {
if (budgetId == null) {
state.postValue(AsyncState.Error("Invalid budget ID"))
return
}
launch {
categoryRepo.findAll(arrayOf(budgetId)).toList()
}
}
suspend fun deleteCategoryById(id: Long) = categoryRepo.delete(id)
suspend fun getBalance(id: Long) = categoryRepo.getBalance(id)
suspend fun getBalance(category: Category): Long {
val multiplier = if (category.expense) -1 else 1
return categoryRepo.getBalance(category.id!!) * multiplier
}
}

View file

@ -1,57 +0,0 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.common.transaction.TransactionRepository
import javax.inject.Inject
class CategoryViewModel : ViewModel(), AsyncViewModel<List<Category>> {
override val state: MutableLiveData<AsyncState<List<Category>>> = MutableLiveData(AsyncState.Loading)
@Inject lateinit var transactionRepo: TransactionRepository
@Inject lateinit var categoryRepo: CategoryRepository
@Inject lateinit var budgetRepo: BudgetRepository
fun getBudget(budgetId: Long): Budget {
budgetRepo.findById(budgetId)
}
suspend fun getBudgets() = showLoader {
budgetRepo.findAll()
}
suspend fun getTransactions(categoryId: Long) = showLoader {
transactionRepo.findAll(categoryIds = listOf(categoryId))
}
suspend fun getCategory(id: Long): Category = showLoader {
categoryRepo.findById(id)
}
suspend fun getCategories(budgetId: Long? = null): Collection<Category> = showLoader {
categoryRepo.findAll(budgetId?.let { arrayOf(it) })
}
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(category: Category) = showLoader {
val balance = categoryRepo.getBalance(category.id!!)
val multiplier = if (category.expense) -1 else 1
return@showLoader (balance * multiplier).toInt()
}
}

View file

@ -21,7 +21,6 @@ class OverviewFragment : Fragment() {
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
viewModel.loadOverview(arguments?.getLong(EXTRA_BUDGET_ID))
}
override fun onCreateView(
@ -54,6 +53,11 @@ class OverviewFragment : Fragment() {
})
}
override fun onStart() {
super.onStart()
viewModel.loadOverview(arguments?.getLong(EXTRA_BUDGET_ID))
}
companion object {
const val EXTRA_BUDGET_ID = "budgetId"
}

View file

@ -15,7 +15,10 @@ class OverviewViewModel : ViewModel() {
lateinit var budgetRepo: BudgetRepository
fun loadOverview(id: Long? = null) {
if (id == null) return // TODO: Handle this or make it non-null
if (id == null) {
state.postValue(AsyncState.Error("Invalid Budget ID"))
return
}
viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {

View file

@ -38,6 +38,7 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
var menu: Menu? = null
var transaction: Transaction? = null
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_edit_transaction)
@ -45,7 +46,7 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setTitle(R.string.title_add_transaction)
edit_transaction_type_expense.isChecked = true
(application as AllowanceApplication).appComponent.inject(this)
(application as AllowanceApplication).appComponent.inject(viewModel)
launch {
val accounts = viewModel.getAccounts().toTypedArray()
setCategories()
@ -69,7 +70,7 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
loadTransaction()
transactionDate.setOnClickListener {
val currentDate = DateFormat.getDateFormat(this@TransactionFormActivity)
.parse(transactionDate.text.toString())
.parse(transactionDate.text.toString()) ?: Date()
DatePickerDialog(
this@TransactionFormActivity,
{ _, year, month, dayOfMonth ->
@ -83,7 +84,7 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
}
transactionTime.setOnClickListener {
val currentDate = DateFormat.getTimeFormat(this@TransactionFormActivity)
.parse(transactionTime.text.toString())
.parse(transactionTime.text.toString()) ?: Date()
TimePickerDialog(
this@TransactionFormActivity,
TimePickerDialog.OnTimeSetListener { _, hourOfDay, minute ->
@ -135,7 +136,7 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
}
}
private fun setCategories(categories: Collection<Category> = emptyList()) {
private fun setCategories(categories: List<Category> = emptyList()) {
val adapter = ArrayAdapter<Category>(
this@TransactionFormActivity,
android.R.layout.simple_list_item_1
@ -191,7 +192,7 @@ class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
id = id,
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
title = edit_transaction_title.text.toString(),
date = date,
date = date.time,
description = edit_transaction_description.text.toString(),
amount = (BigDecimal(edit_transaction_amount.text.toString()) * 100.toBigDecimal()).toLong(),
expense = edit_transaction_type_expense.isChecked,

View file

@ -8,7 +8,6 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
@ -36,12 +35,11 @@ class TransactionListFragment : ListWithAddButtonFragment<Transaction, Transacti
viewModel.getTransactions(arguments?.getLong(EXTRA_BUDGET_ID), arguments?.getLong(EXTRA_CATEGORY_ID))
}
override fun bindData(data: Transaction): BindableData<Transaction> = TransactionData(data)
override fun bindData(data: Transaction): BindableData<Transaction> = BindableData(data, TRANSACTION_VIEW)
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
viewModel.getTransactions()
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -57,9 +55,6 @@ class TransactionListFragment : ListWithAddButtonFragment<Transaction, Transacti
if (item.itemId != R.id.filter) {
return super.onOptionsItemSelected(item)
}
val dialogView = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
}
// TODO: Launch a Google Drive-style search/filter screen
AlertDialog.Builder(requireContext())
.setTitle("Filter Transactions")
@ -85,14 +80,7 @@ class TransactionListFragment : ListWithAddButtonFragment<Transaction, Transacti
const val TRANSACTION_VIEW = R.layout.list_item_transaction
class TransactionData(override val data: Transaction) : BindableData<Transaction> {
override val viewType: Int = TRANSACTION_VIEW
}
class TransactionViewHolder(
itemView: View,
private val navController: NavController
) : BindableAdapter.CoroutineViewHolder<Transaction>(itemView) {
class TransactionViewHolder(itemView: View, val navController: NavController) : BindableAdapter.CoroutineViewHolder<Transaction>(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)

View file

@ -14,7 +14,7 @@
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/scrollView2"
android:id="@+id/categoryForm"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
@ -58,16 +58,16 @@
<RadioButton
android:id="@+id/expense"
android:text="@string/type_expense"
android:checked="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/type_expense" />
<RadioButton
android:id="@+id/income"
android:text="@string/type_income"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
android:text="@string/type_income" />
</RadioGroup>
<CheckBox
@ -89,4 +89,13 @@
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/categoryDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:padding="16dp"
android:id="@+id/categoryDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/balanceLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/label_current_balance"
android:textAlignment="center" />
<TextView
android:id="@+id/balance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAlignment="center"
android:textSize="36sp" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/transactionsFragmentContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
</ScrollView>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>

View file

@ -1,12 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:id="@+id/overviewContent"
android:orientation="vertical"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -15,25 +16,16 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/label_current_balance"
app:layout_constraintBottom_toTopOf="@+id/balance"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
android:text="@string/label_current_balance" />
<TextView
android:id="@+id/balance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textSize="36sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/balanceLabel" />
android:textSize="36sp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/noData"

View file

@ -3,7 +3,7 @@
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">
app:startDestination="@id/overviewFragment">
<fragment
android:id="@+id/categoryListFragment"
android:name="com.wbrawner.budget.ui.categories.CategoryListFragment"
@ -47,7 +47,7 @@
tools:layout="@layout/fragment_profile" />
<fragment
android:id="@+id/categoryFragment"
android:name="com.wbrawner.budget.ui.categories.CategoryFragment"
android:name="com.wbrawner.budget.ui.categories.CategoryDetailsFragment"
android:label="fragment_category"
tools:layout="@layout/fragment_list_with_add_button" />
<activity

View file

@ -14,7 +14,7 @@ interface BudgetApiService {
suspend fun getBudgets(
@Query("count") count: Int? = null,
@Query("page") page: Int? = null
): Collection<Budget>
): List<Budget>
@GET("budgets/{id}")
suspend fun getBudget(@Path("id") id: Long): Budget
@ -40,7 +40,7 @@ interface BudgetApiService {
@Query("budgetIds") budgetIds: Array<Long>? = null,
@Query("count") count: Int? = null,
@Query("page") page: Int? = null
): Collection<Category>
): List<Category>
@GET("categories/{id}")
suspend fun getCategory(@Path("id") id: Long): Category
@ -69,7 +69,7 @@ interface BudgetApiService {
@Query("to") to: String? = null,
@Query("count") count: Int? = null,
@Query("page") page: Int? = null
): Collection<Transaction>
): List<Transaction>
@GET("transactions/{id}")
suspend fun getTransaction(@Path("id") id: Long): Transaction
@ -92,7 +92,7 @@ interface BudgetApiService {
@Query("budgetId") budgetId: Long? = null,
@Query("count") count: Int? = null,
@Query("page") page: Int? = null
): Collection<User>
): List<User>
@POST("users/login")
suspend fun login(@Body request: LoginRequest): User

View file

@ -105,4 +105,4 @@ class NetworkModule {
@Provides
fun provideUserRepository(apiService: BudgetApiService): UserRepository =
NetworkUserRepository(apiService)
}
}

View file

@ -10,7 +10,7 @@ class NetworkBudgetRepository @Inject constructor(private val apiService: Budget
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 findAll(): List<Budget> = apiService.getBudgets().sortedBy { it.name }
override suspend fun findById(id: Long): Budget = apiService.getBudget(id)

View file

@ -7,9 +7,9 @@ import javax.inject.Inject
class NetworkCategoryRepository @Inject constructor(private val apiService: BudgetApiService) : CategoryRepository {
override suspend fun create(newItem: Category): Category = apiService.newCategory(newItem)
override suspend fun findAll(budgetIds: Array<Long>?): Collection<Category> = apiService.getCategories(budgetIds).sortedBy { it.title }
override suspend fun findAll(budgetIds: Array<Long>?): List<Category> = apiService.getCategories(budgetIds).sortedBy { it.title }
override suspend fun findAll(): Collection<Category> = findAll(null)
override suspend fun findAll(): List<Category> = findAll(null)
override suspend fun findById(id: Long): Category = apiService.getCategory(id)

View file

@ -17,7 +17,7 @@ class NetworkTransactionRepository @Inject constructor(private val apiService: B
categoryIds: List<Long>?,
start: Calendar?,
end: Calendar?
): Collection<Transaction> = apiService.getTransactions(
): List<Transaction> = apiService.getTransactions(
budgetIds,
categoryIds,
start?.let {
@ -30,7 +30,7 @@ class NetworkTransactionRepository @Inject constructor(private val apiService: B
}
)
override suspend fun findAll(): Collection<Transaction> = findAll(null)
override suspend fun findAll(): List<Transaction> = findAll(null)
override suspend fun findById(id: Long): Transaction = apiService.getTransaction(id)

View file

@ -15,13 +15,13 @@ class NetworkUserRepository @Inject constructor(private val apiService: BudgetAp
override suspend fun create(newItem: User): User = apiService.newUser(newItem)
override suspend fun findAll(accountId: Long?): Collection<User> = apiService.getUsers(accountId)
override suspend fun findAll(accountId: Long?): List<User> = apiService.getUsers(accountId)
override suspend fun findAll(): Collection<User> = findAll(null)
override suspend fun findAll(): List<User> = findAll(null)
override suspend fun findById(id: Long): User = apiService.getUser(id)
override suspend fun findAllByNameLike(query: String): Collection<User> =
override suspend fun findAllByNameLike(query: String): List<User> =
apiService.searchUsers(query)
override suspend fun update(updatedItem: User): User =

View file

@ -8,7 +8,7 @@ package com.wbrawner.budget.common
*/
interface Repository<T, K> {
suspend fun create(newItem: T): T
suspend fun findAll(): Collection<T>
suspend fun findAll(): List<T>
suspend fun findById(id: K): T
suspend fun update(updatedItem: T): T
suspend fun delete(id: K)

View file

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

View file

@ -4,7 +4,7 @@ import com.wbrawner.budget.common.Identifiable
data class Category(
val budgetId: Long,
val id: Long? = null,
override val id: Long? = null,
val title: String,
val description: String? = null,
val amount: Long,

View file

@ -3,6 +3,6 @@ package com.wbrawner.budget.common.category
import com.wbrawner.budget.common.Repository
interface CategoryRepository : Repository<Category, Long> {
suspend fun findAll(budgetIds: Array<Long>? = null): Collection<Category>
suspend fun findAll(budgetIds: Array<Long>? = null): List<Category>
suspend fun getBalance(id: Long): Long
}

View file

@ -6,7 +6,7 @@ import java.util.*
data class Transaction(
override val id: Long? = null,
val title: String,
val date: Calendar,
val date: Date,
val description: String,
val amount: Long,
val categoryId: Long? = null,

View file

@ -9,5 +9,5 @@ interface TransactionRepository : Repository<Transaction, Long> {
categoryIds: List<Long>? = null,
start: Calendar? = null,
end: Calendar? = null
): Collection<Transaction>
): List<Transaction>
}

View file

@ -5,6 +5,6 @@ import com.wbrawner.budget.common.Repository
interface UserRepository : Repository<User, Long> {
suspend fun login(username: String, password: String): User
suspend fun getProfile(): User
suspend fun findAll(accountId: Long? = null): Collection<User>
suspend fun findAllByNameLike(query: String): Collection<User>
suspend fun findAll(accountId: Long? = null): List<User>
suspend fun findAllByNameLike(query: String): List<User>
}