Imperial units (#24) (#25)

- 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:27:00 +00:00
committed by GitHub
parent 0cf31ab7b5
commit 004fdc387f
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.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.WindowManager
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso
import androidx.test.espresso.Root
import androidx.test.espresso.UiController import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction import androidx.test.espresso.ViewAction
import androidx.test.espresso.assertion.ViewAssertions 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.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.application.TestAppClass 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.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
import com.appttude.h_mal.atlas_weather.utils.Stubs import com.appttude.h_mal.atlas_weather.utils.Stubs
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.hamcrest.Description
import org.hamcrest.Matcher import org.hamcrest.Matcher
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@@ -46,6 +42,8 @@ open class BaseTest<A : Activity>(
private lateinit var testActivity: Activity private lateinit var testActivity: Activity
private lateinit var decorView: View private lateinit var decorView: View
private val prefs by lazy { PreferenceProvider(ApplicationProvider.getApplicationContext()) }
@get:Rule @get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION) var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION)
@@ -87,6 +85,8 @@ open class BaseTest<A : Activity>(
testApp.stubLocation(location, lat, long) testApp.stubLocation(location, lat, long)
} }
fun clearPrefs() = prefs.clearPrefs()
fun getActivity() = testActivity fun getActivity() = testActivity
@After @After

View File

@@ -5,6 +5,7 @@ import android.view.View
import android.widget.DatePicker import android.widget.DatePicker
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.UiController import androidx.test.espresso.UiController
@@ -28,6 +29,8 @@ import org.hamcrest.Matcher
@SuppressWarnings("unused") @SuppressWarnings("unused")
open class BaseTestRobot { open class BaseTestRobot {
fun goBack() = Espresso.pressBack()
fun fillEditText(resId: Int, text: String?): ViewInteraction = fun fillEditText(resId: Int, text: String?): ViewInteraction =
onView(withId(resId)).perform( onView(withId(resId)).perform(
ViewActions.replaceText(text), 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 android.os.Build
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room import androidx.room.Room
import androidx.room.util.UUIDUtil
import androidx.test.platform.app.InstrumentationRegistry 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.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.Converter 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.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem 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.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.test.BuildConfig
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.Assert.* import org.junit.Assert.*
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.mock
import java.util.UUID 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.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.ui.MainActivity
import com.appttude.h_mal.atlas_weather.utils.Stubs 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 com.appttude.h_mal.monoWeather.robot.weatherScreen
import org.junit.Test import org.junit.Test
@@ -11,6 +13,7 @@ class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
override fun beforeLaunch() { override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric) stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
clearPrefs()
} }
@Test @Test
@@ -21,4 +24,25 @@ class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
verifyCurrentLocation("Mock Location") 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 package com.appttude.h_mal.atlas_weather.application
import android.app.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.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider 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 { RepositoryImpl(instance(), instance(), instance()) }
bind() from singleton { SettingsRepositoryImpl(instance()) } bind() from singleton { SettingsRepositoryImpl(instance()) }
bind() from singleton { ServicesHelper(instance(), instance(), 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 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.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.appttude.h_mal.atlas_weather.model.ViewState 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>() private val _uiState = MutableLiveData<ViewState>()
val uiState: LiveData<ViewState> = _uiState val uiState: LiveData<ViewState> = _uiState
@@ -22,4 +27,15 @@ open class BaseViewModel: ViewModel() {
protected fun <E : Any> onError(error: E) { protected fun <E : Any> onError(error: E) {
_uiState.postValue(ViewState.HasError(error)) _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 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 import com.appttude.h_mal.atlas_weather.model.types.LocationType
interface LocationProvider { interface LocationProvider {
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
suspend fun getCurrentLatLong(): Pair<Double, Double> suspend fun getCurrentLatLong(): Pair<Double, Double>
fun getLatLongFromLocationName(location: String): Pair<Double, Double> fun getLatLongFromLocationName(location: String): Pair<Double, Double>
suspend fun getLocationNameFromLatLong( suspend fun getLocationNameFromLatLong(

View File

@@ -5,13 +5,8 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.location.Geocoder import android.location.Geocoder
import android.location.Location import android.location.Location
import android.location.LocationManager
import android.os.HandlerThread
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import com.appttude.h_mal.atlas_weather.model.types.LocationType 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.LocationServices
import com.google.android.gms.location.Priority import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationToken import com.google.android.gms.tasks.CancellationToken
@@ -19,21 +14,23 @@ import com.google.android.gms.tasks.OnTokenCanceledListener
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LocationProviderImpl( class LocationProviderImpl(
private val applicationContext: Context private val applicationContext: Context
) : LocationProvider, LocationHelper(applicationContext) { ) : LocationProvider, LocationHelper(applicationContext) {
private var locationManager =
applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager?
private val client = LocationServices.getFusedLocationProviderClient(applicationContext) private val client = LocationServices.getFusedLocationProviderClient(applicationContext)
private val geoCoder: Geocoder by lazy { Geocoder(applicationContext, Locale.getDefault()) } private val geoCoder: Geocoder by lazy { Geocoder(applicationContext, Locale.getDefault()) }
@RequiresPermission(value = ACCESS_COARSE_LOCATION) @RequiresPermission(value = ACCESS_COARSE_LOCATION)
override suspend fun getCurrentLatLong(): Pair<Double, Double> { 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") return location?.getLatLonPair() ?: throw IOException("Unable to get location")
} }
@@ -68,57 +65,16 @@ class LocationProviderImpl(
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private suspend fun getAFreshLocation(): Location? { private suspend fun getAFreshLocation(): Location? {
return client.getCurrentLocation(Priority.PRIORITY_LOW_POWER, object : CancellationToken() { return client.getCurrentLocation(
override fun isCancellationRequested(): Boolean = false Priority.PRIORITY_HIGH_ACCURACY,
override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken = this cancellationToken
}).await() ).await()
} }
@SuppressLint("MissingPermission") private val cancellationToken = object : CancellationToken() {
private suspend fun requestFreshLocation(): Location? { override fun isCancellationRequested(): Boolean = false
val handlerThread = HandlerThread("MyHandlerThread") override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken =
handlerThread.start() this
// 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 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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION 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 * Shared preferences to save & load last timestamp
*/ */
const val LOCATION_CONST = "location_" const val LOCATION_CONST = "location_"
const val UNIT_CONST = "UnitType"
class PreferenceProvider( class PreferenceProvider(
context: Context context: Context
@@ -30,7 +33,7 @@ class PreferenceProvider(
return preference.getLong(locationName, 0L) return preference.getLong(locationName, 0L)
} }
fun getAllKeys() = preference.all.keys.apply { fun getAllKeysExcludingCurrent() = preference.all.keys.apply {
remove(CURRENT_LOCATION) remove(CURRENT_LOCATION)
} }
@@ -50,4 +53,18 @@ class PreferenceProvider(
return preference.getBoolean("widget_black_background", false) 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 androidx.lifecycle.LiveData
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse 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.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.UnitType
interface Repository { interface Repository {
@@ -18,4 +19,5 @@ interface Repository {
suspend fun deleteSavedWeatherEntry(locationName: String): Boolean suspend fun deleteSavedWeatherEntry(locationName: String): Boolean
fun getSavedLocations(): List<String> fun getSavedLocations(): List<String>
suspend fun getSingleWeather(locationName: String): EntityItem 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.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase 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.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.UnitType
import com.appttude.h_mal.atlas_weather.utils.FALLBACK_TIME import com.appttude.h_mal.atlas_weather.utils.FALLBACK_TIME
@@ -20,7 +21,7 @@ class RepositoryImpl(
lat: String, lat: String,
long: String long: String
): WeatherResponse { ): WeatherResponse {
return responseUnwrap { api.getFromApi(lat, long) } return responseUnwrap { api.getFromApi(lat, long, units = prefs.getUnitsType().name.lowercase()) }
} }
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) { override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
@@ -65,11 +66,15 @@ class RepositoryImpl(
} }
override fun getSavedLocations(): List<String> { override fun getSavedLocations(): List<String> {
return prefs.getAllKeys().toList() return prefs.getAllKeysExcludingCurrent().toList()
} }
override suspend fun getSingleWeather(locationName: String): EntityItem { override suspend fun getSingleWeather(locationName: String): EntityItem {
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName) 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 package com.appttude.h_mal.atlas_weather.data.repository
import com.appttude.h_mal.atlas_weather.model.types.UnitType
interface SettingsRepository { interface SettingsRepository {
fun isNotificationsEnabled(): Boolean fun isNotificationsEnabled(): Boolean
fun setFirstTime() fun setFirstTime()
fun getFirstTime(): Boolean fun getFirstTime(): Boolean
fun isBlackBackground(): 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 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.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.model.types.UnitType
class SettingsRepositoryImpl( class SettingsRepositoryImpl(
private val prefs: PreferenceProvider private val prefs: PreferenceProvider
@@ -12,4 +13,13 @@ class SettingsRepositoryImpl(
override fun getFirstTime(): Boolean = prefs.getFirstTimeRun() override fun getFirstTime(): Boolean = prefs.getFirstTimeRun()
override fun isBlackBackground() = prefs.isWidgetBackground() 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.Color
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.annotation.RequiresPermission 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.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.repository.Repository 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.repository.SettingsRepository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION 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.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.weather.FullWeather
import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetCellData 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.InnerWidgetData
import com.appttude.h_mal.atlas_weather.model.widget.WidgetData 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.model.widget.WidgetWeatherCollection
import com.appttude.h_mal.atlas_weather.utils.toSmallDayName import com.appttude.h_mal.atlas_weather.utils.toSmallDayName
import com.squareup.picasso.Picasso 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? { suspend fun getWidgetWeather(): WidgetData? {
return try { return try {
val result = repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION) 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? { private suspend fun getBitmapFromUrl(imageAddress: String?): Bitmap? {
return suspendCoroutine { cont -> return suspendCoroutine { cont ->
Picasso.get().load(imageAddress).into(object : Target { 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.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.AppBarConfiguration
@@ -10,7 +9,6 @@ import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController import androidx.navigation.ui.setupWithNavController
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.base.BaseActivity 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 com.google.android.material.bottomnavigation.BottomNavigationView
import kotlinx.android.synthetic.main.activity_main_navigation.toolbar import kotlinx.android.synthetic.main.activity_main_navigation.toolbar

View File

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

View File

@@ -2,24 +2,23 @@ package com.appttude.h_mal.atlas_weather.viewmodel
import android.Manifest import android.Manifest
import androidx.annotation.RequiresPermission 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.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.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.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainViewModel( class MainViewModel(
private val locationProvider: LocationProvider, private val locationProvider: LocationProvider,
private val repository: Repository private val weatherSource: WeatherSource
) : WeatherViewModel() { ) : BaseViewModel() {
init { init {
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { weatherSource.repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { w ->
it?.let { w?.let {
val weather = WeatherDisplay(it) val weather = WeatherDisplay(it)
onSuccess(weather) onSuccess(weather)
} }
@@ -29,27 +28,14 @@ class MainViewModel(
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION) @RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
fun fetchData() { fun fetchData() {
onStart() onStart()
CoroutineScope(Dispatchers.IO).launch { job = CoroutineScope(Dispatchers.IO).launch {
try { try {
// Has the search been conducted in the last 5 minutes // Get location
val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) { val latLong = locationProvider.getCurrentLatLong()
// Get location weatherSource.getWeather(latLon = latLong)
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)
} catch (e: Exception) { } 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 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.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.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.model.types.LocationType 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.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException import java.io.IOException
@@ -16,14 +18,12 @@ const val ALL_LOADED = "all_loaded"
class WorldViewModel( class WorldViewModel(
private val locationProvider: LocationProvider, private val locationProvider: LocationProvider,
private val repository: Repository private val weatherSource: WeatherSource
) : WeatherViewModel() { ) : BaseViewModel() {
private var currentLocation: String? = null private var currentLocation: String? = null
private val weatherListLiveData = repository.loadRoomWeatherLiveData()
init { init {
weatherListLiveData.observeForever { weatherSource.repository.loadRoomWeatherLiveData().observeForever {
val list = it.map { data -> val list = it.map { data ->
WeatherDisplay(data) WeatherDisplay(data)
} }
@@ -37,7 +37,7 @@ class WorldViewModel(
fun getSingleLocation(locationName: String) { fun getSingleLocation(locationName: String) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val entity = repository.getSingleWeather(locationName) val entity = weatherSource.repository.getSingleWeather(locationName)
val item = WeatherDisplay(entity) val item = WeatherDisplay(entity)
onSuccess(item) onSuccess(item)
} }
@@ -47,14 +47,8 @@ class WorldViewModel(
onStart() onStart()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val weatherEntity = if (repository.isSearchValid(locationName)) { searchWeatherForLocation(locationName)
createWeatherEntity(locationName)
} else {
repository.getSingleWeather(locationName)
}
onSuccess(Unit) onSuccess(Unit)
repository.saveCurrentWeatherToRoom(weatherEntity)
repository.saveLastSavedAt(weatherEntity.id)
} catch (e: IOException) { } catch (e: IOException) {
onError(e.message!!) onError(e.message!!)
} }
@@ -64,29 +58,20 @@ class WorldViewModel(
fun fetchDataForSingleLocationSearch(locationName: String) { fun fetchDataForSingleLocationSearch(locationName: String) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
onStart() onStart()
// Check if location exists // Check if location already exists
if (repository.getSavedLocations().contains(locationName)) { if (weatherSource.repository.getSavedLocations().contains(locationName)) {
onError("$locationName already exists") onError("$locationName already exists")
return@launch return@launch
} }
try { try {
// Get weather from api val weather = searchWeatherForLocation(locationName)
val entityItem = createWeatherEntity(locationName) val retrievedLocation = weather.locationString
// Check if location exists in stored
// retrieved location name if (weatherSource.repository.getSavedLocations().contains(retrievedLocation)) {
val retrievedLocation = locationProvider.getLocationNameFromLatLong(
entityItem.weather.lat,
entityItem.weather.lon,
LocationType.City
)
if (repository.getSavedLocations().contains(retrievedLocation)) {
onError("$retrievedLocation already exists") onError("$retrievedLocation already exists")
return@launch return@launch
} }
// Save data if not null
repository.saveCurrentWeatherToRoom(entityItem)
repository.saveLastSavedAt(retrievedLocation)
onSuccess("$retrievedLocation saved") onSuccess("$retrievedLocation saved")
} catch (e: IOException) { } catch (e: IOException) {
onError(e.message!!) onError(e.message!!)
@@ -96,30 +81,24 @@ class WorldViewModel(
fun fetchAllLocations() { fun fetchAllLocations() {
onStart() onStart()
if (!repository.isSearchValid(ALL_LOADED)) { if (!weatherSource.repository.isSearchValid(ALL_LOADED)) {
onSuccess(Unit) onSuccess(Unit)
return return
} }
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val list = mutableListOf<EntityItem>() val list = mutableListOf<Deferred<FullWeather>>()
repository.loadWeatherList().forEach { locationName -> weatherSource.repository.loadWeatherList().forEach { locationName ->
// If search not valid move onto next in loop // If search not valid move onto next in loop
if (!repository.isSearchValid(locationName)) return@forEach if (!weatherSource.repository.isSearchValid(locationName)) return@forEach
try { val task = async{ searchWeatherForLocation(locationName) }
val entity = createWeatherEntity(locationName) list.add(task)
list.add(entity)
repository.saveLastSavedAt(locationName)
} catch (e: IOException) {
}
} }
repository.saveWeatherListToRoom(list) list.awaitAll()
repository.saveLastSavedAt(ALL_LOADED) onSuccess(Unit)
} catch (e: IOException) { } catch (e: IOException) {
onError(e.message!!) onError(e.message!!)
} finally {
onSuccess(Unit)
} }
} }
} }
@@ -128,34 +107,29 @@ class WorldViewModel(
onStart() onStart()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val success = repository.deleteSavedWeatherEntry(locationName) val success = weatherSource.repository.deleteSavedWeatherEntry(locationName)
if (!success) { if (!success) {
onError("Failed to delete") onError("Failed to delete")
} else {
onSuccess(Unit)
} }
} catch (e: IOException) { } catch (e: IOException) {
onError(e.message!!) onError(e.message!!)
} finally {
onSuccess(Unit)
} }
} }
} }
private suspend fun getWeather(locationName: String): WeatherResponse { private suspend fun searchWeatherForLocation(locationName: String): FullWeather {
// Get location // Get location
val latLong = val latLong =
locationProvider.getLatLongFromLocationName(locationName) locationProvider.getLatLongFromLocationName(locationName)
val lat = latLong.first // Search for location from provider (provider location name maybe different from #locationName)
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)
val location = val location =
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon, LocationType.City) locationProvider.getLocationNameFromLatLong(
val fullWeather = createFullWeather(weather, location) latLong.first,
return createWeatherEntity(location, fullWeather) 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 appWidgetManager: AppWidgetManager
lateinit var appWidgetIds: IntArray lateinit var appWidgetIds: IntArray
fun initBaseWidget(componentName: ComponentName) { fun initializeWidgetData(componentName: ComponentName) {
appWidgetManager = AppWidgetManager.getInstance(baseContext) appWidgetManager = AppWidgetManager.getInstance(baseContext)
appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
} }

View File

@@ -1,25 +1,21 @@
package com.appttude.h_mal.atlas_weather.widget package com.appttude.h_mal.atlas_weather.widget
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.icu.text.SimpleDateFormat import android.icu.text.SimpleDateFormat
import android.os.PowerManager
import android.widget.RemoteViews 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.R
import com.appttude.h_mal.atlas_weather.helper.ServicesHelper 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.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.model.widget.WidgetWeatherCollection
import com.appttude.h_mal.atlas_weather.ui.MainActivity 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.*
import com.appttude.h_mal.atlas_weather.widget.WidgetState.Companion.getWidgetState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,61 +42,33 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass<NewAppWidget>() {
executeWidgetUpdate() executeWidgetUpdate()
} }
@SuppressLint("MissingPermission")
private fun executeWidgetUpdate() { private fun executeWidgetUpdate() {
val componentName = ComponentName(this, NewAppWidget::class.java) 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 { CoroutineScope(Dispatchers.IO).launch {
val result = getWidgetWeather(fromStorage) setLoadingView()
appWidgetIds.forEach { id -> setupView(id, result) }
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( private fun setupView(
appWidgetId: Int, 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( override fun bindErrorView(
widgetId: Int, widgetId: Int,
views: RemoteViews, 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="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="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="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> </resources>

View File

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

View File

@@ -1,46 +1,24 @@
package com.appttude.h_mal.monoWeather.ui.settings 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.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?) { override fun preferenceChanged(key: String) {
setPreferencesFromResource(R.xml.prefs_screen, rootKey) when (key) {
"UnitType" -> viewModel.refreshWeatherData()
//listener on changed sort order preference: "widget_black_background" -> {
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) viewModel.updateWidget()
prefs.registerOnSharedPreferenceChangeListener { _, key -> displayToast("Widget background has been updates")
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 onSuccess(data: Any?) {
super.onSuccess(data)
if (data is String) displayToast(data)
}
} }

View File

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

View File

@@ -1,10 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> <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"> <PreferenceCategory android:title="Widget Settings">
<SwitchPreference <SwitchPreference
android:defaultValue="false" android:defaultValue="false"
android:key="widget_black_background" android:key="@string/widget_black_background"
android:title="Set widget background black" /> android:title="Set widget background black" />
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </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.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase 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.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.appttude.h_mal.atlas_weather.utils.BaseTest
import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doAnswer
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import retrofit2.Response
import java.io.IOException import java.io.IOException
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
@@ -94,6 +90,7 @@ class RepositoryImplTest : BaseTest() {
//Act //Act
//create a successful retrofit response //create a successful retrofit response
every { prefs.getUnitsType() } returns (UnitType.METRIC)
coEvery { api.getFromApi("", "") }.returns(mockResponse) coEvery { api.getFromApi("", "") }.returns(mockResponse)
// Assert // Assert
@@ -110,6 +107,7 @@ class RepositoryImplTest : BaseTest() {
//Act //Act
//create a successful retrofit response //create a successful retrofit response
every { prefs.getUnitsType() } returns (UnitType.METRIC)
coEvery { api.getFromApi(any(), any()) } returns (mockResponse) coEvery { api.getFromApi(any(), any()) } returns (mockResponse)
// Assert // Assert

View File

@@ -1,26 +1,31 @@
package com.appttude.h_mal.atlas_weather.viewmodel package com.appttude.h_mal.atlas_weather.viewmodel
import androidx.arch.core.executor.testing.InstantTaskExecutorRule 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.location.LocationProviderImpl
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse 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.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.ViewState
import com.appttude.h_mal.atlas_weather.model.types.LocationType 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.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.utils.BaseTest 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.getOrAwaitValue
import com.appttude.h_mal.atlas_weather.utils.sleep import com.appttude.h_mal.atlas_weather.utils.sleep
import com.nhaarman.mockitokotlin2.any
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.mockito.ArgumentMatchers
import org.mockito.ArgumentMatchers.anyList
import org.mockito.ArgumentMatchers.anyString
import java.io.IOException import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
@@ -32,8 +37,8 @@ class WorldViewModelTest : BaseTest() {
@InjectMockKs @InjectMockKs
lateinit var viewModel: WorldViewModel lateinit var viewModel: WorldViewModel
@MockK(relaxed = true) @RelaxedMockK
lateinit var repository: Repository lateinit var weatherSource: WeatherSource
@MockK @MockK
lateinit var locationProvider: LocationProviderImpl lateinit var locationProvider: LocationProviderImpl
@@ -43,30 +48,19 @@ class WorldViewModelTest : BaseTest() {
@Before @Before
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java) weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
} }
@Test @Test
fun fetchDataForSingleLocation_validLocation_validReturn() { fun fetchDataForSingleLocation_validLocation_validReturn() {
// Arrange // Arrange
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply { val latlon = any<Pair<Double, Double>>()
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
// Act // Act
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair( coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
weatherResponse.lat, weatherResponse.lat,
weatherResponse.lon weatherResponse.lon
) )
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
coEvery { coEvery {
locationProvider.getLocationNameFromLatLong( locationProvider.getLocationNameFromLatLong(
weatherResponse.lat, weatherResponse.lat,
@@ -74,66 +68,209 @@ class WorldViewModelTest : BaseTest() {
LocationType.City LocationType.City
) )
}.returns(CURRENT_LOCATION) }.returns(CURRENT_LOCATION)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit coEvery {
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit weatherSource.getWeather(
latlon,
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION) CURRENT_LOCATION,
locationType = LocationType.City
)
} returns FullWeather(weatherResponse)
// Assert // Assert
viewModel.uiState.observeForever { viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
println(it.javaClass.name)
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()) assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
} }
@Test @Test
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() { fun deleteLocation_validLocations_validReturn() {
// Arrange // Arrange
val location = CURRENT_LOCATION val location = anyString()
// Act // Act
every { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } throws IOException("Unable to get location") coEvery { weatherSource.repository.deleteSavedWeatherEntry(location) } returns true
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
viewModel.fetchDataForSingleLocation(location)
// Assert // Assert
sleep(300) viewModel.deleteLocation(location)
assertIs<ViewState.HasError<*>>(viewModel.uiState.getOrAwaitValue())
}
@Test sleep(100)
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)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue()) assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
} }
} }