mirror of
https://github.com/hmalik144/Weather-apps.git
synced 2025-12-10 02:05:20 +00:00
- mid commit
This commit is contained in:
78
.idea/androidTestResultsUserPreferences.xml
generated
78
.idea/androidTestResultsUserPreferences.xml
generated
@@ -42,6 +42,32 @@
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</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">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
@@ -55,6 +81,32 @@
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</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">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
@@ -94,6 +146,19 @@
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</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">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
@@ -120,6 +185,19 @@
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</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>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
345
app/src/androidTest/assets/valid_response_imperial.json
Normal file
345
app/src/androidTest/assets/valid_response_imperial.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
4
app/src/androidTest/assets/wrong_location_response.json
Normal file
4
app/src/androidTest/assets/wrong_location_response.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"cod": "400",
|
||||
"message": "wrong latitude"
|
||||
}
|
||||
@@ -5,20 +5,28 @@ package com.appttude.h_mal.atlas_weather
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.Root
|
||||
import androidx.test.espresso.UiController
|
||||
import androidx.test.espresso.ViewAction
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.appttude.h_mal.atlas_weather.application.TestAppClass
|
||||
import com.appttude.h_mal.atlas_weather.helpers.BaseCustomMatcher
|
||||
import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
|
||||
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
|
||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.Matcher
|
||||
import org.hamcrest.TypeSafeMatcher
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -63,8 +71,8 @@ open class BaseTest<A : Activity>(
|
||||
afterLaunch()
|
||||
}
|
||||
|
||||
fun stubEndpoint(url: String, stub: Stubs) {
|
||||
testApp.stubUrl(url, stub.id)
|
||||
fun stubEndpoint(url: String, stub: Stubs, code: Int = 200) {
|
||||
testApp.stubUrl(url, stub.id, code)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.appttude.h_mal.atlas_weather.application
|
||||
import androidx.room.Room
|
||||
import androidx.test.espresso.IdlingRegistry
|
||||
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.network.NetworkModule
|
||||
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.room.AppDatabase
|
||||
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
|
||||
|
||||
class TestAppClass : BaseAppClass() {
|
||||
@@ -40,11 +43,10 @@ class TestAppClass : BaseAppClass() {
|
||||
.build()
|
||||
}
|
||||
|
||||
fun stubUrl(url: String, rawPath: String) {
|
||||
val id = resources.getIdentifier(rawPath, "raw", packageName)
|
||||
val iStream = resources.openRawResource(id)
|
||||
fun stubUrl(url: String, rawPath: String, code: Int = 200) {
|
||||
val iStream = InstrumentationRegistry.getInstrumentation().context.assets.open("$rawPath.json")
|
||||
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) {
|
||||
|
||||
@@ -11,23 +11,21 @@ class MockingNetworkInterceptor(
|
||||
private val idlingResource: CountingIdlingResource
|
||||
) : Interceptor {
|
||||
|
||||
private var feedMap: MutableMap<String, String> = mutableMapOf()
|
||||
private var urlCallTracker: MutableMap<String, Int> = mutableMapOf()
|
||||
private var feedMap: MutableMap<String, Pair<String, Int>> = mutableMapOf()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
idlingResource.increment()
|
||||
val original = chain.request()
|
||||
val originalHttpUrl = original.url.toString().split("?")[0]
|
||||
|
||||
urlCallTracker.computeIfPresent(originalHttpUrl) { _, j ->
|
||||
j + 1
|
||||
}
|
||||
feedMap[originalHttpUrl]?.let { responsePair ->
|
||||
val code = responsePair.second
|
||||
val jsonBody = responsePair.first
|
||||
|
||||
feedMap[originalHttpUrl]?.let { jsonPath ->
|
||||
val body = jsonPath.toResponseBody("application/json".toMediaType())
|
||||
val body = jsonBody.toResponseBody("application/json".toMediaType())
|
||||
|
||||
val chainResponseBuilder = Response.Builder()
|
||||
.code(200)
|
||||
.code(code)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.request(original)
|
||||
.message("OK")
|
||||
@@ -40,7 +38,7 @@ class MockingNetworkInterceptor(
|
||||
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)
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.appttude.h_mal.atlas_weather.utils
|
||||
enum class Stubs(
|
||||
val id: String
|
||||
) {
|
||||
Valid("valid_response"),
|
||||
Invalid("invalid_response")
|
||||
Metric("valid_response_metric"),
|
||||
Imperial("valid_response_imperial"),
|
||||
WrongLocation("wrong_location_response"),
|
||||
InvalidKey("invalid_api_key_response")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,4 +11,8 @@ class HomeScreenRobot : BaseTestRobot() {
|
||||
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
|
||||
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import org.junit.Test
|
||||
class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,9 @@ class HomeScreenRobot : BaseTestRobot() {
|
||||
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
|
||||
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import org.junit.Test
|
||||
class HomePageUITest : MonoBaseTest() {
|
||||
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.home
|
||||
|
||||
import android.Manifest
|
||||
import android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
@@ -9,39 +8,33 @@ import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.Navigation.findNavController
|
||||
import androidx.navigation.ui.onNavDestinationSelected
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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.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.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.monoWeather.ui.BaseFragment
|
||||
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.
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
private val viewModel by getFragmentViewModel<MainViewModel>()
|
||||
class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
|
||||
private lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
|
||||
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||
recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||
navigateToFurtherDetails(it)
|
||||
})
|
||||
|
||||
@@ -58,23 +51,17 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
override fun onFailure(error: Any?) {
|
||||
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")
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.world
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
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.ui.BaseFragment
|
||||
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.viewmodel.ApplicationViewModelFactory
|
||||
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.*
|
||||
import org.kodein.di.KodeinAware
|
||||
import org.kodein.di.android.x.kodein
|
||||
import org.kodein.di.generic.instance
|
||||
import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
|
||||
import kotlinx.android.synthetic.main.activity_add_forecast.submit
|
||||
|
||||
|
||||
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
||||
|
||||
private val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -31,16 +22,13 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
||||
submit.error = "Location cannot be blank"
|
||||
return@setOnClickListener
|
||||
}
|
||||
viewModel.fetchDataForSingleLocation(locationName)
|
||||
viewModel.fetchDataForSingleLocationSearch(locationName)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
|
||||
viewModel.operationComplete.observe(viewLifecycleOwner) {
|
||||
it?.getContentIfNotHandled()?.let { message ->
|
||||
displayToast(message)
|
||||
}
|
||||
override fun onSuccess(data: Any?) {
|
||||
if (data is String) {
|
||||
displayToast(data)
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ package com.appttude.h_mal.atlas_weather.ui.world
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.observe
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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.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.progressBar2
|
||||
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.
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
|
||||
val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment_add_location) {
|
||||
private lateinit var recyclerAdapter: WorldRecyclerAdapter
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val recyclerAdapter = WorldRecyclerAdapter {
|
||||
recyclerAdapter = WorldRecyclerAdapter {
|
||||
val direction =
|
||||
WorldFragmentDirections.actionWorldFragmentToWorldItemFragment(it)
|
||||
navigateTo(direction)
|
||||
@@ -35,22 +34,18 @@ class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
}
|
||||
|
||||
floatingActionButton.setOnClickListener {
|
||||
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar2))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
|
||||
override fun onSuccess(data: Any?) {
|
||||
super.onSuccess(data)
|
||||
if (data is List<*>) recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
viewModel.fetchAllLocations()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -24,33 +24,32 @@ class RepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
|
||||
db.getSimpleDao().upsertFullWeather(entityItem)
|
||||
db.getWeatherDao().upsertFullWeather(entityItem)
|
||||
}
|
||||
|
||||
override suspend fun saveWeatherListToRoom(
|
||||
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> {
|
||||
return db.getSimpleDao()
|
||||
return db.getWeatherDao()
|
||||
.getWeatherListWithoutCurrent()
|
||||
.map { it.id }
|
||||
}
|
||||
|
||||
override fun loadCurrentWeatherFromRoom(id: String) =
|
||||
db.getSimpleDao().getCurrentFullWeather(id)
|
||||
db.getWeatherDao().getCurrentFullWeather(id)
|
||||
|
||||
override suspend fun loadSingleCurrentWeatherFromRoom(id: String) =
|
||||
db.getSimpleDao().getCurrentFullWeatherSingle(id)
|
||||
db.getWeatherDao().getCurrentFullWeatherSingle(id)
|
||||
|
||||
override fun isSearchValid(locationName: String): Boolean {
|
||||
val lastSaved = prefs
|
||||
.getLastSavedAt("$LOCATION_CONST$locationName")
|
||||
?: return true
|
||||
val difference = System.currentTimeMillis() - lastSaved
|
||||
|
||||
return difference > FALLBACK_TIME
|
||||
@@ -62,7 +61,7 @@ class RepositoryImpl(
|
||||
|
||||
override suspend fun deleteSavedWeatherEntry(locationName: String): Boolean {
|
||||
prefs.deleteLocation(locationName)
|
||||
return db.getSimpleDao().deleteEntry(locationName) > 0
|
||||
return db.getWeatherDao().deleteEntry(locationName) > 0
|
||||
}
|
||||
|
||||
override fun getSavedLocations(): List<String> {
|
||||
@@ -70,7 +69,7 @@ class RepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun getSingleWeather(locationName: String): EntityItem {
|
||||
return db.getSimpleDao().getCurrentFullWeatherSingle(locationName)
|
||||
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
@TypeConverters(Converter::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun getSimpleDao(): WeatherDao
|
||||
abstract fun getWeatherDao(): WeatherDao
|
||||
|
||||
companion object {
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ object GenericsHelper {
|
||||
?.kotlin
|
||||
?: 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]
|
||||
// *
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
@@ -9,16 +7,17 @@ import android.view.View
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.fragment.app.createViewModelLazy
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
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.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.baseViewModels.BaseViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -28,13 +27,18 @@ import org.kodein.di.generic.instance
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
@Suppress("EmptyMethod", "EmptyMethod")
|
||||
abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId),
|
||||
abstract class BaseFragment<V : BaseViewModel>(@LayoutRes contentLayoutId: Int) :
|
||||
Fragment(contentLayoutId),
|
||||
KodeinAware {
|
||||
|
||||
override val kodein by kodein()
|
||||
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>()
|
||||
|
||||
@@ -43,25 +47,42 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
|
||||
shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
|
||||
}
|
||||
|
||||
// toggle visibility of progress spinner while async operations are taking place
|
||||
fun progressBarStateObserver(progressBar: View) = Observer<Event<Boolean>> {
|
||||
it.getContentIfNotHandled()?.let { i ->
|
||||
if (i)
|
||||
progressBar.fadeIn()
|
||||
else
|
||||
progressBar.fadeOut()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
mActivity = activity as BaseActivity
|
||||
configureObserver()
|
||||
}
|
||||
|
||||
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>> {
|
||||
it.getContentIfNotHandled()?.let { message ->
|
||||
displayToast(message)
|
||||
}
|
||||
/**
|
||||
* Called in case of starting operation liveData in viewModel
|
||||
*/
|
||||
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")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
|
||||
@@ -9,10 +9,12 @@ import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupActionBarWithNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.base.BaseActivity
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import kotlinx.android.synthetic.main.activity_main_navigation.toolbar
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
class MainActivity : BaseActivity() {
|
||||
|
||||
lateinit var navHost: NavHostFragment
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.ImageView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.AnimRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.squareup.picasso.Picasso
|
||||
@@ -42,4 +45,14 @@ fun ImageView.loadImage(url: String?) {
|
||||
fun Fragment.hideKeyboard() {
|
||||
val imm = context?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager?
|
||||
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)
|
||||
}
|
||||
@@ -2,13 +2,11 @@ package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
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.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.utils.Event
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -19,48 +17,40 @@ class MainViewModel(
|
||||
private val repository: Repository
|
||||
) : WeatherViewModel() {
|
||||
|
||||
val weatherLiveData = MutableLiveData<WeatherDisplay>()
|
||||
|
||||
val operationState = MutableLiveData<Event<Boolean>>()
|
||||
val operationError = MutableLiveData<Event<String>>()
|
||||
val operationRefresh = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
init {
|
||||
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {
|
||||
it?.let {
|
||||
val weather = WeatherDisplay(it)
|
||||
weatherLiveData.postValue(weather)
|
||||
onSuccess(weather)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
fun fetchData() {
|
||||
if (!repository.isSearchValid(CURRENT_LOCATION)) {
|
||||
operationRefresh.postValue(Event(false))
|
||||
return
|
||||
}
|
||||
|
||||
onStart()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
try {
|
||||
// Get location
|
||||
val latLong = locationProvider.getCurrentLatLong()
|
||||
// Get weather from api
|
||||
val weather = repository
|
||||
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
|
||||
val currentLocation =
|
||||
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
|
||||
val fullWeather = createFullWeather(weather, currentLocation)
|
||||
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
|
||||
// Has the search been conducted in the last 5 minutes
|
||||
val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) {
|
||||
// Get location
|
||||
val latLong = locationProvider.getCurrentLatLong()
|
||||
// Get weather from api
|
||||
val weather = repository
|
||||
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
|
||||
val currentLocation =
|
||||
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
|
||||
val fullWeather = createFullWeather(weather, currentLocation)
|
||||
EntityItem(CURRENT_LOCATION, fullWeather)
|
||||
} else {
|
||||
repository.getSingleWeather(CURRENT_LOCATION)
|
||||
}
|
||||
// Save data if not null
|
||||
repository.saveLastSavedAt(CURRENT_LOCATION)
|
||||
repository.saveCurrentWeatherToRoom(entityItem)
|
||||
onSuccess(Unit)
|
||||
} catch (e: Exception) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
operationRefresh.postValue(Event(false))
|
||||
onError(e.message!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||
import com.appttude.h_mal.atlas_weather.utils.Event
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -21,15 +19,6 @@ class WorldViewModel(
|
||||
private val repository: Repository
|
||||
) : 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()
|
||||
|
||||
init {
|
||||
@@ -37,7 +26,7 @@ class WorldViewModel(
|
||||
val list = it.map { data ->
|
||||
WeatherDisplay(data)
|
||||
}
|
||||
weatherLiveData.postValue(list)
|
||||
onSuccess(list)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,36 +34,34 @@ class WorldViewModel(
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val entity = repository.getSingleWeather(locationName)
|
||||
val item = WeatherDisplay(entity)
|
||||
singleWeatherLiveData.postValue(item)
|
||||
onSuccess(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchDataForSingleLocation(locationName: String) {
|
||||
if (!repository.isSearchValid(locationName)) {
|
||||
operationRefresh.postValue(Event(false))
|
||||
return
|
||||
}
|
||||
onStart()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
try {
|
||||
val weatherEntity = createWeatherEntity(locationName)
|
||||
val weatherEntity = if (repository.isSearchValid(locationName)) {
|
||||
createWeatherEntity(locationName)
|
||||
} else {
|
||||
repository.getSingleWeather(locationName)
|
||||
}
|
||||
repository.saveCurrentWeatherToRoom(weatherEntity)
|
||||
repository.saveLastSavedAt(locationName)
|
||||
onSuccess(Unit)
|
||||
} catch (e: IOException) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
operationRefresh.postValue(Event(false))
|
||||
onError(e.message!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchDataForSingleLocationSearch(locationName: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
onStart()
|
||||
// Check if location exists
|
||||
if (repository.getSavedLocations().contains(locationName)) {
|
||||
operationError.postValue(Event("$locationName already exists"))
|
||||
onError("$locationName already exists")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -89,29 +76,26 @@ class WorldViewModel(
|
||||
LocationType.City
|
||||
)
|
||||
if (repository.getSavedLocations().contains(retrievedLocation)) {
|
||||
operationError.postValue(Event("$retrievedLocation already exists"))
|
||||
onError("$retrievedLocation already exists")
|
||||
return@launch
|
||||
}
|
||||
// Save data if not null
|
||||
repository.saveCurrentWeatherToRoom(entityItem)
|
||||
repository.saveLastSavedAt(retrievedLocation)
|
||||
operationComplete.postValue(Event("$retrievedLocation saved"))
|
||||
|
||||
onSuccess("$retrievedLocation saved")
|
||||
} catch (e: IOException) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
onError(e.message!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchAllLocations() {
|
||||
onStart()
|
||||
if (!repository.isSearchValid(ALL_LOADED)) {
|
||||
operationRefresh.postValue(Event(false))
|
||||
onSuccess(Unit)
|
||||
return
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
try {
|
||||
val list = mutableListOf<EntityItem>()
|
||||
repository.loadWeatherList().forEach { locationName ->
|
||||
@@ -128,25 +112,25 @@ class WorldViewModel(
|
||||
repository.saveWeatherListToRoom(list)
|
||||
repository.saveLastSavedAt(ALL_LOADED)
|
||||
} catch (e: IOException) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
onError(e.message!!)
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
onSuccess(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLocation(locationName: String) {
|
||||
onStart()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
try {
|
||||
val success = repository.deleteSavedWeatherEntry(locationName)
|
||||
if (!success) {
|
||||
operationError.postValue(Event("Failed to delete"))
|
||||
onError("Failed to delete")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
onError(e.message!!)
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
onSuccess(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.model.weather.FullWeather
|
||||
|
||||
abstract class WeatherViewModel : ViewModel() {
|
||||
abstract class WeatherViewModel : BaseViewModel() {
|
||||
|
||||
fun createFullWeather(
|
||||
weather: WeatherResponse,
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="24dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginRight="24dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:background="@color/colorPrimaryDark"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
|
||||
@@ -7,31 +7,17 @@
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_refresh"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/forecast_listview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:backgroundTint="@color/colorPrimaryDark"
|
||||
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>
|
||||
|
||||
<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>
|
||||
13
app/src/main/res/layout/progress_layout.xml
Normal file
13
app/src/main/res/layout/progress_layout.xml
Normal 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>
|
||||
@@ -30,4 +30,5 @@
|
||||
<string name="loading_nforecast">Loading \nforecast…</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="no_weather_to_display">No weather to display</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,10 +12,12 @@ class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
var bodyTV: TextView = itemView.findViewById(R.id.body_text)
|
||||
var headerTV: TextView = itemView.findViewById(R.id.header_text)
|
||||
|
||||
fun bindData(@DrawableRes imageRes: Int?, header: String, body: String) {
|
||||
imageRes?.let { icon.setImageResource(it) }
|
||||
headerTV.text = header
|
||||
bodyTV.text = body
|
||||
|
||||
}
|
||||
fun bindData(
|
||||
@DrawableRes imageRes: Int? = R.drawable.ic_baseline_cloud_off_24,
|
||||
header: String = itemView.resources.getString(R.string.retrieve_warning),
|
||||
body: String = itemView.resources.getString(R.string.empty_retrieve_warning) ){
|
||||
imageRes?.let { icon.setImageResource(it) }
|
||||
headerTV.text = header
|
||||
bodyTV.text = body
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,23 @@ package com.appttude.h_mal.monoWeather.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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.viewmodel.WorldViewModel
|
||||
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.progressBar
|
||||
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 lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
param1 = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName
|
||||
@@ -27,19 +28,12 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val recyclerAdapter = WeatherRecyclerAdapter {
|
||||
recyclerAdapter = WeatherRecyclerAdapter {
|
||||
val directions =
|
||||
WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it)
|
||||
navigateTo(directions)
|
||||
}
|
||||
|
||||
param1?.let { viewModel.getSingleLocation(it) }
|
||||
|
||||
viewModel.singleWeatherLiveData.observe(viewLifecycleOwner, Observer {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
swipe_refresh.isRefreshing = false
|
||||
})
|
||||
|
||||
forecast_listview.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = recyclerAdapter
|
||||
@@ -54,9 +48,20 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
|
||||
param1?.let { viewModel.getSingleLocation(it) }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,11 @@ import androidx.navigation.ui.onNavDestinationSelected
|
||||
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.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.viewmodel.MainViewModel
|
||||
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 kotlinx.android.synthetic.main.fragment_home.*
|
||||
|
||||
@@ -25,22 +26,16 @@ import kotlinx.android.synthetic.main.fragment_home.*
|
||||
* A simple [Fragment] subclass.
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
|
||||
private val viewModel by getFragmentViewModel<MainViewModel>()
|
||||
class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
|
||||
|
||||
lateinit var dialog: PermissionsDeclarationDialog
|
||||
lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
|
||||
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||
navigateToFurtherDetails(it)
|
||||
})
|
||||
|
||||
forecast_listview.adapter = recyclerAdapter
|
||||
dialog = PermissionsDeclarationDialog(requireContext())
|
||||
|
||||
swipe_refresh.apply {
|
||||
@@ -52,13 +47,11 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
}
|
||||
recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||
navigateToFurtherDetails(it)
|
||||
})
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
|
||||
forecast_listview.adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@@ -76,6 +69,20 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
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")
|
||||
override fun permissionsGranted() {
|
||||
viewModel.fetchData()
|
||||
|
||||
@@ -83,7 +83,8 @@ class WeatherRecyclerAdapter(
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (getDataType(getItemViewType(position))) {
|
||||
is ViewType.Empty -> {
|
||||
holder as EmptyViewHolder
|
||||
val emptyViewHolder = holder as EmptyViewHolder
|
||||
emptyViewHolder.bindData()
|
||||
}
|
||||
|
||||
is ViewType.Current -> {
|
||||
@@ -115,7 +116,7 @@ class WeatherRecyclerAdapter(
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.hideKeyboard
|
||||
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.progressBar
|
||||
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?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
|
||||
submit.setOnClickListener {
|
||||
val locationName = location_name_tv.text?.trim()?.toString()
|
||||
if (locationName.isNullOrBlank()) {
|
||||
@@ -30,14 +28,12 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
||||
viewModel.fetchDataForSingleLocationSearch(locationName)
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
|
||||
viewModel.operationComplete.observe(viewLifecycleOwner) {
|
||||
it?.getContentIfNotHandled()?.let { message ->
|
||||
displayToast(message)
|
||||
}
|
||||
override fun onSuccess(data: Any?) {
|
||||
super.onSuccess(data)
|
||||
if (data is String) {
|
||||
displayToast(data)
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.observe
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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.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.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
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.
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class WorldFragment : BaseFragment(R.layout.fragment__two) {
|
||||
private val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment__two) {
|
||||
|
||||
lateinit var recyclerAdapter: WorldRecyclerAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -32,7 +34,7 @@ class WorldFragment : BaseFragment(R.layout.fragment__two) {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val recyclerAdapter = WorldRecyclerAdapter({
|
||||
recyclerAdapter = WorldRecyclerAdapter({
|
||||
val direction =
|
||||
actionWorldFragmentToWorldItemFragment(it.location)
|
||||
navigateTo(direction)
|
||||
@@ -53,17 +55,18 @@ class WorldFragment : BaseFragment(R.layout.fragment__two) {
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
}
|
||||
|
||||
floatingActionButton.setOnClickListener {
|
||||
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>)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
25
app/src/monoWeather/res/layout/initial_layout.xml
Normal file
25
app/src/monoWeather/res/layout/initial_layout.xml
Normal 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>
|
||||
@@ -3,7 +3,8 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/colorPrimaryDark">
|
||||
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="24dp">
|
||||
android:layout_margin="24dp"
|
||||
android:background="@color/colorPrimaryDark">
|
||||
|
||||
<GridView
|
||||
android:id="@+id/grid_mono"
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
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.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
|
||||
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||
import com.nhaarman.mockitokotlin2.any
|
||||
import com.nhaarman.mockitokotlin2.doAnswer
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.justRun
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
|
||||
private const val MORE_THAN_FIVE_MINS = 330000L
|
||||
private const val LESS_THAN_FIVE_MINS = 270000L
|
||||
|
||||
class RepositoryImplTest {
|
||||
class RepositoryImplTest : BaseTest() {
|
||||
|
||||
lateinit var repository: RepositoryImpl
|
||||
|
||||
@@ -71,4 +86,60 @@ class RepositoryImplTest {
|
||||
val valid: Boolean = repository.isSearchValid(location)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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.EntityItem
|
||||
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.coEvery
|
||||
import io.mockk.every
|
||||
@@ -18,9 +18,7 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
class ServicesHelperTest {
|
||||
|
||||
private val gson = Gson()
|
||||
class ServicesHelperTest : BaseTest() {
|
||||
|
||||
lateinit var helper: ServicesHelper
|
||||
|
||||
@@ -40,8 +38,7 @@ class ServicesHelperTest {
|
||||
MockKAnnotations.init(this)
|
||||
helper = ServicesHelper(repository, settingsRepository, locationProvider)
|
||||
|
||||
val json = this::class.java.classLoader!!.getResource("weather_sample.json").readText()
|
||||
weatherResponse = gson.fromJson(json, WeatherResponse::class.java)
|
||||
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -1,23 +1,30 @@
|
||||
package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
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.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
|
||||
import com.appttude.h_mal.atlas_weather.utils.sleep
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertIs
|
||||
|
||||
|
||||
class WorldViewModelTest : BaseTest() {
|
||||
|
||||
@Suppress("unused")
|
||||
class WorldViewModelTest {
|
||||
@get:Rule
|
||||
val rule = InstantTaskExecutorRule()
|
||||
|
||||
@@ -29,46 +36,100 @@ class WorldViewModelTest {
|
||||
@MockK
|
||||
lateinit var locationProvider: LocationProviderImpl
|
||||
|
||||
lateinit var weatherResponse: WeatherResponse
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this)
|
||||
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
|
||||
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
|
||||
// Arrange
|
||||
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)
|
||||
|
||||
assertEquals(viewModel.operationRefresh.getOrAwaitValue()?.getContentIfNotHandled(), false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
// Assert
|
||||
sleep(300)
|
||||
assertIs<ViewState.HasError<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
|
||||
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.
|
||||
if (!latch.await(time, timeUnit)) {
|
||||
throw TimeoutException("LiveData value was never set.")
|
||||
// Act
|
||||
coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem)
|
||||
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false)
|
||||
coEvery {
|
||||
repository.getWeatherFromApi(
|
||||
weatherResponse.lat.toString(),
|
||||
weatherResponse.lon.toString()
|
||||
)
|
||||
}.returns(weatherResponse)
|
||||
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
|
||||
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
|
||||
|
||||
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||
|
||||
// Assert
|
||||
sleep(300)
|
||||
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data as T
|
||||
}
|
||||
@@ -20,3 +20,4 @@ dependencyResolutionManagement {
|
||||
}
|
||||
rootProject.name = "Atlas Weather"
|
||||
include ':app'
|
||||
include ':test-resources'
|
||||
|
||||
Reference in New Issue
Block a user