- mid commit

This commit is contained in:
2023-08-10 23:12:37 +01:00
parent ffa7edf25d
commit 9aaf98a655
56 changed files with 1420 additions and 682 deletions

View File

@@ -42,6 +42,32 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="-1721686438">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1578868619">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-409920851"> <entry key="-409920851">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@@ -55,6 +81,32 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="108569748">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="110413981">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="170536241"> <entry key="170536241">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@@ -94,6 +146,19 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="721647317">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1127175145"> <entry key="1127175145">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@@ -120,6 +185,19 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="1440597283">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map> </map>
</option> </option>
</component> </component>

View File

@@ -0,0 +1,345 @@
{
"lat": 51.51,
"lon": -0.13,
"timezone": "Europe/London",
"timezone_offset": 3600,
"current": {
"dt": 1691537401,
"sunrise": 1691555756,
"sunset": 1691609779,
"temp": 58.57,
"feels_like": 58.5,
"pressure": 1012,
"humidity": 93,
"dew_point": 56.55,
"uvi": 0,
"clouds": 100,
"visibility": 10000,
"wind_speed": 5.75,
"wind_deg": 280,
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04n"
}
]
},
"daily": [
{
"dt": 1691582400,
"sunrise": 1691555756,
"sunset": 1691609779,
"moonrise": 1691620920,
"moonset": 1691592600,
"moon_phase": 0.78,
"temp": {
"day": 71.08,
"min": 54.81,
"max": 75.24,
"night": 65.26,
"eve": 74.3,
"morn": 55.85
},
"feels_like": {
"day": 70.14,
"night": 65.3,
"eve": 73.92,
"morn": 55.04
},
"pressure": 1018,
"humidity": 48,
"dew_point": 50.27,
"wind_speed": 5.03,
"wind_deg": 204,
"wind_gust": 11.03,
"weather": [
{
"id": 802,
"main": "Clouds",
"description": "scattered clouds",
"icon": "03d"
}
],
"clouds": 36,
"pop": 0,
"uvi": 6.76
},
{
"dt": 1691668800,
"sunrise": 1691642251,
"sunset": 1691696069,
"moonrise": 0,
"moonset": 1691683560,
"moon_phase": 0.82,
"temp": {
"day": 76.14,
"min": 61.27,
"max": 79.39,
"night": 68.88,
"eve": 76.66,
"morn": 62.64
},
"feels_like": {
"day": 75.94,
"night": 68.99,
"eve": 76.6,
"morn": 62.31
},
"pressure": 1019,
"humidity": 53,
"dew_point": 58.01,
"wind_speed": 8.57,
"wind_deg": 184,
"wind_gust": 15.05,
"weather": [
{
"id": 803,
"main": "Clouds",
"description": "broken clouds",
"icon": "04d"
}
],
"clouds": 68,
"pop": 0,
"uvi": 6.43
},
{
"dt": 1691755200,
"sunrise": 1691728746,
"sunset": 1691782357,
"moonrise": 1691709180,
"moonset": 1691773920,
"moon_phase": 0.85,
"temp": {
"day": 75.67,
"min": 63.64,
"max": 80.06,
"night": 68.27,
"eve": 74.07,
"morn": 65.26
},
"feels_like": {
"day": 75.9,
"night": 68.27,
"eve": 74.05,
"morn": 65.21
},
"pressure": 1018,
"humidity": 63,
"dew_point": 62.37,
"wind_speed": 11.59,
"wind_deg": 224,
"wind_gust": 19.64,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 0.32,
"rain": 0.12,
"uvi": 5.08
},
{
"dt": 1691841600,
"sunrise": 1691815241,
"sunset": 1691868645,
"moonrise": 1691798160,
"moonset": 1691863560,
"moon_phase": 0.88,
"temp": {
"day": 74.5,
"min": 63.07,
"max": 74.5,
"night": 63.86,
"eve": 70.03,
"morn": 63.07
},
"feels_like": {
"day": 73.53,
"night": 63.57,
"eve": 69.28,
"morn": 62.89
},
"pressure": 1014,
"humidity": 40,
"dew_point": 48.34,
"wind_speed": 14.63,
"wind_deg": 225,
"wind_gust": 22.73,
"weather": [
{
"id": 803,
"main": "Clouds",
"description": "broken clouds",
"icon": "04d"
}
],
"clouds": 67,
"pop": 0.07,
"uvi": 5.66
},
{
"dt": 1691928000,
"sunrise": 1691901736,
"sunset": 1691954930,
"moonrise": 1691887860,
"moonset": 1691952420,
"moon_phase": 0.91,
"temp": {
"day": 71.26,
"min": 61.68,
"max": 71.26,
"night": 63.75,
"eve": 68.59,
"morn": 61.68
},
"feels_like": {
"day": 70.52,
"night": 63.39,
"eve": 67.93,
"morn": 61.21
},
"pressure": 1012,
"humidity": 52,
"dew_point": 52.92,
"wind_speed": 12.08,
"wind_deg": 226,
"wind_gust": 20.87,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 99,
"pop": 0.22,
"rain": 0.16,
"uvi": 3.71
},
{
"dt": 1692014400,
"sunrise": 1691988232,
"sunset": 1692041215,
"moonrise": 1691978220,
"moonset": 1692040560,
"moon_phase": 0.94,
"temp": {
"day": 73.13,
"min": 60.35,
"max": 73.92,
"night": 63.91,
"eve": 69.6,
"morn": 60.82
},
"feels_like": {
"day": 72.12,
"night": 63.54,
"eve": 68.9,
"morn": 60.64
},
"pressure": 1013,
"humidity": 42,
"dew_point": 48.63,
"wind_speed": 12.35,
"wind_deg": 237,
"wind_gust": 16.15,
"weather": [
{
"id": 801,
"main": "Clouds",
"description": "few clouds",
"icon": "02d"
}
],
"clouds": 16,
"pop": 0.1,
"uvi": 4
},
{
"dt": 1692100800,
"sunrise": 1692074727,
"sunset": 1692127498,
"moonrise": 1692068820,
"moonset": 1692128220,
"moon_phase": 0.97,
"temp": {
"day": 71.24,
"min": 58.37,
"max": 71.24,
"night": 66.47,
"eve": 69.06,
"morn": 58.37
},
"feels_like": {
"day": 70.23,
"night": 65.5,
"eve": 68.16,
"morn": 57.61
},
"pressure": 1018,
"humidity": 46,
"dew_point": 49.62,
"wind_speed": 7.38,
"wind_deg": 261,
"wind_gust": 13.49,
"weather": [
{
"id": 801,
"main": "Clouds",
"description": "few clouds",
"icon": "02d"
}
],
"clouds": 23,
"pop": 0,
"uvi": 4
},
{
"dt": 1692187200,
"sunrise": 1692161223,
"sunset": 1692213780,
"moonrise": 1692159660,
"moonset": 1692215580,
"moon_phase": 0,
"temp": {
"day": 74.35,
"min": 60.22,
"max": 75.9,
"night": 66.33,
"eve": 71.44,
"morn": 60.69
},
"feels_like": {
"day": 73.42,
"night": 65.71,
"eve": 70.68,
"morn": 59.7
},
"pressure": 1018,
"humidity": 41,
"dew_point": 49.51,
"wind_speed": 7.92,
"wind_deg": 111,
"wind_gust": 14.92,
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04d"
}
],
"clouds": 91,
"pop": 0,
"uvi": 4
}
]
}

View File

@@ -0,0 +1,4 @@
{
"cod": "400",
"message": "wrong latitude"
}

View File

@@ -5,20 +5,28 @@ package com.appttude.h_mal.atlas_weather
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
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.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.matcher.ViewMatchers 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.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.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
@@ -63,8 +71,8 @@ open class BaseTest<A : Activity>(
afterLaunch() afterLaunch()
} }
fun stubEndpoint(url: String, stub: Stubs) { fun stubEndpoint(url: String, stub: Stubs, code: Int = 200) {
testApp.stubUrl(url, stub.id) testApp.stubUrl(url, stub.id, code)
} }
fun unstubEndpoint(url: String) { fun unstubEndpoint(url: String) {
@@ -91,4 +99,32 @@ open class BaseTest<A : Activity>(
} }
}) })
} }
@Suppress("DEPRECATION")
fun checkToastMessage(message: String) {
Espresso.onView(ViewMatchers.withText(message)).inRoot(object : BaseCustomMatcher<Root>() {
override fun describe(description: Description?) {
description?.appendText("is toast")
}
override fun matchesSafely(root: Root): Boolean {
root.run {
if (windowLayoutParams.get().type == WindowManager.LayoutParams.TYPE_TOAST) {
decorView.run {
if (windowToken === applicationWindowToken) {
// windowToken == appToken means this window isn't contained by any other windows.
// if it was a window for an activity, it would have TYPE_BASE_APPLICATION.
return true
}
}
}
}
return false
}
}
).check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
waitFor(3500)
}
}
} }

View File

@@ -3,6 +3,7 @@ package com.appttude.h_mal.atlas_weather.application
import androidx.room.Room import androidx.room.Room
import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.idling.CountingIdlingResource import androidx.test.espresso.idling.CountingIdlingResource
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
@@ -12,6 +13,8 @@ import com.appttude.h_mal.atlas_weather.data.network.interceptors.QueryParamsInt
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.loggingInterceptor import com.appttude.h_mal.atlas_weather.data.network.networkUtils.loggingInterceptor
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
import com.appttude.h_mal.atlas_weather.test.BuildConfig
import com.appttude.h_mal.atlas_weather.test.BuildConfig.APPLICATION_ID
import java.io.BufferedReader import java.io.BufferedReader
class TestAppClass : BaseAppClass() { class TestAppClass : BaseAppClass() {
@@ -40,11 +43,10 @@ class TestAppClass : BaseAppClass() {
.build() .build()
} }
fun stubUrl(url: String, rawPath: String) { fun stubUrl(url: String, rawPath: String, code: Int = 200) {
val id = resources.getIdentifier(rawPath, "raw", packageName) val iStream = InstrumentationRegistry.getInstrumentation().context.assets.open("$rawPath.json")
val iStream = resources.openRawResource(id)
val data = iStream.bufferedReader().use(BufferedReader::readText) val data = iStream.bufferedReader().use(BufferedReader::readText)
mockingNetworkInterceptor.addUrlStub(url = url, data = data) mockingNetworkInterceptor.addUrlStub(url = url, data = data, code = code)
} }
fun removeUrlStub(url: String) { fun removeUrlStub(url: String) {

View File

@@ -11,23 +11,21 @@ class MockingNetworkInterceptor(
private val idlingResource: CountingIdlingResource private val idlingResource: CountingIdlingResource
) : Interceptor { ) : Interceptor {
private var feedMap: MutableMap<String, String> = mutableMapOf() private var feedMap: MutableMap<String, Pair<String, Int>> = mutableMapOf()
private var urlCallTracker: MutableMap<String, Int> = mutableMapOf()
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
idlingResource.increment() idlingResource.increment()
val original = chain.request() val original = chain.request()
val originalHttpUrl = original.url.toString().split("?")[0] val originalHttpUrl = original.url.toString().split("?")[0]
urlCallTracker.computeIfPresent(originalHttpUrl) { _, j -> feedMap[originalHttpUrl]?.let { responsePair ->
j + 1 val code = responsePair.second
} val jsonBody = responsePair.first
feedMap[originalHttpUrl]?.let { jsonPath -> val body = jsonBody.toResponseBody("application/json".toMediaType())
val body = jsonPath.toResponseBody("application/json".toMediaType())
val chainResponseBuilder = Response.Builder() val chainResponseBuilder = Response.Builder()
.code(200) .code(code)
.protocol(Protocol.HTTP_1_1) .protocol(Protocol.HTTP_1_1)
.request(original) .request(original)
.message("OK") .message("OK")
@@ -40,7 +38,7 @@ class MockingNetworkInterceptor(
return chain.proceed(original) return chain.proceed(original)
} }
fun addUrlStub(url: String, data: String) = feedMap.put(url, data) fun addUrlStub(url: String, data: String, code: Int = 200) = feedMap.put(url, Pair(data, code))
fun removeUrlStub(url: String) = feedMap.remove(url) fun removeUrlStub(url: String) = feedMap.remove(url)
} }

View File

@@ -0,0 +1,12 @@
package com.appttude.h_mal.atlas_weather.helpers
import org.hamcrest.Description
import org.hamcrest.TypeSafeMatcher
open class BaseCustomMatcher<T: Any> : TypeSafeMatcher<T>() {
override fun describeTo(description: Description?) = describe(description)
override fun matchesSafely(item: T): Boolean = match(item)
open fun describe(description: Description?) { }
open fun match(actual: T): Boolean { return false }
}

View File

@@ -1,17 +0,0 @@
package com.appttude.h_mal.atlas_weather.helpers
import android.view.View
import org.hamcrest.BaseMatcher
import org.hamcrest.Description
class BaseMatcher : BaseMatcher<View>() {
override fun describeTo(description: Description?) {
TODO("Not yet implemented")
}
override fun matches(actual: Any?): Boolean {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,110 @@
package com.appttude.h_mal.atlas_weather.testSuite
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.util.UUIDUtil
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.Converter
import com.appttude.h_mal.atlas_weather.data.room.WeatherDao
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.*
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString
import java.util.UUID
class RoomDatabaseTests {
@get:Rule
@Suppress("unused")
var instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var db: AppDatabase
private lateinit var dao: WeatherDao
@Before
fun before() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.addTypeConverter(Converter(context))
.build()
dao = db.getWeatherDao()
}
@After
fun after() {
db.close()
}
@Test
fun whenInitializedThenListIsEmpty() {
assertTrue(dao.getAllFullWeatherWithoutCurrent().getOrAwaitValue().isEmpty())
}
@Test
fun whenElementAddedThenItemIsInDatabase() {
// Arrange
val item = createEntity()
// Act
dao.upsertFullWeather(item)
// Assert
assertEquals(item, dao.getCurrentFullWeather(item.id).getOrAwaitValue())
}
@Test
fun whenCurrentElementAddedThenLiveDataIsEmpty() {
// Arrange
val item = createEntity()
// Act
dao.upsertFullWeather(item)
// Assert
assertEquals(0, dao.getAllFullWeatherWithoutCurrent(CURRENT_LOCATION).getOrAwaitValue().size)
}
@Test
fun whenNewElementAddedThenLiveDataIsNot() {
// Arrange
val elements = createEntityList()
val id = elements.first().id
// Act
dao.upsertListOfFullWeather(elements)
// Assert
assertEquals(elements.size - 1, dao.getAllFullWeatherWithoutCurrent(id).getOrAwaitValue().size)
}
@Test
fun wheElementDeletedThenContainsIsFalse() {
// Arrange
val elements = createEntityList()
val id = elements.first().id
// Act
dao.upsertListOfFullWeather(elements)
dao.deleteEntry(id)
// Assert
assertEquals(elements.size - 1, dao.getAllFullWeatherWithoutCurrent(id).getOrAwaitValue().size)
assertNull(dao.getCurrentFullWeatherSingle(id))
}
private fun createEntity(id: String = CURRENT_LOCATION): EntityItem {
val weather = mockk<FullWeather>()
return EntityItem(id, weather)
}
private fun createEntityList(size: Int = 4): List<EntityItem> {
return (0.. size).map {
val id = UUID.randomUUID().toString()
createEntity(id)
}
}
}

View File

@@ -3,6 +3,8 @@ package com.appttude.h_mal.atlas_weather.utils
enum class Stubs( enum class Stubs(
val id: String val id: String
) { ) {
Valid("valid_response"), Metric("valid_response_metric"),
Invalid("invalid_response") Imperial("valid_response_imperial"),
WrongLocation("wrong_location_response"),
InvalidKey("invalid_api_key_response")
} }

View File

@@ -0,0 +1,32 @@
package com.appttude.h_mal.atlas_weather.utils
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}

View File

@@ -11,4 +11,8 @@ class HomeScreenRobot : BaseTestRobot() {
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location) fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
fun refresh() = pullToRefresh(R.id.swipe_refresh) fun refresh() = pullToRefresh(R.id.swipe_refresh)
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4) fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
fun verifyUnableToRetrieve() {
matchText(R.id.header_text, R.string.retrieve_warning)
matchText(R.id.body_text, R.string.empty_retrieve_warning)
}
} }

View File

@@ -0,0 +1,39 @@
package com.appttude.h_mal.atlas_weather.tests
import com.appttude.h_mal.atlas_weather.BaseTest
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.robot.homeScreen
import org.junit.Test
class HomePageNoDataUITest : BaseTest<MainActivity>(MainActivity::class.java) {
override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.InvalidKey, 400)
}
@Test
fun loadApp_invalidKeyWeatherResponse_returnsEmptyViewPage() {
homeScreen {
// verify empty
verifyUnableToRetrieve()
// verify toast
checkToastMessage("Invalid API key. Please see http://openweathermap.org/faq#error401 for more info.")
}
}
@Test
fun invalidKeyWeatherResponse_swipeToRefresh_returnsValidPage() {
homeScreen {
// verify empty
verifyUnableToRetrieve()
// verify toast
checkToastMessage("Invalid API key. Please see http://openweathermap.org/faq#error401 for more info.")
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
refresh()
verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location")
}
}
}

View File

@@ -10,7 +10,7 @@ import org.junit.Test
class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) { class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
override fun beforeLaunch() { override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid) stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
} }
@Test @Test

View File

@@ -0,0 +1,23 @@
package com.appttude.h_mal.monoWeather.robot
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.withId
import com.appttude.h_mal.atlas_weather.BaseTestRobot
import com.appttude.h_mal.atlas_weather.R
fun container(func: ContainerRobot.() -> Unit) = ContainerRobot().apply { func() }
class ContainerRobot : BaseTestRobot() {
fun tapTabInBottomBar(tab: Tab) {
when (tab) {
Tab.HOME -> Espresso.onView(withId(R.id.nav_world))
Tab.WORLD -> Espresso.onView(withId(R.id.nav_home))
}.perform(click())
}
enum class Tab {
HOME,
WORLD
}
}

View File

@@ -11,4 +11,9 @@ class HomeScreenRobot : BaseTestRobot() {
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location) fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
fun refresh() = pullToRefresh(R.id.swipe_refresh) fun refresh() = pullToRefresh(R.id.swipe_refresh)
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4) fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
fun verifyUnableToRetrieve() {
matchText(R.id.header_text, R.string.retrieve_warning)
matchText(R.id.body_text, R.string.empty_retrieve_warning)
}
} }

View File

@@ -0,0 +1,39 @@
package com.appttude.h_mal.monoWeather.tests
import com.appttude.h_mal.atlas_weather.utils.Stubs
import com.appttude.h_mal.monoWeather.MonoBaseTest
import com.appttude.h_mal.monoWeather.robot.homeScreen
import org.junit.Test
class HomePageNoDataUITest : MonoBaseTest() {
override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.InvalidKey, 400)
}
@Test
fun loadApp_invalidKeyWeatherResponse_returnsEmptyViewPage() {
homeScreen {
// verify empty
verifyUnableToRetrieve()
// verify toast
checkToastMessage("Invalid API key. Please see http://openweathermap.org/faq#error401 for more info.")
}
}
@Test
fun invalidKeyWeatherResponse_swipeToRefresh_returnsValidPage() {
homeScreen {
// verify empty
verifyUnableToRetrieve()
// verify toast
checkToastMessage("Invalid API key. Please see http://openweathermap.org/faq#error401 for more info.")
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
refresh()
verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location")
}
}
}

View File

@@ -9,7 +9,7 @@ import org.junit.Test
class HomePageUITest : MonoBaseTest() { class HomePageUITest : MonoBaseTest() {
override fun beforeLaunch() { override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid) stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
} }
@Test @Test

View File

@@ -0,0 +1,29 @@
package com.appttude.h_mal.monoWeather.tests
import com.appttude.h_mal.atlas_weather.utils.Stubs
import com.appttude.h_mal.monoWeather.MonoBaseTest
import com.appttude.h_mal.monoWeather.robot.ContainerRobot
import com.appttude.h_mal.monoWeather.robot.ContainerRobot.Tab.WORLD
import com.appttude.h_mal.monoWeather.robot.container
import com.appttude.h_mal.monoWeather.robot.homeScreen
import org.junit.Test
class WorldPageUITest : MonoBaseTest() {
// override fun beforeLaunch() {
// stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
// }
//
// @Test
// fun loadApp_addNewLocation_returnsValidPage() {
// container {
// tapTabInBottomBar(WORLD)
// }
// homeScreen {
// isDisplayed()
// verifyCurrentTemperature(2)
// verifyCurrentLocation("Mock Location")
// }
// }
}

View File

@@ -1,6 +1,5 @@
package com.appttude.h_mal.atlas_weather.ui.home package com.appttude.h_mal.atlas_weather.ui.home
import android.Manifest
import android.Manifest.permission.ACCESS_COARSE_LOCATION import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
@@ -9,39 +8,33 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.Navigation.findNavController import androidx.navigation.Navigation.findNavController
import androidx.navigation.ui.onNavDestinationSelected import androidx.navigation.ui.onNavDestinationSelected
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
import com.appttude.h_mal.atlas_weather.ui.dialog.PermissionsDeclarationDialog import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.ui.home.adapter.WeatherRecyclerAdapter import com.appttude.h_mal.atlas_weather.ui.home.adapter.WeatherRecyclerAdapter
import com.appttude.h_mal.atlas_weather.utils.navigateTo import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment
import kotlinx.android.synthetic.main.fragment_home.* import kotlinx.android.synthetic.main.fragment_home.*
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.kodein
import org.kodein.di.generic.instance
/** /**
* A simple [Fragment] subclass. * A simple [Fragment] subclass.
* create an instance of this fragment. * create an instance of this fragment.
*/ */
class HomeFragment : BaseFragment(R.layout.fragment_home) { class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
private val viewModel by getFragmentViewModel<MainViewModel>() private lateinit var recyclerAdapter: WeatherRecyclerAdapter
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = { recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
navigateToFurtherDetails(it) navigateToFurtherDetails(it)
}) })
@@ -58,23 +51,17 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
} }
} }
} }
}
viewModel.weatherLiveData.observe(viewLifecycleOwner) { override fun onFailure(error: Any?) {
recyclerAdapter.addCurrent(it) swipe_refresh.isRefreshing = false
}
override fun onSuccess(data: Any?) {
swipe_refresh.isRefreshing = false
if (data is WeatherDisplay) {
recyclerAdapter.addCurrent(data)
} }
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
viewModel.operationRefresh.observe(viewLifecycleOwner) { it ->
it.getContentIfNotHandled()?.let {
swipe_refresh.isRefreshing = false
}
}
viewModel.operationState.observe(viewLifecycleOwner) {
swipe_refresh.isRefreshing = false
}
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")

View File

@@ -1,26 +1,17 @@
package com.appttude.h_mal.atlas_weather.ui.world package com.appttude.h_mal.atlas_weather.ui.world
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.utils.displayToast import com.appttude.h_mal.atlas_weather.utils.displayToast
import com.appttude.h_mal.atlas_weather.utils.goBack import com.appttude.h_mal.atlas_weather.utils.goBack
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
import kotlinx.android.synthetic.main.activity_add_forecast.* import kotlinx.android.synthetic.main.activity_add_forecast.submit
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.kodein
import org.kodein.di.generic.instance
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) { class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
private val viewModel by getFragmentViewModel<WorldViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -31,16 +22,13 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
submit.error = "Location cannot be blank" submit.error = "Location cannot be blank"
return@setOnClickListener return@setOnClickListener
} }
viewModel.fetchDataForSingleLocation(locationName) viewModel.fetchDataForSingleLocationSearch(locationName)
} }
}
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar)) override fun onSuccess(data: Any?) {
viewModel.operationError.observe(viewLifecycleOwner, errorObserver()) if (data is String) {
displayToast(data)
viewModel.operationComplete.observe(viewLifecycleOwner) {
it?.getContentIfNotHandled()?.let { message ->
displayToast(message)
}
goBack() goBack()
} }
} }

View File

@@ -3,14 +3,13 @@ package com.appttude.h_mal.atlas_weather.ui.world
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.utils.navigateTo import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.floatingActionButton import kotlinx.android.synthetic.atlasWeather.fragment_add_location.floatingActionButton
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.progressBar2
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycler import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycler
@@ -18,13 +17,13 @@ import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycl
* A simple [Fragment] subclass. * A simple [Fragment] subclass.
* create an instance of this fragment. * create an instance of this fragment.
*/ */
class WorldFragment : BaseFragment(R.layout.fragment_add_location) { class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment_add_location) {
val viewModel by getFragmentViewModel<WorldViewModel>() private lateinit var recyclerAdapter: WorldRecyclerAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val recyclerAdapter = WorldRecyclerAdapter { recyclerAdapter = WorldRecyclerAdapter {
val direction = val direction =
WorldFragmentDirections.actionWorldFragmentToWorldItemFragment(it) WorldFragmentDirections.actionWorldFragmentToWorldItemFragment(it)
navigateTo(direction) navigateTo(direction)
@@ -35,22 +34,18 @@ class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
adapter = recyclerAdapter adapter = recyclerAdapter
} }
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
recyclerAdapter.addCurrent(it)
}
floatingActionButton.setOnClickListener { floatingActionButton.setOnClickListener {
navigateTo(R.id.action_worldFragment_to_addLocationFragment) navigateTo(R.id.action_worldFragment_to_addLocationFragment)
} }
}
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar2)) override fun onSuccess(data: Any?) {
viewModel.operationError.observe(viewLifecycleOwner, errorObserver()) super.onSuccess(data)
if (data is List<*>) recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
viewModel.fetchAllLocations() viewModel.fetchAllLocations()
} }

View File

@@ -0,0 +1,91 @@
package com.appttude.h_mal.atlas_weather.base
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.inflate
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelLazy
import com.appttude.h_mal.atlas_weather.R
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.utils.displayToast
import com.appttude.h_mal.atlas_weather.utils.hide
import com.appttude.h_mal.atlas_weather.utils.show
import com.appttude.h_mal.atlas_weather.utils.triggerAnimation
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.BaseViewModel
import org.kodein.di.KodeinAware
import org.kodein.di.android.kodein
import org.kodein.di.generic.instance
abstract class BaseActivity : AppCompatActivity(), KodeinAware {
private lateinit var loadingView: View
override val kodein by kodein()
/**
* Creates a loading view which to be shown during async operations
*
* #setOnClickListener(null) is an ugly work around to prevent under being clicked during
* loading
*/
private fun instantiateLoadingView() {
loadingView = inflate(this, R.layout.progress_layout, null)
loadingView.setOnClickListener(null)
addContentView(loadingView, LayoutParams(MATCH_PARENT, MATCH_PARENT))
loadingView.hide()
}
override fun onStart() {
super.onStart()
instantiateLoadingView()
}
fun <A : AppCompatActivity> startActivity(activity: Class<A>) {
val intent = Intent(this, activity)
startActivity(intent)
}
/**
* Called in case of success or some data emitted from the liveData in viewModel
*/
open fun onStarted() {
loadingView.fadeIn()
}
/**
* Called in case of success or some data emitted from the liveData in viewModel
*/
open fun onSuccess(data: Any?) {
loadingView.fadeOut()
}
/**
* Called in case of failure or some error emitted from the liveData in viewModel
*/
open fun onFailure(error: Any?) {
if (error is String) displayToast(error)
loadingView.fadeOut()
}
private fun View.fadeIn() = apply {
show()
triggerAnimation(R.anim.nav_default_enter_anim) {}
}
private fun View.fadeOut() = apply {
hide()
triggerAnimation(R.anim.nav_default_exit_anim) {}
}
override fun onBackPressed() {
loadingView.hide()
super.onBackPressed()
}
}

View File

@@ -24,33 +24,32 @@ class RepositoryImpl(
} }
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) { override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
db.getSimpleDao().upsertFullWeather(entityItem) db.getWeatherDao().upsertFullWeather(entityItem)
} }
override suspend fun saveWeatherListToRoom( override suspend fun saveWeatherListToRoom(
list: List<EntityItem> list: List<EntityItem>
) { ) {
db.getSimpleDao().upsertListOfFullWeather(list) db.getWeatherDao().upsertListOfFullWeather(list)
} }
override fun loadRoomWeatherLiveData() = db.getSimpleDao().getAllFullWeatherWithoutCurrent() override fun loadRoomWeatherLiveData() = db.getWeatherDao().getAllFullWeatherWithoutCurrent()
override suspend fun loadWeatherList(): List<String> { override suspend fun loadWeatherList(): List<String> {
return db.getSimpleDao() return db.getWeatherDao()
.getWeatherListWithoutCurrent() .getWeatherListWithoutCurrent()
.map { it.id } .map { it.id }
} }
override fun loadCurrentWeatherFromRoom(id: String) = override fun loadCurrentWeatherFromRoom(id: String) =
db.getSimpleDao().getCurrentFullWeather(id) db.getWeatherDao().getCurrentFullWeather(id)
override suspend fun loadSingleCurrentWeatherFromRoom(id: String) = override suspend fun loadSingleCurrentWeatherFromRoom(id: String) =
db.getSimpleDao().getCurrentFullWeatherSingle(id) db.getWeatherDao().getCurrentFullWeatherSingle(id)
override fun isSearchValid(locationName: String): Boolean { override fun isSearchValid(locationName: String): Boolean {
val lastSaved = prefs val lastSaved = prefs
.getLastSavedAt("$LOCATION_CONST$locationName") .getLastSavedAt("$LOCATION_CONST$locationName")
?: return true
val difference = System.currentTimeMillis() - lastSaved val difference = System.currentTimeMillis() - lastSaved
return difference > FALLBACK_TIME return difference > FALLBACK_TIME
@@ -62,7 +61,7 @@ class RepositoryImpl(
override suspend fun deleteSavedWeatherEntry(locationName: String): Boolean { override suspend fun deleteSavedWeatherEntry(locationName: String): Boolean {
prefs.deleteLocation(locationName) prefs.deleteLocation(locationName)
return db.getSimpleDao().deleteEntry(locationName) > 0 return db.getWeatherDao().deleteEntry(locationName) > 0
} }
override fun getSavedLocations(): List<String> { override fun getSavedLocations(): List<String> {
@@ -70,7 +69,7 @@ class RepositoryImpl(
} }
override suspend fun getSingleWeather(locationName: String): EntityItem { override suspend fun getSingleWeather(locationName: String): EntityItem {
return db.getSimpleDao().getCurrentFullWeatherSingle(locationName) return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
} }
} }

View File

@@ -15,7 +15,7 @@ import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
@TypeConverters(Converter::class) @TypeConverters(Converter::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun getSimpleDao(): WeatherDao abstract fun getWeatherDao(): WeatherDao
companion object { companion object {

View File

@@ -11,6 +11,13 @@ object GenericsHelper {
?.kotlin ?.kotlin
?: throw IllegalStateException("Can not find class from generic argument") ?: throw IllegalStateException("Can not find class from generic argument")
// @Suppress("UNCHECKED_CAST")
// fun <CLASS : Any> Any.getGenericClassInMethod(position: Int): KClass<CLASS> =
// ((javaClass.methods as? ParameterizedType)
// ?.actualTypeArguments?.getOrNull(position) as? Class<CLASS>)
// ?.kotlin
// ?: throw IllegalStateException("Can not find class from generic argument")
// /** // /**
// * Create a view binding out of the the generic [VB] // * Create a view binding out of the the generic [VB]
// * // *

View File

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

View File

@@ -1,7 +1,5 @@
package com.appttude.h_mal.monoWeather.ui package com.appttude.h_mal.atlas_weather.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
@@ -9,16 +7,17 @@ import android.view.View
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.createViewModelLazy
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
import com.appttude.h_mal.atlas_weather.base.BaseActivity
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.utils.Event import com.appttude.h_mal.atlas_weather.utils.Event
import com.appttude.h_mal.atlas_weather.utils.displayToast import com.appttude.h_mal.atlas_weather.utils.displayToast
import com.appttude.h_mal.atlas_weather.utils.hide
import com.appttude.h_mal.atlas_weather.utils.show
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.BaseViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -28,13 +27,18 @@ import org.kodein.di.generic.instance
import kotlin.properties.Delegates import kotlin.properties.Delegates
@Suppress("EmptyMethod", "EmptyMethod") @Suppress("EmptyMethod", "EmptyMethod")
abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId), abstract class BaseFragment<V : BaseViewModel>(@LayoutRes contentLayoutId: Int) :
Fragment(contentLayoutId),
KodeinAware { KodeinAware {
override val kodein by kodein() override val kodein by kodein()
val factory by instance<ApplicationViewModelFactory>() val factory by instance<ApplicationViewModelFactory>()
inline fun <reified VM : ViewModel> getFragmentViewModel(): Lazy<VM> = viewModels { factory } 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>() private var shortAnimationDuration by Delegates.notNull<Int>()
@@ -43,25 +47,42 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime) shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
} }
// toggle visibility of progress spinner while async operations are taking place @Suppress("UNCHECKED_CAST")
fun progressBarStateObserver(progressBar: View) = Observer<Event<Boolean>> { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
it.getContentIfNotHandled()?.let { i -> super.onViewCreated(view, savedInstanceState)
if (i) mActivity = activity as BaseActivity
progressBar.fadeIn() configureObserver()
else }
progressBar.fadeOut()
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)
}
} }
} }
// display a toast when operation fails /**
fun errorObserver() = Observer<Event<String>> { * Called in case of starting operation liveData in viewModel
it.getContentIfNotHandled()?.let { message -> */
displayToast(message) open fun onStarted() {
} mActivity?.onStarted()
} }
fun refreshObserver(refresher: SwipeRefreshLayout) = Observer<Event<Boolean>> { /**
refresher.isRefreshing = false * 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)
} }
/** /**
@@ -90,46 +111,6 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
} }
} }
private fun View.fadeIn() {
apply {
// Set the content view to 0% opacity but visible, so that it is visible
// (but fully transparent) during the animation.
alpha = 0f
hide()
// Animate the content view to 100% opacity, and clear any animation
// listener set on the view.
animate()
.alpha(1f)
.setDuration(shortAnimationDuration.toLong())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
show()
}
})
}
}
private fun View.fadeOut() {
apply {
// Set the content view to 0% opacity but visible, so that it is visible
// (but fully transparent) during the animation.
alpha = 1f
show()
// Animate the content view to 100% opacity, and clear any animation
// listener set on the view.
animate()
.alpha(0f)
.setDuration(shortAnimationDuration.toLong())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
hide()
}
})
}
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,

View File

@@ -9,10 +9,12 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController 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.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
class MainActivity : AppCompatActivity() { class MainActivity : BaseActivity() {
lateinit var navHost: NavHostFragment lateinit var navHost: NavHostFragment

View File

@@ -5,9 +5,12 @@ import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.ImageView import android.widget.ImageView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.AnimRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.squareup.picasso.Picasso import com.squareup.picasso.Picasso
@@ -42,4 +45,14 @@ fun ImageView.loadImage(url: String?) {
fun Fragment.hideKeyboard() { fun Fragment.hideKeyboard() {
val imm = context?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager? val imm = context?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager?
imm?.hideSoftInputFromWindow(view?.windowToken, 0) imm?.hideSoftInputFromWindow(view?.windowToken, 0)
}
fun View.triggerAnimation(@AnimRes id: Int, complete: (View) -> Unit) {
val animation = AnimationUtils.loadAnimation(context, id)
animation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) = complete(this@triggerAnimation)
override fun onAnimationStart(a: Animation?) {}
override fun onAnimationRepeat(a: Animation?) {}
})
startAnimation(animation)
} }

View File

@@ -2,13 +2,11 @@ package com.appttude.h_mal.atlas_weather.viewmodel
import android.Manifest import android.Manifest
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import androidx.lifecycle.MutableLiveData
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.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.forecast.WeatherDisplay import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.utils.Event
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -19,48 +17,40 @@ class MainViewModel(
private val repository: Repository private val repository: Repository
) : WeatherViewModel() { ) : WeatherViewModel() {
val weatherLiveData = MutableLiveData<WeatherDisplay>()
val operationState = MutableLiveData<Event<Boolean>>()
val operationError = MutableLiveData<Event<String>>()
val operationRefresh = MutableLiveData<Event<Boolean>>()
init { init {
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {
it?.let { it?.let {
val weather = WeatherDisplay(it) val weather = WeatherDisplay(it)
weatherLiveData.postValue(weather) onSuccess(weather)
} }
} }
} }
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION) @RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
fun fetchData() { fun fetchData() {
if (!repository.isSearchValid(CURRENT_LOCATION)) { onStart()
operationRefresh.postValue(Event(false))
return
}
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try { try {
// Get location // Has the search been conducted in the last 5 minutes
val latLong = locationProvider.getCurrentLatLong() val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) {
// Get weather from api // Get location
val weather = repository val latLong = locationProvider.getCurrentLatLong()
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString()) // Get weather from api
val currentLocation = val weather = repository
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon) .getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
val fullWeather = createFullWeather(weather, currentLocation) val currentLocation =
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather) 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 // Save data if not null
repository.saveLastSavedAt(CURRENT_LOCATION) repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(entityItem) repository.saveCurrentWeatherToRoom(entityItem)
onSuccess(Unit)
} catch (e: Exception) { } catch (e: Exception) {
operationError.postValue(Event(e.message!!)) onError(e.message!!)
} finally {
operationState.postValue(Event(false))
operationRefresh.postValue(Event(false))
} }
} }
} }

View File

@@ -1,13 +1,11 @@
package com.appttude.h_mal.atlas_weather.viewmodel package com.appttude.h_mal.atlas_weather.viewmodel
import androidx.lifecycle.MutableLiveData
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.network.response.forecast.WeatherResponse
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.room.entity.EntityItem 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.utils.Event
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -21,15 +19,6 @@ class WorldViewModel(
private val repository: Repository private val repository: Repository
) : WeatherViewModel() { ) : WeatherViewModel() {
val weatherLiveData = MutableLiveData<List<WeatherDisplay>>()
val singleWeatherLiveData = MutableLiveData<WeatherDisplay>()
val operationState = MutableLiveData<Event<Boolean>>()
val operationError = MutableLiveData<Event<String>>()
val operationRefresh = MutableLiveData<Event<Boolean>>()
val operationComplete = MutableLiveData<Event<String>>()
private val weatherListLiveData = repository.loadRoomWeatherLiveData() private val weatherListLiveData = repository.loadRoomWeatherLiveData()
init { init {
@@ -37,7 +26,7 @@ class WorldViewModel(
val list = it.map { data -> val list = it.map { data ->
WeatherDisplay(data) WeatherDisplay(data)
} }
weatherLiveData.postValue(list) onSuccess(list)
} }
} }
@@ -45,36 +34,34 @@ class WorldViewModel(
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val entity = repository.getSingleWeather(locationName) val entity = repository.getSingleWeather(locationName)
val item = WeatherDisplay(entity) val item = WeatherDisplay(entity)
singleWeatherLiveData.postValue(item) onSuccess(item)
} }
} }
fun fetchDataForSingleLocation(locationName: String) { fun fetchDataForSingleLocation(locationName: String) {
if (!repository.isSearchValid(locationName)) { onStart()
operationRefresh.postValue(Event(false))
return
}
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try { try {
val weatherEntity = createWeatherEntity(locationName) val weatherEntity = if (repository.isSearchValid(locationName)) {
createWeatherEntity(locationName)
} else {
repository.getSingleWeather(locationName)
}
repository.saveCurrentWeatherToRoom(weatherEntity) repository.saveCurrentWeatherToRoom(weatherEntity)
repository.saveLastSavedAt(locationName) repository.saveLastSavedAt(locationName)
onSuccess(Unit)
} catch (e: IOException) { } catch (e: IOException) {
operationError.postValue(Event(e.message!!)) onError(e.message!!)
} finally {
operationState.postValue(Event(false))
operationRefresh.postValue(Event(false))
} }
} }
} }
fun fetchDataForSingleLocationSearch(locationName: String) { fun fetchDataForSingleLocationSearch(locationName: String) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true)) onStart()
// Check if location exists // Check if location exists
if (repository.getSavedLocations().contains(locationName)) { if (repository.getSavedLocations().contains(locationName)) {
operationError.postValue(Event("$locationName already exists")) onError("$locationName already exists")
return@launch return@launch
} }
@@ -89,29 +76,26 @@ class WorldViewModel(
LocationType.City LocationType.City
) )
if (repository.getSavedLocations().contains(retrievedLocation)) { if (repository.getSavedLocations().contains(retrievedLocation)) {
operationError.postValue(Event("$retrievedLocation already exists")) onError("$retrievedLocation already exists")
return@launch return@launch
} }
// Save data if not null // Save data if not null
repository.saveCurrentWeatherToRoom(entityItem) repository.saveCurrentWeatherToRoom(entityItem)
repository.saveLastSavedAt(retrievedLocation) repository.saveLastSavedAt(retrievedLocation)
operationComplete.postValue(Event("$retrievedLocation saved")) onSuccess("$retrievedLocation saved")
} catch (e: IOException) { } catch (e: IOException) {
operationError.postValue(Event(e.message!!)) onError(e.message!!)
} finally {
operationState.postValue(Event(false))
} }
} }
} }
fun fetchAllLocations() { fun fetchAllLocations() {
onStart()
if (!repository.isSearchValid(ALL_LOADED)) { if (!repository.isSearchValid(ALL_LOADED)) {
operationRefresh.postValue(Event(false)) onSuccess(Unit)
return return
} }
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try { try {
val list = mutableListOf<EntityItem>() val list = mutableListOf<EntityItem>()
repository.loadWeatherList().forEach { locationName -> repository.loadWeatherList().forEach { locationName ->
@@ -128,25 +112,25 @@ class WorldViewModel(
repository.saveWeatherListToRoom(list) repository.saveWeatherListToRoom(list)
repository.saveLastSavedAt(ALL_LOADED) repository.saveLastSavedAt(ALL_LOADED)
} catch (e: IOException) { } catch (e: IOException) {
operationError.postValue(Event(e.message!!)) onError(e.message!!)
} finally { } finally {
operationState.postValue(Event(false)) onSuccess(Unit)
} }
} }
} }
fun deleteLocation(locationName: String) { fun deleteLocation(locationName: String) {
onStart()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try { try {
val success = repository.deleteSavedWeatherEntry(locationName) val success = repository.deleteSavedWeatherEntry(locationName)
if (!success) { if (!success) {
operationError.postValue(Event("Failed to delete")) onError("Failed to delete")
} }
} catch (e: IOException) { } catch (e: IOException) {
operationError.postValue(Event(e.message!!)) onError(e.message!!)
} finally { } finally {
operationState.postValue(Event(false)) onSuccess(Unit)
} }
} }
} }

View File

@@ -0,0 +1,25 @@
package com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.appttude.h_mal.atlas_weather.model.ViewState
open class BaseViewModel: ViewModel() {
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))
}
}

View File

@@ -5,7 +5,7 @@ import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherRe
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
abstract class WeatherViewModel : ViewModel() { abstract class WeatherViewModel : BaseViewModel() {
fun createFullWeather( fun createFullWeather(
weather: WeatherResponse, weather: WeatherResponse,

View File

@@ -3,10 +3,11 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginLeft="24dp" android:paddingLeft="24dp"
android:layout_marginTop="6dp" android:paddingTop="6dp"
android:layout_marginRight="24dp" android:paddingRight="24dp"
android:layout_marginBottom="6dp" android:paddingBottom="6dp"
android:background="@color/colorPrimaryDark"
android:orientation="horizontal"> android:orientation="horizontal">
<ImageView <ImageView

View File

@@ -7,31 +7,17 @@
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh" android:id="@+id/swipe_refresh"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/forecast_listview" android:id="@+id/forecast_listview"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:backgroundTint="@color/colorPrimaryDark"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/db_list_item"></androidx.recyclerview.widget.RecyclerView> tools:listitem="@layout/db_list_item" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<FrameLayout
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:visibility="gone"
tools:visibility="gone">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</RelativeLayout> </RelativeLayout>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:elevation="0.2dp" />
</FrameLayout>

View File

@@ -30,4 +30,5 @@
<string name="loading_nforecast">Loading \nforecast…</string> <string name="loading_nforecast">Loading \nforecast…</string>
<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>
</resources> </resources>

View File

@@ -12,10 +12,12 @@ class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var bodyTV: TextView = itemView.findViewById(R.id.body_text) var bodyTV: TextView = itemView.findViewById(R.id.body_text)
var headerTV: TextView = itemView.findViewById(R.id.header_text) var headerTV: TextView = itemView.findViewById(R.id.header_text)
fun bindData(@DrawableRes imageRes: Int?, header: String, body: String) { fun bindData(
imageRes?.let { icon.setImageResource(it) } @DrawableRes imageRes: Int? = R.drawable.ic_baseline_cloud_off_24,
headerTV.text = header header: String = itemView.resources.getString(R.string.retrieve_warning),
bodyTV.text = body body: String = itemView.resources.getString(R.string.empty_retrieve_warning) ){
imageRes?.let { icon.setImageResource(it) }
} headerTV.text = header
bodyTV.text = body
}
} }

View File

@@ -3,22 +3,23 @@ package com.appttude.h_mal.monoWeather.ui
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.utils.navigateTo import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
import kotlinx.android.synthetic.main.fragment_home.forecast_listview import kotlinx.android.synthetic.main.fragment_home.forecast_listview
import kotlinx.android.synthetic.main.fragment_home.progressBar
import kotlinx.android.synthetic.main.fragment_home.swipe_refresh import kotlinx.android.synthetic.main.fragment_home.swipe_refresh
class WorldItemFragment : BaseFragment(R.layout.fragment_home) { class WorldItemFragment : BaseFragment<WorldViewModel>(R.layout.fragment_home) {
private val viewModel by getFragmentViewModel<WorldViewModel>()
private var param1: String? = null private var param1: String? = null
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 param1 = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName
@@ -27,19 +28,12 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val recyclerAdapter = WeatherRecyclerAdapter { recyclerAdapter = WeatherRecyclerAdapter {
val directions = val directions =
WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it) WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it)
navigateTo(directions) navigateTo(directions)
} }
param1?.let { viewModel.getSingleLocation(it) }
viewModel.singleWeatherLiveData.observe(viewLifecycleOwner, Observer {
recyclerAdapter.addCurrent(it)
swipe_refresh.isRefreshing = false
})
forecast_listview.apply { forecast_listview.apply {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
adapter = recyclerAdapter adapter = recyclerAdapter
@@ -54,9 +48,20 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
} }
} }
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar)) param1?.let { viewModel.getSingleLocation(it) }
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
} }
override fun onSuccess(data: Any?) {
super.onSuccess(data)
swipe_refresh.isRefreshing = false
if (data is WeatherDisplay) {
recyclerAdapter.addCurrent(data)
}
}
override fun onFailure(error: Any?) {
super.onFailure(error)
swipe_refresh.isRefreshing = false
}
} }

View File

@@ -13,10 +13,11 @@ import androidx.navigation.ui.onNavDestinationSelected
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.utils.navigateTo import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
import com.appttude.h_mal.monoWeather.dialog.PermissionsDeclarationDialog import com.appttude.h_mal.monoWeather.dialog.PermissionsDeclarationDialog
import com.appttude.h_mal.monoWeather.ui.BaseFragment import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
import kotlinx.android.synthetic.main.fragment_home.* import kotlinx.android.synthetic.main.fragment_home.*
@@ -25,22 +26,16 @@ import kotlinx.android.synthetic.main.fragment_home.*
* A simple [Fragment] subclass. * A simple [Fragment] subclass.
* create an instance of this fragment. * create an instance of this fragment.
*/ */
class HomeFragment : BaseFragment(R.layout.fragment_home) { class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
private val viewModel by getFragmentViewModel<MainViewModel>()
lateinit var dialog: PermissionsDeclarationDialog lateinit var dialog: PermissionsDeclarationDialog
lateinit var recyclerAdapter: WeatherRecyclerAdapter
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
navigateToFurtherDetails(it)
})
forecast_listview.adapter = recyclerAdapter
dialog = PermissionsDeclarationDialog(requireContext()) dialog = PermissionsDeclarationDialog(requireContext())
swipe_refresh.apply { swipe_refresh.apply {
@@ -52,13 +47,11 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
} }
} }
viewModel.weatherLiveData.observe(viewLifecycleOwner) { recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
recyclerAdapter.addCurrent(it) navigateToFurtherDetails(it)
} })
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar)) forecast_listview.adapter = recyclerAdapter
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@@ -76,6 +69,20 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
dialog.dismiss() dialog.dismiss()
} }
override fun onSuccess(data: Any?) {
super.onSuccess(data)
swipe_refresh.isRefreshing = false
if (data is WeatherDisplay) {
recyclerAdapter.addCurrent(data)
}
}
override fun onFailure(error: Any?) {
super.onFailure(error)
swipe_refresh.isRefreshing = false
}
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun permissionsGranted() { override fun permissionsGranted() {
viewModel.fetchData() viewModel.fetchData()

View File

@@ -83,7 +83,8 @@ class WeatherRecyclerAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getDataType(getItemViewType(position))) { when (getDataType(getItemViewType(position))) {
is ViewType.Empty -> { is ViewType.Empty -> {
holder as EmptyViewHolder val emptyViewHolder = holder as EmptyViewHolder
emptyViewHolder.bindData()
} }
is ViewType.Current -> { is ViewType.Current -> {
@@ -115,7 +116,7 @@ class WeatherRecyclerAdapter(
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return if (weather == null) 0 else 3 + (weather?.forecast?.size ?: 0) return if (weather == null) 1 else 3 + (weather?.forecast?.size ?: 0)
} }
} }

View File

@@ -8,19 +8,17 @@ import com.appttude.h_mal.atlas_weather.utils.displayToast
import com.appttude.h_mal.atlas_weather.utils.goBack import com.appttude.h_mal.atlas_weather.utils.goBack
import com.appttude.h_mal.atlas_weather.utils.hideKeyboard import com.appttude.h_mal.atlas_weather.utils.hideKeyboard
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
import kotlinx.android.synthetic.main.activity_add_forecast.progressBar import kotlinx.android.synthetic.main.activity_add_forecast.progressBar
import kotlinx.android.synthetic.main.activity_add_forecast.submit import kotlinx.android.synthetic.main.activity_add_forecast.submit
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) { class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val viewModel by getFragmentViewModel<WorldViewModel>()
submit.setOnClickListener { submit.setOnClickListener {
val locationName = location_name_tv.text?.trim()?.toString() val locationName = location_name_tv.text?.trim()?.toString()
if (locationName.isNullOrBlank()) { if (locationName.isNullOrBlank()) {
@@ -30,14 +28,12 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
viewModel.fetchDataForSingleLocationSearch(locationName) viewModel.fetchDataForSingleLocationSearch(locationName)
hideKeyboard() hideKeyboard()
} }
}
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar)) override fun onSuccess(data: Any?) {
viewModel.operationError.observe(viewLifecycleOwner, errorObserver()) super.onSuccess(data)
if (data is String) {
viewModel.operationComplete.observe(viewLifecycleOwner) { displayToast(data)
it?.getContentIfNotHandled()?.let { message ->
displayToast(message)
}
goBack() goBack()
} }
} }

View File

@@ -6,9 +6,10 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.observe import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.utils.navigateTo import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.monoWeather.ui.world.WorldFragmentDirections.actionWorldFragmentToWorldItemFragment import com.appttude.h_mal.monoWeather.ui.world.WorldFragmentDirections.actionWorldFragmentToWorldItemFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.fragment__two.floatingActionButton import kotlinx.android.synthetic.main.fragment__two.floatingActionButton
@@ -20,8 +21,9 @@ import kotlinx.android.synthetic.main.fragment__two.world_recycler
* A simple [Fragment] subclass. * A simple [Fragment] subclass.
* create an instance of this fragment. * create an instance of this fragment.
*/ */
class WorldFragment : BaseFragment(R.layout.fragment__two) { class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment__two) {
private val viewModel by getFragmentViewModel<WorldViewModel>()
lateinit var recyclerAdapter: WorldRecyclerAdapter
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -32,7 +34,7 @@ class WorldFragment : BaseFragment(R.layout.fragment__two) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val recyclerAdapter = WorldRecyclerAdapter({ recyclerAdapter = WorldRecyclerAdapter({
val direction = val direction =
actionWorldFragmentToWorldItemFragment(it.location) actionWorldFragmentToWorldItemFragment(it.location)
navigateTo(direction) navigateTo(direction)
@@ -53,17 +55,18 @@ class WorldFragment : BaseFragment(R.layout.fragment__two) {
adapter = recyclerAdapter adapter = recyclerAdapter
} }
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
recyclerAdapter.addCurrent(it)
}
floatingActionButton.setOnClickListener { floatingActionButton.setOnClickListener {
navigateTo(R.id.action_worldFragment_to_addLocationFragment) navigateTo(R.id.action_worldFragment_to_addLocationFragment)
} }
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar)) }
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
override fun onSuccess(data: Any?) {
super.onSuccess(data)
if (data is List<*>) {
recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
}
} }
} }

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_baseline_cloud_off_24"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/message"/>
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_weather_to_display"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -3,7 +3,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:background="@color/colorPrimaryDark">
<LinearLayout <LinearLayout

View File

@@ -4,7 +4,8 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="24dp"> android:layout_margin="24dp"
android:background="@color/colorPrimaryDark">
<GridView <GridView
android:id="@+id/grid_mono" android:id="@+id/grid_mono"

View File

@@ -1,20 +1,35 @@
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.network.WeatherApi import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST 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.utils.BaseTest
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.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 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 kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
private const val MORE_THAN_FIVE_MINS = 330000L private const val MORE_THAN_FIVE_MINS = 330000L
private const val LESS_THAN_FIVE_MINS = 270000L private const val LESS_THAN_FIVE_MINS = 270000L
class RepositoryImplTest { class RepositoryImplTest : BaseTest() {
lateinit var repository: RepositoryImpl lateinit var repository: RepositoryImpl
@@ -71,4 +86,60 @@ class RepositoryImplTest {
val valid: Boolean = repository.isSearchValid(location) val valid: Boolean = repository.isSearchValid(location)
assertEquals(valid, true) assertEquals(valid, true)
} }
@Test
fun getWeatherFromApi_validLatLong_validSearch() {
//Arrange
val mockResponse = createSuccessfulRetrofitMock<WeatherResponse>()
//Act
//create a successful retrofit response
coEvery { api.getFromApi("", "") }.returns(mockResponse)
// Assert
runBlocking {
val result = repository.getWeatherFromApi("", "")
assertIs<WeatherResponse>(result)
}
}
@Test
fun getWeatherFromApi_validLatLong_invalidResponse() {
//Arrange
val mockResponse = createErrorRetrofitMock<WeatherResponse>()
//Act
//create a successful retrofit response
coEvery { api.getFromApi(any(), any()) } returns (mockResponse)
// Assert
val ioExceptionReturned = assertFailsWith<IOException> {
runBlocking {
repository.getWeatherFromApi("", "")
}
}
assertEquals(ioExceptionReturned.message, "Error Code: 400")
}
@Test
fun loadWeatherList_validResponse() {
// Arrange
val elements = listOf<EntityItem>(
mockk { every { id } returns any() },
mockk { every { id } returns any() },
mockk { every { id } returns any() },
mockk { every { id } returns any() }
)
//Act
coEvery { db.getWeatherDao().getWeatherListWithoutCurrent() } returns elements
// Assert
runBlocking {
val result = repository.loadWeatherList()
assertIs<List<String>>(result)
}
}
} }

View File

@@ -7,7 +7,7 @@ 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.weather.FullWeather import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.google.gson.Gson import com.appttude.h_mal.atlas_weather.utils.BaseTest
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@@ -18,9 +18,7 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.IOException import java.io.IOException
class ServicesHelperTest { class ServicesHelperTest : BaseTest() {
private val gson = Gson()
lateinit var helper: ServicesHelper lateinit var helper: ServicesHelper
@@ -40,8 +38,7 @@ class ServicesHelperTest {
MockKAnnotations.init(this) MockKAnnotations.init(this)
helper = ServicesHelper(repository, settingsRepository, locationProvider) helper = ServicesHelper(repository, settingsRepository, locationProvider)
val json = this::class.java.classLoader!!.getResource("weather_sample.json").readText() weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
weatherResponse = gson.fromJson(json, WeatherResponse::class.java)
} }
@Test @Test

View File

@@ -1,320 +0,0 @@
{
"lat": 51.5,
"lon": -0.12,
"timezone": "Europe/London",
"timezone_offset": 0,
"current": {
"dt": 1608391380,
"sunrise": 1608364972,
"sunset": 1608393158,
"temp": 10.53,
"feels_like": 5.17,
"pressure": 1006,
"humidity": 71,
"dew_point": 5.5,
"uvi": 0.12,
"clouds": 39,
"visibility": 10000,
"wind_speed": 6.2,
"wind_deg": 210,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"rain": {
"1h": 0.19
}
},
"daily": [
{
"dt": 1608375600,
"sunrise": 1608364972,
"sunset": 1608393158,
"temp": {
"day": 11.78,
"min": 9.1,
"max": 12.31,
"night": 9.1,
"eve": 10.27,
"morn": 10.8
},
"feels_like": {
"day": 6.46,
"night": 5.08,
"eve": 5.58,
"morn": 4.63
},
"pressure": 1005,
"humidity": 69,
"dew_point": 6.42,
"wind_speed": 6.37,
"wind_deg": 217,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 97,
"pop": 0.98,
"rain": 1.69,
"uvi": 0.53
},
{
"dt": 1608462000,
"sunrise": 1608451406,
"sunset": 1608479581,
"temp": {
"day": 9.9,
"min": 7.41,
"max": 10.52,
"night": 7.41,
"eve": 8.83,
"morn": 8.59
},
"feels_like": {
"day": 4.19,
"night": 3.99,
"eve": 4.55,
"morn": 4.79
},
"pressure": 1013,
"humidity": 64,
"dew_point": 3.48,
"wind_speed": 6.12,
"wind_deg": 226,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 8,
"pop": 0.58,
"rain": 0.65,
"uvi": 0.45
},
{
"dt": 1608548400,
"sunrise": 1608537838,
"sunset": 1608566008,
"temp": {
"day": 11.06,
"min": 7.01,
"max": 13.57,
"night": 13.2,
"eve": 12.68,
"morn": 8.59
},
"feels_like": {
"day": 6.32,
"night": 9.99,
"eve": 9.75,
"morn": 4.64
},
"pressure": 1005,
"humidity": 91,
"dew_point": 9.69,
"wind_speed": 6.7,
"wind_deg": 185,
"weather": [
{
"id": 501,
"main": "Rain",
"description": "moderate rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 1,
"rain": 7.85,
"uvi": 0.21
},
{
"dt": 1608634800,
"sunrise": 1608624266,
"sunset": 1608652438,
"temp": {
"day": 12.97,
"min": 11.7,
"max": 13.21,
"night": 11.7,
"eve": 12.37,
"morn": 12.93
},
"feels_like": {
"day": 11.39,
"night": 10.49,
"eve": 10.96,
"morn": 9.65
},
"pressure": 1012,
"humidity": 83,
"dew_point": 10.31,
"wind_speed": 2.38,
"wind_deg": 214,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 1,
"rain": 3.25,
"uvi": 0.34
},
{
"dt": 1608721200,
"sunrise": 1608710690,
"sunset": 1608738871,
"temp": {
"day": 12.28,
"min": 10.12,
"max": 12.62,
"night": 10.12,
"eve": 10.12,
"morn": 11.73
},
"feels_like": {
"day": 7.48,
"night": 6.73,
"eve": 6.6,
"morn": 8.15
},
"pressure": 1006,
"humidity": 64,
"dew_point": 5.76,
"wind_speed": 5.45,
"wind_deg": 224,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 97,
"pop": 0.94,
"rain": 2.52,
"uvi": 0.52
},
{
"dt": 1608811200,
"sunrise": 1608797112,
"sunset": 1608825307,
"temp": {
"day": 7.3,
"min": 4.66,
"max": 8.32,
"night": 4.66,
"eve": 5.76,
"morn": 5.85
},
"feels_like": {
"day": 0.36,
"night": -1.25,
"eve": -0.31,
"morn": -2.46
},
"pressure": 1020,
"humidity": 60,
"dew_point": 0.15,
"wind_speed": 7.09,
"wind_deg": 5,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 85,
"pop": 0.52,
"rain": 0.6,
"uvi": 1
},
{
"dt": 1608897600,
"sunrise": 1608883530,
"sunset": 1608911747,
"temp": {
"day": 4.12,
"min": 2.2,
"max": 4.63,
"night": 2.97,
"eve": 3.44,
"morn": 2.28
},
"feels_like": {
"day": -0.33,
"night": -0.43,
"eve": -0.1,
"morn": -2.82
},
"pressure": 1033,
"humidity": 70,
"dew_point": -3.09,
"wind_speed": 3.35,
"wind_deg": 334,
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
}
],
"clouds": 0,
"pop": 0,
"uvi": 1
},
{
"dt": 1608984000,
"sunrise": 1608969944,
"sunset": 1608998190,
"temp": {
"day": 6.03,
"min": 2.76,
"max": 6.92,
"night": 6.92,
"eve": 6.45,
"morn": 3.59
},
"feels_like": {
"day": 0.49,
"night": -0.96,
"eve": -0.28,
"morn": -0.44
},
"pressure": 1024,
"humidity": 69,
"dew_point": 0.84,
"wind_speed": 5.25,
"wind_deg": 251,
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04d"
}
],
"clouds": 100,
"pop": 0,
"uvi": 1
}
]
}

View File

@@ -0,0 +1,37 @@
package com.appttude.h_mal.atlas_weather.utils
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import io.mockk.every
import io.mockk.mockk
import okhttp3.ResponseBody
import retrofit2.Response
open class BaseTest {
private val gson by lazy { Gson() }
fun <T : Any> getTestData(resourceName: String): T {
val json = this::class.java.classLoader!!.getResource(resourceName).readText()
val typeToken = object : TypeToken<T>() {}.type
return gson.fromJson<T>(json, typeToken)
}
fun <T : Any> getTestData(resourceName: String, cls: Class<T>): T {
val json = this::class.java.classLoader!!.getResource(resourceName).readText()
return gson.fromJson<T>(json, cls)
}
inline fun <reified T : Any> createSuccessfulRetrofitMock(): Response<T> {
val mockResponse = mockk<T>()
return Response.success(mockResponse)
}
fun <T: Any> createErrorRetrofitMock(code: Int = 400): Response<T> {
val responseBody = mockk<ResponseBody>(relaxed = true)
val rawResponse = mockk<okhttp3.Response>().also {
every { it.code } returns code
}
return Response.error<T>(code, responseBody)
}
}

View File

@@ -0,0 +1,39 @@
package com.appttude.h_mal.atlas_weather.utils
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}
fun sleep(millis: Long = 1000) {
runBlocking(Dispatchers.IO) { delay(millis) }
}

View File

@@ -1,23 +1,30 @@
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 androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
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.repository.Repository 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.types.LocationType
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.utils.BaseTest
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
import com.appttude.h_mal.atlas_weather.utils.sleep
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import org.junit.Assert.assertEquals
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 java.util.concurrent.CountDownLatch import java.io.IOException
import java.util.concurrent.TimeUnit import kotlin.test.assertIs
import java.util.concurrent.TimeoutException
class WorldViewModelTest : BaseTest() {
@Suppress("unused")
class WorldViewModelTest {
@get:Rule @get:Rule
val rule = InstantTaskExecutorRule() val rule = InstantTaskExecutorRule()
@@ -29,46 +36,100 @@ class WorldViewModelTest {
@MockK @MockK
lateinit var locationProvider: LocationProviderImpl lateinit var locationProvider: LocationProviderImpl
lateinit var weatherResponse: WeatherResponse
@Before @Before
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
viewModel = WorldViewModel(locationProvider, repository) viewModel = WorldViewModel(locationProvider, repository)
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
}
@Test
fun fetchDataForSingleLocation_validLocation_validReturn() {
// Arrange
val location = CURRENT_LOCATION
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
// Act
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
weatherResponse.lat,
weatherResponse.lon
)
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,
LocationType.City
)
}.returns(CURRENT_LOCATION)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
viewModel.fetchDataForSingleLocation(location)
// Assert
sleep(300)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
} }
@Test @Test
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() { fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
// Arrange
val location = CURRENT_LOCATION val location = CURRENT_LOCATION
// Act
every { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } throws IOException("Unable to get location")
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
viewModel.fetchDataForSingleLocation(location) viewModel.fetchDataForSingleLocation(location)
assertEquals(viewModel.operationRefresh.getOrAwaitValue()?.getContentIfNotHandled(), false) // Assert
} sleep(300)
} assertIs<ViewState.HasError<*>>(viewModel.uiState.getOrAwaitValue())
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
} }
this.observeForever(observer) @Test
fun searchAboveFallbackTime_validLocation_validReturn() {
// Arrange
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
// Don't wait indefinitely if the LiveData is not set. // Act
if (!latch.await(time, timeUnit)) { coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem)
throw TimeoutException("LiveData value was never set.") 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())
} }
@Suppress("UNCHECKED_CAST")
return data as T
} }

View File

@@ -20,3 +20,4 @@ dependencyResolutionManagement {
} }
rootProject.name = "Atlas Weather" rootProject.name = "Atlas Weather"
include ':app' include ':app'
include ':test-resources'