Issue resolved (#9)

* Issue resolved

Took 2 hours 5 minutes

* Issue resolved with unit tests

Took 1 hour 31 minutes
This commit is contained in:
2022-08-30 23:38:09 +01:00
committed by GitHub
parent 14c45b87ca
commit 4b8f2944cb
27 changed files with 368 additions and 291 deletions

View File

@@ -1,12 +1,7 @@
package com.appttude.h_mal.easycc.data.network.api
import com.appttude.h_mal.easycc.data.network.interceptors.NetworkConnectionInterceptor
import com.appttude.h_mal.easycc.data.network.response.CurrencyResponse
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query

View File

@@ -1,20 +1,14 @@
package com.appttude.h_mal.easycc.data.network.api
import com.appttude.h_mal.easycc.data.network.interceptors.NetworkConnectionInterceptor
import com.appttude.h_mal.easycc.data.network.interceptors.QueryInterceptor
import com.appttude.h_mal.easycc.data.network.response.ResponseObject
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit Network class to currency api calls
*/
interface CurrencyApi : Api{
interface CurrencyApi : Api {
// Get rate from server with arguments passed in Repository
@GET("convert?")

View File

@@ -5,7 +5,7 @@ import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Inject
class RemoteDataSource @Inject constructor(){
class RemoteDataSource @Inject constructor() {
fun <Api> buildApi(
okkHttpclient: OkHttpClient,

View File

@@ -5,22 +5,22 @@ import com.appttude.h_mal.easycc.models.CurrencyModelInterface
import com.google.gson.annotations.SerializedName
data class CurrencyResponse(
@field:SerializedName("date")
val date: String? = null,
@field:SerializedName("amount")
val amount: Double? = null,
@field:SerializedName("rates")
var rates: Map<String, Double>? = null,
@field:SerializedName("base")
val base: String? = null
@field:SerializedName("date")
val date: String? = null,
@field:SerializedName("amount")
val amount: Double? = null,
@field:SerializedName("rates")
var rates: Map<String, Double>? = null,
@field:SerializedName("base")
val base: String? = null
) : CurrencyModelInterface {
override fun getCurrencyModel(): CurrencyModel {
return CurrencyModel(
base,
rates?.iterator()?.next()?.key,
rates?.iterator()?.next()?.value ?: 0.0
)
base,
rates?.iterator()?.next()?.key,
rates?.iterator()?.next()?.value ?: 0.0
)
}
}

View File

@@ -1,16 +1,13 @@
package com.appttude.h_mal.easycc.data.repository
import com.appttude.h_mal.easycc.data.network.response.CurrencyResponse
import com.appttude.h_mal.easycc.data.network.response.ResponseObject
import com.appttude.h_mal.easycc.models.CurrencyModel
/**
* Main entry point for accessing currency data.
*/
interface Repository {
suspend fun getDataFromApi(fromCurrency: String, toCurrency: String): ResponseObject
suspend fun getBackupDataFromApi(fromCurrency: String, toCurrency: String): CurrencyResponse
suspend fun getDataFromApi(fromCurrency: String, toCurrency: String): CurrencyModel
fun getConversionPair(): Pair<String?, String?>

View File

@@ -5,10 +5,10 @@ import com.appttude.h_mal.easycc.R
import com.appttude.h_mal.easycc.data.network.SafeApiRequest
import com.appttude.h_mal.easycc.data.network.api.BackupCurrencyApi
import com.appttude.h_mal.easycc.data.network.api.CurrencyApi
import com.appttude.h_mal.easycc.data.network.response.CurrencyResponse
import com.appttude.h_mal.easycc.data.network.response.ResponseObject
import com.appttude.h_mal.easycc.data.prefs.PreferenceProvider
import com.appttude.h_mal.easycc.models.CurrencyModel
import com.appttude.h_mal.easycc.utils.convertPairsListToString
import java.io.IOException
import javax.inject.Inject
/**
@@ -23,17 +23,19 @@ class RepositoryImpl @Inject constructor(
override suspend fun getDataFromApi(
fromCurrency: String,
toCurrency: String
): ResponseObject {
// Set currency pairs as correct string for api query eg. AUD_GBP
val currencyPair = convertPairsListToString(fromCurrency, toCurrency)
return responseUnwrap { api.getCurrencyRate(currencyPair) }
}
override suspend fun getBackupDataFromApi(
fromCurrency: String,
toCurrency: String
): CurrencyResponse {
return responseUnwrap { backUpApi.getCurrencyRate(fromCurrency, toCurrency) }
): CurrencyModel {
return try {
// Set currency pairs as correct string for api query eg. AUD_GBP
val currencyPair = convertPairsListToString(fromCurrency, toCurrency)
responseUnwrap { api.getCurrencyRate(currencyPair) }.getCurrencyModel()
} catch (e: IOException) {
responseUnwrap {
backUpApi.getCurrencyRate(
fromCurrency,
toCurrency
)
}.getCurrencyModel()
}
}
override fun getConversionPair(): Pair<String?, String?> {

View File

@@ -1,19 +0,0 @@
package com.appttude.h_mal.easycc.helper
import com.appttude.h_mal.easycc.data.repository.Repository
import com.appttude.h_mal.easycc.models.CurrencyModelInterface
import javax.inject.Inject
class CurrencyDataHelper @Inject constructor(
val repository: Repository
) {
suspend fun getDataFromApi(from: String, to: String): CurrencyModelInterface {
return try {
repository.getDataFromApi(from, to)
} catch (e: Exception) {
e.printStackTrace()
repository.getBackupDataFromApi(from, to)
}
}
}

View File

@@ -6,7 +6,6 @@ import com.appttude.h_mal.easycc.utils.trimToThree
import javax.inject.Inject
class WidgetHelper @Inject constructor(
private val helper: CurrencyDataHelper,
val repository: Repository
) {
@@ -16,7 +15,7 @@ class WidgetHelper @Inject constructor(
val s1 = pair.first?.trimToThree() ?: return null
val s2 = pair.second?.trimToThree() ?: return null
return helper.getDataFromApi(s1, s2).getCurrencyModel()
return repository.getDataFromApi(s1, s2)
} catch (e: Exception) {
e.printStackTrace()
return null

View File

@@ -3,12 +3,12 @@ package com.appttude.h_mal.easycc.models
import com.google.gson.annotations.SerializedName
data class CurrencyObject(
@SerializedName("id")
var id: String,
@SerializedName("fr")
var fr: String,
@SerializedName("to")
var to: String,
@SerializedName("val")
var value: Double
@SerializedName("id")
var id: String,
@SerializedName("fr")
var fr: String,
@SerializedName("to")
var to: String,
@SerializedName("val")
var value: Double
)

View File

@@ -0,0 +1,96 @@
package com.appttude.h_mal.easycc.ui
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.appcompat.app.AppCompatActivity
import com.appttude.h_mal.easycc.R
import com.appttude.h_mal.easycc.utils.*
abstract class BaseActivity<V : BaseViewModel> : AppCompatActivity() {
private lateinit var loadingView: View
abstract val viewModel: V?
private var loading: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
configureObserver()
}
/**
* Creates a loading view which to be shown during async operations
*
* #setOnClickListener(null) is an ugly work around to prevent under being clicked during
* loading
*/
private fun instantiateLoadingView() {
loadingView = layoutInflater.inflate(R.layout.progress_layout, null)
loadingView.setOnClickListener(null)
addContentView(loadingView, LayoutParams(MATCH_PARENT, MATCH_PARENT))
loadingView.hide()
}
override fun onStart() {
super.onStart()
instantiateLoadingView()
}
fun <A : AppCompatActivity> startActivity(activity: Class<A>) {
val intent = Intent(this, activity)
startActivity(intent)
}
open fun onStarted() {
loadingView.fadeIn()
loading = true
}
/**
* Called in case of success or some data emitted from the liveData in viewModel
*/
open fun onSuccess(data: Any?) {
loadingView.fadeOut()
loading = false
}
/**
* Called in case of failure or some error emitted from the liveData in viewModel
*/
open fun onFailure(error: String?) {
error?.let { displayToast(it) }
loadingView.fadeOut()
loading = false
}
private fun configureObserver() {
viewModel?.uiState?.observe(this) {
when (it) {
is ViewState.HasStarted -> onStarted()
is ViewState.HasData<*> -> onSuccess(it.data.getContentIfNotHandled())
is ViewState.HasError -> onFailure(it.error.getContentIfNotHandled())
}
}
}
private fun View.fadeIn() = apply {
show()
triggerAnimation(android.R.anim.fade_in) {}
}
private fun View.fadeOut() = apply {
hide()
triggerAnimation(android.R.anim.fade_out) {}
}
override fun onBackPressed() {
if (!loading) super.onBackPressed()
}
}

View File

@@ -0,0 +1,22 @@
package com.appttude.h_mal.easycc.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.appttude.h_mal.easycc.utils.Event
import com.appttude.h_mal.easycc.utils.ViewState
abstract class BaseViewModel : ViewModel() {
open val uiState: MutableLiveData<ViewState> = MutableLiveData()
fun onStart() {
uiState.postValue(ViewState.HasStarted)
}
fun <T : Any> onSuccess(result: T) {
uiState.postValue(ViewState.HasData(Event(result)))
}
protected fun onError(error: String) {
uiState.postValue(ViewState.HasError(Event(error)))
}
}

View File

@@ -9,7 +9,6 @@ import android.view.WindowManager
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.TextView
import androidx.appcompat.widget.SearchView
import com.appttude.h_mal.easycc.R
/**
@@ -18,7 +17,7 @@ import com.appttude.h_mal.easycc.R
@Suppress("DEPRECATION")
class CustomDialogClass(
context: Context,
private val clickListener: ClickListener
val onSelect: (String) -> Unit
) : Dialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -30,7 +29,6 @@ class CustomDialogClass(
// Keyboard not to overlap dialog
window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
// array adapter for list of currencies in R.Strings
val arrayAdapter =
ArrayAdapter.createFromResource(
context, R.array.currency_arrays,
@@ -52,13 +50,8 @@ class CustomDialogClass(
// interface selection back to calling activity
list_view.setOnItemClickListener { adapterView, _, i, _ ->
clickListener.onText(adapterView.getItemAtPosition(i).toString())
onSelect.invoke(adapterView.getItemAtPosition(i).toString())
dismiss()
}
}
}
// Interface to handle selection within dialog
interface ClickListener {
fun onText(currencyName: String)
}

View File

@@ -7,18 +7,16 @@ import android.view.View
import android.view.WindowManager
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.appttude.h_mal.easycc.R
import com.appttude.h_mal.easycc.databinding.ActivityMainBinding
import com.appttude.h_mal.easycc.models.CurrencyModel
import com.appttude.h_mal.easycc.ui.BaseActivity
import com.appttude.h_mal.easycc.utils.clearEditText
import com.appttude.h_mal.easycc.utils.displayToast
import com.appttude.h_mal.easycc.utils.hideView
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : AppCompatActivity(), View.OnClickListener {
class MainActivity : BaseActivity<MainViewModel>(), View.OnClickListener {
private val viewModel: MainViewModel by viewModels()
override val viewModel: MainViewModel by viewModels()
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
@@ -29,34 +27,22 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
* Prevent keyboard overlapping views
*/
window.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN or
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
)
viewModel.initiate(intent.extras)
binding.currencyOne.text = viewModel.rateIdTo
binding.currencyTwo.text = viewModel.rateIdFrom
binding.currencyOne.text = viewModel.rateIdFrom
binding.currencyTwo.text = viewModel.rateIdTo
setUpListeners()
setUpObservers()
}
private fun setUpObservers() {
viewModel.operationStartedListener.observe(this) {
binding.progressBar.hideView(false)
}
viewModel.operationFinishedListener.observe(this) { pair ->
// hide progress bar
binding.progressBar.hideView(true)
if (pair.first) {
// Operation was successful remove text in EditTexts
binding.bottomInsertValues.clearEditText()
binding.topInsertValue.clearEditText()
} else {
// Display Toast with error message returned from Viewmodel
pair.second?.let { displayToast(it) }
}
override fun onSuccess(data: Any?) {
super.onSuccess(data)
if (data is CurrencyModel) {
binding.bottomInsertValues.clearEditText()
binding.topInsertValue.clearEditText()
}
}
@@ -69,25 +55,21 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
}
private fun showCustomDialog(view: View?) {
CustomDialogClass(this, object : ClickListener {
override fun onText(currencyName: String) {
(view as TextView).text = currencyName
viewModel.setCurrencyName(view.tag, currencyName)
}
}).show()
CustomDialogClass(this) {
(view as TextView).text = it
viewModel.setCurrencyName(view.tag, it)
}.show()
}
override fun onClick(view: View?) {
showCustomDialog(view)
}
// Text watcher applied to EditText @topInsertValue
private val textWatcherClass: TextWatcher = object : TextWatcher {
override fun onTextChanged(s: CharSequence, st: Int, b: Int, c: Int) {
// Remove text watcher on other text watcher to prevent infinite loop
binding.bottomInsertValues.removeTextChangedListener(textWatcherClass2)
// Clear any values if current EditText is empty
if (binding.topInsertValue.text.isNullOrEmpty())
binding.bottomInsertValues.setText("")
}
@@ -102,7 +84,6 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
private val textWatcherClass2: TextWatcher = object : TextWatcher {
override fun onTextChanged(s: CharSequence, st: Int, b: Int, c: Int) {
binding.topInsertValue.removeTextChangedListener(textWatcherClass)
if (binding.bottomInsertValues.text.isNullOrEmpty())
binding.topInsertValue.clearEditText()
@@ -115,4 +96,5 @@ class MainActivity : AppCompatActivity(), View.OnClickListener {
}
}
}

View File

@@ -1,17 +1,12 @@
package com.appttude.h_mal.easycc.ui.main
import android.os.Bundle
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.appttude.h_mal.easycc.data.repository.Repository
import com.appttude.h_mal.easycc.helper.CurrencyDataHelper
import com.appttude.h_mal.easycc.ui.BaseViewModel
import com.appttude.h_mal.easycc.utils.toTwoDpString
import com.appttude.h_mal.easycc.utils.trimToThree
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
@@ -21,60 +16,48 @@ import javax.inject.Inject
*/
@HiltViewModel
class MainViewModel @Inject constructor(
private val currencyDataHelper: CurrencyDataHelper,
private val repository: Repository
) : ViewModel() {
) : BaseViewModel() {
private val conversionPairs by lazy { repository.getConversionPair() }
// Viewbinding to textviews in @activity_main.xml
// Viewbinded variables
var rateIdFrom: String? = null
var rateIdTo: String? = null
//operation results livedata based on outcome of operation
val operationStartedListener = MutableLiveData<Boolean>()
val operationFinishedListener = MutableLiveData<Pair<Boolean, String?>>()
private var conversionRate: Double = 1.00
private fun getExchangeRate() {
operationStartedListener.postValue(true)
// view binded exchange rates selected null checked
onStart()
if (rateIdFrom.isNullOrEmpty() || rateIdTo.isNullOrEmpty()) {
operationFinishedListener.postValue(Pair(false, "Select currencies"))
onError("Select both currencies")
return
}
// No need to call api as it will return exchange rate as 1
if (rateIdFrom == rateIdTo) {
conversionRate = 1.00
operationFinishedListener.postValue(Pair(true, null))
onError("Currency selections are the same")
return
}
viewModelScope.launch {
try {
// Non-null assertion (!!) as values have been null checked and have not changed
val exchangeResponse = currencyDataHelper.getDataFromApi(
val exchangeResponse = repository.getDataFromApi(
rateIdFrom!!.trimToThree(),
rateIdTo!!.trimToThree()
)
exchangeResponse.getCurrencyModel().let {
exchangeResponse.let {
conversionRate = it.rate
repository.setConversionPair(rateIdFrom!!, rateIdTo!!)
operationFinishedListener.postValue(Pair(true, null))
onSuccess(it)
return@launch
}
} catch (e: IOException) {
e.message?.let {
operationFinishedListener.postValue(Pair(false, it))
return@launch
}
e.message?.let { onError(it) }
}
operationFinishedListener.postValue(Pair(false, "Failed to retrieve rate"))
}
}

View File

@@ -7,11 +7,9 @@ import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.appttude.h_mal.easycc.databinding.CurrencyAppWidgetConfigureBinding
import com.appttude.h_mal.easycc.ui.main.ClickListener
import com.appttude.h_mal.easycc.ui.BaseActivity
import com.appttude.h_mal.easycc.ui.main.CustomDialogClass
import com.appttude.h_mal.easycc.utils.displayToast
import com.appttude.h_mal.easycc.utils.transformIntToArray
import com.appttude.h_mal.easycc.widget.CurrencyAppWidgetKotlin
import dagger.hilt.android.AndroidEntryPoint
@@ -20,16 +18,16 @@ import dagger.hilt.android.AndroidEntryPoint
* The configuration screen for the [CurrencyAppWidgetKotlin] AppWidget.
*/
@AndroidEntryPoint
class CurrencyAppWidgetConfigureActivityKotlin : AppCompatActivity(),
class CurrencyAppWidgetConfigureActivityKotlin : BaseActivity<WidgetViewModel>(),
View.OnClickListener {
val viewModel: WidgetViewModel by viewModels()
override val viewModel: WidgetViewModel by viewModels()
private lateinit var binding: CurrencyAppWidgetConfigureBinding
private var mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
public override fun onCreate(icicle: Bundle?) {
super.onCreate(icicle)
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = CurrencyAppWidgetConfigureBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -50,10 +48,8 @@ class CurrencyAppWidgetConfigureActivityKotlin : AppCompatActivity(),
finish()
return
}
viewModel.initiate(mAppWidgetId)
setupObserver()
setupClickListener()
}
@@ -63,17 +59,9 @@ class CurrencyAppWidgetConfigureActivityKotlin : AppCompatActivity(),
binding.currencyTwo.setOnClickListener(this)
}
private fun setupObserver() {
viewModel.operationFinishedListener.observe(this) {
// it.first is a the success of the operation
if (it.first) {
displaySubmitDialog()
} else {
// failed operation - display toast with message from it.second
it.second?.let { message -> displayToast(message) }
}
}
override fun onSuccess(data: Any?) {
super.onSuccess(data)
displaySubmitDialog()
}
override fun onClick(view: View?) {
@@ -98,12 +86,10 @@ class CurrencyAppWidgetConfigureActivityKotlin : AppCompatActivity(),
private fun showCustomDialog(view: View?) {
CustomDialogClass(this, object : ClickListener {
override fun onText(currencyName: String) {
(view as TextView).text = currencyName
viewModel.setCurrencyName(view.tag, currencyName)
}
}).show()
CustomDialogClass(this) {
(view as TextView).text = it
viewModel.setCurrencyName(view.tag, it)
}.show()
}
fun finishCurrencyWidgetActivity() {

View File

@@ -3,8 +3,6 @@ package com.appttude.h_mal.easycc.ui.widget
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import com.appttude.h_mal.easycc.R
import com.appttude.h_mal.easycc.databinding.ActivityMainBinding
import com.appttude.h_mal.easycc.databinding.ConfirmDialogBinding

View File

@@ -1,8 +1,7 @@
package com.appttude.h_mal.easycc.ui.widget
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.appttude.h_mal.easycc.data.repository.Repository
import com.appttude.h_mal.easycc.ui.BaseViewModel
import com.appttude.h_mal.easycc.utils.trimToThree
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@@ -10,7 +9,7 @@ import javax.inject.Inject
@HiltViewModel
class WidgetViewModel @Inject constructor(
private val repository: Repository
) : ViewModel() {
) : BaseViewModel() {
private val defaultCurrency: String by lazy { repository.getCurrenciesList()[0] }
var appWidgetId: Int? = null
@@ -20,7 +19,6 @@ class WidgetViewModel @Inject constructor(
var rateIdTo: String? = null
// Live data to feedback to @CurrencyAppWidgetConfigureActivityKotlin
val operationFinishedListener = MutableLiveData<Pair<Boolean, String?>>()
// Setup viewmodel app widget ID
// Set default values for text views
@@ -43,15 +41,14 @@ class WidgetViewModel @Inject constructor(
fun submitSelectionOnClick() {
if (rateIdTo == null || rateIdFrom == null) {
operationFinishedListener.value = Pair(false, "Selections incomplete")
onError("Selections incomplete")
return
}
if (rateIdFrom == rateIdTo) {
operationFinishedListener.value =
Pair(false, "Selected rates cannot be the same ${rateIdFrom}${rateIdTo}")
onError("Selected rates cannot be the same")
return
}
operationFinishedListener.value = Pair(true, null)
onSuccess(Unit)
}
fun setWidgetStored() {

View File

@@ -0,0 +1,24 @@
package com.appttude.h_mal.easycc.utils
open class Event<out T>(private val content: T) {
var hasBeenHandled = false
private set // Allow external read but not write
/**
* Returns the content and prevents its use again.
*/
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
/**
* Returns the content, even if it's already been handled.
*/
fun peekContent(): T = content
}

View File

@@ -0,0 +1,7 @@
package com.appttude.h_mal.easycc.utils
sealed class ViewState {
object HasStarted : ViewState()
class HasData<T : Any>(val data: Event<T>) : ViewState()
class HasError(val error: Event<String>) : ViewState()
}

View File

@@ -2,21 +2,34 @@ package com.appttude.h_mal.easycc.utils
import android.content.Context
import android.view.View
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.EditText
import android.widget.Toast
import androidx.annotation.AnimRes
fun EditText.clearEditText() {
this.setText("")
}
fun View.hideView(vis: Boolean) {
visibility = if (vis) {
View.GONE
} else {
View.VISIBLE
}
}
fun Context.displayToast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
fun View.triggerAnimation(@AnimRes id: Int, complete: (View) -> Unit) {
val animation = AnimationUtils.loadAnimation(context, id)
animation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) = complete(this@triggerAnimation)
override fun onAnimationStart(a: Animation?) {}
override fun onAnimationRepeat(a: Animation?) {}
})
startAnimation(animation)
}
fun View.show() {
this.visibility = View.VISIBLE
}
fun View.hide() {
this.visibility = View.GONE
}

View File

@@ -1,7 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="192dp"
android:width="1080dp"
android:height="1920dp"
android:viewportWidth="1080"
android:viewportHeight="1920">
<path android:pathData="M0,0L1080,0L1080,1920L0,1920L0,0Z">
@@ -21,4 +21,4 @@
</gradient>
</aapt:attr>
</path>
</vector>
</vector>

View File

@@ -99,17 +99,4 @@
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:indeterminateTint="@color/colour_four"
android:indeterminateTintMode="src_atop"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,18 @@
<?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"
android:background="#4D000000">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:layout_gravity="center"
android:elevation="0.2dp"
android:indeterminateTint="@color/colour_four"
android:indeterminateTintMode="src_atop"/>
</FrameLayout>

View File

@@ -1,11 +1,13 @@
package com.appttude.h_mal.easycc.repository
import com.appttude.h_mal.easycc.data.network.SafeApiRequest
import com.appttude.h_mal.easycc.data.network.api.BackupCurrencyApi
import com.appttude.h_mal.easycc.data.network.api.CurrencyApi
import com.appttude.h_mal.easycc.data.network.response.ResponseObject
import com.appttude.h_mal.easycc.data.prefs.PreferenceProvider
import com.appttude.h_mal.easycc.data.repository.Repository
import com.appttude.h_mal.easycc.data.repository.RepositoryImpl
import com.appttude.h_mal.easycc.models.CurrencyModel
import com.appttude.h_mal.easycc.utils.convertPairsListToString
import kotlinx.coroutines.runBlocking
import okhttp3.ResponseBody
@@ -22,7 +24,7 @@ import java.io.IOException
import kotlin.test.assertFailsWith
class RepositoryNetworkTest {
class RepositoryNetworkTest : SafeApiRequest() {
lateinit var repository: Repository
@@ -49,15 +51,16 @@ class RepositoryNetworkTest {
//create a successful retrofit response
val mockCurrencyResponse = mock(ResponseObject::class.java)
val re = Response.success(mockCurrencyResponse)
val currencyModel = mock(CurrencyModel::class.java)
//WHEN - loginApiRequest to return a successful response
val currencyPair = convertPairsListToString(s1, s2)
Mockito.`when`(api.getCurrencyRate(currencyPair)).thenReturn(re)
Mockito.`when`(responseUnwrap { api.getCurrencyRate(currencyPair) }.getCurrencyModel()).thenReturn(currencyModel)
//THEN - the unwrapped login response contains the correct values
val currencyResponse = repository.getDataFromApi(s1, s2)
assertNotNull(currencyResponse)
assertEquals(currencyResponse, mockCurrencyResponse)
assertEquals(currencyResponse, currencyModel)
}
@Test
@@ -74,6 +77,7 @@ class RepositoryNetworkTest {
//WHEN
val currencyPair = convertPairsListToString(s1, s2)
Mockito.`when`(api.getCurrencyRate(currencyPair)).thenAnswer { re }
Mockito.`when`(apiBackup.getCurrencyRate(s1, s2)).thenAnswer { re }
//THEN - assert exception is not null
val ioExceptionReturned = assertFailsWith<IOException> {

View File

@@ -0,0 +1,22 @@
package com.appttude.h_mal.easycc.ui
import androidx.lifecycle.MutableLiveData
import com.appttude.h_mal.easycc.utils.ViewState
abstract class BaseViewModelTest<V : BaseViewModel> {
abstract val viewModel: V?
open fun setUp() {
viewModel?.uiState?.observeForever {
when (it) {
is ViewState.HasStarted -> Unit
is ViewState.HasData<*> -> dataPost.postValue(it.data.getContentIfNotHandled())
is ViewState.HasError -> errorPost.postValue(it.error.getContentIfNotHandled())
}
}
}
var dataPost: MutableLiveData<Any> = MutableLiveData()
var errorPost: MutableLiveData<String> = MutableLiveData()
}

View File

@@ -1,17 +1,17 @@
package com.appttude.h_mal.easycc.ui.main
import android.os.Bundle
import android.util.Log
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.appttude.h_mal.easycc.data.network.response.ResponseObject
import com.appttude.h_mal.easycc.data.repository.Repository
import com.appttude.h_mal.easycc.helper.CurrencyDataHelper
import com.appttude.h_mal.easycc.models.CurrencyModel
import com.appttude.h_mal.easycc.ui.BaseViewModelTest
import com.appttude.h_mal.easycc.utils.MainCoroutineRule
import com.appttude.h_mal.easycc.utils.observeOnce
import com.nhaarman.mockitokotlin2.doAnswer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -22,7 +22,7 @@ import org.mockito.MockitoAnnotations
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelTest {
class MainViewModelTest : BaseViewModelTest<MainViewModel>(){
// Run tasks synchronously
@get:Rule
@@ -31,27 +31,27 @@ class MainViewModelTest {
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
lateinit var viewModel: MainViewModel
override lateinit var viewModel: MainViewModel
@Mock
lateinit var repository: Repository
@Mock
lateinit var helper: CurrencyDataHelper
private val currencyOne = "AUD - Australian Dollar"
private val currencyTwo = "GBP - British Pound"
@Before
fun setUp() {
override fun setUp() {
MockitoAnnotations.initMocks(this)
viewModel = MainViewModel(helper, repository)
viewModel = MainViewModel(repository)
super.setUp()
}
@Test
fun initiate_validBundleValues_successResponse() = runBlocking {
//GIVEN
val currencyOne = "AUD - Australian Dollar"
val currencyTwo = "GBP - British Pound"
val bundle = mock(Bundle()::class.java)
val responseObject = mock(ResponseObject::class.java)
val responseObject = mock(CurrencyModel::class.java)
//WHEN
Mockito.`when`(bundle.getString("parse_1")).thenReturn(currencyOne)
@@ -61,59 +61,50 @@ class MainViewModelTest {
//THEN
viewModel.initiate(bundle)
viewModel.operationStartedListener.observeOnce {
assertEquals(true, it)
}
viewModel.operationFinishedListener.observeOnce {
assertEquals(true, it.first)
assertNull(it.second)
dataPost.observeOnce {
assertEquals(it, responseObject)
}
}
@Test
fun initiate_invalidBundleValues_successfulResponse() = runBlocking {
//GIVEN
val currencyOne = "corrupted data"
val currencyTwo = "corrupted data again"
val bundle = mock(Bundle()::class.java)
val error = "Corrupted data found"
//WHEN
Mockito.`when`(bundle.getString("parse_1")).thenReturn(currencyOne)
Mockito.`when`(bundle.getString("parse_2")).thenReturn(currencyTwo)
Mockito.`when`(helper.getDataFromApi(currencyOne, currencyTwo))
Mockito.`when`(repository.getDataFromApi(currencyOne, currencyTwo))
.doAnswer { throw IOException(error) }
//THEN
viewModel.initiate(bundle)
viewModel.operationStartedListener.observeOnce {
assertEquals(true, it)
}
viewModel.operationFinishedListener.observeOnce {
assertEquals(false, it.first)
assertEquals(it.second, error)
errorPost.observeOnce {
assertEquals(error, it)
}
}
@Test
fun initiate_sameBundleValues_successfulResponse() = runBlocking {
//GIVEN
val currencyOne = "AUD - Australian Dollar"
val bundle = mock(Bundle()::class.java)
val responseObject = mock(CurrencyModel::class.java)
//WHEN
Mockito.`when`(bundle.getString("parse_1")).thenReturn(null)
Mockito.`when`(bundle.getString("parse_2")).thenReturn(null)
Mockito.`when`(repository.getConversionPair()).thenReturn(Pair(currencyOne, currencyOne))
Mockito.`when`(repository.getDataFromApi(currencyOne, currencyTwo))
.thenReturn(responseObject)
//THEN
viewModel.initiate(bundle)
viewModel.operationStartedListener.observeOnce {
assertEquals(true, it)
}
viewModel.operationFinishedListener.observeOnce {
assertEquals(true, it.first)
assertNull(it.second)
dataPost.observeOnce {
assertEquals(responseObject, it)
}
}
@@ -129,12 +120,9 @@ class MainViewModelTest {
//THEN
viewModel.initiate(bundle)
viewModel.operationStartedListener.observeOnce {
assertEquals(true, it)
}
viewModel.operationFinishedListener.observeOnce {
assertEquals(false, it.first)
assertEquals("Select currencies", it.second)
errorPost.observeOnce {
assertEquals("Select both currencies", it)
}
}
@@ -142,11 +130,9 @@ class MainViewModelTest {
@Test
fun setCurrencyName_validValues_successResponse() = runBlocking {
//GIVEN
val currencyOne = "AUD - Australian Dollar"
val currencyTwo = "GBP - British Pound"
viewModel.rateIdTo = currencyTwo
val tag = "top"
val responseObject = mock(ResponseObject::class.java)
val responseObject = mock(CurrencyModel::class.java)
//WHEN
Mockito.`when`(repository.getDataFromApi(currencyOne, currencyTwo))
@@ -154,24 +140,18 @@ class MainViewModelTest {
//THEN
viewModel.setCurrencyName(tag, currencyOne)
viewModel.operationStartedListener.observeOnce {
assertEquals(true, it)
}
viewModel.operationFinishedListener.observeOnce {
assertEquals(true, it.first)
Log.i("tag", "${it.first} ${it.second}")
assertNull(it.second)
dataPost.observeOnce {
assertEquals(responseObject, it)
}
}
@Test
fun setCurrencyName_sameValues_successfulResponse() = runBlocking {
//GIVEN
val currencyOne = "AUD - Australian Dollar"
val currencyTwo = "GBP - British Pound"
viewModel.rateIdTo = currencyOne
val tag = "top"
val responseObject = mock(ResponseObject::class.java)
val responseObject = mock(CurrencyModel::class.java)
//WHEN
Mockito.`when`(repository.getDataFromApi(currencyOne, currencyTwo))
@@ -179,35 +159,27 @@ class MainViewModelTest {
//THEN
viewModel.setCurrencyName(tag, currencyOne)
viewModel.operationStartedListener.observeOnce {
assertEquals(true, it)
}
viewModel.operationFinishedListener.observeOnce {
assertEquals(true, it.first)
assertNull(it.second)
dataPost.observeOnce {
assertEquals(responseObject, it)
}
}
@Test
fun setCurrencyName_invalidValues_unsuccessfulResponse() = runBlocking {
//GIVEN
val currencyOne = "AUD - Australian Dollar"
val currencyTwo = "GBP - British Pound"
val error = "Data is corrupted"
viewModel.rateIdTo = "corrupted"
val tag = "top"
val responseObject = mock(ResponseObject::class.java)
val responseObject = mock(CurrencyModel::class.java)
//WHEN
Mockito.`when`(repository.getDataFromApi(currencyOne, currencyTwo))
.thenReturn(responseObject)
Mockito.`when`(repository.getDataFromApi(currencyOne, "corrupted"))
.doAnswer { throw IOException(error) }
//THEN
viewModel.setCurrencyName(tag, currencyOne)
viewModel.operationStartedListener.observeOnce {
assertEquals(true, it)
}
viewModel.operationFinishedListener.observeOnce {
assertEquals(false, it.first)
assertNotNull(it.second)
errorPost.observeOnce {
assertEquals(error, it)
}
}

View File

@@ -2,6 +2,7 @@ package com.appttude.h_mal.easycc.ui.widget
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.appttude.h_mal.easycc.data.repository.Repository
import com.appttude.h_mal.easycc.ui.BaseViewModelTest
import com.appttude.h_mal.easycc.utils.observeOnce
import org.junit.Assert.*
import org.junit.Before
@@ -15,21 +16,23 @@ import org.mockito.MockitoAnnotations
private const val currencyOne = "AUD - Australian Dollar"
private const val currencyTwo = "GBP - British Pound"
class WidgetViewModelTest {
class WidgetViewModelTest : BaseViewModelTest<WidgetViewModel>(){
// Run tasks synchronously
@get:Rule
val instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule()
lateinit var viewModel: WidgetViewModel
override lateinit var viewModel: WidgetViewModel
@Mock
lateinit var repository: Repository
@Before
fun setUp() {
override fun setUp() {
MockitoAnnotations.initMocks(this)
viewModel = WidgetViewModel(repository)
super.setUp()
}
@Test
@@ -75,7 +78,6 @@ class WidgetViewModelTest {
//THEN
val dialogResult = viewModel.getSubmitDialogMessage()
assertEquals(dialogResult, "Create widget for AUDGBP?")
}
@Test
@@ -86,9 +88,9 @@ class WidgetViewModelTest {
//THEN
viewModel.submitSelectionOnClick()
viewModel.operationFinishedListener.observeOnce {
assertEquals(it.first, true)
assertNull(it.second)
dataPost.observeOnce {
assert(it is Unit)
}
}
@@ -100,20 +102,23 @@ class WidgetViewModelTest {
//THEN
viewModel.submitSelectionOnClick()
viewModel.operationFinishedListener.observeOnce {
assertEquals(it.first, false)
assertNotNull(it.second)
errorPost.observeOnce {
assertEquals("Selected rates cannot be the same", it)
}
}
@Test
fun submitSelectionOnClick_noInput_unsuccessfulResponse() {
//GIVEN
viewModel.rateIdFrom = null
viewModel.rateIdTo = null
//THEN
viewModel.submitSelectionOnClick()
viewModel.operationFinishedListener.observeOnce {
assertEquals(it.first, false)
assertNotNull(it.second)
errorPost.observeOnce {
assertEquals("Selections incomplete", it)
}
}
}