Clean up code and fix some discrepancies between API and local models/requests

This commit is contained in:
William Brawner 2020-06-04 15:13:10 -07:00
parent 623f65ea43
commit 7f3f1d82bc
29 changed files with 217 additions and 89 deletions

View file

@ -21,7 +21,7 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// buildConfigField "String", "API_URL", "\"http://192.168.86.163:8080/\"" // buildConfigField "String", "API_URL", "\"http://192.168.86.163:8080/\""
// buildConfigField "String", "API_URL", "\"http://10.0.2.2:8080/\"" // buildConfigField "String", "API_URL", "\"http://10.0.2.2:8080/\""
buildConfigField "String", "API_URL", "\"https://budget-api.intra.wbrawner.com/\"" buildConfigField "String", "API_URL", "\"https://api.twigs.brawner.dev/\""
} }
buildTypes { buildTypes {
release { release {

View file

@ -12,7 +12,8 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="GoogleAppIndexingWarning"> tools:ignore="GoogleAppIndexingWarning"
tools:targetApi="n">
<activity <activity
android:name=".ui.SplashActivity" android:name=".ui.SplashActivity"
android:theme="@style/SplashTheme" android:theme="@style/SplashTheme"
@ -27,7 +28,8 @@
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
<activity android:name=".ui.MainActivity" /> <activity android:name=".ui.MainActivity"
android:theme="@style/AppTheme" />
<activity <activity
android:name=".ui.transactions.AddEditTransactionActivity" android:name=".ui.transactions.AddEditTransactionActivity"
android:parentActivityName=".ui.MainActivity"> android:parentActivityName=".ui.MainActivity">

View file

@ -46,6 +46,7 @@ class SplashActivity : AppCompatActivity(), CoroutineScope {
R.id.loginFragment R.id.loginFragment
} }
navController.navigate(navId) navController.navigate(navId)
if (navId == R.id.mainActivity) finish()
} }
} }
} }

View file

@ -1,7 +1,6 @@
package com.wbrawner.budget.ui.auth package com.wbrawner.budget.ui.auth
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -9,10 +8,10 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R import com.wbrawner.budget.R
import com.wbrawner.budget.di.BudgetViewModelFactory import com.wbrawner.budget.di.BudgetViewModelFactory
import com.wbrawner.budget.ui.MainActivity
import com.wbrawner.budget.ui.SplashViewModel import com.wbrawner.budget.ui.SplashViewModel
import com.wbrawner.budget.ui.ensureNotEmpty import com.wbrawner.budget.ui.ensureNotEmpty
import com.wbrawner.budget.ui.show import com.wbrawner.budget.ui.show
@ -49,7 +48,7 @@ class LoginFragment : Fragment(), CoroutineScope {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.isLoading.observe(this, Observer { isLoading -> viewModel.isLoading.observe(viewLifecycleOwner, Observer { isLoading ->
formPrompt.show(!isLoading) formPrompt.show(!isLoading)
usernameContainer.show(!isLoading) usernameContainer.show(!isLoading)
passwordContainer.show(!isLoading) passwordContainer.show(!isLoading)
@ -69,7 +68,8 @@ class LoginFragment : Fragment(), CoroutineScope {
try { try {
val user = viewModel.login(username.text.toString(), password.text.toString()) val user = viewModel.login(username.text.toString(), password.text.toString())
(requireActivity().application as AllowanceApplication).currentUser = user (requireActivity().application as AllowanceApplication).currentUser = user
startActivity(Intent(requireContext().applicationContext, MainActivity::class.java)) findNavController().navigate(R.id.mainActivity)
activity?.finish()
} catch (e: Exception) { } catch (e: Exception) {
username.error = "Invalid username/password" username.error = "Invalid username/password"
password.error = "Invalid username/password" password.error = "Invalid username/password"

View file

@ -21,7 +21,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
abstract class ListWithAddButtonFragment<T : LoadingViewModel> : Fragment(), CoroutineScope { abstract class ListWithAddButtonFragment<T : LoadingViewModel, State: BindableState> : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main override val coroutineContext: CoroutineContext = Dispatchers.Main
@Inject @Inject
lateinit var viewModelFactory: BudgetViewModelFactory lateinit var viewModelFactory: BudgetViewModelFactory
@ -30,7 +30,7 @@ abstract class ListWithAddButtonFragment<T : LoadingViewModel> : Fragment(), Cor
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel = ViewModelProviders.of(this, viewModelFactory).get(viewModelClass) viewModel = ViewModelProvider(this, viewModelFactory).get(viewModelClass)
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
@ -40,7 +40,7 @@ abstract class ListWithAddButtonFragment<T : LoadingViewModel> : Fragment(), Cor
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.isLoading.observe(this, Observer { viewModel.isLoading.observe(viewLifecycleOwner, Observer {
view.findViewById<ProgressBar?>(R.id.progressBar)?.show(it) view.findViewById<ProgressBar?>(R.id.progressBar)?.show(it)
}) })
recyclerView.layoutManager = LinearLayoutManager(view.context) recyclerView.layoutManager = LinearLayoutManager(view.context)
@ -61,14 +61,14 @@ abstract class ListWithAddButtonFragment<T : LoadingViewModel> : Fragment(), Cor
if (view == null) return@launch if (view == null) return@launch
val (items, constructors) = loadItems() val (items, constructors) = loadItems()
if (items.isEmpty()) { if (items.isEmpty()) {
recyclerView.adapter = null recyclerView?.adapter = null
recyclerView.visibility = View.GONE recyclerView?.visibility = View.GONE
noItemsTextView.setText(noItemsStringRes) noItemsTextView?.setText(noItemsStringRes)
noItemsTextView.visibility = View.VISIBLE noItemsTextView?.visibility = View.VISIBLE
} else { } else {
recyclerView.adapter = BindableAdapter(items, constructors) recyclerView?.adapter = BindableAdapter(items, constructors)
recyclerView.visibility = View.VISIBLE recyclerView?.visibility = View.VISIBLE
noItemsTextView.visibility = View.GONE noItemsTextView?.visibility = View.GONE
} }
} }
} }
@ -88,7 +88,7 @@ abstract class ListWithAddButtonFragment<T : LoadingViewModel> : Fragment(), Cor
abstract fun addItem() abstract fun addItem()
abstract suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> abstract suspend fun loadItems(): Pair<List<State>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<State>>>
} }
abstract class LoadingViewModel : ViewModel() { abstract class LoadingViewModel : ViewModel() {

View file

@ -10,12 +10,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class BindableAdapter( class BindableAdapter<T : BindableState>(
private val items: List<BindableState>, private val items: List<T>,
private val constructors: Map<Int, (view: View) -> BindableViewHolder<in BindableState>> private val constructors: Map<Int, (view: View) -> BindableViewHolder<T>>
) : RecyclerView.Adapter<BindableAdapter.BindableViewHolder<in BindableState>>() { ) : RecyclerView.Adapter<BindableAdapter.BindableViewHolder<T>>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
: BindableViewHolder<in BindableState> = constructors[viewType] : BindableViewHolder<T> = constructors[viewType]
?.invoke(LayoutInflater.from(parent.context).inflate(viewType, parent, false)) ?.invoke(LayoutInflater.from(parent.context).inflate(viewType, parent, false))
?: throw IllegalStateException("Attempted to create ViewHolder without proper constructor provided") ?: throw IllegalStateException("Attempted to create ViewHolder without proper constructor provided")
@ -23,11 +23,11 @@ class BindableAdapter(
override fun getItemViewType(position: Int): Int = items[position].viewType override fun getItemViewType(position: Int): Int = items[position].viewType
override fun onBindViewHolder(holder: BindableViewHolder<in BindableState>, position: Int) { override fun onBindViewHolder(holder: BindableViewHolder<T>, position: Int) {
holder.onBind(items[position]) holder.onBind(items[position])
} }
override fun onViewRecycled(holder: BindableViewHolder<in BindableState>) { override fun onViewRecycled(holder: BindableViewHolder<T>) {
holder.onUnbind() holder.onUnbind()
} }

View file

@ -15,7 +15,7 @@ import com.wbrawner.budget.ui.base.BindableState
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
class BudgetListFragment : ListWithAddButtonFragment<BudgetViewModel>(), CoroutineScope { class BudgetListFragment : ListWithAddButtonFragment<BudgetViewModel, BudgetState>(), CoroutineScope {
override val noItemsStringRes: Int = R.string.overview_no_data override val noItemsStringRes: Int = R.string.overview_no_data
override val viewModelClass: Class<BudgetViewModel> = BudgetViewModel::class.java override val viewModelClass: Class<BudgetViewModel> = BudgetViewModel::class.java
@ -24,12 +24,19 @@ class BudgetListFragment : ListWithAddButtonFragment<BudgetViewModel>(), Corouti
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
} }
override suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> { override suspend fun loadItems(): Pair<List<BudgetState>, Map<Int, (view: View) -> BudgetViewHolder>> {
val budgetItems = viewModel.getBudgets().map { BudgetState(it) } val budgetItems = viewModel.getBudgets().map { BudgetState(it) }
val navController = findNavController()
return Pair( return Pair(
budgetItems, budgetItems,
mapOf(BUDGET_VIEW to { v -> BudgetViewHolder(v, navController) as BindableAdapter.BindableViewHolder<in BindableState> }) mapOf(BUDGET_VIEW to { v ->
BudgetViewHolder(v) { _, budget ->
val bundle = Bundle().apply {
putLong(EXTRA_BUDGET_ID, budget.id!!)
}
findNavController().navigate(R.id.categoryListFragment, bundle)
}
})
) )
} }
@ -46,7 +53,7 @@ class BudgetState(val budget: Budget) : BindableState {
class BudgetViewHolder( class BudgetViewHolder(
itemView: View, itemView: View,
private val navController: NavController private val budgetClickListener: (View, Budget) -> Unit
) : BindableAdapter.BindableViewHolder<BudgetState>(itemView) { ) : BindableAdapter.BindableViewHolder<BudgetState>(itemView) {
private val name: TextView = itemView.findViewById(R.id.budgetName) private val name: TextView = itemView.findViewById(R.id.budgetName)
private val description: TextView = itemView.findViewById(R.id.budgetDescription) private val description: TextView = itemView.findViewById(R.id.budgetDescription)
@ -62,10 +69,7 @@ class BudgetViewHolder(
description.text = budget.description description.text = budget.description
} }
itemView.setOnClickListener { itemView.setOnClickListener {
val bundle = Bundle().apply { budgetClickListener(it, budget)
putLong(EXTRA_BUDGET_ID, budget.id!!)
}
navController.navigate(R.id.categoryListFragment, bundle)
} }
} }
} }

View file

@ -21,7 +21,7 @@ import com.wbrawner.budget.ui.transactions.TransactionViewHolder
/** /**
* A simple [Fragment] subclass. * A simple [Fragment] subclass.
*/ */
class CategoryFragment : ListWithAddButtonFragment<CategoryViewModel>() { class CategoryFragment : ListWithAddButtonFragment<CategoryViewModel, TransactionState>() {
override val noItemsStringRes: Int = R.string.transactions_no_data override val noItemsStringRes: Int = R.string.transactions_no_data
override val viewModelClass: Class<CategoryViewModel> = CategoryViewModel::class.java override val viewModelClass: Class<CategoryViewModel> = CategoryViewModel::class.java
@ -31,7 +31,7 @@ class CategoryFragment : ListWithAddButtonFragment<CategoryViewModel>() {
setHasOptionsMenu(true) setHasOptionsMenu(true)
} }
override suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> { override suspend fun loadItems(): Pair<List<TransactionState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<TransactionState>>> {
val categoryId = arguments?.getLong(EXTRA_CATEGORY_ID) val categoryId = arguments?.getLong(EXTRA_CATEGORY_ID)
if (categoryId == null) { if (categoryId == null) {
findNavController().navigateUp() findNavController().navigateUp()
@ -40,12 +40,12 @@ class CategoryFragment : ListWithAddButtonFragment<CategoryViewModel>() {
val category = viewModel.getCategory(categoryId) val category = viewModel.getCategory(categoryId)
activity?.title = category.title activity?.title = category.title
// TODO: Add category details here as well // TODO: Add category details here as well
val items = ArrayList<BindableState>() val items = ArrayList<TransactionState>()
items.addAll(viewModel.getTransactions(categoryId).map { TransactionState(it) }) items.addAll(viewModel.getTransactions(categoryId).map { TransactionState(it) })
return Pair( return Pair(
items, items,
mapOf(TRANSACTION_VIEW to { v -> mapOf(TRANSACTION_VIEW to { v ->
TransactionViewHolder(v, findNavController()) as BindableAdapter.BindableViewHolder<in BindableState> TransactionViewHolder(v, findNavController())
}) })
) )
} }

View file

@ -18,7 +18,7 @@ import com.wbrawner.budget.ui.base.BindableState
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class CategoryListFragment : ListWithAddButtonFragment<CategoryViewModel>() { class CategoryListFragment : ListWithAddButtonFragment<CategoryViewModel, CategoryState>() {
override val noItemsStringRes: Int = R.string.categories_no_data override val noItemsStringRes: Int = R.string.categories_no_data
override val viewModelClass: Class<CategoryViewModel> = CategoryViewModel::class.java override val viewModelClass: Class<CategoryViewModel> = CategoryViewModel::class.java
@ -27,7 +27,7 @@ class CategoryListFragment : ListWithAddButtonFragment<CategoryViewModel>() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
} }
override suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> { override suspend fun loadItems(): Pair<List<CategoryState>, Map<Int, (view: View) -> CategoryViewHolder>> {
val budgetId = arguments?.getLong(EXTRA_BUDGET_ID) val budgetId = arguments?.getLong(EXTRA_BUDGET_ID)
if (budgetId == null) { if (budgetId == null) {
findNavController().navigateUp() findNavController().navigateUp()
@ -38,7 +38,7 @@ class CategoryListFragment : ListWithAddButtonFragment<CategoryViewModel>() {
return Pair( return Pair(
viewModel.getCategories(budgetId).map { CategoryState(it) }, viewModel.getCategories(budgetId).map { CategoryState(it) },
mapOf(CATEGORY_VIEW to { v -> mapOf(CATEGORY_VIEW to { v ->
CategoryViewHolder(v, viewModel, findNavController()) as BindableAdapter.BindableViewHolder<in BindableState> CategoryViewHolder(v, viewModel, findNavController())
}) })
) )
} }

View file

@ -17,7 +17,7 @@ class CategoryListViewModel @Inject constructor(
suspend fun getCategory(id: Long): Category = categoryRepo.findById(id) suspend fun getCategory(id: Long): Category = categoryRepo.findById(id)
suspend fun getCategories(budgetId: Long? = null): Collection<Category> = suspend fun getCategories(budgetId: Long? = null): Collection<Category> =
categoryRepo.findAll(budgetId) categoryRepo.findAll(budgetId?.let { arrayOf(it) })
suspend fun saveCategory(category: Category) = if (category.id == null) categoryRepo.create(category) suspend fun saveCategory(category: Category) = if (category.id == null) categoryRepo.create(category)
else categoryRepo.update(category) else categoryRepo.update(category)

View file

@ -35,7 +35,7 @@ class CategoryViewModel @Inject constructor(
} }
suspend fun getCategories(budgetId: Long? = null): Collection<Category> = showLoader { suspend fun getCategories(budgetId: Long? = null): Collection<Category> = showLoader {
categoryRepo.findAll(budgetId) categoryRepo.findAll(budgetId?.let { arrayOf(it) })
} }
suspend fun saveCategory(category: Category) = showLoader { suspend fun saveCategory(category: Category) = showLoader {

View file

@ -16,7 +16,7 @@ class AddEditTransactionViewModel @Inject constructor(
private val categoryRepository: CategoryRepository, private val categoryRepository: CategoryRepository,
private val transactionRepository: TransactionRepository private val transactionRepository: TransactionRepository
) : ViewModel() { ) : ViewModel() {
suspend fun getCategories(accountId: Long) = categoryRepository.findAll(accountId) suspend fun getCategories(budgetId: Long) = categoryRepository.findAll(arrayOf(budgetId))
suspend fun getTransaction(id: Long) = transactionRepository.findById(id) suspend fun getTransaction(id: Long) = transactionRepository.findById(id)

View file

@ -3,8 +3,14 @@ package com.wbrawner.budget.ui.transactions
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -17,32 +23,71 @@ import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
import com.wbrawner.budget.ui.base.BindableAdapter import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableState import com.wbrawner.budget.ui.base.BindableState
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.*
class TransactionListFragment : ListWithAddButtonFragment<TransactionListViewModel>() { class TransactionListFragment : ListWithAddButtonFragment<TransactionListViewModel, TransactionState>() {
override val viewModelClass: Class<TransactionListViewModel> = TransactionListViewModel::class.java override val viewModelClass: Class<TransactionListViewModel> = TransactionListViewModel::class.java
override val noItemsStringRes: Int = R.string.transactions_no_data override val noItemsStringRes: Int = R.string.transactions_no_data
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(this) (requireActivity().application as AllowanceApplication).appComponent.inject(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_transaction_list, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId != R.id.filter) {
return super.onOptionsItemSelected(item)
}
val dialogView = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
}
// TODO: Launch a Google Drive-style search/filter screen
AlertDialog.Builder(requireContext())
.setTitle("Filter Transactions")
.setPositiveButton(R.string.action_submit) { _, _ ->
launch {
loadItems(
arguments?.getLong(EXTRA_BUDGET_ID),
arguments?.getLong(EXTRA_CATEGORY_ID)
)
}
}
.setNegativeButton(R.string.action_cancel) { _, _ ->
// Do nothing
}
.create()
.show()
return true
} }
override fun addItem() { override fun addItem() {
startActivity(Intent(activity, AddEditTransactionActivity::class.java)) startActivity(Intent(activity, AddEditTransactionActivity::class.java))
} }
override suspend fun loadItems(): Pair<List<BindableState>, Map<Int, (view: View) -> BindableAdapter.BindableViewHolder<in BindableState>>> { override suspend fun loadItems(): Pair<List<TransactionState>, Map<Int, (View) -> TransactionViewHolder>> = loadItems(
return Pair( arguments?.getLong(EXTRA_BUDGET_ID),
viewModel.getTransactions( arguments?.getLong(EXTRA_CATEGORY_ID)
arguments?.getLong(EXTRA_BUDGET_ID), )
arguments?.getLong(EXTRA_CATEGORY_ID)
).map { TransactionState(it) }, private suspend fun loadItems(
mapOf(TRANSACTION_VIEW to { v -> budgetId: Long?,
TransactionViewHolder(v, findNavController()) as BindableAdapter.BindableViewHolder<in BindableState> categoryId: Long?,
}) from: Calendar? = null,
) to: Calendar? = null
} ): Pair<List<TransactionState>, Map<Int, (View) -> TransactionViewHolder>> = Pair(
viewModel.getTransactions(budgetId, categoryId, from, to).map { TransactionState(it) },
mapOf(TRANSACTION_VIEW to { v ->
TransactionViewHolder(v, findNavController())
})
)
companion object { companion object {
const val TAG_FRAGMENT = "transactions" const val TAG_FRAGMENT = "transactions"

View file

@ -7,12 +7,18 @@ import com.wbrawner.budget.ui.base.LoadingViewModel
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.multibindings.IntoMap import dagger.multibindings.IntoMap
import java.util.*
import javax.inject.Inject import javax.inject.Inject
class TransactionListViewModel @Inject constructor(private val transactionRepo: TransactionRepository) : class TransactionListViewModel @Inject constructor(private val transactionRepo: TransactionRepository) :
LoadingViewModel() { LoadingViewModel() {
suspend fun getTransactions(budgetId: Long? = null, categoryId: Long? = null) = showLoader { suspend fun getTransactions(
transactionRepo.findAll(budgetId = budgetId, categoryId = categoryId) budgetId: Long? = null,
categoryId: Long? = null,
start: Calendar? = null,
end: Calendar? = null
) = showLoader {
transactionRepo.findAll(budgetId, categoryId, start, end)
} }
} }

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z"/>
</vector>

View file

@ -9,10 +9,10 @@
<path <path
android:pathData="m74.798,72.259l0,14.924 0,6.993c0,23.605 18.905,42.982 42.329,43.75l0,0.027l7.463,0 1.445,0 1.971,0l0,3.874 0,14.456 0,11.467c0,9.885 4.738,19.192 12.731,25.008 7.993,5.816 18.305,7.46 27.711,4.419l-4.591,-14.201c-4.877,1.577 -10.194,0.728 -14.338,-2.288 -4.144,-3.015 -6.587,-7.813 -6.587,-12.939l0,-11.467l3.411,0 6.019,0 1.444,0l0,-0.024c23.425,-0.768 42.33,-20.145 42.33,-43.749l0,-7.463 0,-14.456l-7.463,0 -9.43,0 -1.445,0 -7.462,0l0,0.024c-11.392,0.374 -21.714,5.148 -29.313,12.67 -5.353,-17.472 -21.398,-30.374 -40.426,-30.999l0,-0.024l-1.444,0 -6.018,0 -10.874,0zM89.723,87.183l3.412,0 6.018,0c16.021,0 28.849,12.829 28.849,28.85l0,6.993l-1.967,0 -1.445,0 -6.018,0c-16.021,0 -28.849,-12.828 -28.849,-28.85zM171.781,105.513l6.017,0 1.445,0 1.968,0l0,6.994c0,16.021 -12.828,28.849 -28.849,28.849l-6.019,0 -3.411,0l0,-6.993c0,-16.021 12.827,-28.85 28.849,-28.85z" android:pathData="m74.798,72.259l0,14.924 0,6.993c0,23.605 18.905,42.982 42.329,43.75l0,0.027l7.463,0 1.445,0 1.971,0l0,3.874 0,14.456 0,11.467c0,9.885 4.738,19.192 12.731,25.008 7.993,5.816 18.305,7.46 27.711,4.419l-4.591,-14.201c-4.877,1.577 -10.194,0.728 -14.338,-2.288 -4.144,-3.015 -6.587,-7.813 -6.587,-12.939l0,-11.467l3.411,0 6.019,0 1.444,0l0,-0.024c23.425,-0.768 42.33,-20.145 42.33,-43.749l0,-7.463 0,-14.456l-7.463,0 -9.43,0 -1.445,0 -7.462,0l0,0.024c-11.392,0.374 -21.714,5.148 -29.313,12.67 -5.353,-17.472 -21.398,-30.374 -40.426,-30.999l0,-0.024l-1.444,0 -6.018,0 -10.874,0zM89.723,87.183l3.412,0 6.018,0c16.021,0 28.849,12.829 28.849,28.85l0,6.993l-1.967,0 -1.445,0 -6.018,0c-16.021,0 -28.849,-12.828 -28.849,-28.85zM171.781,105.513l6.017,0 1.445,0 1.968,0l0,6.994c0,16.021 -12.828,28.849 -28.849,28.849l-6.019,0 -3.411,0l0,-6.993c0,-16.021 12.827,-28.85 28.849,-28.85z"
android:strokeWidth=".586787" android:strokeWidth=".586787"
android:fillColor="@color/colorTextPrimary" /> android:fillColor="#1a1a1a" />
<path <path
android:pathData="m89.723,87.183l0,6.993c0,16.022 12.828,28.85 28.849,28.85l6.018,0 1.445,0 1.967,0l0,-6.993c0,-16.021 -12.828,-28.85 -28.849,-28.85l-6.018,0z" android:pathData="m89.723,87.183l0,6.993c0,16.022 12.828,28.85 28.849,28.85l6.018,0 1.445,0 1.967,0l0,-6.993c0,-16.021 -12.828,-28.85 -28.849,-28.85l-6.018,0z"
android:strokeWidth=".586787" android:strokeWidth=".586787"
android:fillColor="@color/colorAccent" /> android:fillColor="#bfff00" />
</group> </group>
</vector> </vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="416.8205"
android:viewportHeight="416.82053">
<group
android:translateX="72.94359"
android:translateY="72.943596">
<path
android:pathData="m74.798,72.259l0,14.924 0,6.993c0,23.605 18.905,42.982 42.329,43.75l0,0.027l7.463,0 1.445,0 1.971,0l0,3.874 0,14.456 0,11.467c0,9.885 4.738,19.192 12.731,25.008 7.993,5.816 18.305,7.46 27.711,4.419l-4.591,-14.201c-4.877,1.577 -10.194,0.728 -14.338,-2.288 -4.144,-3.015 -6.587,-7.813 -6.587,-12.939l0,-11.467l3.411,0 6.019,0 1.444,0l0,-0.024c23.425,-0.768 42.33,-20.145 42.33,-43.749l0,-7.463 0,-14.456l-7.463,0 -9.43,0 -1.445,0 -7.462,0l0,0.024c-11.392,0.374 -21.714,5.148 -29.313,12.67 -5.353,-17.472 -21.398,-30.374 -40.426,-30.999l0,-0.024l-1.444,0 -6.018,0 -10.874,0zM89.723,87.183l3.412,0 6.018,0c16.021,0 28.849,12.829 28.849,28.85l0,6.993l-1.967,0 -1.445,0 -6.018,0c-16.021,0 -28.849,-12.828 -28.849,-28.85zM171.781,105.513l6.017,0 1.445,0 1.968,0l0,6.994c0,16.021 -12.828,28.849 -28.849,28.849l-6.019,0 -3.411,0l0,-6.993c0,-16.021 12.827,-28.85 28.849,-28.85z"
android:strokeWidth=".586787"
android:fillColor="@color/colorTextPrimary" />
<path
android:pathData="m89.723,87.183l0,6.993c0,16.022 12.828,28.85 28.849,28.85l6.018,0 1.445,0 1.967,0l0,-6.993c0,-16.021 -12.828,-28.85 -28.849,-28.85l-6.018,0z"
android:strokeWidth=".586787"
android:fillColor="@color/colorAccent" />
</group>
</vector>

View file

@ -22,18 +22,16 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar"> app:layout_constraintTop_toBottomOf="@+id/action_bar">
<androidx.constraintlayout.widget.ConstraintLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"> android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_name" android:id="@+id/container_edit_category_name"
style="@style/AppTheme.EditText.Container" style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_name" android:hint="@string/prompt_category_name">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_name" android:id="@+id/edit_category_name"
@ -44,10 +42,7 @@
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_amount" android:id="@+id/container_edit_category_amount"
style="@style/AppTheme.EditText.Container" style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_amount" android:hint="@string/prompt_category_amount">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_category_name">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_amount" android:id="@+id/edit_category_amount"
@ -60,15 +55,12 @@
style="@style/TextAppearance.MaterialComponents.Caption" style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/prompt_account" android:text="@string/prompt_budget" />
app:layout_constraintTop_toBottomOf="@+id/container_edit_category_amount"
app:layout_constraintStart_toStartOf="parent" />
<androidx.appcompat.widget.AppCompatSpinner <androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/budgetSpinner" android:id="@+id/budgetSpinner"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content" />
app:layout_constraintTop_toBottomOf="@+id/budgetHint" /> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView> </ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -36,6 +36,7 @@
android:layout_margin="16dp" android:layout_margin="16dp"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:backgroundTint="@color/colorPrimary"
app:srcCompat="@drawable/ic_add_white_24dp" /> app:srcCompat="@drawable/ic_add_white_24dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

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/filter"
android:icon="@drawable/ic_filter_list"
android:title="@string/action_filter"
app:showAsAction="ifRoom" />
</menu>

View file

@ -3,8 +3,8 @@
<color name="colorBackgroundPrimary">#FFFFFFFF</color> <color name="colorBackgroundPrimary">#FFFFFFFF</color>
<color name="colorPrimary">#30d158</color> <color name="colorPrimary">#30d158</color>
<color name="colorPrimaryDark">#34c759</color> <color name="colorPrimaryDark">#34c759</color>
<color name="colorAccent">#bfff00</color> <color name="colorAccent">#30d158</color>
<color name="colorTextPrimary">#FF000000</color> <color name="colorTextPrimary">#1a1a1a</color>
<color name="colorTextGreen">#388e3c</color> <color name="colorTextGreen">#388e3c</color>
<color name="colorTextRed">#d32f2f</color> <color name="colorTextRed">#d32f2f</color>
<color name="colorTextPrimaryInverted">#FFDADADA</color> <color name="colorTextPrimaryInverted">#FFDADADA</color>

View file

@ -40,7 +40,6 @@
<string name="action_login">Login</string> <string name="action_login">Login</string>
<string name="title_register">Register</string> <string name="title_register">Register</string>
<string name="title_forgot_password">Forgot password?</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_name">Name</string>
<string name="prompt_account_description">Description</string> <string name="prompt_account_description">Description</string>
<string name="prompt_share_with">Share with...</string> <string name="prompt_share_with">Share with...</string>
@ -50,4 +49,8 @@
<string name="title_edit_budget">Edit Budget</string> <string name="title_edit_budget">Edit Budget</string>
<string name="title_edit">Edit</string> <string name="title_edit">Edit</string>
<string name="description_logo">Twigs Logo</string> <string name="description_logo">Twigs Logo</string>
<string name="action_filter">Filter</string>
<string name="action_cancel">Cancel</string>
<string name="prompt_month">Month</string>
<string name="prompt_year">Year</string>
</resources> </resources>

View file

@ -29,7 +29,8 @@ dependencies {
// Retrofit // Retrofit
api "com.squareup.retrofit2:retrofit:$rootProject.ext.retrofit" api "com.squareup.retrofit2:retrofit:$rootProject.ext.retrofit"
implementation "com.squareup.retrofit2:converter-moshi:$rootProject.ext.retrofit" implementation "com.squareup.retrofit2:converter-moshi:$rootProject.ext.retrofit"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'
implementation("com.squareup.okhttp3:logging-interceptor:4.7.2")
api "com.squareup.moshi:moshi:$rootProject.ext.moshi" api "com.squareup.moshi:moshi:$rootProject.ext.moshi"
implementation "com.squareup.moshi:moshi-adapters:$rootProject.ext.moshi" implementation "com.squareup.moshi:moshi-adapters:$rootProject.ext.moshi"
// Dagger // Dagger

View file

@ -37,7 +37,7 @@ interface BudgetApiService {
// Categories // Categories
@GET("categories") @GET("categories")
suspend fun getCategories( suspend fun getCategories(
@Query("budgetId") budgetId: Long? = null, @Query("budgetIds") budgetIds: Array<Long>? = null,
@Query("count") count: Int? = null, @Query("count") count: Int? = null,
@Query("page") page: Int? = null @Query("page") page: Int? = null
): Collection<Category> ): Collection<Category>
@ -65,6 +65,8 @@ interface BudgetApiService {
suspend fun getTransactions( suspend fun getTransactions(
@Query("budgetId") budgetId: Long? = null, @Query("budgetId") budgetId: Long? = null,
@Query("categoryId") categoryId: Long? = null, @Query("categoryId") categoryId: Long? = null,
@Query("from") from: String? = null,
@Query("to") to: String? = null,
@Query("count") count: Int? = null, @Query("count") count: Int? = null,
@Query("page") page: Int? = null @Query("page") page: Int? = null
): Collection<Transaction> ): Collection<Transaction>

View file

@ -12,12 +12,14 @@ import com.wbrawner.budget.lib.repository.NetworkBudgetRepository
import com.wbrawner.budget.lib.repository.NetworkCategoryRepository import com.wbrawner.budget.lib.repository.NetworkCategoryRepository
import com.wbrawner.budget.lib.repository.NetworkTransactionRepository import com.wbrawner.budget.lib.repository.NetworkTransactionRepository
import com.wbrawner.budget.lib.repository.NetworkUserRepository import com.wbrawner.budget.lib.repository.NetworkUserRepository
import com.wbrawner.budgetlib.BuildConfig
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import java.nio.charset.Charset import java.nio.charset.Charset
@ -34,10 +36,10 @@ class NetworkModule {
@Provides @Provides
fun provideCookieJar(sharedPreferences: SharedPreferences): CookieJar { fun provideCookieJar(sharedPreferences: SharedPreferences): CookieJar {
return object : CookieJar { return object : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) { override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
sharedPreferences.edit() sharedPreferences.edit()
.putString( .putString(
url.host(), url.host,
cookies.joinToString(separator = ",") { cookies.joinToString(separator = ",") {
Base64.encode(it.toString().toByteArray(), 0) Base64.encode(it.toString().toByteArray(), 0)
.toString(charset = Charset.forName("UTF-8")) .toString(charset = Charset.forName("UTF-8"))
@ -47,7 +49,7 @@ class NetworkModule {
} }
override fun loadForRequest(url: HttpUrl): MutableList<Cookie> { override fun loadForRequest(url: HttpUrl): MutableList<Cookie> {
return sharedPreferences.getString(url.host(), "") return sharedPreferences.getString(url.host, "")
?.split(",") ?.split(",")
?.mapNotNull { ?.mapNotNull {
Cookie.parse( Cookie.parse(
@ -64,7 +66,13 @@ class NetworkModule {
@Provides @Provides
fun provideOkHttpClient(cookieJar: CookieJar): OkHttpClient = OkHttpClient.Builder() fun provideOkHttpClient(cookieJar: CookieJar): OkHttpClient = OkHttpClient.Builder()
.cookieJar(cookieJar) .cookieJar(cookieJar)
// TODO: Add Gander interceptor .apply {
if (BuildConfig.DEBUG)
this.addInterceptor(
HttpLoggingInterceptor(HttpLoggingInterceptor.Logger.DEFAULT)
.setLevel(HttpLoggingInterceptor.Level.BODY)
)
}
.build() .build()
@Provides @Provides

View file

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

View file

@ -3,13 +3,33 @@ package com.wbrawner.budget.lib.repository
import com.wbrawner.budget.common.transaction.Transaction import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.transaction.TransactionRepository import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.lib.network.BudgetApiService import com.wbrawner.budget.lib.network.BudgetApiService
import java.text.SimpleDateFormat
import java.time.format.DateTimeFormatter
import java.util.*
import javax.inject.Inject import javax.inject.Inject
class NetworkTransactionRepository @Inject constructor(private val apiService: BudgetApiService) : TransactionRepository { class NetworkTransactionRepository @Inject constructor(private val apiService: BudgetApiService) : TransactionRepository {
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH)
override suspend fun create(newItem: Transaction): Transaction = apiService.newTransaction(newItem) override suspend fun create(newItem: Transaction): Transaction = apiService.newTransaction(newItem)
override suspend fun findAll(budgetId: Long?, categoryId: Long?): Collection<Transaction> = override suspend fun findAll(
apiService.getTransactions(budgetId, categoryId).sortedByDescending { it.date } budgetId: Long?,
categoryId: Long?,
start: Calendar?,
end: Calendar?
): Collection<Transaction> = apiService.getTransactions(
budgetId,
categoryId,
start?.let {
it.timeZone = TimeZone.getTimeZone("UTC")
dateFormatter.format(it.time)
},
end?.let {
it.timeZone = TimeZone.getTimeZone("UTC")
dateFormatter.format(it.time)
}
)
override suspend fun findAll(): Collection<Transaction> = findAll(null) override suspend fun findAll(): Collection<Transaction> = findAll(null)

View file

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

View file

@ -1,7 +1,13 @@
package com.wbrawner.budget.common.transaction package com.wbrawner.budget.common.transaction
import com.wbrawner.budget.common.Repository import com.wbrawner.budget.common.Repository
import java.util.*
interface TransactionRepository : Repository<Transaction, Long> { interface TransactionRepository : Repository<Transaction, Long> {
suspend fun findAll(budgetId: Long? = null, categoryId: Long? = null): Collection<Transaction> suspend fun findAll(
budgetId: Long? = null,
categoryId: Long? = null,
start: Calendar? = null,
end: Calendar? = null
): Collection<Transaction>
} }