Test suite expansion (#20)

- Code inspection
 - Redundant resources removed
 - Resources moved the corresponding flavours
 - Deprecated dependencies upgraded
 - lint changes
 - circleci updated to capture screenshot
 - Testsuite expansion
This commit is contained in:
2023-08-12 18:39:20 +01:00
committed by GitHub
parent ce1d13e630
commit 1fa34764df
74 changed files with 2194 additions and 927 deletions
+1 -2
View File
@@ -65,6 +65,7 @@ gen-external-apklibs
/out/
# User-specific configurations
.idea/androidTestResultsUserPreferences.xml
.idea/caches/
.idea/libraries/
.idea/shelf/
@@ -94,5 +95,3 @@ gen-external-apklibs
/fastlane/report.xml
# Google play files
/google-play-key.json
/.idea/androidTestResultsUserPreferences.xml
-126
View File
@@ -1,126 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="-2146704034">
<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="-2008434490">
<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="-1906103057">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-409920851">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="170536241">
<value>
<AndroidTestResultsTableState>
<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="287238248">
<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="408375334">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1127175145">
<value>
<AndroidTestResultsTableState>
<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="1256180664">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_2_API_27" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>
+218
View File
@@ -0,0 +1,218 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.6)
rexml
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.798.0)
aws-sdk-core (3.180.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.71.0)
aws-sdk-core (~> 3, >= 3.177.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.132.0)
aws-sdk-core (~> 3, >= 3.179.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.100.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
fastlane (2.214.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (~> 0.1.1)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (>= 1.4.5, < 2.0.0)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.46.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.7.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
memoist (0.16.2)
mini_magick (4.12.0)
mini_mime (1.1.2)
multi_json (1.15.0)
multipart-post (2.3.0)
nanaimo (0.3.0)
naturally (2.2.1)
optparse (0.1.1)
os (1.1.4)
plist (3.7.0)
public_suffix (5.0.3)
rake (13.0.6)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.6)
rouge (2.0.7)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
security (0.1.3)
signet (0.17.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.8.1)
word_wrap (1.0.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane
BUNDLED WITH
2.3.5
@@ -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
}
]
}
@@ -0,0 +1,350 @@
{
"lat": -33.89,
"lon": -151.12,
"timezone": "Etc/GMT+10",
"timezone_offset": -36000,
"current": {
"dt": 1691771435,
"sunrise": 1691772463,
"sunset": 1691811108,
"temp": 12.48,
"feels_like": 11.25,
"pressure": 1025,
"humidity": 56,
"dew_point": 3.95,
"uvi": 0,
"clouds": 100,
"visibility": 10000,
"wind_speed": 2.3,
"wind_deg": 66,
"wind_gust": 2.68,
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04n"
}
]
},
"daily": [
{
"dt": 1691791200,
"sunrise": 1691772463,
"sunset": 1691811108,
"moonrise": 1691761680,
"moonset": 1691796060,
"moon_phase": 0.86,
"temp": {
"day": 12.94,
"min": 12.26,
"max": 13.59,
"night": 13.59,
"eve": 12.46,
"morn": 12.46
},
"feels_like": {
"day": 11.91,
"night": 13.38,
"eve": 11.8,
"morn": 11.2
},
"pressure": 1022,
"humidity": 62,
"dew_point": 5.89,
"wind_speed": 17.33,
"wind_deg": 36,
"wind_gust": 21.77,
"weather": [
{
"id": 501,
"main": "Rain",
"description": "moderate rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 1,
"rain": 5.93,
"uvi": 2.13
},
{
"dt": 1691877600,
"sunrise": 1691858802,
"sunset": 1691897551,
"moonrise": 1691851260,
"moonset": 1691885640,
"moon_phase": 0.89,
"temp": {
"day": 15.41,
"min": 14.02,
"max": 16.03,
"night": 15.48,
"eve": 15.27,
"morn": 15.67
},
"feels_like": {
"day": 15.23,
"night": 15.25,
"eve": 14.92,
"morn": 15.77
},
"pressure": 1008,
"humidity": 85,
"dew_point": 12.76,
"wind_speed": 16.89,
"wind_deg": 26,
"wind_gust": 22.91,
"weather": [
{
"id": 501,
"main": "Rain",
"description": "moderate rain",
"icon": "10d"
}
],
"clouds": 7,
"pop": 1,
"rain": 11.8,
"uvi": 4.27
},
{
"dt": 1691964000,
"sunrise": 1691945139,
"sunset": 1691983994,
"moonrise": 1691940540,
"moonset": 1691975520,
"moon_phase": 0.92,
"temp": {
"day": 14.49,
"min": 13.32,
"max": 15.51,
"night": 13.32,
"eve": 13.92,
"morn": 15.02
},
"feels_like": {
"day": 14.11,
"night": 12.67,
"eve": 13.56,
"morn": 14.59
},
"pressure": 1005,
"humidity": 81,
"dew_point": 11.26,
"wind_speed": 16.29,
"wind_deg": 281,
"wind_gust": 21.74,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 84,
"pop": 0.63,
"rain": 1.42,
"uvi": 3.5
},
{
"dt": 1692050400,
"sunrise": 1692031476,
"sunset": 1692070437,
"moonrise": 1692029400,
"moonset": 1692065400,
"moon_phase": 0.95,
"temp": {
"day": 12.81,
"min": 12.63,
"max": 13.09,
"night": 12.63,
"eve": 12.74,
"morn": 12.84
},
"feels_like": {
"day": 11.71,
"night": 11.36,
"eve": 11.51,
"morn": 11.9
},
"pressure": 1019,
"humidity": 60,
"dew_point": 5.36,
"wind_speed": 15.72,
"wind_deg": 218,
"wind_gust": 18.13,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 74,
"pop": 0.47,
"rain": 0.46,
"uvi": 2.37
},
{
"dt": 1692136800,
"sunrise": 1692117812,
"sunset": 1692156879,
"moonrise": 1692117900,
"moonset": 1692155340,
"moon_phase": 0,
"temp": {
"day": 12.21,
"min": 12.21,
"max": 13.31,
"night": 13.3,
"eve": 13.14,
"morn": 12.29
},
"feels_like": {
"day": 11,
"night": 12.28,
"eve": 11.97,
"morn": 10.98
},
"pressure": 1023,
"humidity": 58,
"dew_point": 4.12,
"wind_speed": 6.03,
"wind_deg": 241,
"wind_gust": 7.73,
"weather": [
{
"id": 803,
"main": "Clouds",
"description": "broken clouds",
"icon": "04d"
}
],
"clouds": 80,
"pop": 0,
"uvi": 3
},
{
"dt": 1692223200,
"sunrise": 1692204146,
"sunset": 1692243322,
"moonrise": 1692206100,
"moonset": 1692245160,
"moon_phase": 0.02,
"temp": {
"day": 12.69,
"min": 12.33,
"max": 13.65,
"night": 12.97,
"eve": 13.1,
"morn": 13.58
},
"feels_like": {
"day": 12.05,
"night": 11.84,
"eve": 12.14,
"morn": 12.93
},
"pressure": 1024,
"humidity": 78,
"dew_point": 8.88,
"wind_speed": 7.54,
"wind_deg": 130,
"wind_gust": 7.83,
"weather": [
{
"id": 501,
"main": "Rain",
"description": "moderate rain",
"icon": "10d"
}
],
"clouds": 86,
"pop": 0.8,
"rain": 10.37,
"uvi": 3
},
{
"dt": 1692309600,
"sunrise": 1692290480,
"sunset": 1692329764,
"moonrise": 1692294120,
"moonset": 1692334980,
"moon_phase": 0.05,
"temp": {
"day": 12.81,
"min": 12.44,
"max": 13.25,
"night": 13.25,
"eve": 13.05,
"morn": 12.79
},
"feels_like": {
"day": 11.58,
"night": 12.28,
"eve": 11.93,
"morn": 11.61
},
"pressure": 1030,
"humidity": 55,
"dew_point": 3.97,
"wind_speed": 7.57,
"wind_deg": 145,
"wind_gust": 7.26,
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
}
],
"clouds": 5,
"pop": 0,
"uvi": 3
},
{
"dt": 1692396000,
"sunrise": 1692376812,
"sunset": 1692416207,
"moonrise": 1692382020,
"moonset": 1692424680,
"moon_phase": 0.08,
"temp": {
"day": 13.99,
"min": 13.52,
"max": 14.8,
"night": 14.8,
"eve": 14.4,
"morn": 13.52
},
"feels_like": {
"day": 13.14,
"night": 14.29,
"eve": 13.62,
"morn": 12.73
},
"pressure": 1027,
"humidity": 65,
"dew_point": 7.62,
"wind_speed": 12.53,
"wind_deg": 47,
"wind_gust": 14.21,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 99,
"pop": 0.31,
"rain": 0.31,
"uvi": 3
}
]
}
@@ -0,0 +1,4 @@
{
"cod": "400",
"message": "wrong latitude"
}
@@ -5,20 +5,30 @@ package com.appttude.h_mal.atlas_weather
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso
import androidx.test.espresso.Root
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.RootMatchers.withDecorView
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.application.TestAppClass
import com.appttude.h_mal.atlas_weather.helpers.BaseCustomMatcher
import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
import com.appttude.h_mal.atlas_weather.utils.Stubs
import kotlinx.coroutines.runBlocking
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -34,6 +44,7 @@ open class BaseTest<A : Activity>(
lateinit var scenario: ActivityScenario<A>
private lateinit var testApp: TestAppClass
private lateinit var testActivity: Activity
private lateinit var decorView: View
@get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION)
@@ -58,19 +69,24 @@ open class BaseTest<A : Activity>(
scenario = ActivityScenario.launch(startIntent)
scenario.onActivity {
decorView = it.window.decorView
testActivity = it
}
afterLaunch()
}
fun stubEndpoint(url: String, stub: Stubs) {
testApp.stubUrl(url, stub.id)
fun stubEndpoint(url: String, stub: Stubs, code: Int = 200) {
testApp.stubUrl(url, stub.id, code)
}
fun unstubEndpoint(url: String) {
testApp.removeUrlStub(url)
}
fun stubLocation(location: String, lat: Double = 0.00, long: Double = 0.00) {
testApp.stubLocation(location, lat, long)
}
fun getActivity() = testActivity
@After
@@ -91,4 +107,13 @@ open class BaseTest<A : Activity>(
}
})
}
@Suppress("DEPRECATION")
fun checkToastMessage(message: String) {
Espresso.onView(ViewMatchers.withText(message)).inRoot(withDecorView(Matchers.not(decorView)))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
waitFor(3500)
}
}
}
@@ -3,6 +3,8 @@ package com.appttude.h_mal.atlas_weather.application
import androidx.room.Room
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.idling.CountingIdlingResource
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
@@ -18,6 +20,9 @@ class TestAppClass : BaseAppClass() {
private val idlingResources = CountingIdlingResource("Data_loader")
private val mockingNetworkInterceptor = MockingNetworkInterceptor(idlingResources)
lateinit var database: AppDatabase
lateinit var locationProvider: MockLocationProvider
override fun onCreate() {
super.onCreate()
IdlingRegistry.getInstance().register(idlingResources)
@@ -32,23 +37,31 @@ class TestAppClass : BaseAppClass() {
) as WeatherApi
}
override fun createLocationModule() = MockLocationProvider()
override fun createRoomDatabase(): AppDatabase {
return Room.inMemoryDatabaseBuilder(this, AppDatabase::class.java)
.addTypeConverter(Converter(this))
.build()
override fun createLocationModule(): LocationProvider {
locationProvider = MockLocationProvider()
return locationProvider
}
fun stubUrl(url: String, rawPath: String) {
val id = resources.getIdentifier(rawPath, "raw", packageName)
val iStream = resources.openRawResource(id)
override fun createRoomDatabase(): AppDatabase {
database = Room.inMemoryDatabaseBuilder(this, AppDatabase::class.java)
.addTypeConverter(Converter(this))
.build()
return database
}
fun stubUrl(url: String, rawPath: String, code: Int = 200) {
val iStream =
InstrumentationRegistry.getInstrumentation().context.assets.open("$rawPath.json")
val data = iStream.bufferedReader().use(BufferedReader::readText)
mockingNetworkInterceptor.addUrlStub(url = url, data = data)
mockingNetworkInterceptor.addUrlStub(url = url, data = data, code = code)
}
fun removeUrlStub(url: String) {
mockingNetworkInterceptor.removeUrlStub(url = url)
}
fun stubLocation(location: String, lat: Double = 0.00, long: Double = 0.00) {
locationProvider.addLocationToList(location, lat, long)
}
}
@@ -3,17 +3,25 @@ package com.appttude.h_mal.atlas_weather.data.location
import com.appttude.h_mal.atlas_weather.model.types.LocationType
class MockLocationProvider : LocationProvider {
private val latLong = Pair(0.00, 0.00)
private var feedMap: MutableMap<String, Pair<Double, Double>> = mutableMapOf()
private var latLong = Pair(0.00, 0.00)
override suspend fun getCurrentLatLong() = latLong
override fun getLatLongFromLocationName(location: String) = latLong
override fun getLatLongFromLocationName(location: String): Pair<Double, Double> {
return feedMap[location] ?: Pair(0.00, 0.00)
}
override suspend fun getLocationNameFromLatLong(
lat: Double,
long: Double,
type: LocationType
): String {
return "Mock Location"
return feedMap.filterValues { it.first == lat && it.second == long }.keys.firstOrNull() ?: "Mock Location"
}
fun addLocationToList(name: String, lat: Double, long: Double) {
val latLong = Pair(lat, long)
feedMap.put(name, latLong)
}
}
@@ -11,23 +11,21 @@ class MockingNetworkInterceptor(
private val idlingResource: CountingIdlingResource
) : Interceptor {
private var feedMap: MutableMap<String, String> = mutableMapOf()
private var urlCallTracker: MutableMap<String, Int> = mutableMapOf()
private var feedMap: MutableMap<String, Pair<String, Int>> = mutableMapOf()
override fun intercept(chain: Interceptor.Chain): Response {
idlingResource.increment()
val original = chain.request()
val originalHttpUrl = original.url.toString().split("?")[0]
urlCallTracker.computeIfPresent(originalHttpUrl) { _, j ->
j + 1
}
feedMap[originalHttpUrl]?.let { responsePair ->
val code = responsePair.second
val jsonBody = responsePair.first
feedMap[originalHttpUrl]?.let { jsonPath ->
val body = jsonPath.toResponseBody("application/json".toMediaType())
val body = jsonBody.toResponseBody("application/json".toMediaType())
val chainResponseBuilder = Response.Builder()
.code(200)
.code(code)
.protocol(Protocol.HTTP_1_1)
.request(original)
.message("OK")
@@ -40,7 +38,7 @@ class MockingNetworkInterceptor(
return chain.proceed(original)
}
fun addUrlStub(url: String, data: String) = feedMap.put(url, data)
fun addUrlStub(url: String, data: String, code: Int = 200) = feedMap.put(url, Pair(data, code))
fun removeUrlStub(url: String) = feedMap.remove(url)
}
@@ -0,0 +1,12 @@
package com.appttude.h_mal.atlas_weather.helpers
import org.hamcrest.Description
import org.hamcrest.TypeSafeMatcher
open class BaseCustomMatcher<T: Any> : TypeSafeMatcher<T>() {
override fun describeTo(description: Description?) = describe(description)
override fun matchesSafely(item: T): Boolean = match(item)
open fun describe(description: Description?) { }
open fun match(actual: T): Boolean { return false }
}
@@ -0,0 +1,117 @@
package com.appttude.h_mal.atlas_weather.testSuite
import android.os.Build
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.room.util.UUIDUtil
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.Converter
import com.appttude.h_mal.atlas_weather.data.room.WeatherDao
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.test.BuildConfig
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.*
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.mock
import java.util.UUID
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 = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
FullWeather()
} else {
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,9 @@ package com.appttude.h_mal.atlas_weather.utils
enum class Stubs(
val id: String
) {
Valid("valid_response"),
Invalid("invalid_response")
Metric("valid_response_metric"),
Imperial("valid_response_imperial"),
WrongLocation("wrong_location_response"),
InvalidKey("invalid_api_key_response"),
Sydney("valid_response_metric_sydney")
}
@@ -0,0 +1,32 @@
package com.appttude.h_mal.atlas_weather.utils
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}
@@ -11,4 +11,8 @@ class HomeScreenRobot : BaseTestRobot() {
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
fun refresh() = pullToRefresh(R.id.swipe_refresh)
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
fun verifyUnableToRetrieve() {
matchText(R.id.header_text, R.string.retrieve_warning)
matchText(R.id.body_text, R.string.empty_retrieve_warning)
}
}
@@ -0,0 +1,37 @@
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 {
waitFor(2000)
// verify empty
verifyUnableToRetrieve()
}
}
@Test
fun invalidKeyWeatherResponse_swipeToRefresh_returnsValidPage() {
homeScreen {
waitFor(2000)
// verify empty
verifyUnableToRetrieve()
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
refresh()
verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location")
}
}
}
@@ -10,7 +10,7 @@ import org.junit.Test
class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
}
@Test
@@ -0,0 +1,12 @@
package com.appttude.h_mal.monoWeather.robot
import com.appttude.h_mal.atlas_weather.BaseTestRobot
import com.appttude.h_mal.atlas_weather.R
fun addLocation(func: AddLocationScreenRobot.() -> Unit) = AddLocationScreenRobot().apply { func() }
class AddLocationScreenRobot : BaseTestRobot() {
fun setLocation(location: String) =
fillEditText(R.id.location_name_tv, location)
fun submit() = clickButton(R.id.submit)
}
@@ -0,0 +1,23 @@
package com.appttude.h_mal.monoWeather.robot
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
import com.appttude.h_mal.atlas_weather.helpers.EspressoHelper
fun container(func: ContainerRobot.() -> Unit) = ContainerRobot().apply { func() }
class ContainerRobot : BaseTestRobot() {
fun tapTabInBottomBar(tab: Tab) {
when (tab) {
Tab.WORLD -> EspressoHelper.waitForView(withId(R.id.nav_world))
Tab.HOME -> EspressoHelper.waitForView(withId(R.id.nav_home))
}.perform(click())
}
enum class Tab {
HOME,
WORLD
}
}
@@ -3,12 +3,17 @@ package com.appttude.h_mal.monoWeather.robot
import com.appttude.h_mal.atlas_weather.BaseTestRobot
import com.appttude.h_mal.atlas_weather.R
fun homeScreen(func: HomeScreenRobot.() -> Unit) = HomeScreenRobot().apply { func() }
class HomeScreenRobot : BaseTestRobot() {
fun weatherScreen(func: WeatherScreen.() -> Unit) = WeatherScreen().apply { func() }
class WeatherScreen : BaseTestRobot() {
fun verifyCurrentTemperature(temperature: Int) =
matchText(R.id.temp_main_4, temperature.toString())
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
fun refresh() = pullToRefresh(R.id.swipe_refresh)
fun 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,24 @@
package com.appttude.h_mal.monoWeather.robot
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.matcher.ViewMatchers.withId
import com.appttude.h_mal.atlas_weather.BaseTestRobot
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.helpers.EspressoHelper
fun world(func: WorldScreenRobot.() -> Unit) = WorldScreenRobot().apply { func() }
class WorldScreenRobot : BaseTestRobot() {
fun clickFab() = clickButton(R.id.floatingActionButton)
fun clickItemInList(location: String) {
EspressoHelper.waitForView(withId(R.id.world_recycler))
clickViewInRecycler<RecyclerView.ViewHolder>(R.id.world_recycler, location)
}
fun clickItemInListByPosition(position: Int) =
clickViewInRecycler<RecyclerView.ViewHolder>(R.id.world_recycler, position)
fun emptyViewDisplayed() {
matchText(R.id.body_text, R.string.retrieve_warning)
matchText(R.id.header_text, R.string.empty_retrieve_warning)
}
}
@@ -0,0 +1,35 @@
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.weatherScreen
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() {
weatherScreen {
// verify empty
verifyUnableToRetrieve()
}
}
@Test
fun invalidKeyWeatherResponse_swipeToRefresh_returnsValidPage() {
weatherScreen {
// verify empty
verifyUnableToRetrieve()
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
refresh()
verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location")
}
}
}
@@ -3,18 +3,18 @@ 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 com.appttude.h_mal.monoWeather.robot.weatherScreen
import org.junit.Test
class HomePageUITest : MonoBaseTest() {
override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
}
@Test
fun loadApp_validWeatherResponse_returnsValidPage() {
homeScreen {
weatherScreen {
isDisplayed()
verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location")
@@ -0,0 +1,42 @@
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.Tab.WORLD
import com.appttude.h_mal.monoWeather.robot.addLocation
import com.appttude.h_mal.monoWeather.robot.container
import com.appttude.h_mal.monoWeather.robot.weatherScreen
import com.appttude.h_mal.monoWeather.robot.world
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)
}
world {
clickFab()
}
addLocation {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Sydney)
stubLocation("Sydney", -33.89, -151.12)
setLocation("Sydney")
submit()
}
world {
clickItemInList("Sydney")
}
weatherScreen {
isDisplayed()
verifyCurrentTemperature(12)
verifyCurrentLocation("Sydney")
}
}
}
@@ -1,6 +1,5 @@
package com.appttude.h_mal.atlas_weather.ui.home
import android.Manifest
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.annotation.SuppressLint
import android.os.Bundle
@@ -9,47 +8,32 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.Navigation.findNavController
import androidx.navigation.ui.onNavDestinationSelected
import androidx.recyclerview.widget.LinearLayoutManager
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
import com.appttude.h_mal.atlas_weather.ui.dialog.PermissionsDeclarationDialog
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.ui.home.adapter.WeatherRecyclerAdapter
import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment
import kotlinx.android.synthetic.main.fragment_home.*
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.kodein
import org.kodein.di.generic.instance
/**
* A simple [Fragment] subclass.
* create an instance of this fragment.
*/
class HomeFragment : BaseFragment(R.layout.fragment_home) {
private val viewModel by getFragmentViewModel<MainViewModel>()
class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
private lateinit var recyclerAdapter: WeatherRecyclerAdapter
@SuppressLint("MissingPermission")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true)
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
navigateToFurtherDetails(it)
})
forecast_listview.apply {
layoutManager = LinearLayoutManager(context)
adapter = recyclerAdapter
}
swipe_refresh.apply {
setOnRefreshListener {
getPermissionResult(ACCESS_COARSE_LOCATION, LOCATION_PERMISSION_REQUEST) {
@@ -59,24 +43,16 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
}
}
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
recyclerAdapter.addCurrent(it)
}
recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
navigateToFurtherDetails(it)
})
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
viewModel.operationRefresh.observe(viewLifecycleOwner) { it ->
it.getContentIfNotHandled()?.let {
swipe_refresh.isRefreshing = false
forecast_listview.apply {
layoutManager = LinearLayoutManager(context)
adapter = recyclerAdapter
}
}
viewModel.operationState.observe(viewLifecycleOwner) {
swipe_refresh.isRefreshing = false
}
}
@SuppressLint("MissingPermission")
override fun onStart() {
super.onStart()
@@ -86,6 +62,19 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
}
}
override fun onSuccess(data: Any?) {
super.onSuccess(data)
swipe_refresh.isRefreshing = false
if (data is WeatherDisplay) {
recyclerAdapter.addCurrent(data)
}
}
override fun onFailure(error: Any?) {
super.onFailure(error)
swipe_refresh.isRefreshing = false
}
@SuppressLint("MissingPermission")
override fun permissionsGranted() {
viewModel.fetchData()
@@ -1,6 +1,23 @@
package com.appttude.h_mal.atlas_weather.ui.home.adapter
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.recyclerview.widget.RecyclerView
import com.appttude.h_mal.atlas_weather.R
class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val icon: ImageView = itemView.findViewById(R.id.icon)
var bodyTV: TextView = itemView.findViewById(R.id.body_text)
var headerTV: TextView = itemView.findViewById(R.id.header_text)
fun bindData(
@DrawableRes imageRes: Int? = R.drawable.ic_baseline_cloud_off_24,
header: String = itemView.resources.getString(R.string.retrieve_warning),
body: String = itemView.resources.getString(R.string.empty_retrieve_warning) ){
imageRes?.let { icon.setImageResource(it) }
headerTV.text = header
bodyTV.text = body
}
}
@@ -1,20 +1,19 @@
package com.appttude.h_mal.atlas_weather.ui.home.adapter
import android.annotation.SuppressLint
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.appttude.h_mal.atlas_weather.R
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.generateView
import com.appttude.h_mal.atlas_weather.ui.home.adapter.forecast.ViewHolderForecast
import com.appttude.h_mal.atlas_weather.ui.home.adapter.forecastDaily.ViewHolderForecastDaily
class WeatherRecyclerAdapter(
val itemClick: (Forecast) -> Unit
private val itemClick: (Forecast) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var weather: WeatherDisplay? = null
@SuppressLint("NotifyDataSetChanged")
fun addCurrent(current: WeatherDisplay) {
weather = current
notifyDataSetChanged()
@@ -23,7 +22,7 @@ class WeatherRecyclerAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (getDataType(viewType)) {
is ViewType.Empty -> {
val emptyViewHolder = View(parent.context)
val emptyViewHolder = parent.generateView(R.layout.empty_state_layout)
EmptyViewHolder(emptyViewHolder)
}
@@ -32,8 +31,8 @@ class WeatherRecyclerAdapter(
ViewHolderCurrent(viewCurrent)
}
is ViewType.Forecast -> {
val viewForecast = parent.generateView(R.layout.list_item_forecast)
is ViewType.ForecastHourly -> {
val viewForecast = parent.generateView(R.layout.hourly_item_forecast)
ViewHolderForecast(viewForecast)
}
@@ -41,13 +40,19 @@ class WeatherRecyclerAdapter(
val viewFurther = parent.generateView(R.layout.list_item_further)
ViewHolderFurtherDetails(viewFurther)
}
is ViewType.ForecastDaily -> {
val viewForecast = parent.generateView(R.layout.list_item_forecast)
ViewHolderForecastDaily(viewForecast)
}
}
}
sealed class ViewType {
object Empty : ViewType()
object Current : ViewType()
object Forecast : ViewType()
object ForecastHourly : ViewType()
object ForecastDaily : ViewType()
object Further : ViewType()
}
@@ -55,19 +60,20 @@ class WeatherRecyclerAdapter(
return when (type) {
0 -> ViewType.Empty
1 -> ViewType.Current
2 -> ViewType.Forecast
2 -> ViewType.ForecastHourly
3 -> ViewType.Further
4 -> ViewType.ForecastDaily
else -> ViewType.Empty
}
}
override fun getItemViewType(position: Int): Int {
if (weather == null) return 0
return when (position) {
0 -> 1
in 1 until itemCount - 2 -> 2
itemCount - 1 -> 3
1 -> 3
2 -> 2
in 3 until (itemCount) -> 4
else -> 0
}
}
@@ -75,7 +81,8 @@ class WeatherRecyclerAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getDataType(getItemViewType(position))) {
is ViewType.Empty -> {
holder as EmptyViewHolder
val emptyViewHolder = holder as EmptyViewHolder
emptyViewHolder.bindData()
}
is ViewType.Current -> {
@@ -83,28 +90,31 @@ class WeatherRecyclerAdapter(
viewHolderCurrent.bindData(weather)
}
is ViewType.Forecast -> {
val viewHolderForecast = holder as ViewHolderForecast
weather?.forecast?.get(position - 1)?.let { i ->
viewHolderForecast.bindView(i)
viewHolderForecast.itemView.setOnClickListener {
itemClick(i)
}
}
}
is ViewType.Further -> {
val viewHolderCurrent = holder as ViewHolderFurtherDetails
viewHolderCurrent.bindData(weather)
}
is ViewType.ForecastHourly -> {
val viewHolderForecast = holder as ViewHolderForecast
viewHolderForecast.bindView(weather?.hourly)
}
is ViewType.ForecastDaily -> {
val viewHolderForecast = holder as ViewHolderForecastDaily
weather?.forecast?.getOrNull(position - 3)?.let { f ->
viewHolderForecast.bindView(f)
viewHolderForecast.itemView.setOnClickListener {
itemClick.invoke(f)
}
}
}
}
}
override fun getItemCount(): Int {
if (weather == null) return 0
return 2 + (weather?.forecast?.size ?: 0)
return if (weather == null) 1 else 3 + (weather?.forecast?.size ?: 0)
}
}
@@ -0,0 +1,23 @@
package com.appttude.h_mal.monoWeather.ui.home.adapter.forecast
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.weather.Hour
import com.appttude.h_mal.atlas_weather.utils.loadImage
import com.appttude.h_mal.atlas_weather.utils.toTime
class GridCellHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var dayTV: TextView = itemView.findViewById(R.id.widget_item_day)
var weatherIV: ImageView = itemView.findViewById(R.id.widget_item_image)
var mainTempTV: TextView = itemView.findViewById(R.id.widget_item_temp_high)
fun bindView(hour: Hour?) {
dayTV.text = hour?.dt?.toTime()
weatherIV.loadImage(hour?.icon)
mainTempTV.text = hour?.temp?.toInt()?.toString()
}
}
@@ -0,0 +1,32 @@
package com.appttude.h_mal.atlas_weather.ui.home.adapter.forecast
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.weather.Hour
import com.appttude.h_mal.atlas_weather.utils.generateView
import com.appttude.h_mal.monoWeather.ui.home.adapter.forecast.GridCellHolder
class GridForecastAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var weather: MutableList<Hour> = mutableListOf()
fun addCurrent(current: List<Hour>?) {
weather.clear()
current?.let { weather.addAll(it) }
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val viewCurrent = parent.generateView(R.layout.hourly_forecast_grid_item)
return GridCellHolder(viewCurrent)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val view = holder as GridCellHolder
val forecast = weather[position]
view.bindView(forecast)
}
override fun getItemCount(): Int = weather.size
}
@@ -0,0 +1,20 @@
package com.appttude.h_mal.atlas_weather.ui.home.adapter.forecast
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.weather.Hour
import com.appttude.h_mal.atlas_weather.ui.home.adapter.forecast.GridForecastAdapter
class ViewHolderForecast(
itemView: View
) : RecyclerView.ViewHolder(itemView) {
var recyclerView: RecyclerView = itemView.findViewById(R.id.forecast_recyclerview)
fun bindView(forecasts: List<Hour>?) {
val adapter = GridForecastAdapter()
adapter.addCurrent(forecasts)
recyclerView.adapter = adapter
}
}
@@ -1,4 +1,4 @@
package com.appttude.h_mal.atlas_weather.ui.home.adapter
package com.appttude.h_mal.atlas_weather.ui.home.adapter.forecastDaily
import android.view.View
import android.widget.ImageView
@@ -8,21 +8,21 @@ import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
import com.appttude.h_mal.atlas_weather.utils.loadImage
class ViewHolderForecast(itemView: View) : RecyclerView.ViewHolder(itemView) {
class ViewHolderForecastDaily(itemView: View) : RecyclerView.ViewHolder(itemView) {
var dateTV: TextView = itemView.findViewById(R.id.list_date)
var dayTV: TextView = itemView.findViewById(R.id.list_day)
var conditionTV: TextView = itemView.findViewById(R.id.list_condition)
var weatherIV: ImageView = itemView.findViewById(R.id.list_icon)
var mainTempTV: TextView = itemView.findViewById(R.id.list_main_temp)
var minorTempTV: TextView = itemView.findViewById(R.id.list_minor_temp)
var maxTempTV: TextView = itemView.findViewById(R.id.list_main_temp)
var minTempTV: TextView = itemView.findViewById(R.id.list_minor_temp)
var conditionTV: TextView = itemView.findViewById(R.id.list_condition)
fun bindView(forecast: Forecast?) {
dateTV.text = forecast?.date
dayTV.text = forecast?.day
conditionTV.text = forecast?.condition
weatherIV.loadImage(forecast?.weatherIcon)
mainTempTV.text = forecast?.mainTemp
minorTempTV.text = forecast?.minorTemp
maxTempTV.text = forecast?.mainTemp
minTempTV.text = forecast?.minorTemp
conditionTV.text = forecast?.condition
}
}
@@ -1,26 +1,17 @@
package com.appttude.h_mal.atlas_weather.ui.world
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.utils.displayToast
import com.appttude.h_mal.atlas_weather.utils.goBack
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment
import kotlinx.android.synthetic.main.activity_add_forecast.*
import org.kodein.di.KodeinAware
import org.kodein.di.android.x.kodein
import org.kodein.di.generic.instance
import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
import kotlinx.android.synthetic.main.activity_add_forecast.submit
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
private val viewModel by getFragmentViewModel<WorldViewModel>()
class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -31,16 +22,13 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
submit.error = "Location cannot be blank"
return@setOnClickListener
}
viewModel.fetchDataForSingleLocation(locationName)
viewModel.fetchDataForSingleLocationSearch(locationName)
}
}
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
viewModel.operationComplete.observe(viewLifecycleOwner) {
it?.getContentIfNotHandled()?.let { message ->
displayToast(message)
}
override fun onSuccess(data: Any?) {
if (data is String) {
displayToast(data)
goBack()
}
}
@@ -3,14 +3,13 @@ package com.appttude.h_mal.atlas_weather.ui.world
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.floatingActionButton
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.progressBar2
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycler
@@ -18,13 +17,13 @@ import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycl
* A simple [Fragment] subclass.
* create an instance of this fragment.
*/
class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
val viewModel by getFragmentViewModel<WorldViewModel>()
class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment_add_location) {
private lateinit var recyclerAdapter: WorldRecyclerAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val recyclerAdapter = WorldRecyclerAdapter {
recyclerAdapter = WorldRecyclerAdapter {
val direction =
WorldFragmentDirections.actionWorldFragmentToWorldItemFragment(it)
navigateTo(direction)
@@ -35,22 +34,18 @@ class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
adapter = recyclerAdapter
}
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
recyclerAdapter.addCurrent(it)
}
floatingActionButton.setOnClickListener {
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
}
}
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar2))
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
override fun onSuccess(data: Any?) {
super.onSuccess(data)
if (data is List<*>) recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
}
override fun onResume() {
super.onResume()
viewModel.fetchAllLocations()
}
@@ -36,20 +36,4 @@
android:textStyle="bold" />
</LinearLayout>
<FrameLayout
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:visibility="gone">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</RelativeLayout>
@@ -43,17 +43,5 @@
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:layout="@layout/fragment_home" />
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:elevation="0.2dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -31,15 +31,4 @@
app:layout_constraintRight_toRightOf="parent"
app:srcCompat="@android:drawable/ic_input_add" />
<ProgressBar
android:id="@+id/progressBar2"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_item_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="3dp"
android:layout_marginRight="3dp"
android:orientation="vertical">
<TextView
android:id="@+id/widget_item_day"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="#ffffff"
tools:text="Dec 1" />
<ImageView
android:id="@+id/widget_item_image"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:contentDescription="@string/image_string"
tools:src="@drawable/cloud_symbol"
tools:tint="@color/colour_one" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_item_temp_high"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#ffffff"
android:textSize="12sp"
android:textStyle="bold"
tools:text="20" />
<TextView
android:id="@+id/db_temp_unit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/degrees" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,24 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="24dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/forecast_recyclerview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="1"
tools:itemCount="24"
tools:listitem="@layout/widget_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,91 @@
package com.appttude.h_mal.atlas_weather.base
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.inflate
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelLazy
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.helper.GenericsHelper.getGenericClassAt
import com.appttude.h_mal.atlas_weather.model.ViewState
import com.appttude.h_mal.atlas_weather.utils.displayToast
import com.appttude.h_mal.atlas_weather.utils.hide
import com.appttude.h_mal.atlas_weather.utils.show
import com.appttude.h_mal.atlas_weather.utils.triggerAnimation
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.BaseViewModel
import org.kodein.di.KodeinAware
import org.kodein.di.android.kodein
import org.kodein.di.generic.instance
abstract class BaseActivity : AppCompatActivity(), KodeinAware {
private lateinit var loadingView: View
override val kodein by kodein()
/**
* Creates a loading view which to be shown during async operations
*
* #setOnClickListener(null) is an ugly work around to prevent under being clicked during
* loading
*/
private fun instantiateLoadingView() {
loadingView = inflate(this, R.layout.progress_layout, null)
loadingView.setOnClickListener(null)
addContentView(loadingView, LayoutParams(MATCH_PARENT, MATCH_PARENT))
loadingView.hide()
}
override fun onStart() {
super.onStart()
instantiateLoadingView()
}
fun <A : AppCompatActivity> startActivity(activity: Class<A>) {
val intent = Intent(this, activity)
startActivity(intent)
}
/**
* Called in case of success or some data emitted from the liveData in viewModel
*/
open fun onStarted() {
loadingView.fadeIn()
}
/**
* Called in case of success or some data emitted from the liveData in viewModel
*/
open fun onSuccess(data: Any?) {
loadingView.fadeOut()
}
/**
* Called in case of failure or some error emitted from the liveData in viewModel
*/
open fun onFailure(error: Any?) {
if (error is String) displayToast(error)
loadingView.fadeOut()
}
private fun View.fadeIn() = apply {
show()
triggerAnimation(R.anim.nav_default_enter_anim) {}
}
private fun View.fadeOut() = apply {
hide()
triggerAnimation(R.anim.nav_default_exit_anim) {}
}
override fun onBackPressed() {
loadingView.hide()
super.onBackPressed()
}
}
@@ -24,33 +24,32 @@ class RepositoryImpl(
}
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
db.getSimpleDao().upsertFullWeather(entityItem)
db.getWeatherDao().upsertFullWeather(entityItem)
}
override suspend fun saveWeatherListToRoom(
list: List<EntityItem>
) {
db.getSimpleDao().upsertListOfFullWeather(list)
db.getWeatherDao().upsertListOfFullWeather(list)
}
override fun loadRoomWeatherLiveData() = db.getSimpleDao().getAllFullWeatherWithoutCurrent()
override fun loadRoomWeatherLiveData() = db.getWeatherDao().getAllFullWeatherWithoutCurrent()
override suspend fun loadWeatherList(): List<String> {
return db.getSimpleDao()
return db.getWeatherDao()
.getWeatherListWithoutCurrent()
.map { it.id }
}
override fun loadCurrentWeatherFromRoom(id: String) =
db.getSimpleDao().getCurrentFullWeather(id)
db.getWeatherDao().getCurrentFullWeather(id)
override suspend fun loadSingleCurrentWeatherFromRoom(id: String) =
db.getSimpleDao().getCurrentFullWeatherSingle(id)
db.getWeatherDao().getCurrentFullWeatherSingle(id)
override fun isSearchValid(locationName: String): Boolean {
val lastSaved = prefs
.getLastSavedAt("$LOCATION_CONST$locationName")
?: return true
val difference = System.currentTimeMillis() - lastSaved
return difference > FALLBACK_TIME
@@ -62,7 +61,7 @@ class RepositoryImpl(
override suspend fun deleteSavedWeatherEntry(locationName: String): Boolean {
prefs.deleteLocation(locationName)
return db.getSimpleDao().deleteEntry(locationName) > 0
return db.getWeatherDao().deleteEntry(locationName) > 0
}
override fun getSavedLocations(): List<String> {
@@ -70,7 +69,7 @@ class RepositoryImpl(
}
override suspend fun getSingleWeather(locationName: String): EntityItem {
return db.getSimpleDao().getCurrentFullWeatherSingle(locationName)
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
}
}
@@ -15,7 +15,7 @@ import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
@TypeConverters(Converter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun getSimpleDao(): WeatherDao
abstract fun getWeatherDao(): WeatherDao
companion object {
@@ -11,6 +11,13 @@ object GenericsHelper {
?.kotlin
?: throw IllegalStateException("Can not find class from generic argument")
// @Suppress("UNCHECKED_CAST")
// fun <CLASS : Any> Any.getGenericClassInMethod(position: Int): KClass<CLASS> =
// ((javaClass.methods as? ParameterizedType)
// ?.actualTypeArguments?.getOrNull(position) as? Class<CLASS>)
// ?.kotlin
// ?: throw IllegalStateException("Can not find class from generic argument")
// /**
// * Create a view binding out of the the generic [VB]
// *
@@ -0,0 +1,7 @@
package com.appttude.h_mal.atlas_weather.model
sealed class ViewState {
object HasStarted : ViewState()
class HasData<T : Any>(val data: T) : ViewState()
class HasError<T : Any>(val error: T) : ViewState()
}
@@ -1,7 +1,5 @@
package com.appttude.h_mal.monoWeather.ui
package com.appttude.h_mal.atlas_weather.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.Bundle
@@ -9,16 +7,17 @@ import android.view.View
import androidx.annotation.LayoutRes
import androidx.core.app.ActivityCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.fragment.app.createViewModelLazy
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
import com.appttude.h_mal.atlas_weather.base.BaseActivity
import com.appttude.h_mal.atlas_weather.helper.GenericsHelper.getGenericClassAt
import com.appttude.h_mal.atlas_weather.model.ViewState
import com.appttude.h_mal.atlas_weather.utils.Event
import com.appttude.h_mal.atlas_weather.utils.displayToast
import com.appttude.h_mal.atlas_weather.utils.hide
import com.appttude.h_mal.atlas_weather.utils.show
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.BaseViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -28,13 +27,18 @@ import org.kodein.di.generic.instance
import kotlin.properties.Delegates
@Suppress("EmptyMethod", "EmptyMethod")
abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId),
abstract class BaseFragment<V : BaseViewModel>(@LayoutRes contentLayoutId: Int) :
Fragment(contentLayoutId),
KodeinAware {
override val kodein by kodein()
val factory by instance<ApplicationViewModelFactory>()
inline fun <reified VM : ViewModel> getFragmentViewModel(): Lazy<VM> = viewModels { factory }
val viewModel: V by getFragmentViewModel()
var mActivity: BaseActivity? = null
private fun getFragmentViewModel(): Lazy<V> =
createViewModelLazy(getGenericClassAt(0), { viewModelStore }, factoryProducer = { factory })
private var shortAnimationDuration by Delegates.notNull<Int>()
@@ -43,25 +47,42 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
}
// toggle visibility of progress spinner while async operations are taking place
fun progressBarStateObserver(progressBar: View) = Observer<Event<Boolean>> {
it.getContentIfNotHandled()?.let { i ->
if (i)
progressBar.fadeIn()
else
progressBar.fadeOut()
@Suppress("UNCHECKED_CAST")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mActivity = activity as BaseActivity
configureObserver()
}
private fun configureObserver() {
viewModel.uiState.observe(viewLifecycleOwner) {
when (it) {
is ViewState.HasStarted -> onStarted()
is ViewState.HasData<*> -> onSuccess(it.data)
is ViewState.HasError<*> -> onFailure(it.error)
}
}
}
// display a toast when operation fails
fun errorObserver() = Observer<Event<String>> {
it.getContentIfNotHandled()?.let { message ->
displayToast(message)
}
/**
* Called in case of starting operation liveData in viewModel
*/
open fun onStarted() {
mActivity?.onStarted()
}
fun refreshObserver(refresher: SwipeRefreshLayout) = Observer<Event<Boolean>> {
refresher.isRefreshing = false
/**
* Called in case of success or some data emitted from the liveData in viewModel
*/
open fun onSuccess(data: Any?) {
mActivity?.onSuccess(data)
}
/**
* Called in case of failure or some error emitted from the liveData in viewModel
*/
open fun onFailure(error: Any?) {
mActivity?.onFailure(error)
}
/**
@@ -90,46 +111,6 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
}
}
private fun View.fadeIn() {
apply {
// Set the content view to 0% opacity but visible, so that it is visible
// (but fully transparent) during the animation.
alpha = 0f
hide()
// Animate the content view to 100% opacity, and clear any animation
// listener set on the view.
animate()
.alpha(1f)
.setDuration(shortAnimationDuration.toLong())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
show()
}
})
}
}
private fun View.fadeOut() {
apply {
// Set the content view to 0% opacity but visible, so that it is visible
// (but fully transparent) during the animation.
alpha = 1f
show()
// Animate the content view to 100% opacity, and clear any animation
// listener set on the view.
animate()
.alpha(0f)
.setDuration(shortAnimationDuration.toLong())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
hide()
}
})
}
}
@SuppressLint("MissingPermission")
override fun onRequestPermissionsResult(
requestCode: Int,
@@ -9,10 +9,12 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.base.BaseActivity
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
import com.google.android.material.bottomnavigation.BottomNavigationView
import kotlinx.android.synthetic.main.activity_main_navigation.toolbar
class MainActivity : AppCompatActivity() {
class MainActivity : BaseActivity() {
lateinit var navHost: NavHostFragment
@@ -5,9 +5,12 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import android.widget.Toast
import androidx.annotation.AnimRes
import androidx.fragment.app.Fragment
import com.appttude.h_mal.atlas_weather.R
import com.squareup.picasso.Picasso
@@ -43,3 +46,13 @@ fun Fragment.hideKeyboard() {
val imm = context?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager?
imm?.hideSoftInputFromWindow(view?.windowToken, 0)
}
fun View.triggerAnimation(@AnimRes id: Int, complete: (View) -> Unit) {
val animation = AnimationUtils.loadAnimation(context, id)
animation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) = complete(this@triggerAnimation)
override fun onAnimationStart(a: Animation?) {}
override fun onAnimationRepeat(a: Animation?) {}
})
startAnimation(animation)
}
@@ -2,13 +2,11 @@ package com.appttude.h_mal.atlas_weather.viewmodel
import android.Manifest
import androidx.annotation.RequiresPermission
import androidx.lifecycle.MutableLiveData
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.repository.Repository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.utils.Event
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -19,31 +17,22 @@ class MainViewModel(
private val repository: Repository
) : WeatherViewModel() {
val weatherLiveData = MutableLiveData<WeatherDisplay>()
val operationState = MutableLiveData<Event<Boolean>>()
val operationError = MutableLiveData<Event<String>>()
val operationRefresh = MutableLiveData<Event<Boolean>>()
init {
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {
it?.let {
val weather = WeatherDisplay(it)
weatherLiveData.postValue(weather)
onSuccess(weather)
}
}
}
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
fun fetchData() {
if (!repository.isSearchValid(CURRENT_LOCATION)) {
operationRefresh.postValue(Event(false))
return
}
onStart()
CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try {
// Has the search been conducted in the last 5 minutes
val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) {
// Get location
val latLong = locationProvider.getCurrentLatLong()
// Get weather from api
@@ -52,15 +41,15 @@ class MainViewModel(
val currentLocation =
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
val fullWeather = createFullWeather(weather, currentLocation)
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
EntityItem(CURRENT_LOCATION, fullWeather)
} else {
repository.getSingleWeather(CURRENT_LOCATION)
}
// Save data if not null
repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(entityItem)
} catch (e: Exception) {
operationError.postValue(Event(e.message!!))
} finally {
operationState.postValue(Event(false))
operationRefresh.postValue(Event(false))
onError(e.message!!)
}
}
}
@@ -1,13 +1,11 @@
package com.appttude.h_mal.atlas_weather.viewmodel
import androidx.lifecycle.MutableLiveData
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.repository.Repository
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.appttude.h_mal.atlas_weather.utils.Event
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -20,15 +18,7 @@ class WorldViewModel(
private val locationProvider: LocationProvider,
private val repository: Repository
) : WeatherViewModel() {
val weatherLiveData = MutableLiveData<List<WeatherDisplay>>()
val singleWeatherLiveData = MutableLiveData<WeatherDisplay>()
val operationState = MutableLiveData<Event<Boolean>>()
val operationError = MutableLiveData<Event<String>>()
val operationRefresh = MutableLiveData<Event<Boolean>>()
val operationComplete = MutableLiveData<Event<String>>()
private var currentLocation: String? = null
private val weatherListLiveData = repository.loadRoomWeatherLiveData()
@@ -37,44 +27,46 @@ class WorldViewModel(
val list = it.map { data ->
WeatherDisplay(data)
}
weatherLiveData.postValue(list)
onSuccess(list)
currentLocation?.let { i -> list.first { j -> j.location == i } }
?.let { k -> onSuccess(k) }
}
}
fun setLocation(location: String) = run { currentLocation = location }
fun getSingleLocation(locationName: String) {
CoroutineScope(Dispatchers.IO).launch {
val entity = repository.getSingleWeather(locationName)
val item = WeatherDisplay(entity)
singleWeatherLiveData.postValue(item)
onSuccess(item)
}
}
fun fetchDataForSingleLocation(locationName: String) {
if (!repository.isSearchValid(locationName)) {
operationRefresh.postValue(Event(false))
return
}
onStart()
CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try {
val weatherEntity = createWeatherEntity(locationName)
val weatherEntity = if (repository.isSearchValid(locationName)) {
createWeatherEntity(locationName)
} else {
repository.getSingleWeather(locationName)
}
onSuccess(Unit)
repository.saveCurrentWeatherToRoom(weatherEntity)
repository.saveLastSavedAt(locationName)
repository.saveLastSavedAt(weatherEntity.id)
} catch (e: IOException) {
operationError.postValue(Event(e.message!!))
} finally {
operationState.postValue(Event(false))
operationRefresh.postValue(Event(false))
onError(e.message!!)
}
}
}
fun fetchDataForSingleLocationSearch(locationName: String) {
CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
onStart()
// Check if location exists
if (repository.getSavedLocations().contains(locationName)) {
operationError.postValue(Event("$locationName already exists"))
onError("$locationName already exists")
return@launch
}
@@ -89,29 +81,26 @@ class WorldViewModel(
LocationType.City
)
if (repository.getSavedLocations().contains(retrievedLocation)) {
operationError.postValue(Event("$retrievedLocation already exists"))
onError("$retrievedLocation already exists")
return@launch
}
// Save data if not null
repository.saveCurrentWeatherToRoom(entityItem)
repository.saveLastSavedAt(retrievedLocation)
operationComplete.postValue(Event("$retrievedLocation saved"))
onSuccess("$retrievedLocation saved")
} catch (e: IOException) {
operationError.postValue(Event(e.message!!))
} finally {
operationState.postValue(Event(false))
onError(e.message!!)
}
}
}
fun fetchAllLocations() {
onStart()
if (!repository.isSearchValid(ALL_LOADED)) {
operationRefresh.postValue(Event(false))
onSuccess(Unit)
return
}
CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try {
val list = mutableListOf<EntityItem>()
repository.loadWeatherList().forEach { locationName ->
@@ -128,25 +117,25 @@ class WorldViewModel(
repository.saveWeatherListToRoom(list)
repository.saveLastSavedAt(ALL_LOADED)
} catch (e: IOException) {
operationError.postValue(Event(e.message!!))
onError(e.message!!)
} finally {
operationState.postValue(Event(false))
onSuccess(Unit)
}
}
}
fun deleteLocation(locationName: String) {
onStart()
CoroutineScope(Dispatchers.IO).launch {
operationState.postValue(Event(true))
try {
val success = repository.deleteSavedWeatherEntry(locationName)
if (!success) {
operationError.postValue(Event("Failed to delete"))
onError("Failed to delete")
}
} catch (e: IOException) {
operationError.postValue(Event(e.message!!))
onError(e.message!!)
} finally {
operationState.postValue(Event(false))
onSuccess(Unit)
}
}
}
@@ -0,0 +1,25 @@
package com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.appttude.h_mal.atlas_weather.model.ViewState
open class BaseViewModel: ViewModel() {
private val _uiState = MutableLiveData<ViewState>()
val uiState: LiveData<ViewState> = _uiState
fun onStart() {
_uiState.postValue(ViewState.HasStarted)
}
fun <T : Any> onSuccess(result: T) {
_uiState.postValue(ViewState.HasData(result))
}
protected fun <E : Any> onError(error: E) {
_uiState.postValue(ViewState.HasError(error))
}
}
@@ -5,7 +5,7 @@ import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherRe
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
abstract class WeatherViewModel : ViewModel() {
abstract class WeatherViewModel : BaseViewModel() {
fun createFullWeather(
weather: WeatherResponse,
@@ -37,20 +37,4 @@
android:textStyle="bold" />
</LinearLayout>
<FrameLayout
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:visibility="gone">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</RelativeLayout>
+5 -4
View File
@@ -3,10 +3,11 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="24dp"
android:layout_marginTop="6dp"
android:layout_marginRight="24dp"
android:layout_marginBottom="6dp"
android:paddingLeft="24dp"
android:paddingTop="6dp"
android:paddingRight="24dp"
android:paddingBottom="6dp"
android:background="@color/colorPrimaryDark"
android:orientation="horizontal">
<ImageView
+4 -18
View File
@@ -7,31 +7,17 @@
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/forecast_listview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:backgroundTint="@color/colorPrimaryDark"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/db_list_item"></androidx.recyclerview.widget.RecyclerView>
tools:listitem="@layout/db_list_item" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<FrameLayout
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:visibility="gone"
tools:visibility="gone">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</RelativeLayout>
@@ -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>
+1
View File
@@ -30,4 +30,5 @@
<string name="loading_nforecast">Loading \nforecast…</string>
<string name="retrieve_warning">Unable to retrieve weather</string>
<string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string>
<string name="no_weather_to_display">No weather to display</string>
</resources>
@@ -12,10 +12,12 @@ class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var bodyTV: TextView = itemView.findViewById(R.id.body_text)
var headerTV: TextView = itemView.findViewById(R.id.header_text)
fun bindData(@DrawableRes imageRes: Int?, header: String, body: String) {
fun bindData(
@DrawableRes imageRes: Int? = R.drawable.ic_baseline_cloud_off_24,
header: String = itemView.resources.getString(R.string.retrieve_warning),
body: String = itemView.resources.getString(R.string.empty_retrieve_warning) ){
imageRes?.let { icon.setImageResource(it) }
headerTV.text = header
bodyTV.text = body
}
}
@@ -3,43 +3,38 @@ package com.appttude.h_mal.monoWeather.ui
import android.os.Bundle
import android.view.View
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
import kotlinx.android.synthetic.main.fragment_home.forecast_listview
import kotlinx.android.synthetic.main.fragment_home.progressBar
import kotlinx.android.synthetic.main.fragment_home.swipe_refresh
class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
class WorldItemFragment : BaseFragment<WorldViewModel>(R.layout.fragment_home) {
private val viewModel by getFragmentViewModel<WorldViewModel>()
private var param1: String? = null
private lateinit var recyclerAdapter: WeatherRecyclerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
param1 = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName
param1?.let { viewModel.setLocation(it) }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val recyclerAdapter = WeatherRecyclerAdapter {
recyclerAdapter = WeatherRecyclerAdapter {
val directions =
WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it)
navigateTo(directions)
}
param1?.let { viewModel.getSingleLocation(it) }
viewModel.singleWeatherLiveData.observe(viewLifecycleOwner, Observer {
recyclerAdapter.addCurrent(it)
swipe_refresh.isRefreshing = false
})
forecast_listview.apply {
layoutManager = LinearLayoutManager(context)
adapter = recyclerAdapter
@@ -54,9 +49,19 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
}
}
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
param1?.let { viewModel.getSingleLocation(it) }
}
override fun onSuccess(data: Any?) {
if (data is WeatherDisplay) {
recyclerAdapter.addCurrent(data)
}
super.onSuccess(data)
swipe_refresh.isRefreshing = false
}
override fun onFailure(error: Any?) {
super.onFailure(error)
swipe_refresh.isRefreshing = false
}
}
@@ -13,10 +13,11 @@ import androidx.navigation.ui.onNavDestinationSelected
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
import com.appttude.h_mal.monoWeather.dialog.PermissionsDeclarationDialog
import com.appttude.h_mal.monoWeather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
import kotlinx.android.synthetic.main.fragment_home.*
@@ -25,22 +26,16 @@ import kotlinx.android.synthetic.main.fragment_home.*
* A simple [Fragment] subclass.
* create an instance of this fragment.
*/
class HomeFragment : BaseFragment(R.layout.fragment_home) {
private val viewModel by getFragmentViewModel<MainViewModel>()
class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
lateinit var dialog: PermissionsDeclarationDialog
lateinit var recyclerAdapter: WeatherRecyclerAdapter
@SuppressLint("MissingPermission")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true)
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
navigateToFurtherDetails(it)
})
forecast_listview.adapter = recyclerAdapter
dialog = PermissionsDeclarationDialog(requireContext())
swipe_refresh.apply {
@@ -52,13 +47,11 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
}
}
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
recyclerAdapter.addCurrent(it)
}
recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
navigateToFurtherDetails(it)
})
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
forecast_listview.adapter = recyclerAdapter
}
@SuppressLint("MissingPermission")
@@ -76,6 +69,20 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
dialog.dismiss()
}
override fun onSuccess(data: Any?) {
super.onSuccess(data)
swipe_refresh.isRefreshing = false
if (data is WeatherDisplay) {
recyclerAdapter.addCurrent(data)
}
}
override fun onFailure(error: Any?) {
super.onFailure(error)
swipe_refresh.isRefreshing = false
}
@SuppressLint("MissingPermission")
override fun permissionsGranted() {
viewModel.fetchData()
@@ -83,7 +83,8 @@ class WeatherRecyclerAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getDataType(getItemViewType(position))) {
is ViewType.Empty -> {
holder as EmptyViewHolder
val emptyViewHolder = holder as EmptyViewHolder
emptyViewHolder.bindData()
}
is ViewType.Current -> {
@@ -115,7 +116,7 @@ class WeatherRecyclerAdapter(
}
override fun getItemCount(): Int {
return if (weather == null) 0 else 3 + (weather?.forecast?.size ?: 0)
return if (weather == null) 1 else 3 + (weather?.forecast?.size ?: 0)
}
}
@@ -15,6 +15,5 @@ class ViewHolderForecast(
val adapter = GridForecastAdapter()
adapter.addCurrent(forecasts)
recyclerView.adapter = adapter
}
}
@@ -2,25 +2,21 @@ package com.appttude.h_mal.monoWeather.ui.world
import android.os.Bundle
import android.view.View
import androidx.lifecycle.observe
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.utils.displayToast
import com.appttude.h_mal.atlas_weather.utils.goBack
import com.appttude.h_mal.atlas_weather.utils.hideKeyboard
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.progressBar
import kotlinx.android.synthetic.main.activity_add_forecast.submit
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val viewModel by getFragmentViewModel<WorldViewModel>()
submit.setOnClickListener {
val locationName = location_name_tv.text?.trim()?.toString()
if (locationName.isNullOrBlank()) {
@@ -30,14 +26,12 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
viewModel.fetchDataForSingleLocationSearch(locationName)
hideKeyboard()
}
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
viewModel.operationComplete.observe(viewLifecycleOwner) {
it?.getContentIfNotHandled()?.let { message ->
displayToast(message)
}
override fun onSuccess(data: Any?) {
super.onSuccess(data)
if (data is String) {
displayToast(data)
goBack()
}
}
@@ -3,36 +3,35 @@ package com.appttude.h_mal.monoWeather.ui.world
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
import com.appttude.h_mal.atlas_weather.utils.navigateTo
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
import com.appttude.h_mal.monoWeather.ui.BaseFragment
import com.appttude.h_mal.monoWeather.ui.world.WorldFragmentDirections.actionWorldFragmentToWorldItemFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.android.synthetic.main.fragment__two.floatingActionButton
import kotlinx.android.synthetic.main.fragment__two.progressBar
import kotlinx.android.synthetic.main.fragment__two.world_recycler
import kotlinx.android.synthetic.monoWeather.fragment__two.floatingActionButton
import kotlinx.android.synthetic.monoWeather.fragment__two.world_recycler
/**
* A simple [Fragment] subclass.
* create an instance of this fragment.
*/
class WorldFragment : BaseFragment(R.layout.fragment__two) {
private val viewModel by getFragmentViewModel<WorldViewModel>()
class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment__two) {
lateinit var recyclerAdapter: WorldRecyclerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.fetchAllLocations()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val recyclerAdapter = WorldRecyclerAdapter({
recyclerAdapter = WorldRecyclerAdapter({
val direction =
actionWorldFragmentToWorldItemFragment(it.location)
navigateTo(direction)
@@ -53,17 +52,19 @@ class WorldFragment : BaseFragment(R.layout.fragment__two) {
adapter = recyclerAdapter
}
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
recyclerAdapter.addCurrent(it)
}
floatingActionButton.setOnClickListener {
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
}
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
}
@Suppress("UNCHECKED_CAST")
override fun onSuccess(data: Any?) {
super.onSuccess(data)
if (data is List<*>) {
recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
}
}
}
@@ -27,18 +27,4 @@
android:contentDescription="@string/image_string"
app:srcCompat="@drawable/ic_baseline_add_24" />
<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="visible">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</RelativeLayout>
@@ -3,7 +3,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:background="@color/colorPrimaryDark">
<LinearLayout
@@ -4,7 +4,8 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="24dp">
android:layout_margin="24dp"
android:background="@color/colorPrimaryDark">
<GridView
android:id="@+id/grid_mono"
@@ -1,20 +1,35 @@
package com.appttude.h_mal.atlas_weather.data.repository
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.utils.BaseTest
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doAnswer
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.justRun
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import retrofit2.Response
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
private const val MORE_THAN_FIVE_MINS = 330000L
private const val LESS_THAN_FIVE_MINS = 270000L
class RepositoryImplTest {
class RepositoryImplTest : BaseTest() {
lateinit var repository: RepositoryImpl
@@ -71,4 +86,60 @@ class RepositoryImplTest {
val valid: Boolean = repository.isSearchValid(location)
assertEquals(valid, true)
}
@Test
fun getWeatherFromApi_validLatLong_validSearch() {
//Arrange
val mockResponse = createSuccessfulRetrofitMock<WeatherResponse>()
//Act
//create a successful retrofit response
coEvery { api.getFromApi("", "") }.returns(mockResponse)
// Assert
runBlocking {
val result = repository.getWeatherFromApi("", "")
assertIs<WeatherResponse>(result)
}
}
@Test
fun getWeatherFromApi_validLatLong_invalidResponse() {
//Arrange
val mockResponse = createErrorRetrofitMock<WeatherResponse>()
//Act
//create a successful retrofit response
coEvery { api.getFromApi(any(), any()) } returns (mockResponse)
// Assert
val ioExceptionReturned = assertFailsWith<IOException> {
runBlocking {
repository.getWeatherFromApi("", "")
}
}
assertEquals(ioExceptionReturned.message, "Error Code: 400")
}
@Test
fun loadWeatherList_validResponse() {
// Arrange
val elements = listOf<EntityItem>(
mockk { every { id } returns any() },
mockk { every { id } returns any() },
mockk { every { id } returns any() },
mockk { every { id } returns any() }
)
//Act
coEvery { db.getWeatherDao().getWeatherListWithoutCurrent() } returns elements
// Assert
runBlocking {
val result = repository.loadWeatherList()
assertIs<List<String>>(result)
}
}
}
@@ -7,7 +7,7 @@ import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.google.gson.Gson
import com.appttude.h_mal.atlas_weather.utils.BaseTest
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
@@ -18,9 +18,7 @@ import org.junit.Before
import org.junit.Test
import java.io.IOException
class ServicesHelperTest {
private val gson = Gson()
class ServicesHelperTest : BaseTest() {
lateinit var helper: ServicesHelper
@@ -40,8 +38,7 @@ class ServicesHelperTest {
MockKAnnotations.init(this)
helper = ServicesHelper(repository, settingsRepository, locationProvider)
val json = this::class.java.classLoader!!.getResource("weather_sample.json").readText()
weatherResponse = gson.fromJson(json, WeatherResponse::class.java)
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
}
@Test
@@ -1,320 +0,0 @@
{
"lat": 51.5,
"lon": -0.12,
"timezone": "Europe/London",
"timezone_offset": 0,
"current": {
"dt": 1608391380,
"sunrise": 1608364972,
"sunset": 1608393158,
"temp": 10.53,
"feels_like": 5.17,
"pressure": 1006,
"humidity": 71,
"dew_point": 5.5,
"uvi": 0.12,
"clouds": 39,
"visibility": 10000,
"wind_speed": 6.2,
"wind_deg": 210,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"rain": {
"1h": 0.19
}
},
"daily": [
{
"dt": 1608375600,
"sunrise": 1608364972,
"sunset": 1608393158,
"temp": {
"day": 11.78,
"min": 9.1,
"max": 12.31,
"night": 9.1,
"eve": 10.27,
"morn": 10.8
},
"feels_like": {
"day": 6.46,
"night": 5.08,
"eve": 5.58,
"morn": 4.63
},
"pressure": 1005,
"humidity": 69,
"dew_point": 6.42,
"wind_speed": 6.37,
"wind_deg": 217,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 97,
"pop": 0.98,
"rain": 1.69,
"uvi": 0.53
},
{
"dt": 1608462000,
"sunrise": 1608451406,
"sunset": 1608479581,
"temp": {
"day": 9.9,
"min": 7.41,
"max": 10.52,
"night": 7.41,
"eve": 8.83,
"morn": 8.59
},
"feels_like": {
"day": 4.19,
"night": 3.99,
"eve": 4.55,
"morn": 4.79
},
"pressure": 1013,
"humidity": 64,
"dew_point": 3.48,
"wind_speed": 6.12,
"wind_deg": 226,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 8,
"pop": 0.58,
"rain": 0.65,
"uvi": 0.45
},
{
"dt": 1608548400,
"sunrise": 1608537838,
"sunset": 1608566008,
"temp": {
"day": 11.06,
"min": 7.01,
"max": 13.57,
"night": 13.2,
"eve": 12.68,
"morn": 8.59
},
"feels_like": {
"day": 6.32,
"night": 9.99,
"eve": 9.75,
"morn": 4.64
},
"pressure": 1005,
"humidity": 91,
"dew_point": 9.69,
"wind_speed": 6.7,
"wind_deg": 185,
"weather": [
{
"id": 501,
"main": "Rain",
"description": "moderate rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 1,
"rain": 7.85,
"uvi": 0.21
},
{
"dt": 1608634800,
"sunrise": 1608624266,
"sunset": 1608652438,
"temp": {
"day": 12.97,
"min": 11.7,
"max": 13.21,
"night": 11.7,
"eve": 12.37,
"morn": 12.93
},
"feels_like": {
"day": 11.39,
"night": 10.49,
"eve": 10.96,
"morn": 9.65
},
"pressure": 1012,
"humidity": 83,
"dew_point": 10.31,
"wind_speed": 2.38,
"wind_deg": 214,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 100,
"pop": 1,
"rain": 3.25,
"uvi": 0.34
},
{
"dt": 1608721200,
"sunrise": 1608710690,
"sunset": 1608738871,
"temp": {
"day": 12.28,
"min": 10.12,
"max": 12.62,
"night": 10.12,
"eve": 10.12,
"morn": 11.73
},
"feels_like": {
"day": 7.48,
"night": 6.73,
"eve": 6.6,
"morn": 8.15
},
"pressure": 1006,
"humidity": 64,
"dew_point": 5.76,
"wind_speed": 5.45,
"wind_deg": 224,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 97,
"pop": 0.94,
"rain": 2.52,
"uvi": 0.52
},
{
"dt": 1608811200,
"sunrise": 1608797112,
"sunset": 1608825307,
"temp": {
"day": 7.3,
"min": 4.66,
"max": 8.32,
"night": 4.66,
"eve": 5.76,
"morn": 5.85
},
"feels_like": {
"day": 0.36,
"night": -1.25,
"eve": -0.31,
"morn": -2.46
},
"pressure": 1020,
"humidity": 60,
"dew_point": 0.15,
"wind_speed": 7.09,
"wind_deg": 5,
"weather": [
{
"id": 500,
"main": "Rain",
"description": "light rain",
"icon": "10d"
}
],
"clouds": 85,
"pop": 0.52,
"rain": 0.6,
"uvi": 1
},
{
"dt": 1608897600,
"sunrise": 1608883530,
"sunset": 1608911747,
"temp": {
"day": 4.12,
"min": 2.2,
"max": 4.63,
"night": 2.97,
"eve": 3.44,
"morn": 2.28
},
"feels_like": {
"day": -0.33,
"night": -0.43,
"eve": -0.1,
"morn": -2.82
},
"pressure": 1033,
"humidity": 70,
"dew_point": -3.09,
"wind_speed": 3.35,
"wind_deg": 334,
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
}
],
"clouds": 0,
"pop": 0,
"uvi": 1
},
{
"dt": 1608984000,
"sunrise": 1608969944,
"sunset": 1608998190,
"temp": {
"day": 6.03,
"min": 2.76,
"max": 6.92,
"night": 6.92,
"eve": 6.45,
"morn": 3.59
},
"feels_like": {
"day": 0.49,
"night": -0.96,
"eve": -0.28,
"morn": -0.44
},
"pressure": 1024,
"humidity": 69,
"dew_point": 0.84,
"wind_speed": 5.25,
"wind_deg": 251,
"weather": [
{
"id": 804,
"main": "Clouds",
"description": "overcast clouds",
"icon": "04d"
}
],
"clouds": 100,
"pop": 0,
"uvi": 1
}
]
}
@@ -0,0 +1,37 @@
package com.appttude.h_mal.atlas_weather.utils
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import io.mockk.every
import io.mockk.mockk
import okhttp3.ResponseBody
import retrofit2.Response
open class BaseTest {
private val gson by lazy { Gson() }
fun <T : Any> getTestData(resourceName: String): T {
val json = this::class.java.classLoader!!.getResource(resourceName).readText()
val typeToken = object : TypeToken<T>() {}.type
return gson.fromJson<T>(json, typeToken)
}
fun <T : Any> getTestData(resourceName: String, cls: Class<T>): T {
val json = this::class.java.classLoader!!.getResource(resourceName).readText()
return gson.fromJson<T>(json, cls)
}
inline fun <reified T : Any> createSuccessfulRetrofitMock(): Response<T> {
val mockResponse = mockk<T>()
return Response.success(mockResponse)
}
fun <T: Any> createErrorRetrofitMock(code: Int = 400): Response<T> {
val responseBody = mockk<ResponseBody>(relaxed = true)
val rawResponse = mockk<okhttp3.Response>().also {
every { it.code } returns code
}
return Response.error<T>(code, responseBody)
}
}
@@ -0,0 +1,39 @@
package com.appttude.h_mal.atlas_weather.utils
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}
fun sleep(millis: Long = 1000) {
runBlocking(Dispatchers.Default) { delay(millis) }
}
@@ -1,26 +1,35 @@
package com.appttude.h_mal.atlas_weather.viewmodel
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.repository.Repository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.ViewState
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
import com.appttude.h_mal.atlas_weather.utils.BaseTest
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
import com.appttude.h_mal.atlas_weather.utils.sleep
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import java.io.IOException
import kotlin.test.assertIs
class WorldViewModelTest : BaseTest() {
@Suppress("unused")
class WorldViewModelTest {
@get:Rule
val rule = InstantTaskExecutorRule()
@InjectMockKs
lateinit var viewModel: WorldViewModel
@MockK(relaxed = true)
@@ -29,46 +38,102 @@ class WorldViewModelTest {
@MockK
lateinit var locationProvider: LocationProviderImpl
private lateinit var weatherResponse: WeatherResponse
@Before
fun setUp() {
MockKAnnotations.init(this)
viewModel = WorldViewModel(locationProvider, repository)
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
}
@Test
fun fetchDataForSingleLocation_validLocation_validReturn() {
// Arrange
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
// Act
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
weatherResponse.lat,
weatherResponse.lon
)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon,
LocationType.City
)
}.returns(CURRENT_LOCATION)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
// Assert
viewModel.uiState.observeForever {
println(it.javaClass.name)
}
sleep(3000)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
}
@Test
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
// Arrange
val location = CURRENT_LOCATION
// Act
every { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } throws IOException("Unable to get location")
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
)
}.returns(weatherResponse)
viewModel.fetchDataForSingleLocation(location)
assertEquals(viewModel.operationRefresh.getOrAwaitValue()?.getContentIfNotHandled(), false)
// 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
}
+1
View File
@@ -20,3 +20,4 @@ dependencyResolutionManagement {
}
rootProject.name = "Atlas Weather"
include ':app'
include ':test-resources'