Imperial units (#24)

- change location retrieval accuracy
 - change location retrieval caching from location provider
 - expand unit test suite
 - code refactoring
 - Imperial units added
 - UI test for unit change
This commit is contained in:
2023-12-25 18:07:48 +00:00
committed by GitHub
parent 5cf4b312dc
commit 74b3d062c9
41 changed files with 1227 additions and 781 deletions

View File

@@ -1,407 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WizardSettings">
<option name="children">
<map>
<entry key="imageWizard">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="confirmationStep">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="resourceDirectory" value="monoWeather" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="imageAssetPanel">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="actionbar">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="clipArt">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\ic_home_black_24dp.xml" />
<entry key="paddingPercent" value="-10" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="clipartAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material_design_icons/action/ic_home_black_24dp.xml" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="text">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="textAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="outputName" value="ic_home" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="launcher">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="foregroundClipArt">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="ffffff" />
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\baseline_public_24.xml" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="foregroundClipartAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material/icons/materialicons/public/baseline_public_24.xml" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="foregroundImage">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="foregroundText">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="foregroundTextAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="backgroundAssetType" value="COLOR" />
<entry key="backgroundColor" value="000000" />
<entry key="foregroundAssetType" value="CLIP_ART" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="launcherLegacy">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="clipArt">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="ffffff" />
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\ic_grade_black_24dp.xml" />
<entry key="paddingPercent" value="-10" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="clipartAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material_design_icons/action/ic_grade_black_24dp.xml" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="image">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
<entry key="imagePath" value="C:\Users\h_mal\Desktop\Atlas weather\notif.png" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="text">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="textAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="assetType" value="IMAGE" />
<entry key="iconShape" value="NONE" />
<entry key="imageAsset" value="C:\Users\h_mal\Desktop\Atlas weather\notif.png" />
<entry key="outputName" value="ic_notif" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="notification">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="clipArt">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\ic_android_black_24dp.xml" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="text">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="textAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="tvBanner">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="foregroundText">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="tvChannel">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="foregroundClipArt">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\ic_android_black_24dp.xml" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="foregroundImage">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="foregroundText">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="foregroundTextAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="vectorWizard">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="vectorAssetStep">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="clipartAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material/icons/materialicons/cloud_queue/baseline_cloud_queue_24.xml" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="color" value="ffffff" />
<entry key="outputName" value="ic_baseline_cloud_queue_24" />
<entry key="sourceFile" value="D:\Android Studio Projects\Private work\Altas_-_Weather" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@@ -8,10 +8,9 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso
import androidx.test.espresso.Root
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.assertion.ViewAssertions
@@ -20,15 +19,12 @@ import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.application.TestAppClass
import com.appttude.h_mal.atlas_weather.helpers.BaseCustomMatcher
import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
import com.appttude.h_mal.atlas_weather.utils.Stubs
import kotlinx.coroutines.runBlocking
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -46,6 +42,8 @@ open class BaseTest<A : Activity>(
private lateinit var testActivity: Activity
private lateinit var decorView: View
private val prefs by lazy { PreferenceProvider(ApplicationProvider.getApplicationContext()) }
@get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION)
@@ -87,6 +85,8 @@ open class BaseTest<A : Activity>(
testApp.stubLocation(location, lat, long)
}
fun clearPrefs() = prefs.clearPrefs()
fun getActivity() = testActivity
@After

View File

@@ -5,6 +5,7 @@ import android.view.View
import android.widget.DatePicker
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.UiController
@@ -28,6 +29,8 @@ import org.hamcrest.Matcher
@SuppressWarnings("unused")
open class BaseTestRobot {
fun goBack() = Espresso.pressBack()
fun fillEditText(resId: Int, text: String?): ViewInteraction =
onView(withId(resId)).perform(
ViewActions.replaceText(text),
@@ -151,4 +154,8 @@ open class BaseTestRobot {
)
)
}
fun openMenuItem() {
matchView(R.id.settings_fragment).perform(click())
}
}

View File

@@ -0,0 +1,81 @@
package com.appttude.h_mal.atlas_weather.data.prefs
import androidx.test.core.app.ApplicationProvider
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.model.types.UnitType
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import java.lang.Thread.sleep
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class PreferenceProviderTest {
lateinit var preferenceProvider: PreferenceProvider
@Before
fun setUp() {
preferenceProvider = PreferenceProvider(ApplicationProvider.getApplicationContext())
}
@Test
fun saveAndGetLastSavedAt() {
// Arrange
val input = anyString()
// Act
preferenceProvider.saveLastSavedAt(input)
// Assert
val result = preferenceProvider.getLastSavedAt(input)
runBlocking { sleep(100) }
assert(result < System.currentTimeMillis())
}
@Test
fun getAllKeysAndDeleteKeys() {
// Arrange
val listOfLocations = listOf(CURRENT_LOCATION, "sydney", "london", "berlin", "dublin")
// Act
listOfLocations.forEach { preferenceProvider.saveLastSavedAt(it) }
// Assert
val result = preferenceProvider.getAllKeysExcludingCurrent()
assert(result.size > 0)
assertFalse { result.contains(CURRENT_LOCATION) }
// Act
listOfLocations.forEach{ preferenceProvider.deleteLocation(it)}
// Assert
val deletedResults = preferenceProvider.getAllKeysExcludingCurrent()
assertFalse { deletedResults.containsAll(listOfLocations) }
}
@Test
fun setAndGetFirstTimeRun() {
// Act
preferenceProvider.setFirstTimeRun()
runBlocking { sleep(100) }
// Assert
val result = preferenceProvider.getFirstTimeRun()
assertFalse(result)
}
@Test
fun setAndGetUnitsType() {
// Arrange
val input = UnitType.values()[kotlin.random.Random.nextInt(UnitType.values().size)]
// Act
preferenceProvider.setUnitsType(input)
// Assert
val result = preferenceProvider.getUnitsType()
assertEquals(result, input)
}
}

View File

@@ -3,7 +3,6 @@ package com.appttude.h_mal.atlas_weather.testSuite
import android.os.Build
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.util.UUIDUtil
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.Converter
@@ -11,18 +10,13 @@ import com.appttude.h_mal.atlas_weather.data.room.WeatherDao
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.test.BuildConfig
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.*
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.mock
import java.util.UUID

View File

@@ -0,0 +1,48 @@
package com.appttude.h_mal.monoWeather.robot
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.appttude.h_mal.atlas_weather.BaseTestRobot
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.types.UnitType
fun settingsScreen(func: SettingsScreen.() -> Unit) = SettingsScreen().apply { func() }
class SettingsScreen : BaseTestRobot() {
fun selectWeatherUnits(unitType: UnitType) {
onView(withId(androidx.preference.R.id.recycler_view))
.perform(
RecyclerViewActions.actionOnItem<ViewHolder>(
ViewMatchers.hasDescendant(withText(R.string.weather_units)),
click()))
val label = when (unitType) {
UnitType.METRIC -> "Metric"
UnitType.IMPERIAL -> "Imperial"
}
onView(withText(label))
.inRoot(isDialog())
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.perform(click())
}
fun verifyCurrentTemperature(temperature: Int) =
matchText(R.id.temp_main_4, temperature.toString())
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
fun refresh() = pullToRefresh(R.id.swipe_refresh)
fun verifyUnableToRetrieve() {
matchText(R.id.header_text, R.string.retrieve_warning)
matchText(R.id.body_text, R.string.empty_retrieve_warning)
}
}

View File

@@ -2,8 +2,10 @@ package com.appttude.h_mal.monoWeather.tests
import com.appttude.h_mal.atlas_weather.BaseTest
import com.appttude.h_mal.atlas_weather.model.types.UnitType
import com.appttude.h_mal.atlas_weather.ui.MainActivity
import com.appttude.h_mal.atlas_weather.utils.Stubs
import com.appttude.h_mal.monoWeather.robot.settingsScreen
import com.appttude.h_mal.monoWeather.robot.weatherScreen
import org.junit.Test
@@ -11,6 +13,7 @@ class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
clearPrefs()
}
@Test
@@ -21,4 +24,25 @@ class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
verifyCurrentLocation("Mock Location")
}
}
@Test
fun loadApp_changeToImperial_returnsValidPage() {
weatherScreen {
isDisplayed()
verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location")
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Imperial)
openMenuItem()
}
settingsScreen {
selectWeatherUnits(UnitType.IMPERIAL)
goBack()
}
weatherScreen {
isDisplayed()
refresh()
verifyCurrentTemperature(58)
verifyCurrentLocation("Mock Location")
}
}
}

View File

@@ -1,6 +1,7 @@
package com.appttude.h_mal.atlas_weather.application
import android.app.Application
import com.appttude.h_mal.atlas_weather.data.WeatherSource
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
@@ -33,7 +34,8 @@ abstract class BaseAppClass : Application(), KodeinAware {
bind() from singleton { RepositoryImpl(instance(), instance(), instance()) }
bind() from singleton { SettingsRepositoryImpl(instance()) }
bind() from singleton { ServicesHelper(instance(), instance(), instance()) }
bind() from provider { ApplicationViewModelFactory(instance(), instance()) }
bind() from singleton { WeatherSource(instance(), instance()) }
bind() from provider { ApplicationViewModelFactory(this@BaseAppClass, instance(), instance(),instance()) }
}
abstract fun createNetworkModule(): WeatherApi

View File

@@ -0,0 +1,87 @@
package com.appttude.h_mal.atlas_weather.base
import android.os.Bundle
import android.view.View
import androidx.annotation.XmlRes
import androidx.fragment.app.createViewModelLazy
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseAndroidViewModel
import com.appttude.h_mal.atlas_weather.helper.GenericsHelper.getGenericClassAt
import com.appttude.h_mal.atlas_weather.model.ViewState
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.kodein
import org.kodein.di.generic.instance
import kotlin.properties.Delegates
@Suppress("EmptyMethod", "EmptyMethod")
abstract class BasePreferencesFragment<V : BaseAndroidViewModel>(@XmlRes private val preferencesResId: Int) :
PreferenceFragmentCompat(),
KodeinAware {
override val kodein by kodein()
private val factory by instance<ApplicationViewModelFactory>()
val viewModel: V by getFragmentViewModel()
var mActivity: BaseActivity? = null
private fun getFragmentViewModel(): Lazy<V> =
createViewModelLazy(getGenericClassAt(0), { viewModelStore }, factoryProducer = { factory })
private var shortAnimationDuration by Delegates.notNull<Int>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mActivity = activity as BaseActivity
configureObserver()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(preferencesResId, rootKey)
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
prefs.registerOnSharedPreferenceChangeListener { _, s ->
preferenceChanged(s)
}
}
private fun configureObserver() {
viewModel.uiState.observe(viewLifecycleOwner) {
when (it) {
is ViewState.HasStarted -> onStarted()
is ViewState.HasData<*> -> onSuccess(it.data)
is ViewState.HasError<*> -> onFailure(it.error)
}
}
}
open fun preferenceChanged(key: String) { }
/**
* Called in case of starting operation liveData in viewModel
*/
open fun onStarted() {
mActivity?.onStarted()
}
/**
* Called in case of success or some data emitted from the liveData in viewModel
*/
open fun onSuccess(data: Any?) {
mActivity?.onSuccess(data)
}
/**
* Called in case of failure or some error emitted from the liveData in viewModel
*/
open fun onFailure(error: Any?) {
mActivity?.onFailure(error)
}
}

View File

@@ -0,0 +1,42 @@
package com.appttude.h_mal.atlas_weather.base.baseViewModels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.appttude.h_mal.atlas_weather.model.ViewState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
open class BaseAndroidViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableLiveData<ViewState>()
val uiState: LiveData<ViewState> = _uiState
fun onStart() {
_uiState.postValue(ViewState.HasStarted)
}
fun <T : Any> onSuccess(result: T) {
_uiState.postValue(ViewState.HasData(result))
}
protected fun <E : Any> onError(error: E) {
_uiState.postValue(ViewState.HasError(error))
}
protected var job: Job? = null
fun cancelOperation() {
CoroutineScope(Dispatchers.IO).launch {
job?.run {
cancelAndJoin()
onSuccess(Unit)
}
}
}
}

View File

@@ -4,8 +4,13 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.appttude.h_mal.atlas_weather.model.ViewState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
open class BaseViewModel: ViewModel() {
open class BaseViewModel : ViewModel() {
private val _uiState = MutableLiveData<ViewState>()
val uiState: LiveData<ViewState> = _uiState
@@ -22,4 +27,15 @@ open class BaseViewModel: ViewModel() {
protected fun <E : Any> onError(error: E) {
_uiState.postValue(ViewState.HasError(error))
}
protected var job: Job? = null
fun cancelOperation() {
CoroutineScope(Dispatchers.IO).launch {
job?.run {
cancelAndJoin()
onSuccess(Unit)
}
}
}
}

View File

@@ -1,29 +0,0 @@
package com.appttude.h_mal.atlas_weather.base.baseViewModels
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
abstract class WeatherViewModel : BaseViewModel() {
fun createFullWeather(
weather: WeatherResponse,
location: String
): FullWeather {
return FullWeather(weather).apply {
temperatureUnit = "°C"
locationString = location
}
}
fun createWeatherEntity(
locationId: String,
weather: FullWeather
): EntityItem {
weather.apply {
locationString = locationId
}
return EntityItem(locationId, weather)
}
}

View File

@@ -0,0 +1,72 @@
package com.appttude.h_mal.atlas_weather.data
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.repository.Repository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.appttude.h_mal.atlas_weather.model.types.UnitType
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import java.io.IOException
class WeatherSource(
val repository: Repository,
private val locationProvider: LocationProvider
) {
@Throws(IOException::class)
suspend fun getWeather(
latLon: Pair<Double, Double>,
locationName: String? = null,
locationType: LocationType = LocationType.Town
): FullWeather {
val location = locationName ?: CURRENT_LOCATION
// Has the search been conducted in the last 5 minutes
return if (repository.isSearchValid(location)) {
fetchWeather(latLon, location, locationType)
} else {
val weather = repository.getSingleWeather(location)
repository.saveCurrentWeatherToRoom(weather)
weather.weather
}
}
@Throws(IOException::class)
suspend fun forceFetchWeather(latLon: Pair<Double, Double>,
locationType: LocationType = LocationType.Town): FullWeather {
// get data from database
val weatherEntity = repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION)
// check unit type - if same do nothing
val units = if (repository.getUnitType() == UnitType.METRIC) "°C" else "°F"
if (weatherEntity.weather.temperatureUnit == units) return weatherEntity.weather
// load data for forced
return fetchWeather(
Pair(latLon.first, latLon.second),
CURRENT_LOCATION, locationType
)
}
private suspend fun fetchWeather(
latLon: Pair<Double, Double>,
locationName: String,
locationType: LocationType = LocationType.Town
): FullWeather {
// Get weather from api
val weather = repository
.getWeatherFromApi(latLon.first.toString(), latLon.second.toString())
val currentLocation =
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon, locationType)
val unit = repository.getUnitType()
val fullWeather = FullWeather(weather).apply {
temperatureUnit = if (unit == UnitType.METRIC) "°C" else "°F"
locationString = currentLocation
}
val entityItem = EntityItem(locationName, fullWeather)
// Save data if not null
repository.saveLastSavedAt(locationName)
repository.saveCurrentWeatherToRoom(entityItem)
return fullWeather
}
}

View File

@@ -1,8 +1,11 @@
package com.appttude.h_mal.atlas_weather.data.location
import android.Manifest
import androidx.annotation.RequiresPermission
import com.appttude.h_mal.atlas_weather.model.types.LocationType
interface LocationProvider {
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
suspend fun getCurrentLatLong(): Pair<Double, Double>
fun getLatLongFromLocationName(location: String): Pair<Double, Double>
suspend fun getLocationNameFromLatLong(

View File

@@ -5,13 +5,8 @@ import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.os.HandlerThread
import androidx.annotation.RequiresPermission
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationToken
@@ -19,21 +14,23 @@ import com.google.android.gms.tasks.OnTokenCanceledListener
import kotlinx.coroutines.tasks.await
import java.io.IOException
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LocationProviderImpl(
private val applicationContext: Context
) : LocationProvider, LocationHelper(applicationContext) {
private var locationManager =
applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager?
private val client = LocationServices.getFusedLocationProviderClient(applicationContext)
private val geoCoder: Geocoder by lazy { Geocoder(applicationContext, Locale.getDefault()) }
@RequiresPermission(value = ACCESS_COARSE_LOCATION)
override suspend fun getCurrentLatLong(): Pair<Double, Double> {
val location = client.lastLocation.await() ?: getAFreshLocation()
val lastLocation = client.lastLocation.await()
lastLocation?.let {
val delta = it.time - System.currentTimeMillis()
if (delta < 300000) return it.getLatLonPair()
}
val location = getAFreshLocation()
return location?.getLatLonPair() ?: throw IOException("Unable to get location")
}
@@ -68,57 +65,16 @@ class LocationProviderImpl(
@SuppressLint("MissingPermission")
private suspend fun getAFreshLocation(): Location? {
return client.getCurrentLocation(Priority.PRIORITY_LOW_POWER, object : CancellationToken() {
override fun isCancellationRequested(): Boolean = false
override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken = this
}).await()
return client.getCurrentLocation(
Priority.PRIORITY_HIGH_ACCURACY,
cancellationToken
).await()
}
@SuppressLint("MissingPermission")
private suspend fun requestFreshLocation(): Location? {
val handlerThread = HandlerThread("MyHandlerThread")
handlerThread.start()
// Now get the Looper from the HandlerThread
// NOTE: This call will block until the HandlerThread gets control and initializes its Looper
val looper = handlerThread.looper
return suspendCoroutine { cont ->
val callback = object : LocationCallback() {
override fun onLocationResult(p0: LocationResult) {
client.removeLocationUpdates(this)
cont.resume(p0.lastLocation)
}
}
with(locationManager!!) {
when {
isProviderEnabled(LocationManager.GPS_PROVIDER) -> {
client.requestLocationUpdates(
createLocationRequest(Priority.PRIORITY_HIGH_ACCURACY),
callback,
looper
)
}
isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> {
client.requestLocationUpdates(
createLocationRequest(Priority.PRIORITY_LOW_POWER),
callback,
looper
)
}
else -> {
cont.resume(null)
}
}
}
}
private val cancellationToken = object : CancellationToken() {
override fun isCancellationRequested(): Boolean = false
override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken =
this
}
private fun createLocationRequest(priority: Int) = LocationRequest.create()
.setPriority(priority)
.setNumUpdates(1)
.setExpirationDuration(1000)
}

View File

@@ -2,13 +2,16 @@ package com.appttude.h_mal.atlas_weather.data.prefs
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import androidx.preference.PreferenceManager
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.model.types.UnitType
/**
* Shared preferences to save & load last timestamp
*/
const val LOCATION_CONST = "location_"
const val UNIT_CONST = "UnitType"
class PreferenceProvider(
context: Context
@@ -30,7 +33,7 @@ class PreferenceProvider(
return preference.getLong(locationName, 0L)
}
fun getAllKeys() = preference.all.keys.apply {
fun getAllKeysExcludingCurrent() = preference.all.keys.apply {
remove(CURRENT_LOCATION)
}
@@ -50,4 +53,18 @@ class PreferenceProvider(
return preference.getBoolean("widget_black_background", false)
}
fun setUnitsType(type: UnitType) {
preference.edit().putString(UNIT_CONST, type.name).apply()
}
fun getUnitsType(): UnitType {
val unit = preference.getString(UNIT_CONST, UnitType.METRIC.name)
return UnitType.getByName(unit) ?: UnitType.METRIC
}
@VisibleForTesting
fun clearPrefs() {
preference.edit().clear().apply()
}
}

View File

@@ -3,6 +3,7 @@ package com.appttude.h_mal.atlas_weather.data.repository
import androidx.lifecycle.LiveData
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.UnitType
interface Repository {
@@ -18,4 +19,5 @@ interface Repository {
suspend fun deleteSavedWeatherEntry(locationName: String): Boolean
fun getSavedLocations(): List<String>
suspend fun getSingleWeather(locationName: String): EntityItem
fun getUnitType() : UnitType
}

View File

@@ -7,6 +7,7 @@ import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.UnitType
import com.appttude.h_mal.atlas_weather.utils.FALLBACK_TIME
@@ -20,7 +21,7 @@ class RepositoryImpl(
lat: String,
long: String
): WeatherResponse {
return responseUnwrap { api.getFromApi(lat, long) }
return responseUnwrap { api.getFromApi(lat, long, units = prefs.getUnitsType().name.lowercase()) }
}
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
@@ -65,11 +66,15 @@ class RepositoryImpl(
}
override fun getSavedLocations(): List<String> {
return prefs.getAllKeys().toList()
return prefs.getAllKeysExcludingCurrent().toList()
}
override suspend fun getSingleWeather(locationName: String): EntityItem {
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
}
override fun getUnitType(): UnitType {
return prefs.getUnitsType()
}
}

View File

@@ -1,8 +1,12 @@
package com.appttude.h_mal.atlas_weather.data.repository
import com.appttude.h_mal.atlas_weather.model.types.UnitType
interface SettingsRepository {
fun isNotificationsEnabled(): Boolean
fun setFirstTime()
fun getFirstTime(): Boolean
fun isBlackBackground(): Boolean
fun saveUnitType(unitType: UnitType)
fun getUnitType(): UnitType
}

View File

@@ -1,6 +1,7 @@
package com.appttude.h_mal.atlas_weather.data.repository
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.model.types.UnitType
class SettingsRepositoryImpl(
private val prefs: PreferenceProvider
@@ -12,4 +13,13 @@ class SettingsRepositoryImpl(
override fun getFirstTime(): Boolean = prefs.getFirstTimeRun()
override fun isBlackBackground() = prefs.isWidgetBackground()
override fun saveUnitType(unitType: UnitType) {
prefs.setUnitsType(unitType)
}
override fun getUnitType(): UnitType {
return prefs.getUnitsType()
}
}

View File

@@ -5,15 +5,19 @@ import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Drawable
import androidx.annotation.RequiresPermission
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.repository.Repository
import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.UnitType
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetCellData
import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetData
import com.appttude.h_mal.atlas_weather.model.widget.WidgetData
import com.appttude.h_mal.atlas_weather.model.widget.WidgetError
import com.appttude.h_mal.atlas_weather.model.widget.WidgetState
import com.appttude.h_mal.atlas_weather.model.widget.WidgetWeatherCollection
import com.appttude.h_mal.atlas_weather.utils.toSmallDayName
import com.squareup.picasso.Picasso
@@ -58,6 +62,77 @@ class ServicesHelper(
}
}
@RequiresPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
suspend fun fetchWidgetData(): WidgetState {
// Data was loaded within last 5 minutes - no need to retrieve again
if (!repository.isSearchValid(CURRENT_LOCATION)) {
val data = getWidgetWeatherCollection()
data?.let {
return WidgetState.HasData(it)
}
}
// Try and retrieve location
val latLong = try {
locationProvider.getCurrentLatLong()
} catch (e: IOException) {
val data = getWidgetWeatherCollection()
data?.let {
return WidgetState.HasData(it)
}
val error = WidgetError(
icon = R.drawable.ic_baseline_cloud_off_24,
errorMessage = "Failed to retrieve location, check location"
)
return WidgetState.HasError(error)
}
// Get weather from api
val weather = try {
repository
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
} catch (e: IOException) {
val data = getWidgetWeatherCollection()
data?.let {
return WidgetState.HasData(it)
}
val error = WidgetError(
icon = R.drawable.ic_baseline_cloud_off_24,
errorMessage = "Failed to retrieve weather data, check connection"
)
return WidgetState.HasError(error)
}
val currentLocation = try {
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
} catch (e: IOException) {
val data = getWidgetWeatherCollection()
data?.let {
return WidgetState.HasData(it)
}
val error = WidgetError(
icon = R.drawable.ic_baseline_cloud_off_24,
errorMessage = "Failed to retrieve location name"
)
return WidgetState.HasError(error)
}
val fullWeather = FullWeather(weather).apply {
temperatureUnit = if (repository.getUnitType() == UnitType.METRIC) "°C" else "°F"
locationString = currentLocation
}
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
// Save data to database
repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(entityItem)
val data = createWidgetWeatherCollection(entityItem, currentLocation)
return WidgetState.HasData(data)
}
suspend fun getWidgetWeather(): WidgetData? {
return try {
val result = repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION)
@@ -127,6 +202,33 @@ class ServicesHelper(
}
}
private fun createWidgetWeatherCollection(
result: EntityItem,
locationName: String
): WidgetWeatherCollection {
val widgetData = result.weather.let {
val bitmap = it.current?.icon
val temp = it.current?.temp?.toInt().toString()
val epoc = System.currentTimeMillis()
WidgetData(locationName, bitmap, temp, epoc)
}
val list = mutableListOf<InnerWidgetCellData>()
result.weather.daily?.drop(1)?.dropLast(2)?.forEach { dailyWeather ->
val day = dailyWeather.dt?.toSmallDayName()
val icon = dailyWeather.icon
val temp = dailyWeather.max?.toInt().toString()
val item = InnerWidgetCellData(day, icon, temp)
list.add(item)
}
list.toList()
return WidgetWeatherCollection(widgetData, list)
}
private suspend fun getBitmapFromUrl(imageAddress: String?): Bitmap? {
return suspendCoroutine { cont ->
Picasso.get().load(imageAddress).into(object : Target {

View File

@@ -0,0 +1,18 @@
package com.appttude.h_mal.atlas_weather.model.types
import java.util.Locale
enum class UnitType {
METRIC,
IMPERIAL;
companion object {
fun getByName(name: String?): UnitType? {
return values().firstOrNull {
it.name.lowercase(Locale.ROOT) == name?.lowercase(
Locale.ROOT
)
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.appttude.h_mal.atlas_weather.model.widget
import androidx.annotation.DrawableRes
data class WidgetError(
@DrawableRes
val icon: Int,
val errorMessage: String
)

View File

@@ -0,0 +1,6 @@
package com.appttude.h_mal.atlas_weather.model.widget
sealed class WidgetState {
class HasData<T : Any>(val data: T) : WidgetState()
class HasError<T : Any>(val error: T) : WidgetState()
}

View File

@@ -2,7 +2,6 @@ package com.appttude.h_mal.atlas_weather.ui
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
@@ -10,7 +9,6 @@ import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.base.BaseActivity
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
import com.google.android.material.bottomnavigation.BottomNavigationView
import kotlinx.android.synthetic.main.activity_main_navigation.toolbar

View File

@@ -1,3 +1,3 @@
package com.appttude.h_mal.atlas_weather.utils
val FALLBACK_TIME: Long = 300000L
const val FALLBACK_TIME: Long = 300000L

View File

@@ -1,14 +1,18 @@
package com.appttude.h_mal.atlas_weather.viewmodel
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.appttude.h_mal.atlas_weather.data.WeatherSource
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.repository.RepositoryImpl
import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository
class ApplicationViewModelFactory(
private val application: Application,
private val locationProvider: LocationProvider,
private val repository: RepositoryImpl
private val source: WeatherSource,
private val settingsRepository: SettingsRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
@@ -17,12 +21,16 @@ class ApplicationViewModelFactory(
return when {
isAssignableFrom(WorldViewModel::class.java) -> WorldViewModel(
locationProvider,
repository
source
)
isAssignableFrom(MainViewModel::class.java) -> MainViewModel(
locationProvider,
repository
source
)
isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
application, locationProvider, source, settingsRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class")

View File

@@ -2,24 +2,23 @@ package com.appttude.h_mal.atlas_weather.viewmodel
import android.Manifest
import androidx.annotation.RequiresPermission
import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseViewModel
import com.appttude.h_mal.atlas_weather.data.WeatherSource
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.repository.Repository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainViewModel(
private val locationProvider: LocationProvider,
private val repository: Repository
) : WeatherViewModel() {
private val weatherSource: WeatherSource
) : BaseViewModel() {
init {
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {
it?.let {
weatherSource.repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { w ->
w?.let {
val weather = WeatherDisplay(it)
onSuccess(weather)
}
@@ -29,27 +28,14 @@ class MainViewModel(
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
fun fetchData() {
onStart()
CoroutineScope(Dispatchers.IO).launch {
job = CoroutineScope(Dispatchers.IO).launch {
try {
// Has the search been conducted in the last 5 minutes
val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) {
// Get location
val latLong = locationProvider.getCurrentLatLong()
// Get weather from api
val weather = repository
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
val currentLocation =
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
val fullWeather = createFullWeather(weather, currentLocation)
EntityItem(CURRENT_LOCATION, fullWeather)
} else {
repository.getSingleWeather(CURRENT_LOCATION)
}
// Save data if not null
repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(entityItem)
// Get location
val latLong = locationProvider.getCurrentLatLong()
weatherSource.getWeather(latLon = latLong)
} catch (e: Exception) {
onError(e.message!!)
e.printStackTrace()
onError(e.message ?: "Retrieving weather failed")
}
}
}

View File

@@ -0,0 +1,71 @@
package com.appttude.h_mal.atlas_weather.viewmodel
import android.Manifest
import android.app.Application
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.application.BaseAppClass
import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseAndroidViewModel
import com.appttude.h_mal.atlas_weather.data.WeatherSource
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository
import com.appttude.h_mal.atlas_weather.widget.NewAppWidget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Locale
class SettingsViewModel(
application: Application,
private val locationProvider: LocationProvider,
private val weatherSource: WeatherSource,
private val settingsRepository: SettingsRepository
) : BaseAndroidViewModel(application) {
private fun getContext() = getApplication<BaseAppClass>().applicationContext
fun updateWidget() {
val context = getContext()
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
val widgetManager = AppWidgetManager.getInstance(context)
val ids =
widgetManager.getAppWidgetIds(
ComponentName(
context,
NewAppWidget::class.java
)
)
AppWidgetManager.getInstance(context)
.notifyAppWidgetViewDataChanged(ids, R.id.whole_widget_view)
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
context.sendBroadcast(intent)
}
fun refreshWeatherData() {
onStart()
job = CoroutineScope(Dispatchers.IO).launch {
try {
if (ActivityCompat.checkSelfPermission(
getContext(),
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
) {
// Get location
val latLong = locationProvider.getCurrentLatLong()
weatherSource.forceFetchWeather(latLong)
}
updateWidget()
val units = settingsRepository.getUnitType().name.lowercase(Locale.ROOT)
onSuccess("Units have been changes to $units")
} catch (e: Exception) {
e.printStackTrace()
onError(e.message ?: "Retrieving weather failed")
}
}
}
}

View File

@@ -1,14 +1,16 @@
package com.appttude.h_mal.atlas_weather.viewmodel
import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseViewModel
import com.appttude.h_mal.atlas_weather.data.WeatherSource
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.repository.Repository
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import java.io.IOException
@@ -16,14 +18,12 @@ const val ALL_LOADED = "all_loaded"
class WorldViewModel(
private val locationProvider: LocationProvider,
private val repository: Repository
) : WeatherViewModel() {
private val weatherSource: WeatherSource
) : BaseViewModel() {
private var currentLocation: String? = null
private val weatherListLiveData = repository.loadRoomWeatherLiveData()
init {
weatherListLiveData.observeForever {
weatherSource.repository.loadRoomWeatherLiveData().observeForever {
val list = it.map { data ->
WeatherDisplay(data)
}
@@ -37,7 +37,7 @@ class WorldViewModel(
fun getSingleLocation(locationName: String) {
CoroutineScope(Dispatchers.IO).launch {
val entity = repository.getSingleWeather(locationName)
val entity = weatherSource.repository.getSingleWeather(locationName)
val item = WeatherDisplay(entity)
onSuccess(item)
}
@@ -47,14 +47,8 @@ class WorldViewModel(
onStart()
CoroutineScope(Dispatchers.IO).launch {
try {
val weatherEntity = if (repository.isSearchValid(locationName)) {
createWeatherEntity(locationName)
} else {
repository.getSingleWeather(locationName)
}
searchWeatherForLocation(locationName)
onSuccess(Unit)
repository.saveCurrentWeatherToRoom(weatherEntity)
repository.saveLastSavedAt(weatherEntity.id)
} catch (e: IOException) {
onError(e.message!!)
}
@@ -64,29 +58,20 @@ class WorldViewModel(
fun fetchDataForSingleLocationSearch(locationName: String) {
CoroutineScope(Dispatchers.IO).launch {
onStart()
// Check if location exists
if (repository.getSavedLocations().contains(locationName)) {
// Check if location already exists
if (weatherSource.repository.getSavedLocations().contains(locationName)) {
onError("$locationName already exists")
return@launch
}
try {
// Get weather from api
val entityItem = createWeatherEntity(locationName)
// retrieved location name
val retrievedLocation = locationProvider.getLocationNameFromLatLong(
entityItem.weather.lat,
entityItem.weather.lon,
LocationType.City
)
if (repository.getSavedLocations().contains(retrievedLocation)) {
val weather = searchWeatherForLocation(locationName)
val retrievedLocation = weather.locationString
// Check if location exists in stored
if (weatherSource.repository.getSavedLocations().contains(retrievedLocation)) {
onError("$retrievedLocation already exists")
return@launch
}
// Save data if not null
repository.saveCurrentWeatherToRoom(entityItem)
repository.saveLastSavedAt(retrievedLocation)
onSuccess("$retrievedLocation saved")
} catch (e: IOException) {
onError(e.message!!)
@@ -96,30 +81,24 @@ class WorldViewModel(
fun fetchAllLocations() {
onStart()
if (!repository.isSearchValid(ALL_LOADED)) {
if (!weatherSource.repository.isSearchValid(ALL_LOADED)) {
onSuccess(Unit)
return
}
CoroutineScope(Dispatchers.IO).launch {
try {
val list = mutableListOf<EntityItem>()
repository.loadWeatherList().forEach { locationName ->
val list = mutableListOf<Deferred<FullWeather>>()
weatherSource.repository.loadWeatherList().forEach { locationName ->
// If search not valid move onto next in loop
if (!repository.isSearchValid(locationName)) return@forEach
if (!weatherSource.repository.isSearchValid(locationName)) return@forEach
try {
val entity = createWeatherEntity(locationName)
list.add(entity)
repository.saveLastSavedAt(locationName)
} catch (e: IOException) {
}
val task = async{ searchWeatherForLocation(locationName) }
list.add(task)
}
repository.saveWeatherListToRoom(list)
repository.saveLastSavedAt(ALL_LOADED)
list.awaitAll()
onSuccess(Unit)
} catch (e: IOException) {
onError(e.message!!)
} finally {
onSuccess(Unit)
}
}
}
@@ -128,34 +107,29 @@ class WorldViewModel(
onStart()
CoroutineScope(Dispatchers.IO).launch {
try {
val success = repository.deleteSavedWeatherEntry(locationName)
val success = weatherSource.repository.deleteSavedWeatherEntry(locationName)
if (!success) {
onError("Failed to delete")
} else {
onSuccess(Unit)
}
} catch (e: IOException) {
onError(e.message!!)
} finally {
onSuccess(Unit)
}
}
}
private suspend fun getWeather(locationName: String): WeatherResponse {
private suspend fun searchWeatherForLocation(locationName: String): FullWeather {
// Get location
val latLong =
locationProvider.getLatLongFromLocationName(locationName)
val lat = latLong.first
val lon = latLong.second
// Get weather from api
return repository.getWeatherFromApi(lat.toString(), lon.toString())
}
private suspend fun createWeatherEntity(locationName: String): EntityItem {
val weather = getWeather(locationName)
// Search for location from provider (provider location name maybe different from #locationName)
val location =
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon, LocationType.City)
val fullWeather = createFullWeather(weather, location)
return createWeatherEntity(location, fullWeather)
locationProvider.getLocationNameFromLatLong(
latLong.first,
latLong.second,
LocationType.City
)
return weatherSource.getWeather(latLong, location, LocationType.City)
}
}

View File

@@ -22,7 +22,7 @@ abstract class BaseWidgetServiceIntentClass<T : AppWidgetProvider> : JobIntentSe
lateinit var appWidgetManager: AppWidgetManager
lateinit var appWidgetIds: IntArray
fun initBaseWidget(componentName: ComponentName) {
fun initializeWidgetData(componentName: ComponentName) {
appWidgetManager = AppWidgetManager.getInstance(baseContext)
appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
}

View File

@@ -1,25 +1,21 @@
package com.appttude.h_mal.atlas_weather.widget
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.icu.text.SimpleDateFormat
import android.os.PowerManager
import android.widget.RemoteViews
import androidx.core.app.ActivityCompat.checkSelfPermission
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.helper.ServicesHelper
import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetCellData
import com.appttude.h_mal.atlas_weather.model.widget.WidgetError
import com.appttude.h_mal.atlas_weather.model.widget.WidgetState.HasData
import com.appttude.h_mal.atlas_weather.model.widget.WidgetState.HasError
import com.appttude.h_mal.atlas_weather.model.widget.WidgetWeatherCollection
import com.appttude.h_mal.atlas_weather.ui.MainActivity
import com.appttude.h_mal.atlas_weather.utils.isInternetAvailable
import com.appttude.h_mal.atlas_weather.utils.tryOrNullSuspended
import com.appttude.h_mal.atlas_weather.widget.WidgetState.*
import com.appttude.h_mal.atlas_weather.widget.WidgetState.Companion.getWidgetState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -46,61 +42,33 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass<NewAppWidget>() {
executeWidgetUpdate()
}
@SuppressLint("MissingPermission")
private fun executeWidgetUpdate() {
val componentName = ComponentName(this, NewAppWidget::class.java)
initBaseWidget(componentName)
initializeWidgetData(componentName)
initiateWidgetUpdate(getCurrentWidgetState())
}
private fun initiateWidgetUpdate(state: WidgetState) {
when (state) {
NO_LOCATION, SCREEN_ON_CONNECTION_UNAVAILABLE -> updateErrorWidget(state)
SCREEN_ON_CONNECTION_AVAILABLE -> updateWidget(false)
SCREEN_OFF_CONNECTION_AVAILABLE -> updateWidget(true)
SCREEN_OFF_CONNECTION_UNAVAILABLE -> return
}
}
private fun updateWidget(fromStorage: Boolean) {
CoroutineScope(Dispatchers.IO).launch {
val result = getWidgetWeather(fromStorage)
appWidgetIds.forEach { id -> setupView(id, result) }
setLoadingView()
val widgetState = helper.fetchWidgetData()
appWidgetIds.forEach { id ->
when (widgetState) {
is HasData<*> -> {
val data = widgetState.data as WidgetWeatherCollection
setupView(id, data)
}
is HasError<*> -> {
if (widgetState.error is WidgetError) {
val error = widgetState.error
setupErrorView(id, error)
}
}
}
}
}
}
private fun updateErrorWidget(state: WidgetState) {
appWidgetIds.forEach { id -> setEmptyView(id, state) }
}
private fun getCurrentWidgetState(): WidgetState {
val pm = getSystemService(POWER_SERVICE) as PowerManager
val isScreenOn = pm.isInteractive
val locationGranted =
checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED
val internetAvailable = isInternetAvailable(this.applicationContext)
return getWidgetState(locationGranted, isScreenOn, internetAvailable)
}
@SuppressLint("MissingPermission")
suspend fun getWidgetWeather(storageOnly: Boolean): WidgetWeatherCollection? {
return tryOrNullSuspended {
if (!storageOnly) helper.fetchData()
helper.getWidgetWeatherCollection()
}
}
private fun setEmptyView(appWidgetId: Int, state: WidgetState) {
val error = when (state) {
NO_LOCATION -> "No Location Permission"
SCREEN_ON_CONNECTION_UNAVAILABLE -> "No network available"
else -> "No data"
}
val views = createRemoteView(R.layout.weather_app_widget)
bindErrorView(appWidgetId, views, error)
}
private fun setupView(
appWidgetId: Int,
@@ -117,6 +85,49 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass<NewAppWidget>() {
}
}
private fun setupErrorView(
appWidgetId: Int,
error: WidgetError
) {
val views = createRemoteView(R.layout.weather_app_widget)
// setLastUpdated(views, collection?.widgetData?.timeStamp)
views.setInt(R.id.whole_widget_view, "setBackgroundColor", helper.getWidgetBackground())
bindEmptyView(appWidgetId, views, error.errorMessage)
}
@SuppressLint("DiscouragedApi")
private fun setLoadingView() {
appWidgetIds.forEach { id ->
val views = createRemoteView(R.layout.weather_app_widget)
views.setInt(R.id.whole_widget_view, "setBackgroundColor", helper.getWidgetBackground())
val clickUpdate = createUpdatePendingIntent(NewAppWidget::class.java, id)
views.apply {
setTextViewText(R.id.widget_current_location, "Loading...")
setImageViewResource(R.id.location_icon, R.drawable.location_flag)
setOnClickPendingIntent(R.id.widget_current_location, clickUpdate)
(0..4).forEach { i ->
val dayId: Int =
resources.getIdentifier("widget_item_day_$i", "id", packageName)
val imageId: Int =
resources.getIdentifier("widget_item_image_$i", "id", packageName)
val tempId: Int =
resources.getIdentifier("widget_item_temp_high_$i", "id", packageName)
views.setTextViewText(dayId, "loading")
views.setTextViewText(tempId, "")
setImageViewResource(imageId, R.drawable.baseline_refresh_24)
}
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(id, views)
}
}
}
override fun bindErrorView(
widgetId: Int,
views: RemoteViews,

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@@ -31,4 +31,12 @@
<string name="retrieve_warning">Unable to retrieve weather</string>
<string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string>
<string name="no_weather_to_display">No weather to display</string>
<string name="unit_key">Units</string>
<string name="widget_black_background">widget_black_background</string>
<string name="weather_units">Weather units</string>
<string-array name="units">
<item>Metric</item>
<item>Imperial</item>
</string-array>
</resources>

View File

@@ -16,22 +16,22 @@ import kotlinx.android.synthetic.main.fragment_home.swipe_refresh
class WorldItemFragment : BaseFragment<WorldViewModel>(R.layout.fragment_home) {
private var param1: String? = null
private var retrievedLocationName: String? = null
private lateinit var recyclerAdapter: WeatherRecyclerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
param1 = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName
param1?.let { viewModel.setLocation(it) }
retrievedLocationName = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName
retrievedLocationName?.let { viewModel.setLocation(it) }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerAdapter = WeatherRecyclerAdapter {
val directions =
WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it)
val directions = WorldItemFragmentDirections
.actionWorldItemFragmentToFurtherDetailsFragment(it)
navigateTo(directions)
}
@@ -42,14 +42,14 @@ class WorldItemFragment : BaseFragment<WorldViewModel>(R.layout.fragment_home) {
swipe_refresh.apply {
setOnRefreshListener {
param1?.let {
retrievedLocationName?.let {
viewModel.fetchDataForSingleLocation(it)
isRefreshing = true
}
}
}
param1?.let { viewModel.getSingleLocation(it) }
retrievedLocationName?.let { viewModel.getSingleLocation(it) }
}
override fun onSuccess(data: Any?) {

View File

@@ -1,46 +1,24 @@
package com.appttude.h_mal.monoWeather.ui.settings
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.widget.NewAppWidget
import com.appttude.h_mal.atlas_weather.base.BasePreferencesFragment
import com.appttude.h_mal.atlas_weather.utils.displayToast
import com.appttude.h_mal.atlas_weather.viewmodel.SettingsViewModel
class SettingsFragment : PreferenceFragmentCompat() {
class SettingsFragment : BasePreferencesFragment<SettingsViewModel>(R.xml.prefs_screen) {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.prefs_screen, rootKey)
//listener on changed sort order preference:
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
prefs.registerOnSharedPreferenceChangeListener { _, key ->
if (key == "temp_units") {
val intent = Intent(requireContext(), NewAppWidget::class.java)
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val ids = AppWidgetManager.getInstance(requireContext())
.getAppWidgetIds(ComponentName(requireContext(), NewAppWidget::class.java))
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
requireContext().sendBroadcast(intent)
}
if (key == "widget_black_background") {
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
val widgetManager = AppWidgetManager.getInstance(requireContext())
val ids =
widgetManager.getAppWidgetIds(
ComponentName(
requireContext(),
NewAppWidget::class.java
)
)
AppWidgetManager.getInstance(requireContext())
.notifyAppWidgetViewDataChanged(ids, R.id.whole_widget_view)
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
requireContext().sendBroadcast(intent)
override fun preferenceChanged(key: String) {
when (key) {
"UnitType" -> viewModel.refreshWeatherData()
"widget_black_background" -> {
viewModel.updateWidget()
displayToast("Widget background has been updates")
}
}
}
override fun onSuccess(data: Any?) {
super.onSuccess(data)
if (data is String) displayToast(data)
}
}

View File

@@ -23,8 +23,8 @@
android:layout_height="32dp"
android:layout_marginEnd="12dp"
android:contentDescription="@string/image_string"
app:tint="@color/colorAccent"
app:srcCompat="@drawable/maps_and_flags" />
app:srcCompat="@drawable/maps_and_flags"
app:tint="@color/colorAccent" />
<TextView
android:id="@+id/location_main_4"
@@ -51,11 +51,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:text="@string/degrees_c"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintLeft_toRightOf="@id/temp_main_4"
app:layout_constraintTop_toTopOf="@id/temp_main_4" />
app:layout_constraintTop_toTopOf="@id/temp_main_4"
tools:text="@string/degrees_c" />
<ImageView
android:id="@+id/icon_main_4"
@@ -64,11 +64,11 @@
android:layout_marginTop="6dp"
android:layout_marginBottom="12dp"
android:contentDescription="@string/image_string"
app:tint="@color/colorAccent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/temp_main_4"
app:srcCompat="@drawable/cloud_symbol" />
app:srcCompat="@drawable/cloud_symbol"
app:tint="@color/colorAccent" />
<TextView
android:id="@+id/condition_main_4"

View File

@@ -1,10 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<ListPreference
android:title="@string/weather_units"
android:entries="@array/units"
android:entryValues="@array/units"
android:defaultValue="Metric"
android:key="UnitType"
/>
<PreferenceCategory android:title="Widget Settings">
<SwitchPreference
android:defaultValue="false"
android:key="widget_black_background"
android:key="@string/widget_black_background"
android:title="Set widget background black" />
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -0,0 +1,196 @@
package com.appttude.h_mal.atlas_weather.data
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.repository.RepositoryImpl
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.appttude.h_mal.atlas_weather.model.types.UnitType
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.utils.BaseTest
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import java.io.IOException
import kotlin.test.assertEquals
class WeatherSourceTest : BaseTest() {
@InjectMockKs
lateinit var weatherSource: WeatherSource
@MockK
lateinit var repository: RepositoryImpl
@MockK
lateinit var locationProvider: LocationProviderImpl
private lateinit var weatherResponse: WeatherResponse
@Before
fun setUp() {
MockKAnnotations.init(this)
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
}
@Test
fun fetchDataForSingleLocation_validLocation_validReturn() {
// Arrange
val latlon = Pair(
weatherResponse.lat,
weatherResponse.lon
)
val fullWeather = FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
}
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
// Act
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns latlon
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon,
LocationType.City
)
}.returns(CURRENT_LOCATION)
every { repository.getUnitType() } returns UnitType.METRIC
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
// Assert
val result =
runBlocking { weatherSource.getWeather(latlon, locationType = LocationType.City) }
assertEquals(result, fullWeather)
}
@Test(expected = IOException::class)
fun fetchDataForSingleLocation_failedWeatherApi_invalidReturn() {
// Arrange
val latlon = Pair(
weatherResponse.lat,
weatherResponse.lon
)
// Act
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
} throws IOException("Unable fetch data")
// Assert
runBlocking { weatherSource.getWeather(latlon) }
}
@Test(expected = IOException::class)
fun fetchDataForSingleLocation_failedLocation_invalidReturn() {
// Arrange
val latlon = Pair(
weatherResponse.lat,
weatherResponse.lon
)
// Act
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
} returns weatherResponse
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon
)
}.throws(IOException())
// Assert
runBlocking { weatherSource.getWeather(latlon) }
}
@Test
fun searchAboveFallbackTime_validLocation_validReturn() {
// Arrange
val latlon = Pair(
weatherResponse.lat,
weatherResponse.lon
)
val fullWeather = FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
}
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
// Act
coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem)
coEvery { repository.saveCurrentWeatherToRoom(entityItem) }.returns(Unit)
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false)
// Assert
val result = runBlocking { weatherSource.getWeather(latlon) }
assertEquals(result, fullWeather)
}
@Test
fun forceFetchDataForSingleLocation_validLocation_validReturn() {
// Arrange
val latlon = Pair(
weatherResponse.lat,
weatherResponse.lon
)
val fullWeather = FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
}
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
// Act
coEvery { repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION) } returns entityItem
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns latlon
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon,
LocationType.City
)
}.returns(CURRENT_LOCATION)
every { repository.getUnitType() } returns UnitType.METRIC
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
// Assert
val result = runBlocking {
weatherSource.forceFetchWeather(
latlon,
locationType = LocationType.City
)
}
assertEquals(result, fullWeather)
}
}

View File

@@ -6,21 +6,17 @@ import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.UnitType
import com.appttude.h_mal.atlas_weather.utils.BaseTest
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doAnswer
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.justRun
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import retrofit2.Response
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -94,6 +90,7 @@ class RepositoryImplTest : BaseTest() {
//Act
//create a successful retrofit response
every { prefs.getUnitsType() } returns (UnitType.METRIC)
coEvery { api.getFromApi("", "") }.returns(mockResponse)
// Assert
@@ -110,6 +107,7 @@ class RepositoryImplTest : BaseTest() {
//Act
//create a successful retrofit response
every { prefs.getUnitsType() } returns (UnitType.METRIC)
coEvery { api.getFromApi(any(), any()) } returns (mockResponse)
// Assert

View File

@@ -1,26 +1,31 @@
package com.appttude.h_mal.atlas_weather.viewmodel
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.appttude.h_mal.atlas_weather.data.WeatherSource
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.repository.Repository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.ViewState
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.utils.BaseTest
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
import com.appttude.h_mal.atlas_weather.utils.sleep
import com.nhaarman.mockitokotlin2.any
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.ArgumentMatchers
import org.mockito.ArgumentMatchers.anyList
import org.mockito.ArgumentMatchers.anyString
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertIs
@@ -32,8 +37,8 @@ class WorldViewModelTest : BaseTest() {
@InjectMockKs
lateinit var viewModel: WorldViewModel
@MockK(relaxed = true)
lateinit var repository: Repository
@RelaxedMockK
lateinit var weatherSource: WeatherSource
@MockK
lateinit var locationProvider: LocationProviderImpl
@@ -43,30 +48,19 @@ class WorldViewModelTest : BaseTest() {
@Before
fun setUp() {
MockKAnnotations.init(this)
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
}
@Test
fun fetchDataForSingleLocation_validLocation_validReturn() {
// Arrange
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
val latlon = any<Pair<Double, Double>>()
// Act
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
weatherResponse.lat,
weatherResponse.lon
)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
@@ -74,66 +68,209 @@ class WorldViewModelTest : BaseTest() {
LocationType.City
)
}.returns(CURRENT_LOCATION)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
coEvery {
weatherSource.getWeather(
latlon,
CURRENT_LOCATION,
locationType = LocationType.City
)
} returns FullWeather(weatherResponse)
// Assert
viewModel.uiState.observeForever {
println(it.javaClass.name)
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
sleep(100)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
}
@Test
fun fetchDataForSingleLocation_failedLocation_validReturn() {
// Arrange
val errorMessage = ArgumentMatchers.anyString()
// Act
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
weatherResponse.lat,
weatherResponse.lon
)
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon,
LocationType.City
)
} throws IOException(errorMessage)
// Assert
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
sleep(100)
val observerResults = viewModel.uiState.getOrAwaitValue()
assertIs<ViewState.HasError<*>>(observerResults)
assertEquals(observerResults.error as String, errorMessage)
}
@Test
fun fetchDataForSingleLocation_failedApi_validReturn() {
// Arrange
val latlon = Pair(weatherResponse.lat, weatherResponse.lon)
val errorMessage = ArgumentMatchers.anyString()
// Act
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
weatherResponse.lat,
weatherResponse.lon
)
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon,
LocationType.City
)
}.returns(CURRENT_LOCATION)
coEvery {
weatherSource.getWeather(
latlon,
CURRENT_LOCATION,
locationType = LocationType.City
)
} throws IOException(errorMessage)
// Assert
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
sleep(100)
val observerResults = viewModel.uiState.getOrAwaitValue()
assertIs<ViewState.HasError<*>>(observerResults)
assertEquals(observerResults.error as String, errorMessage)
}
@Test
fun fetchDataForSingleLocationSearch_validLocation_validReturn() {
// Arrange
val latlon = Pair(weatherResponse.lat, weatherResponse.lon)
// Act
every { weatherSource.repository.getSavedLocations() } returns anyList()
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns latlon
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon,
LocationType.City
)
}.returns(CURRENT_LOCATION)
coEvery {
weatherSource.getWeather(
latlon,
CURRENT_LOCATION,
locationType = LocationType.City
)
} returns FullWeather(weatherResponse).apply { locationString = CURRENT_LOCATION }
// Assert
viewModel.fetchDataForSingleLocationSearch(CURRENT_LOCATION)
sleep(100)
val result = viewModel.uiState.getOrAwaitValue()
assertIs<ViewState.HasData<*>>(result)
assertEquals(result.data as String, "$CURRENT_LOCATION saved")
}
@Test
fun fetchDataForSingleLocationSearch_locationAlreadyExists_errorReceived() {
// Act
every { weatherSource.repository.getSavedLocations() } returns listOf(CURRENT_LOCATION)
// Assert
viewModel.fetchDataForSingleLocationSearch(CURRENT_LOCATION)
sleep(100)
val result = viewModel.uiState.getOrAwaitValue()
assertIs<ViewState.HasError<*>>(result)
assertEquals(result.error as String, "$CURRENT_LOCATION already exists")
}
@Test
fun fetchDataForSingleLocationSearch_retrievedLocationExists_validError() {
// Arrange
val latlon = Pair(weatherResponse.lat, weatherResponse.lon)
val retrievedLocation = anyString()
// Act
every { weatherSource.repository.getSavedLocations() } returns listOf(retrievedLocation)
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns latlon
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon,
LocationType.City
)
}.returns(CURRENT_LOCATION)
coEvery {
weatherSource.getWeather(
latlon,
CURRENT_LOCATION,
locationType = LocationType.City
)
} returns FullWeather(weatherResponse).apply { locationString = retrievedLocation }
// Assert
viewModel.fetchDataForSingleLocationSearch(CURRENT_LOCATION)
sleep(100)
val result = viewModel.uiState.getOrAwaitValue()
assertIs<ViewState.HasError<*>>(result)
assertEquals(result.error as String, "$retrievedLocation already exists")
}
@Test
fun fetchAllLocations_validLocations_validReturn() {
// Arrange
val listOfPlaces = listOf("Sydney", "London", "Cairo")
// Act
listOfPlaces.forEachIndexed { index, s ->
every { weatherSource.repository.isSearchValid(s) } returns true
coEvery { locationProvider.getLatLongFromLocationName(s) } returns Pair(
index.toDouble(),
index.toDouble()
)
coEvery {
locationProvider.getLocationNameFromLatLong(
index.toDouble(),
index.toDouble(),
LocationType.City
)
}.returns(s)
coEvery {
weatherSource.getWeather(
Pair(index.toDouble(), index.toDouble()),
s,
LocationType.City
)
}
}
coEvery { weatherSource.repository.loadWeatherList() } returns listOfPlaces
sleep(3000)
// Assert
viewModel.fetchAllLocations()
sleep(100)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
}
@Test
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
fun deleteLocation_validLocations_validReturn() {
// Arrange
val location = CURRENT_LOCATION
val location = anyString()
// Act
every { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } throws IOException("Unable to get location")
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
viewModel.fetchDataForSingleLocation(location)
coEvery { weatherSource.repository.deleteSavedWeatherEntry(location) } returns true
// Assert
sleep(300)
assertIs<ViewState.HasError<*>>(viewModel.uiState.getOrAwaitValue())
}
viewModel.deleteLocation(location)
@Test
fun searchAboveFallbackTime_validLocation_validReturn() {
// Arrange
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
// Act
coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem)
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
// Assert
sleep(300)
sleep(100)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
}
}