mirror of
https://github.com/hmalik144/Weather-apps.git
synced 2026-03-18 07:26:04 +00:00
- mid commit
This commit is contained in:
78
.idea/androidTestResultsUserPreferences.xml
generated
78
.idea/androidTestResultsUserPreferences.xml
generated
@@ -42,6 +42,32 @@
|
|||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
|
<entry key="-1721686438">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_2_API_27" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="-1578868619">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_2_API_27" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
<entry key="-409920851">
|
<entry key="-409920851">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
@@ -55,6 +81,32 @@
|
|||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
|
<entry key="108569748">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_2_API_27" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="110413981">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_2_API_27" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
<entry key="170536241">
|
<entry key="170536241">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
@@ -94,6 +146,19 @@
|
|||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
|
<entry key="721647317">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_2_API_27" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
<entry key="1127175145">
|
<entry key="1127175145">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
@@ -120,6 +185,19 @@
|
|||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
|
<entry key="1440597283">
|
||||||
|
<value>
|
||||||
|
<AndroidTestResultsTableState>
|
||||||
|
<option name="preferredColumnWidths">
|
||||||
|
<map>
|
||||||
|
<entry key="Duration" value="90" />
|
||||||
|
<entry key="Pixel_2_API_27" value="120" />
|
||||||
|
<entry key="Tests" value="360" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</AndroidTestResultsTableState>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
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.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.test.core.app.ActivityScenario
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.espresso.Espresso
|
import androidx.test.espresso.Espresso
|
||||||
|
import androidx.test.espresso.Root
|
||||||
import androidx.test.espresso.UiController
|
import androidx.test.espresso.UiController
|
||||||
import androidx.test.espresso.ViewAction
|
import androidx.test.espresso.ViewAction
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions
|
||||||
import androidx.test.espresso.matcher.ViewMatchers
|
import androidx.test.espresso.matcher.ViewMatchers
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.rule.GrantPermissionRule
|
import androidx.test.rule.GrantPermissionRule
|
||||||
import com.appttude.h_mal.atlas_weather.application.TestAppClass
|
import com.appttude.h_mal.atlas_weather.application.TestAppClass
|
||||||
|
import com.appttude.h_mal.atlas_weather.helpers.BaseCustomMatcher
|
||||||
|
import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
|
||||||
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
|
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
|
||||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.hamcrest.Description
|
||||||
import org.hamcrest.Matcher
|
import org.hamcrest.Matcher
|
||||||
|
import org.hamcrest.TypeSafeMatcher
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
@@ -63,8 +71,8 @@ open class BaseTest<A : Activity>(
|
|||||||
afterLaunch()
|
afterLaunch()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stubEndpoint(url: String, stub: Stubs) {
|
fun stubEndpoint(url: String, stub: Stubs, code: Int = 200) {
|
||||||
testApp.stubUrl(url, stub.id)
|
testApp.stubUrl(url, stub.id, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unstubEndpoint(url: String) {
|
fun unstubEndpoint(url: String) {
|
||||||
@@ -91,4 +99,32 @@ open class BaseTest<A : Activity>(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun checkToastMessage(message: String) {
|
||||||
|
Espresso.onView(ViewMatchers.withText(message)).inRoot(object : BaseCustomMatcher<Root>() {
|
||||||
|
override fun describe(description: Description?) {
|
||||||
|
description?.appendText("is toast")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun matchesSafely(root: Root): Boolean {
|
||||||
|
root.run {
|
||||||
|
if (windowLayoutParams.get().type == WindowManager.LayoutParams.TYPE_TOAST) {
|
||||||
|
decorView.run {
|
||||||
|
if (windowToken === applicationWindowToken) {
|
||||||
|
// windowToken == appToken means this window isn't contained by any other windows.
|
||||||
|
// if it was a window for an activity, it would have TYPE_BASE_APPLICATION.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
|
waitFor(3500)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.appttude.h_mal.atlas_weather.application
|
|||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.test.espresso.IdlingRegistry
|
import androidx.test.espresso.IdlingRegistry
|
||||||
import androidx.test.espresso.idling.CountingIdlingResource
|
import androidx.test.espresso.idling.CountingIdlingResource
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider
|
import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider
|
||||||
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
|
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
|
||||||
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
|
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
|
||||||
@@ -12,6 +13,8 @@ import com.appttude.h_mal.atlas_weather.data.network.interceptors.QueryParamsInt
|
|||||||
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.loggingInterceptor
|
import com.appttude.h_mal.atlas_weather.data.network.networkUtils.loggingInterceptor
|
||||||
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
||||||
import com.appttude.h_mal.atlas_weather.data.room.Converter
|
import com.appttude.h_mal.atlas_weather.data.room.Converter
|
||||||
|
import com.appttude.h_mal.atlas_weather.test.BuildConfig
|
||||||
|
import com.appttude.h_mal.atlas_weather.test.BuildConfig.APPLICATION_ID
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
|
|
||||||
class TestAppClass : BaseAppClass() {
|
class TestAppClass : BaseAppClass() {
|
||||||
@@ -40,11 +43,10 @@ class TestAppClass : BaseAppClass() {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stubUrl(url: String, rawPath: String) {
|
fun stubUrl(url: String, rawPath: String, code: Int = 200) {
|
||||||
val id = resources.getIdentifier(rawPath, "raw", packageName)
|
val iStream = InstrumentationRegistry.getInstrumentation().context.assets.open("$rawPath.json")
|
||||||
val iStream = resources.openRawResource(id)
|
|
||||||
val data = iStream.bufferedReader().use(BufferedReader::readText)
|
val data = iStream.bufferedReader().use(BufferedReader::readText)
|
||||||
mockingNetworkInterceptor.addUrlStub(url = url, data = data)
|
mockingNetworkInterceptor.addUrlStub(url = url, data = data, code = code)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeUrlStub(url: String) {
|
fun removeUrlStub(url: String) {
|
||||||
|
|||||||
@@ -11,23 +11,21 @@ class MockingNetworkInterceptor(
|
|||||||
private val idlingResource: CountingIdlingResource
|
private val idlingResource: CountingIdlingResource
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
private var feedMap: MutableMap<String, String> = mutableMapOf()
|
private var feedMap: MutableMap<String, Pair<String, Int>> = mutableMapOf()
|
||||||
private var urlCallTracker: MutableMap<String, Int> = mutableMapOf()
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
idlingResource.increment()
|
idlingResource.increment()
|
||||||
val original = chain.request()
|
val original = chain.request()
|
||||||
val originalHttpUrl = original.url.toString().split("?")[0]
|
val originalHttpUrl = original.url.toString().split("?")[0]
|
||||||
|
|
||||||
urlCallTracker.computeIfPresent(originalHttpUrl) { _, j ->
|
feedMap[originalHttpUrl]?.let { responsePair ->
|
||||||
j + 1
|
val code = responsePair.second
|
||||||
}
|
val jsonBody = responsePair.first
|
||||||
|
|
||||||
feedMap[originalHttpUrl]?.let { jsonPath ->
|
val body = jsonBody.toResponseBody("application/json".toMediaType())
|
||||||
val body = jsonPath.toResponseBody("application/json".toMediaType())
|
|
||||||
|
|
||||||
val chainResponseBuilder = Response.Builder()
|
val chainResponseBuilder = Response.Builder()
|
||||||
.code(200)
|
.code(code)
|
||||||
.protocol(Protocol.HTTP_1_1)
|
.protocol(Protocol.HTTP_1_1)
|
||||||
.request(original)
|
.request(original)
|
||||||
.message("OK")
|
.message("OK")
|
||||||
@@ -40,7 +38,7 @@ class MockingNetworkInterceptor(
|
|||||||
return chain.proceed(original)
|
return chain.proceed(original)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addUrlStub(url: String, data: String) = feedMap.put(url, data)
|
fun addUrlStub(url: String, data: String, code: Int = 200) = feedMap.put(url, Pair(data, code))
|
||||||
fun removeUrlStub(url: String) = feedMap.remove(url)
|
fun removeUrlStub(url: String) = feedMap.remove(url)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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(
|
enum class Stubs(
|
||||||
val id: String
|
val id: String
|
||||||
) {
|
) {
|
||||||
Valid("valid_response"),
|
Metric("valid_response_metric"),
|
||||||
Invalid("invalid_response")
|
Imperial("valid_response_imperial"),
|
||||||
|
WrongLocation("wrong_location_response"),
|
||||||
|
InvalidKey("invalid_api_key_response")
|
||||||
}
|
}
|
||||||
@@ -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 verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
|
||||||
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
||||||
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
|
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
|
||||||
|
fun verifyUnableToRetrieve() {
|
||||||
|
matchText(R.id.header_text, R.string.retrieve_warning)
|
||||||
|
matchText(R.id.body_text, R.string.empty_retrieve_warning)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
|
||||||
|
|
||||||
override fun beforeLaunch() {
|
override fun beforeLaunch() {
|
||||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
|
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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 verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
|
||||||
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
||||||
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
|
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
|
||||||
|
|
||||||
|
fun verifyUnableToRetrieve() {
|
||||||
|
matchText(R.id.header_text, R.string.retrieve_warning)
|
||||||
|
matchText(R.id.body_text, R.string.empty_retrieve_warning)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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() {
|
class HomePageUITest : MonoBaseTest() {
|
||||||
|
|
||||||
override fun beforeLaunch() {
|
override fun beforeLaunch() {
|
||||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
|
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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
|
package com.appttude.h_mal.atlas_weather.ui.home
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.Manifest.permission.ACCESS_COARSE_LOCATION
|
import android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -9,39 +8,33 @@ import android.view.MenuInflater
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
|
||||||
import androidx.navigation.Navigation.findNavController
|
import androidx.navigation.Navigation.findNavController
|
||||||
import androidx.navigation.ui.onNavDestinationSelected
|
import androidx.navigation.ui.onNavDestinationSelected
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.appttude.h_mal.atlas_weather.R
|
import com.appttude.h_mal.atlas_weather.R
|
||||||
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
||||||
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
|
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
|
||||||
import com.appttude.h_mal.atlas_weather.ui.dialog.PermissionsDeclarationDialog
|
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||||
|
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||||
import com.appttude.h_mal.atlas_weather.ui.home.adapter.WeatherRecyclerAdapter
|
import com.appttude.h_mal.atlas_weather.ui.home.adapter.WeatherRecyclerAdapter
|
||||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
|
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
||||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.*
|
import kotlinx.android.synthetic.main.fragment_home.*
|
||||||
import org.kodein.di.KodeinAware
|
|
||||||
import org.kodein.di.android.x.kodein
|
|
||||||
|
|
||||||
import org.kodein.di.generic.instance
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple [Fragment] subclass.
|
* A simple [Fragment] subclass.
|
||||||
* create an instance of this fragment.
|
* create an instance of this fragment.
|
||||||
*/
|
*/
|
||||||
class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
|
||||||
private val viewModel by getFragmentViewModel<MainViewModel>()
|
private lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
|
|
||||||
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||||
navigateToFurtherDetails(it)
|
navigateToFurtherDetails(it)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,23 +51,17 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
override fun onFailure(error: Any?) {
|
||||||
recyclerAdapter.addCurrent(it)
|
swipe_refresh.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSuccess(data: Any?) {
|
||||||
|
swipe_refresh.isRefreshing = false
|
||||||
|
if (data is WeatherDisplay) {
|
||||||
|
recyclerAdapter.addCurrent(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
|
||||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
|
||||||
viewModel.operationRefresh.observe(viewLifecycleOwner) { it ->
|
|
||||||
it.getContentIfNotHandled()?.let {
|
|
||||||
swipe_refresh.isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.operationState.observe(viewLifecycleOwner) {
|
|
||||||
swipe_refresh.isRefreshing = false
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
package com.appttude.h_mal.atlas_weather.ui.world
|
package com.appttude.h_mal.atlas_weather.ui.world
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.fragment.app.viewModels
|
|
||||||
import androidx.lifecycle.observe
|
|
||||||
import com.appttude.h_mal.atlas_weather.R
|
import com.appttude.h_mal.atlas_weather.R
|
||||||
|
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||||
import com.appttude.h_mal.atlas_weather.utils.displayToast
|
import com.appttude.h_mal.atlas_weather.utils.displayToast
|
||||||
import com.appttude.h_mal.atlas_weather.utils.goBack
|
import com.appttude.h_mal.atlas_weather.utils.goBack
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
|
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
|
||||||
import kotlinx.android.synthetic.main.activity_add_forecast.*
|
import kotlinx.android.synthetic.main.activity_add_forecast.submit
|
||||||
import org.kodein.di.KodeinAware
|
|
||||||
import org.kodein.di.android.x.kodein
|
|
||||||
import org.kodein.di.generic.instance
|
|
||||||
|
|
||||||
|
|
||||||
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
|
||||||
|
|
||||||
private val viewModel by getFragmentViewModel<WorldViewModel>()
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
@@ -31,16 +22,13 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
|||||||
submit.error = "Location cannot be blank"
|
submit.error = "Location cannot be blank"
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
viewModel.fetchDataForSingleLocation(locationName)
|
viewModel.fetchDataForSingleLocationSearch(locationName)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
override fun onSuccess(data: Any?) {
|
||||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
if (data is String) {
|
||||||
|
displayToast(data)
|
||||||
viewModel.operationComplete.observe(viewLifecycleOwner) {
|
|
||||||
it?.getContentIfNotHandled()?.let { message ->
|
|
||||||
displayToast(message)
|
|
||||||
}
|
|
||||||
goBack()
|
goBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ package com.appttude.h_mal.atlas_weather.ui.world
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.observe
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.appttude.h_mal.atlas_weather.R
|
import com.appttude.h_mal.atlas_weather.R
|
||||||
|
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||||
|
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
|
||||||
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.floatingActionButton
|
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.floatingActionButton
|
||||||
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.progressBar2
|
|
||||||
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycler
|
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycler
|
||||||
|
|
||||||
|
|
||||||
@@ -18,13 +17,13 @@ import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycl
|
|||||||
* A simple [Fragment] subclass.
|
* A simple [Fragment] subclass.
|
||||||
* create an instance of this fragment.
|
* create an instance of this fragment.
|
||||||
*/
|
*/
|
||||||
class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
|
class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment_add_location) {
|
||||||
val viewModel by getFragmentViewModel<WorldViewModel>()
|
private lateinit var recyclerAdapter: WorldRecyclerAdapter
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
val recyclerAdapter = WorldRecyclerAdapter {
|
recyclerAdapter = WorldRecyclerAdapter {
|
||||||
val direction =
|
val direction =
|
||||||
WorldFragmentDirections.actionWorldFragmentToWorldItemFragment(it)
|
WorldFragmentDirections.actionWorldFragmentToWorldItemFragment(it)
|
||||||
navigateTo(direction)
|
navigateTo(direction)
|
||||||
@@ -35,22 +34,18 @@ class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
|
|||||||
adapter = recyclerAdapter
|
adapter = recyclerAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
|
||||||
recyclerAdapter.addCurrent(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
floatingActionButton.setOnClickListener {
|
floatingActionButton.setOnClickListener {
|
||||||
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
|
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar2))
|
override fun onSuccess(data: Any?) {
|
||||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
super.onSuccess(data)
|
||||||
|
if (data is List<*>) recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
viewModel.fetchAllLocations()
|
viewModel.fetchAllLocations()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
|
||||||
db.getSimpleDao().upsertFullWeather(entityItem)
|
db.getWeatherDao().upsertFullWeather(entityItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun saveWeatherListToRoom(
|
override suspend fun saveWeatherListToRoom(
|
||||||
list: List<EntityItem>
|
list: List<EntityItem>
|
||||||
) {
|
) {
|
||||||
db.getSimpleDao().upsertListOfFullWeather(list)
|
db.getWeatherDao().upsertListOfFullWeather(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loadRoomWeatherLiveData() = db.getSimpleDao().getAllFullWeatherWithoutCurrent()
|
override fun loadRoomWeatherLiveData() = db.getWeatherDao().getAllFullWeatherWithoutCurrent()
|
||||||
|
|
||||||
override suspend fun loadWeatherList(): List<String> {
|
override suspend fun loadWeatherList(): List<String> {
|
||||||
return db.getSimpleDao()
|
return db.getWeatherDao()
|
||||||
.getWeatherListWithoutCurrent()
|
.getWeatherListWithoutCurrent()
|
||||||
.map { it.id }
|
.map { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loadCurrentWeatherFromRoom(id: String) =
|
override fun loadCurrentWeatherFromRoom(id: String) =
|
||||||
db.getSimpleDao().getCurrentFullWeather(id)
|
db.getWeatherDao().getCurrentFullWeather(id)
|
||||||
|
|
||||||
override suspend fun loadSingleCurrentWeatherFromRoom(id: String) =
|
override suspend fun loadSingleCurrentWeatherFromRoom(id: String) =
|
||||||
db.getSimpleDao().getCurrentFullWeatherSingle(id)
|
db.getWeatherDao().getCurrentFullWeatherSingle(id)
|
||||||
|
|
||||||
override fun isSearchValid(locationName: String): Boolean {
|
override fun isSearchValid(locationName: String): Boolean {
|
||||||
val lastSaved = prefs
|
val lastSaved = prefs
|
||||||
.getLastSavedAt("$LOCATION_CONST$locationName")
|
.getLastSavedAt("$LOCATION_CONST$locationName")
|
||||||
?: return true
|
|
||||||
val difference = System.currentTimeMillis() - lastSaved
|
val difference = System.currentTimeMillis() - lastSaved
|
||||||
|
|
||||||
return difference > FALLBACK_TIME
|
return difference > FALLBACK_TIME
|
||||||
@@ -62,7 +61,7 @@ class RepositoryImpl(
|
|||||||
|
|
||||||
override suspend fun deleteSavedWeatherEntry(locationName: String): Boolean {
|
override suspend fun deleteSavedWeatherEntry(locationName: String): Boolean {
|
||||||
prefs.deleteLocation(locationName)
|
prefs.deleteLocation(locationName)
|
||||||
return db.getSimpleDao().deleteEntry(locationName) > 0
|
return db.getWeatherDao().deleteEntry(locationName) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSavedLocations(): List<String> {
|
override fun getSavedLocations(): List<String> {
|
||||||
@@ -70,7 +69,7 @@ class RepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getSingleWeather(locationName: String): EntityItem {
|
override suspend fun getSingleWeather(locationName: String): EntityItem {
|
||||||
return db.getSimpleDao().getCurrentFullWeatherSingle(locationName)
|
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
|||||||
@TypeConverters(Converter::class)
|
@TypeConverters(Converter::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
abstract fun getSimpleDao(): WeatherDao
|
abstract fun getWeatherDao(): WeatherDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ object GenericsHelper {
|
|||||||
?.kotlin
|
?.kotlin
|
||||||
?: throw IllegalStateException("Can not find class from generic argument")
|
?: throw IllegalStateException("Can not find class from generic argument")
|
||||||
|
|
||||||
|
// @Suppress("UNCHECKED_CAST")
|
||||||
|
// fun <CLASS : Any> Any.getGenericClassInMethod(position: Int): KClass<CLASS> =
|
||||||
|
// ((javaClass.methods as? ParameterizedType)
|
||||||
|
// ?.actualTypeArguments?.getOrNull(position) as? Class<CLASS>)
|
||||||
|
// ?.kotlin
|
||||||
|
// ?: throw IllegalStateException("Can not find class from generic argument")
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
// * Create a view binding out of the the generic [VB]
|
// * Create a view binding out of the the generic [VB]
|
||||||
// *
|
// *
|
||||||
|
|||||||
@@ -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.annotation.SuppressLint
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -9,16 +7,17 @@ import android.view.View
|
|||||||
import androidx.annotation.LayoutRes
|
import androidx.annotation.LayoutRes
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.createViewModelLazy
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
||||||
|
import com.appttude.h_mal.atlas_weather.base.BaseActivity
|
||||||
|
import com.appttude.h_mal.atlas_weather.helper.GenericsHelper.getGenericClassAt
|
||||||
|
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||||
import com.appttude.h_mal.atlas_weather.utils.Event
|
import com.appttude.h_mal.atlas_weather.utils.Event
|
||||||
import com.appttude.h_mal.atlas_weather.utils.displayToast
|
import com.appttude.h_mal.atlas_weather.utils.displayToast
|
||||||
import com.appttude.h_mal.atlas_weather.utils.hide
|
|
||||||
import com.appttude.h_mal.atlas_weather.utils.show
|
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
|
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
|
||||||
|
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.BaseViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -28,13 +27,18 @@ import org.kodein.di.generic.instance
|
|||||||
import kotlin.properties.Delegates
|
import kotlin.properties.Delegates
|
||||||
|
|
||||||
@Suppress("EmptyMethod", "EmptyMethod")
|
@Suppress("EmptyMethod", "EmptyMethod")
|
||||||
abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId),
|
abstract class BaseFragment<V : BaseViewModel>(@LayoutRes contentLayoutId: Int) :
|
||||||
|
Fragment(contentLayoutId),
|
||||||
KodeinAware {
|
KodeinAware {
|
||||||
|
|
||||||
override val kodein by kodein()
|
override val kodein by kodein()
|
||||||
val factory by instance<ApplicationViewModelFactory>()
|
val factory by instance<ApplicationViewModelFactory>()
|
||||||
|
|
||||||
inline fun <reified VM : ViewModel> getFragmentViewModel(): Lazy<VM> = viewModels { factory }
|
val viewModel: V by getFragmentViewModel()
|
||||||
|
|
||||||
|
var mActivity: BaseActivity? = null
|
||||||
|
private fun getFragmentViewModel(): Lazy<V> =
|
||||||
|
createViewModelLazy(getGenericClassAt(0), { viewModelStore }, factoryProducer = { factory })
|
||||||
|
|
||||||
private var shortAnimationDuration by Delegates.notNull<Int>()
|
private var shortAnimationDuration by Delegates.notNull<Int>()
|
||||||
|
|
||||||
@@ -43,25 +47,42 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
|
|||||||
shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
|
shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toggle visibility of progress spinner while async operations are taking place
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun progressBarStateObserver(progressBar: View) = Observer<Event<Boolean>> {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
it.getContentIfNotHandled()?.let { i ->
|
super.onViewCreated(view, savedInstanceState)
|
||||||
if (i)
|
mActivity = activity as BaseActivity
|
||||||
progressBar.fadeIn()
|
configureObserver()
|
||||||
else
|
}
|
||||||
progressBar.fadeOut()
|
|
||||||
|
private fun configureObserver() {
|
||||||
|
viewModel.uiState.observe(viewLifecycleOwner) {
|
||||||
|
when (it) {
|
||||||
|
is ViewState.HasStarted -> onStarted()
|
||||||
|
is ViewState.HasData<*> -> onSuccess(it.data)
|
||||||
|
is ViewState.HasError<*> -> onFailure(it.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// display a toast when operation fails
|
/**
|
||||||
fun errorObserver() = Observer<Event<String>> {
|
* Called in case of starting operation liveData in viewModel
|
||||||
it.getContentIfNotHandled()?.let { message ->
|
*/
|
||||||
displayToast(message)
|
open fun onStarted() {
|
||||||
}
|
mActivity?.onStarted()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshObserver(refresher: SwipeRefreshLayout) = Observer<Event<Boolean>> {
|
/**
|
||||||
refresher.isRefreshing = false
|
* Called in case of success or some data emitted from the liveData in viewModel
|
||||||
|
*/
|
||||||
|
open fun onSuccess(data: Any?) {
|
||||||
|
mActivity?.onSuccess(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called in case of failure or some error emitted from the liveData in viewModel
|
||||||
|
*/
|
||||||
|
open fun onFailure(error: Any?) {
|
||||||
|
mActivity?.onFailure(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,46 +111,6 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun View.fadeIn() {
|
|
||||||
apply {
|
|
||||||
// Set the content view to 0% opacity but visible, so that it is visible
|
|
||||||
// (but fully transparent) during the animation.
|
|
||||||
alpha = 0f
|
|
||||||
hide()
|
|
||||||
|
|
||||||
// Animate the content view to 100% opacity, and clear any animation
|
|
||||||
// listener set on the view.
|
|
||||||
animate()
|
|
||||||
.alpha(1f)
|
|
||||||
.setDuration(shortAnimationDuration.toLong())
|
|
||||||
.setListener(object : AnimatorListenerAdapter() {
|
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun View.fadeOut() {
|
|
||||||
apply {
|
|
||||||
// Set the content view to 0% opacity but visible, so that it is visible
|
|
||||||
// (but fully transparent) during the animation.
|
|
||||||
alpha = 1f
|
|
||||||
show()
|
|
||||||
|
|
||||||
// Animate the content view to 100% opacity, and clear any animation
|
|
||||||
// listener set on the view.
|
|
||||||
animate()
|
|
||||||
.alpha(0f)
|
|
||||||
.setDuration(shortAnimationDuration.toLong())
|
|
||||||
.setListener(object : AnimatorListenerAdapter() {
|
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
override fun onRequestPermissionsResult(
|
override fun onRequestPermissionsResult(
|
||||||
requestCode: Int,
|
requestCode: Int,
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import androidx.navigation.ui.AppBarConfiguration
|
|||||||
import androidx.navigation.ui.setupActionBarWithNavController
|
import androidx.navigation.ui.setupActionBarWithNavController
|
||||||
import androidx.navigation.ui.setupWithNavController
|
import androidx.navigation.ui.setupWithNavController
|
||||||
import com.appttude.h_mal.atlas_weather.R
|
import com.appttude.h_mal.atlas_weather.R
|
||||||
|
import com.appttude.h_mal.atlas_weather.base.BaseActivity
|
||||||
|
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
||||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||||
import kotlinx.android.synthetic.main.activity_main_navigation.toolbar
|
import kotlinx.android.synthetic.main.activity_main_navigation.toolbar
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : BaseActivity() {
|
||||||
|
|
||||||
lateinit var navHost: NavHostFragment
|
lateinit var navHost: NavHostFragment
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import android.content.Context
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.animation.Animation
|
||||||
|
import android.view.animation.AnimationUtils
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.AnimRes
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.appttude.h_mal.atlas_weather.R
|
import com.appttude.h_mal.atlas_weather.R
|
||||||
import com.squareup.picasso.Picasso
|
import com.squareup.picasso.Picasso
|
||||||
@@ -43,3 +46,13 @@ fun Fragment.hideKeyboard() {
|
|||||||
val imm = context?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager?
|
val imm = context?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager?
|
||||||
imm?.hideSoftInputFromWindow(view?.windowToken, 0)
|
imm?.hideSoftInputFromWindow(view?.windowToken, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun View.triggerAnimation(@AnimRes id: Int, complete: (View) -> Unit) {
|
||||||
|
val animation = AnimationUtils.loadAnimation(context, id)
|
||||||
|
animation.setAnimationListener(object : Animation.AnimationListener {
|
||||||
|
override fun onAnimationEnd(animation: Animation?) = complete(this@triggerAnimation)
|
||||||
|
override fun onAnimationStart(a: Animation?) {}
|
||||||
|
override fun onAnimationRepeat(a: Animation?) {}
|
||||||
|
})
|
||||||
|
startAnimation(animation)
|
||||||
|
}
|
||||||
@@ -2,13 +2,11 @@ package com.appttude.h_mal.atlas_weather.viewmodel
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import androidx.annotation.RequiresPermission
|
import androidx.annotation.RequiresPermission
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||||
import com.appttude.h_mal.atlas_weather.utils.Event
|
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -19,48 +17,40 @@ class MainViewModel(
|
|||||||
private val repository: Repository
|
private val repository: Repository
|
||||||
) : WeatherViewModel() {
|
) : WeatherViewModel() {
|
||||||
|
|
||||||
val weatherLiveData = MutableLiveData<WeatherDisplay>()
|
|
||||||
|
|
||||||
val operationState = MutableLiveData<Event<Boolean>>()
|
|
||||||
val operationError = MutableLiveData<Event<String>>()
|
|
||||||
val operationRefresh = MutableLiveData<Event<Boolean>>()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {
|
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {
|
||||||
it?.let {
|
it?.let {
|
||||||
val weather = WeatherDisplay(it)
|
val weather = WeatherDisplay(it)
|
||||||
weatherLiveData.postValue(weather)
|
onSuccess(weather)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
|
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||||
fun fetchData() {
|
fun fetchData() {
|
||||||
if (!repository.isSearchValid(CURRENT_LOCATION)) {
|
onStart()
|
||||||
operationRefresh.postValue(Event(false))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
operationState.postValue(Event(true))
|
|
||||||
try {
|
try {
|
||||||
// Get location
|
// Has the search been conducted in the last 5 minutes
|
||||||
val latLong = locationProvider.getCurrentLatLong()
|
val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) {
|
||||||
// Get weather from api
|
// Get location
|
||||||
val weather = repository
|
val latLong = locationProvider.getCurrentLatLong()
|
||||||
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
|
// Get weather from api
|
||||||
val currentLocation =
|
val weather = repository
|
||||||
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
|
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
|
||||||
val fullWeather = createFullWeather(weather, currentLocation)
|
val currentLocation =
|
||||||
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
|
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
|
||||||
|
val fullWeather = createFullWeather(weather, currentLocation)
|
||||||
|
EntityItem(CURRENT_LOCATION, fullWeather)
|
||||||
|
} else {
|
||||||
|
repository.getSingleWeather(CURRENT_LOCATION)
|
||||||
|
}
|
||||||
// Save data if not null
|
// Save data if not null
|
||||||
repository.saveLastSavedAt(CURRENT_LOCATION)
|
repository.saveLastSavedAt(CURRENT_LOCATION)
|
||||||
repository.saveCurrentWeatherToRoom(entityItem)
|
repository.saveCurrentWeatherToRoom(entityItem)
|
||||||
|
onSuccess(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
operationError.postValue(Event(e.message!!))
|
onError(e.message!!)
|
||||||
} finally {
|
|
||||||
operationState.postValue(Event(false))
|
|
||||||
operationRefresh.postValue(Event(false))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
package com.appttude.h_mal.atlas_weather.viewmodel
|
package com.appttude.h_mal.atlas_weather.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||||
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||||
import com.appttude.h_mal.atlas_weather.utils.Event
|
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -21,15 +19,6 @@ class WorldViewModel(
|
|||||||
private val repository: Repository
|
private val repository: Repository
|
||||||
) : WeatherViewModel() {
|
) : WeatherViewModel() {
|
||||||
|
|
||||||
val weatherLiveData = MutableLiveData<List<WeatherDisplay>>()
|
|
||||||
val singleWeatherLiveData = MutableLiveData<WeatherDisplay>()
|
|
||||||
|
|
||||||
val operationState = MutableLiveData<Event<Boolean>>()
|
|
||||||
val operationError = MutableLiveData<Event<String>>()
|
|
||||||
val operationRefresh = MutableLiveData<Event<Boolean>>()
|
|
||||||
|
|
||||||
val operationComplete = MutableLiveData<Event<String>>()
|
|
||||||
|
|
||||||
private val weatherListLiveData = repository.loadRoomWeatherLiveData()
|
private val weatherListLiveData = repository.loadRoomWeatherLiveData()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -37,7 +26,7 @@ class WorldViewModel(
|
|||||||
val list = it.map { data ->
|
val list = it.map { data ->
|
||||||
WeatherDisplay(data)
|
WeatherDisplay(data)
|
||||||
}
|
}
|
||||||
weatherLiveData.postValue(list)
|
onSuccess(list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,36 +34,34 @@ class WorldViewModel(
|
|||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val entity = repository.getSingleWeather(locationName)
|
val entity = repository.getSingleWeather(locationName)
|
||||||
val item = WeatherDisplay(entity)
|
val item = WeatherDisplay(entity)
|
||||||
singleWeatherLiveData.postValue(item)
|
onSuccess(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchDataForSingleLocation(locationName: String) {
|
fun fetchDataForSingleLocation(locationName: String) {
|
||||||
if (!repository.isSearchValid(locationName)) {
|
onStart()
|
||||||
operationRefresh.postValue(Event(false))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
operationState.postValue(Event(true))
|
|
||||||
try {
|
try {
|
||||||
val weatherEntity = createWeatherEntity(locationName)
|
val weatherEntity = if (repository.isSearchValid(locationName)) {
|
||||||
|
createWeatherEntity(locationName)
|
||||||
|
} else {
|
||||||
|
repository.getSingleWeather(locationName)
|
||||||
|
}
|
||||||
repository.saveCurrentWeatherToRoom(weatherEntity)
|
repository.saveCurrentWeatherToRoom(weatherEntity)
|
||||||
repository.saveLastSavedAt(locationName)
|
repository.saveLastSavedAt(locationName)
|
||||||
|
onSuccess(Unit)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
operationError.postValue(Event(e.message!!))
|
onError(e.message!!)
|
||||||
} finally {
|
|
||||||
operationState.postValue(Event(false))
|
|
||||||
operationRefresh.postValue(Event(false))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchDataForSingleLocationSearch(locationName: String) {
|
fun fetchDataForSingleLocationSearch(locationName: String) {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
operationState.postValue(Event(true))
|
onStart()
|
||||||
// Check if location exists
|
// Check if location exists
|
||||||
if (repository.getSavedLocations().contains(locationName)) {
|
if (repository.getSavedLocations().contains(locationName)) {
|
||||||
operationError.postValue(Event("$locationName already exists"))
|
onError("$locationName already exists")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,29 +76,26 @@ class WorldViewModel(
|
|||||||
LocationType.City
|
LocationType.City
|
||||||
)
|
)
|
||||||
if (repository.getSavedLocations().contains(retrievedLocation)) {
|
if (repository.getSavedLocations().contains(retrievedLocation)) {
|
||||||
operationError.postValue(Event("$retrievedLocation already exists"))
|
onError("$retrievedLocation already exists")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
// Save data if not null
|
// Save data if not null
|
||||||
repository.saveCurrentWeatherToRoom(entityItem)
|
repository.saveCurrentWeatherToRoom(entityItem)
|
||||||
repository.saveLastSavedAt(retrievedLocation)
|
repository.saveLastSavedAt(retrievedLocation)
|
||||||
operationComplete.postValue(Event("$retrievedLocation saved"))
|
onSuccess("$retrievedLocation saved")
|
||||||
|
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
operationError.postValue(Event(e.message!!))
|
onError(e.message!!)
|
||||||
} finally {
|
|
||||||
operationState.postValue(Event(false))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchAllLocations() {
|
fun fetchAllLocations() {
|
||||||
|
onStart()
|
||||||
if (!repository.isSearchValid(ALL_LOADED)) {
|
if (!repository.isSearchValid(ALL_LOADED)) {
|
||||||
operationRefresh.postValue(Event(false))
|
onSuccess(Unit)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
operationState.postValue(Event(true))
|
|
||||||
try {
|
try {
|
||||||
val list = mutableListOf<EntityItem>()
|
val list = mutableListOf<EntityItem>()
|
||||||
repository.loadWeatherList().forEach { locationName ->
|
repository.loadWeatherList().forEach { locationName ->
|
||||||
@@ -128,25 +112,25 @@ class WorldViewModel(
|
|||||||
repository.saveWeatherListToRoom(list)
|
repository.saveWeatherListToRoom(list)
|
||||||
repository.saveLastSavedAt(ALL_LOADED)
|
repository.saveLastSavedAt(ALL_LOADED)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
operationError.postValue(Event(e.message!!))
|
onError(e.message!!)
|
||||||
} finally {
|
} finally {
|
||||||
operationState.postValue(Event(false))
|
onSuccess(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteLocation(locationName: String) {
|
fun deleteLocation(locationName: String) {
|
||||||
|
onStart()
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
operationState.postValue(Event(true))
|
|
||||||
try {
|
try {
|
||||||
val success = repository.deleteSavedWeatherEntry(locationName)
|
val success = repository.deleteSavedWeatherEntry(locationName)
|
||||||
if (!success) {
|
if (!success) {
|
||||||
operationError.postValue(Event("Failed to delete"))
|
onError("Failed to delete")
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
operationError.postValue(Event(e.message!!))
|
onError(e.message!!)
|
||||||
} finally {
|
} finally {
|
||||||
operationState.postValue(Event(false))
|
onSuccess(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.data.room.entity.EntityItem
|
||||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||||
|
|
||||||
abstract class WeatherViewModel : ViewModel() {
|
abstract class WeatherViewModel : BaseViewModel() {
|
||||||
|
|
||||||
fun createFullWeather(
|
fun createFullWeather(
|
||||||
weather: WeatherResponse,
|
weather: WeatherResponse,
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginLeft="24dp"
|
android:paddingLeft="24dp"
|
||||||
android:layout_marginTop="6dp"
|
android:paddingTop="6dp"
|
||||||
android:layout_marginRight="24dp"
|
android:paddingRight="24dp"
|
||||||
android:layout_marginBottom="6dp"
|
android:paddingBottom="6dp"
|
||||||
|
android:background="@color/colorPrimaryDark"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
|
|||||||
@@ -7,31 +7,17 @@
|
|||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/swipe_refresh"
|
android:id="@+id/swipe_refresh"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/forecast_listview"
|
android:id="@+id/forecast_listview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
android:backgroundTint="@color/colorPrimaryDark"
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
tools:listitem="@layout/db_list_item"></androidx.recyclerview.widget.RecyclerView>
|
tools:listitem="@layout/db_list_item" />
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/progressBar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="@android:color/black"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:visibility="gone">
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
style="?android:attr/progressBarStyle"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center" />
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
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="loading_nforecast">Loading \nforecast…</string>
|
||||||
<string name="retrieve_warning">Unable to retrieve weather</string>
|
<string name="retrieve_warning">Unable to retrieve weather</string>
|
||||||
<string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string>
|
<string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string>
|
||||||
|
<string name="no_weather_to_display">No weather to display</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
|||||||
var bodyTV: TextView = itemView.findViewById(R.id.body_text)
|
var bodyTV: TextView = itemView.findViewById(R.id.body_text)
|
||||||
var headerTV: TextView = itemView.findViewById(R.id.header_text)
|
var headerTV: TextView = itemView.findViewById(R.id.header_text)
|
||||||
|
|
||||||
fun bindData(@DrawableRes imageRes: Int?, header: String, body: String) {
|
fun bindData(
|
||||||
imageRes?.let { icon.setImageResource(it) }
|
@DrawableRes imageRes: Int? = R.drawable.ic_baseline_cloud_off_24,
|
||||||
headerTV.text = header
|
header: String = itemView.resources.getString(R.string.retrieve_warning),
|
||||||
bodyTV.text = body
|
body: String = itemView.resources.getString(R.string.empty_retrieve_warning) ){
|
||||||
|
imageRes?.let { icon.setImageResource(it) }
|
||||||
}
|
headerTV.text = header
|
||||||
|
bodyTV.text = body
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,22 +3,23 @@ package com.appttude.h_mal.monoWeather.ui
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.lifecycle.Observer
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.appttude.h_mal.atlas_weather.R
|
import com.appttude.h_mal.atlas_weather.R
|
||||||
|
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||||
|
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||||
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
|
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
|
||||||
import kotlinx.android.synthetic.main.fragment_home.forecast_listview
|
import kotlinx.android.synthetic.main.fragment_home.forecast_listview
|
||||||
import kotlinx.android.synthetic.main.fragment_home.progressBar
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.swipe_refresh
|
import kotlinx.android.synthetic.main.fragment_home.swipe_refresh
|
||||||
|
|
||||||
|
|
||||||
class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
|
class WorldItemFragment : BaseFragment<WorldViewModel>(R.layout.fragment_home) {
|
||||||
|
|
||||||
private val viewModel by getFragmentViewModel<WorldViewModel>()
|
|
||||||
private var param1: String? = null
|
private var param1: String? = null
|
||||||
|
|
||||||
|
private lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
param1 = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName
|
param1 = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName
|
||||||
@@ -27,19 +28,12 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
val recyclerAdapter = WeatherRecyclerAdapter {
|
recyclerAdapter = WeatherRecyclerAdapter {
|
||||||
val directions =
|
val directions =
|
||||||
WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it)
|
WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it)
|
||||||
navigateTo(directions)
|
navigateTo(directions)
|
||||||
}
|
}
|
||||||
|
|
||||||
param1?.let { viewModel.getSingleLocation(it) }
|
|
||||||
|
|
||||||
viewModel.singleWeatherLiveData.observe(viewLifecycleOwner, Observer {
|
|
||||||
recyclerAdapter.addCurrent(it)
|
|
||||||
swipe_refresh.isRefreshing = false
|
|
||||||
})
|
|
||||||
|
|
||||||
forecast_listview.apply {
|
forecast_listview.apply {
|
||||||
layoutManager = LinearLayoutManager(context)
|
layoutManager = LinearLayoutManager(context)
|
||||||
adapter = recyclerAdapter
|
adapter = recyclerAdapter
|
||||||
@@ -54,9 +48,20 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
param1?.let { viewModel.getSingleLocation(it) }
|
||||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
|
||||||
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSuccess(data: Any?) {
|
||||||
|
super.onSuccess(data)
|
||||||
|
swipe_refresh.isRefreshing = false
|
||||||
|
|
||||||
|
if (data is WeatherDisplay) {
|
||||||
|
recyclerAdapter.addCurrent(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(error: Any?) {
|
||||||
|
super.onFailure(error)
|
||||||
|
swipe_refresh.isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,10 +13,11 @@ import androidx.navigation.ui.onNavDestinationSelected
|
|||||||
import com.appttude.h_mal.atlas_weather.R
|
import com.appttude.h_mal.atlas_weather.R
|
||||||
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
||||||
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
|
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
|
||||||
|
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
||||||
import com.appttude.h_mal.monoWeather.dialog.PermissionsDeclarationDialog
|
import com.appttude.h_mal.monoWeather.dialog.PermissionsDeclarationDialog
|
||||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||||
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
|
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
|
||||||
import kotlinx.android.synthetic.main.fragment_home.*
|
import kotlinx.android.synthetic.main.fragment_home.*
|
||||||
|
|
||||||
@@ -25,22 +26,16 @@ import kotlinx.android.synthetic.main.fragment_home.*
|
|||||||
* A simple [Fragment] subclass.
|
* A simple [Fragment] subclass.
|
||||||
* create an instance of this fragment.
|
* create an instance of this fragment.
|
||||||
*/
|
*/
|
||||||
class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
|
||||||
|
|
||||||
private val viewModel by getFragmentViewModel<MainViewModel>()
|
|
||||||
|
|
||||||
lateinit var dialog: PermissionsDeclarationDialog
|
lateinit var dialog: PermissionsDeclarationDialog
|
||||||
|
lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
|
|
||||||
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
|
||||||
navigateToFurtherDetails(it)
|
|
||||||
})
|
|
||||||
|
|
||||||
forecast_listview.adapter = recyclerAdapter
|
|
||||||
dialog = PermissionsDeclarationDialog(requireContext())
|
dialog = PermissionsDeclarationDialog(requireContext())
|
||||||
|
|
||||||
swipe_refresh.apply {
|
swipe_refresh.apply {
|
||||||
@@ -52,13 +47,11 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||||
recyclerAdapter.addCurrent(it)
|
navigateToFurtherDetails(it)
|
||||||
}
|
})
|
||||||
|
|
||||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
forecast_listview.adapter = recyclerAdapter
|
||||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
|
||||||
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
@@ -76,6 +69,20 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
|||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSuccess(data: Any?) {
|
||||||
|
super.onSuccess(data)
|
||||||
|
swipe_refresh.isRefreshing = false
|
||||||
|
|
||||||
|
if (data is WeatherDisplay) {
|
||||||
|
recyclerAdapter.addCurrent(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(error: Any?) {
|
||||||
|
super.onFailure(error)
|
||||||
|
swipe_refresh.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
override fun permissionsGranted() {
|
override fun permissionsGranted() {
|
||||||
viewModel.fetchData()
|
viewModel.fetchData()
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ class WeatherRecyclerAdapter(
|
|||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (getDataType(getItemViewType(position))) {
|
when (getDataType(getItemViewType(position))) {
|
||||||
is ViewType.Empty -> {
|
is ViewType.Empty -> {
|
||||||
holder as EmptyViewHolder
|
val emptyViewHolder = holder as EmptyViewHolder
|
||||||
|
emptyViewHolder.bindData()
|
||||||
}
|
}
|
||||||
|
|
||||||
is ViewType.Current -> {
|
is ViewType.Current -> {
|
||||||
@@ -115,7 +116,7 @@ class WeatherRecyclerAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
return if (weather == null) 0 else 3 + (weather?.forecast?.size ?: 0)
|
return if (weather == null) 1 else 3 + (weather?.forecast?.size ?: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -8,19 +8,17 @@ import com.appttude.h_mal.atlas_weather.utils.displayToast
|
|||||||
import com.appttude.h_mal.atlas_weather.utils.goBack
|
import com.appttude.h_mal.atlas_weather.utils.goBack
|
||||||
import com.appttude.h_mal.atlas_weather.utils.hideKeyboard
|
import com.appttude.h_mal.atlas_weather.utils.hideKeyboard
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||||
import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
|
import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
|
||||||
import kotlinx.android.synthetic.main.activity_add_forecast.progressBar
|
import kotlinx.android.synthetic.main.activity_add_forecast.progressBar
|
||||||
import kotlinx.android.synthetic.main.activity_add_forecast.submit
|
import kotlinx.android.synthetic.main.activity_add_forecast.submit
|
||||||
|
|
||||||
|
|
||||||
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
val viewModel by getFragmentViewModel<WorldViewModel>()
|
|
||||||
|
|
||||||
submit.setOnClickListener {
|
submit.setOnClickListener {
|
||||||
val locationName = location_name_tv.text?.trim()?.toString()
|
val locationName = location_name_tv.text?.trim()?.toString()
|
||||||
if (locationName.isNullOrBlank()) {
|
if (locationName.isNullOrBlank()) {
|
||||||
@@ -30,14 +28,12 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
|||||||
viewModel.fetchDataForSingleLocationSearch(locationName)
|
viewModel.fetchDataForSingleLocationSearch(locationName)
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
override fun onSuccess(data: Any?) {
|
||||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
super.onSuccess(data)
|
||||||
|
if (data is String) {
|
||||||
viewModel.operationComplete.observe(viewLifecycleOwner) {
|
displayToast(data)
|
||||||
it?.getContentIfNotHandled()?.let { message ->
|
|
||||||
displayToast(message)
|
|
||||||
}
|
|
||||||
goBack()
|
goBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import androidx.fragment.app.Fragment
|
|||||||
import androidx.lifecycle.observe
|
import androidx.lifecycle.observe
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.appttude.h_mal.atlas_weather.R
|
import com.appttude.h_mal.atlas_weather.R
|
||||||
|
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||||
import com.appttude.h_mal.monoWeather.ui.world.WorldFragmentDirections.actionWorldFragmentToWorldItemFragment
|
import com.appttude.h_mal.monoWeather.ui.world.WorldFragmentDirections.actionWorldFragmentToWorldItemFragment
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import kotlinx.android.synthetic.main.fragment__two.floatingActionButton
|
import kotlinx.android.synthetic.main.fragment__two.floatingActionButton
|
||||||
@@ -20,8 +21,9 @@ import kotlinx.android.synthetic.main.fragment__two.world_recycler
|
|||||||
* A simple [Fragment] subclass.
|
* A simple [Fragment] subclass.
|
||||||
* create an instance of this fragment.
|
* create an instance of this fragment.
|
||||||
*/
|
*/
|
||||||
class WorldFragment : BaseFragment(R.layout.fragment__two) {
|
class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment__two) {
|
||||||
private val viewModel by getFragmentViewModel<WorldViewModel>()
|
|
||||||
|
lateinit var recyclerAdapter: WorldRecyclerAdapter
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -32,7 +34,7 @@ class WorldFragment : BaseFragment(R.layout.fragment__two) {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
val recyclerAdapter = WorldRecyclerAdapter({
|
recyclerAdapter = WorldRecyclerAdapter({
|
||||||
val direction =
|
val direction =
|
||||||
actionWorldFragmentToWorldItemFragment(it.location)
|
actionWorldFragmentToWorldItemFragment(it.location)
|
||||||
navigateTo(direction)
|
navigateTo(direction)
|
||||||
@@ -53,17 +55,18 @@ class WorldFragment : BaseFragment(R.layout.fragment__two) {
|
|||||||
adapter = recyclerAdapter
|
adapter = recyclerAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
|
||||||
recyclerAdapter.addCurrent(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
floatingActionButton.setOnClickListener {
|
floatingActionButton.setOnClickListener {
|
||||||
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
|
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
}
|
||||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
|
||||||
|
|
||||||
|
override fun onSuccess(data: Any?) {
|
||||||
|
super.onSuccess(data)
|
||||||
|
|
||||||
|
if (data is List<*>) {
|
||||||
|
recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
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:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/colorPrimaryDark">
|
||||||
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="24dp">
|
android:layout_margin="24dp"
|
||||||
|
android:background="@color/colorPrimaryDark">
|
||||||
|
|
||||||
<GridView
|
<GridView
|
||||||
android:id="@+id/grid_mono"
|
android:id="@+id/grid_mono"
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
package com.appttude.h_mal.atlas_weather.data.repository
|
package com.appttude.h_mal.atlas_weather.data.repository
|
||||||
|
|
||||||
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
|
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
|
||||||
|
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||||
import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
|
import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
|
||||||
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
|
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
|
||||||
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
||||||
|
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||||
|
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||||
|
import com.nhaarman.mockitokotlin2.any
|
||||||
|
import com.nhaarman.mockitokotlin2.doAnswer
|
||||||
import io.mockk.MockKAnnotations
|
import io.mockk.MockKAnnotations
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coJustRun
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.impl.annotations.MockK
|
import io.mockk.impl.annotations.MockK
|
||||||
|
import io.mockk.justRun
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.mockito.ArgumentMatchers.anyString
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.io.IOException
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
|
||||||
private const val MORE_THAN_FIVE_MINS = 330000L
|
private const val MORE_THAN_FIVE_MINS = 330000L
|
||||||
private const val LESS_THAN_FIVE_MINS = 270000L
|
private const val LESS_THAN_FIVE_MINS = 270000L
|
||||||
|
|
||||||
class RepositoryImplTest {
|
class RepositoryImplTest : BaseTest() {
|
||||||
|
|
||||||
lateinit var repository: RepositoryImpl
|
lateinit var repository: RepositoryImpl
|
||||||
|
|
||||||
@@ -71,4 +86,60 @@ class RepositoryImplTest {
|
|||||||
val valid: Boolean = repository.isSearchValid(location)
|
val valid: Boolean = repository.isSearchValid(location)
|
||||||
assertEquals(valid, true)
|
assertEquals(valid, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getWeatherFromApi_validLatLong_validSearch() {
|
||||||
|
//Arrange
|
||||||
|
val mockResponse = createSuccessfulRetrofitMock<WeatherResponse>()
|
||||||
|
|
||||||
|
//Act
|
||||||
|
//create a successful retrofit response
|
||||||
|
coEvery { api.getFromApi("", "") }.returns(mockResponse)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
runBlocking {
|
||||||
|
val result = repository.getWeatherFromApi("", "")
|
||||||
|
assertIs<WeatherResponse>(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getWeatherFromApi_validLatLong_invalidResponse() {
|
||||||
|
//Arrange
|
||||||
|
val mockResponse = createErrorRetrofitMock<WeatherResponse>()
|
||||||
|
|
||||||
|
//Act
|
||||||
|
//create a successful retrofit response
|
||||||
|
coEvery { api.getFromApi(any(), any()) } returns (mockResponse)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
val ioExceptionReturned = assertFailsWith<IOException> {
|
||||||
|
runBlocking {
|
||||||
|
repository.getWeatherFromApi("", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(ioExceptionReturned.message, "Error Code: 400")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadWeatherList_validResponse() {
|
||||||
|
// Arrange
|
||||||
|
val elements = listOf<EntityItem>(
|
||||||
|
mockk { every { id } returns any() },
|
||||||
|
mockk { every { id } returns any() },
|
||||||
|
mockk { every { id } returns any() },
|
||||||
|
mockk { every { id } returns any() }
|
||||||
|
)
|
||||||
|
|
||||||
|
//Act
|
||||||
|
coEvery { db.getWeatherDao().getWeatherListWithoutCurrent() } returns elements
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
runBlocking {
|
||||||
|
val result = repository.loadWeatherList()
|
||||||
|
assertIs<List<String>>(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository
|
|||||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||||
import com.google.gson.Gson
|
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||||
import io.mockk.MockKAnnotations
|
import io.mockk.MockKAnnotations
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -18,9 +18,7 @@ import org.junit.Before
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class ServicesHelperTest {
|
class ServicesHelperTest : BaseTest() {
|
||||||
|
|
||||||
private val gson = Gson()
|
|
||||||
|
|
||||||
lateinit var helper: ServicesHelper
|
lateinit var helper: ServicesHelper
|
||||||
|
|
||||||
@@ -40,8 +38,7 @@ class ServicesHelperTest {
|
|||||||
MockKAnnotations.init(this)
|
MockKAnnotations.init(this)
|
||||||
helper = ServicesHelper(repository, settingsRepository, locationProvider)
|
helper = ServicesHelper(repository, settingsRepository, locationProvider)
|
||||||
|
|
||||||
val json = this::class.java.classLoader!!.getResource("weather_sample.json").readText()
|
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
|
||||||
weatherResponse = gson.fromJson(json, WeatherResponse::class.java)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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
|
package com.appttude.h_mal.atlas_weather.viewmodel
|
||||||
|
|
||||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.Observer
|
|
||||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
|
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
|
||||||
|
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||||
|
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||||
|
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||||
|
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||||
|
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||||
|
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||||
|
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
|
||||||
|
import com.appttude.h_mal.atlas_weather.utils.sleep
|
||||||
import io.mockk.MockKAnnotations
|
import io.mockk.MockKAnnotations
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.impl.annotations.MockK
|
import io.mockk.impl.annotations.MockK
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import kotlin.test.assertIs
|
||||||
import java.util.concurrent.TimeoutException
|
|
||||||
|
|
||||||
|
class WorldViewModelTest : BaseTest() {
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
class WorldViewModelTest {
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val rule = InstantTaskExecutorRule()
|
val rule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
@@ -29,46 +36,100 @@ class WorldViewModelTest {
|
|||||||
@MockK
|
@MockK
|
||||||
lateinit var locationProvider: LocationProviderImpl
|
lateinit var locationProvider: LocationProviderImpl
|
||||||
|
|
||||||
|
lateinit var weatherResponse: WeatherResponse
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
MockKAnnotations.init(this)
|
MockKAnnotations.init(this)
|
||||||
viewModel = WorldViewModel(locationProvider, repository)
|
viewModel = WorldViewModel(locationProvider, repository)
|
||||||
|
|
||||||
|
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchDataForSingleLocation_validLocation_validReturn() {
|
||||||
|
// Arrange
|
||||||
|
val location = CURRENT_LOCATION
|
||||||
|
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
|
||||||
|
temperatureUnit = "°C"
|
||||||
|
locationString = CURRENT_LOCATION
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
|
||||||
|
weatherResponse.lat,
|
||||||
|
weatherResponse.lon
|
||||||
|
)
|
||||||
|
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
|
||||||
|
coEvery {
|
||||||
|
repository.getWeatherFromApi(
|
||||||
|
weatherResponse.lat.toString(),
|
||||||
|
weatherResponse.lon.toString()
|
||||||
|
)
|
||||||
|
}.returns(weatherResponse)
|
||||||
|
coEvery {
|
||||||
|
locationProvider.getLocationNameFromLatLong(
|
||||||
|
weatherResponse.lat,
|
||||||
|
weatherResponse.lon,
|
||||||
|
LocationType.City
|
||||||
|
)
|
||||||
|
}.returns(CURRENT_LOCATION)
|
||||||
|
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
|
||||||
|
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
|
||||||
|
|
||||||
|
viewModel.fetchDataForSingleLocation(location)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
sleep(300)
|
||||||
|
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
|
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
|
||||||
|
// Arrange
|
||||||
val location = CURRENT_LOCATION
|
val location = CURRENT_LOCATION
|
||||||
|
|
||||||
|
// Act
|
||||||
|
every { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } throws IOException("Unable to get location")
|
||||||
|
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
|
||||||
|
coEvery {
|
||||||
|
repository.getWeatherFromApi(
|
||||||
|
weatherResponse.lat.toString(),
|
||||||
|
weatherResponse.lon.toString()
|
||||||
|
)
|
||||||
|
}.returns(weatherResponse)
|
||||||
|
|
||||||
viewModel.fetchDataForSingleLocation(location)
|
viewModel.fetchDataForSingleLocation(location)
|
||||||
|
|
||||||
assertEquals(viewModel.operationRefresh.getOrAwaitValue()?.getContentIfNotHandled(), false)
|
// Assert
|
||||||
|
sleep(300)
|
||||||
|
assertIs<ViewState.HasError<*>>(viewModel.uiState.getOrAwaitValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchAboveFallbackTime_validLocation_validReturn() {
|
||||||
|
// Arrange
|
||||||
|
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
|
||||||
|
temperatureUnit = "°C"
|
||||||
|
locationString = CURRENT_LOCATION
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem)
|
||||||
|
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false)
|
||||||
|
coEvery {
|
||||||
|
repository.getWeatherFromApi(
|
||||||
|
weatherResponse.lat.toString(),
|
||||||
|
weatherResponse.lon.toString()
|
||||||
|
)
|
||||||
|
}.returns(weatherResponse)
|
||||||
|
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
|
||||||
|
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
|
||||||
|
|
||||||
|
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
sleep(300)
|
||||||
|
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun <T> LiveData<T>.getOrAwaitValue(
|
|
||||||
time: Long = 2,
|
|
||||||
timeUnit: TimeUnit = TimeUnit.SECONDS
|
|
||||||
): T {
|
|
||||||
var data: T? = null
|
|
||||||
val latch = CountDownLatch(1)
|
|
||||||
val observer = object : Observer<T> {
|
|
||||||
override fun onChanged(o: T?) {
|
|
||||||
data = o
|
|
||||||
latch.countDown()
|
|
||||||
this@getOrAwaitValue.removeObserver(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.observeForever(observer)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -20,3 +20,4 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
rootProject.name = "Atlas Weather"
|
rootProject.name = "Atlas Weather"
|
||||||
include ':app'
|
include ':app'
|
||||||
|
include ':test-resources'
|
||||||
|
|||||||
Reference in New Issue
Block a user