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

3
.gitignore vendored
View File

@@ -65,6 +65,7 @@ gen-external-apklibs
/out/ /out/
# User-specific configurations # User-specific configurations
.idea/androidTestResultsUserPreferences.xml
.idea/caches/ .idea/caches/
.idea/libraries/ .idea/libraries/
.idea/shelf/ .idea/shelf/
@@ -94,5 +95,3 @@ gen-external-apklibs
/fastlane/report.xml /fastlane/report.xml
# Google play files # Google play files
/google-play-key.json /google-play-key.json
/.idea/androidTestResultsUserPreferences.xml

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
Gemfile.lock Normal file
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

View File

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

View File

@@ -0,0 +1,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
}
]
}

View File

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

View File

@@ -5,20 +5,30 @@ package com.appttude.h_mal.atlas_weather
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.WindowManager
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso
import androidx.test.espresso.Root
import androidx.test.espresso.UiController import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction import androidx.test.espresso.ViewAction
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.RootMatchers.withDecorView
import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule import androidx.test.rule.GrantPermissionRule
import com.appttude.h_mal.atlas_weather.application.TestAppClass import com.appttude.h_mal.atlas_weather.application.TestAppClass
import com.appttude.h_mal.atlas_weather.helpers.BaseCustomMatcher
import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
import com.appttude.h_mal.atlas_weather.utils.Stubs import com.appttude.h_mal.atlas_weather.utils.Stubs
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.hamcrest.Description
import org.hamcrest.Matcher import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@@ -34,6 +44,7 @@ open class BaseTest<A : Activity>(
lateinit var scenario: ActivityScenario<A> lateinit var scenario: ActivityScenario<A>
private lateinit var testApp: TestAppClass private lateinit var testApp: TestAppClass
private lateinit var testActivity: Activity private lateinit var testActivity: Activity
private lateinit var decorView: View
@get:Rule @get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION) var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION)
@@ -58,19 +69,24 @@ open class BaseTest<A : Activity>(
scenario = ActivityScenario.launch(startIntent) scenario = ActivityScenario.launch(startIntent)
scenario.onActivity { scenario.onActivity {
decorView = it.window.decorView
testActivity = it testActivity = it
} }
afterLaunch() afterLaunch()
} }
fun stubEndpoint(url: String, stub: Stubs) { fun stubEndpoint(url: String, stub: Stubs, code: Int = 200) {
testApp.stubUrl(url, stub.id) testApp.stubUrl(url, stub.id, code)
} }
fun unstubEndpoint(url: String) { fun unstubEndpoint(url: String) {
testApp.removeUrlStub(url) testApp.removeUrlStub(url)
} }
fun stubLocation(location: String, lat: Double = 0.00, long: Double = 0.00) {
testApp.stubLocation(location, lat, long)
}
fun getActivity() = testActivity fun getActivity() = testActivity
@After @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)
}
}
} }

View File

@@ -3,6 +3,8 @@ package com.appttude.h_mal.atlas_weather.application
import androidx.room.Room import androidx.room.Room
import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.idling.CountingIdlingResource import androidx.test.espresso.idling.CountingIdlingResource
import androidx.test.platform.app.InstrumentationRegistry
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
@@ -18,6 +20,9 @@ class TestAppClass : BaseAppClass() {
private val idlingResources = CountingIdlingResource("Data_loader") private val idlingResources = CountingIdlingResource("Data_loader")
private val mockingNetworkInterceptor = MockingNetworkInterceptor(idlingResources) private val mockingNetworkInterceptor = MockingNetworkInterceptor(idlingResources)
lateinit var database: AppDatabase
lateinit var locationProvider: MockLocationProvider
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
IdlingRegistry.getInstance().register(idlingResources) IdlingRegistry.getInstance().register(idlingResources)
@@ -32,23 +37,31 @@ class TestAppClass : BaseAppClass() {
) as WeatherApi ) as WeatherApi
} }
override fun createLocationModule() = MockLocationProvider() override fun createLocationModule(): LocationProvider {
locationProvider = MockLocationProvider()
override fun createRoomDatabase(): AppDatabase { return locationProvider
return Room.inMemoryDatabaseBuilder(this, AppDatabase::class.java)
.addTypeConverter(Converter(this))
.build()
} }
fun stubUrl(url: String, rawPath: String) { override fun createRoomDatabase(): AppDatabase {
val id = resources.getIdentifier(rawPath, "raw", packageName) database = Room.inMemoryDatabaseBuilder(this, AppDatabase::class.java)
val iStream = resources.openRawResource(id) .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) val data = iStream.bufferedReader().use(BufferedReader::readText)
mockingNetworkInterceptor.addUrlStub(url = url, data = data) mockingNetworkInterceptor.addUrlStub(url = url, data = data, code = code)
} }
fun removeUrlStub(url: String) { fun removeUrlStub(url: String) {
mockingNetworkInterceptor.removeUrlStub(url = url) mockingNetworkInterceptor.removeUrlStub(url = url)
} }
fun stubLocation(location: String, lat: Double = 0.00, long: Double = 0.00) {
locationProvider.addLocationToList(location, lat, long)
}
} }

View File

@@ -3,17 +3,25 @@ package com.appttude.h_mal.atlas_weather.data.location
import com.appttude.h_mal.atlas_weather.model.types.LocationType import com.appttude.h_mal.atlas_weather.model.types.LocationType
class MockLocationProvider : LocationProvider { 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 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( override suspend fun getLocationNameFromLatLong(
lat: Double, lat: Double,
long: Double, long: Double,
type: LocationType type: LocationType
): String { ): 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)
}
} }

View File

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

View File

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

View File

@@ -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)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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")
}
}
}

View File

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

View File

@@ -0,0 +1,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)
}

View File

@@ -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
}
}

View File

@@ -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.BaseTestRobot
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
fun homeScreen(func: HomeScreenRobot.() -> Unit) = HomeScreenRobot().apply { func() } fun weatherScreen(func: WeatherScreen.() -> Unit) = WeatherScreen().apply { func() }
class HomeScreenRobot : BaseTestRobot() { class WeatherScreen : BaseTestRobot() {
fun verifyCurrentTemperature(temperature: Int) = fun verifyCurrentTemperature(temperature: Int) =
matchText(R.id.temp_main_4, temperature.toString()) matchText(R.id.temp_main_4, temperature.toString())
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location) fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
fun refresh() = pullToRefresh(R.id.swipe_refresh) fun refresh() = pullToRefresh(R.id.swipe_refresh)
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4) fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
fun verifyUnableToRetrieve() {
matchText(R.id.header_text, R.string.retrieve_warning)
matchText(R.id.body_text, R.string.empty_retrieve_warning)
}
} }

View File

@@ -0,0 +1,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)
}
}

View File

@@ -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")
}
}
}

View File

@@ -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.atlas_weather.utils.Stubs
import com.appttude.h_mal.monoWeather.MonoBaseTest 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 import org.junit.Test
class HomePageUITest : MonoBaseTest() { class HomePageUITest : MonoBaseTest() {
override fun beforeLaunch() { override fun beforeLaunch() {
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid) stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
} }
@Test @Test
fun loadApp_validWeatherResponse_returnsValidPage() { fun loadApp_validWeatherResponse_returnsValidPage() {
homeScreen { weatherScreen {
isDisplayed() isDisplayed()
verifyCurrentTemperature(2) verifyCurrentTemperature(2)
verifyCurrentLocation("Mock Location") verifyCurrentLocation("Mock Location")

View File

@@ -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")
}
}
}

View File

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

View File

@@ -1,6 +1,23 @@
package com.appttude.h_mal.atlas_weather.ui.home.adapter package com.appttude.h_mal.atlas_weather.ui.home.adapter
import android.view.View import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.recyclerview.widget.RecyclerView 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
}
}

View File

@@ -1,20 +1,19 @@
package com.appttude.h_mal.atlas_weather.ui.home.adapter package com.appttude.h_mal.atlas_weather.ui.home.adapter
import android.annotation.SuppressLint
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.utils.generateView 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( class WeatherRecyclerAdapter(
val itemClick: (Forecast) -> Unit private val itemClick: (Forecast) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var weather: WeatherDisplay? = null var weather: WeatherDisplay? = null
@SuppressLint("NotifyDataSetChanged")
fun addCurrent(current: WeatherDisplay) { fun addCurrent(current: WeatherDisplay) {
weather = current weather = current
notifyDataSetChanged() notifyDataSetChanged()
@@ -23,7 +22,7 @@ class WeatherRecyclerAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (getDataType(viewType)) { return when (getDataType(viewType)) {
is ViewType.Empty -> { is ViewType.Empty -> {
val emptyViewHolder = View(parent.context) val emptyViewHolder = parent.generateView(R.layout.empty_state_layout)
EmptyViewHolder(emptyViewHolder) EmptyViewHolder(emptyViewHolder)
} }
@@ -32,8 +31,8 @@ class WeatherRecyclerAdapter(
ViewHolderCurrent(viewCurrent) ViewHolderCurrent(viewCurrent)
} }
is ViewType.Forecast -> { is ViewType.ForecastHourly -> {
val viewForecast = parent.generateView(R.layout.list_item_forecast) val viewForecast = parent.generateView(R.layout.hourly_item_forecast)
ViewHolderForecast(viewForecast) ViewHolderForecast(viewForecast)
} }
@@ -41,13 +40,19 @@ class WeatherRecyclerAdapter(
val viewFurther = parent.generateView(R.layout.list_item_further) val viewFurther = parent.generateView(R.layout.list_item_further)
ViewHolderFurtherDetails(viewFurther) ViewHolderFurtherDetails(viewFurther)
} }
is ViewType.ForecastDaily -> {
val viewForecast = parent.generateView(R.layout.list_item_forecast)
ViewHolderForecastDaily(viewForecast)
}
} }
} }
sealed class ViewType { sealed class ViewType {
object Empty : ViewType() object Empty : ViewType()
object Current : ViewType() object Current : ViewType()
object Forecast : ViewType() object ForecastHourly : ViewType()
object ForecastDaily : ViewType()
object Further : ViewType() object Further : ViewType()
} }
@@ -55,19 +60,20 @@ class WeatherRecyclerAdapter(
return when (type) { return when (type) {
0 -> ViewType.Empty 0 -> ViewType.Empty
1 -> ViewType.Current 1 -> ViewType.Current
2 -> ViewType.Forecast 2 -> ViewType.ForecastHourly
3 -> ViewType.Further 3 -> ViewType.Further
4 -> ViewType.ForecastDaily
else -> ViewType.Empty else -> ViewType.Empty
} }
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
if (weather == null) return 0 if (weather == null) return 0
return when (position) { return when (position) {
0 -> 1 0 -> 1
in 1 until itemCount - 2 -> 2 1 -> 3
itemCount - 1 -> 3 2 -> 2
in 3 until (itemCount) -> 4
else -> 0 else -> 0
} }
} }
@@ -75,7 +81,8 @@ class WeatherRecyclerAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getDataType(getItemViewType(position))) { when (getDataType(getItemViewType(position))) {
is ViewType.Empty -> { is ViewType.Empty -> {
holder as EmptyViewHolder val emptyViewHolder = holder as EmptyViewHolder
emptyViewHolder.bindData()
} }
is ViewType.Current -> { is ViewType.Current -> {
@@ -83,28 +90,31 @@ class WeatherRecyclerAdapter(
viewHolderCurrent.bindData(weather) 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 -> { is ViewType.Further -> {
val viewHolderCurrent = holder as ViewHolderFurtherDetails val viewHolderCurrent = holder as ViewHolderFurtherDetails
viewHolderCurrent.bindData(weather) 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 { override fun getItemCount(): Int {
if (weather == null) return 0 return if (weather == null) 1 else 3 + (weather?.forecast?.size ?: 0)
return 2 + (weather?.forecast?.size ?: 0)
} }
} }

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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.view.View
import android.widget.ImageView 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.model.forecast.Forecast
import com.appttude.h_mal.atlas_weather.utils.loadImage 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 dateTV: TextView = itemView.findViewById(R.id.list_date)
var dayTV: TextView = itemView.findViewById(R.id.list_day) 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 weatherIV: ImageView = itemView.findViewById(R.id.list_icon)
var mainTempTV: TextView = itemView.findViewById(R.id.list_main_temp) var maxTempTV: TextView = itemView.findViewById(R.id.list_main_temp)
var minorTempTV: TextView = itemView.findViewById(R.id.list_minor_temp) var minTempTV: TextView = itemView.findViewById(R.id.list_minor_temp)
var conditionTV: TextView = itemView.findViewById(R.id.list_condition)
fun bindView(forecast: Forecast?) { fun bindView(forecast: Forecast?) {
dateTV.text = forecast?.date dateTV.text = forecast?.date
dayTV.text = forecast?.day dayTV.text = forecast?.day
conditionTV.text = forecast?.condition
weatherIV.loadImage(forecast?.weatherIcon) weatherIV.loadImage(forecast?.weatherIcon)
mainTempTV.text = forecast?.mainTemp maxTempTV.text = forecast?.mainTemp
minorTempTV.text = forecast?.minorTemp minTempTV.text = forecast?.minorTemp
conditionTV.text = forecast?.condition
} }
} }

View File

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

View File

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

View File

@@ -36,20 +36,4 @@
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </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> </RelativeLayout>

View File

@@ -43,17 +43,5 @@
app:layout_constraintTop_toBottomOf="@id/toolbar" app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:layout="@layout/fragment_home" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -31,15 +31,4 @@
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:srcCompat="@android:drawable/ic_input_add" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,10 +9,12 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController import androidx.navigation.ui.setupWithNavController
import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.base.BaseActivity
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomnavigation.BottomNavigationView
import kotlinx.android.synthetic.main.activity_main_navigation.toolbar import kotlinx.android.synthetic.main.activity_main_navigation.toolbar
class MainActivity : AppCompatActivity() { class MainActivity : BaseActivity() {
lateinit var navHost: NavHostFragment lateinit var navHost: NavHostFragment

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
package com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.appttude.h_mal.atlas_weather.model.ViewState
open class BaseViewModel: ViewModel() {
private val _uiState = MutableLiveData<ViewState>()
val uiState: LiveData<ViewState> = _uiState
fun onStart() {
_uiState.postValue(ViewState.HasStarted)
}
fun <T : Any> onSuccess(result: T) {
_uiState.postValue(ViewState.HasData(result))
}
protected fun <E : Any> onError(error: E) {
_uiState.postValue(ViewState.HasError(error))
}
}

View File

@@ -5,7 +5,7 @@ import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherRe
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
abstract class WeatherViewModel : ViewModel() { abstract class WeatherViewModel : BaseViewModel() {
fun createFullWeather( fun createFullWeather(
weather: WeatherResponse, weather: WeatherResponse,

View File

@@ -37,20 +37,4 @@
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </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> </RelativeLayout>

View File

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

View File

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

View File

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

View File

@@ -30,4 +30,5 @@
<string name="loading_nforecast">Loading \nforecast…</string> <string name="loading_nforecast">Loading \nforecast…</string>
<string name="retrieve_warning">Unable to retrieve weather</string> <string name="retrieve_warning">Unable to retrieve weather</string>
<string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string> <string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string>
<string name="no_weather_to_display">No weather to display</string>
</resources> </resources>

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,5 @@ class ViewHolderForecast(
val adapter = GridForecastAdapter() val adapter = GridForecastAdapter()
adapter.addCurrent(forecasts) adapter.addCurrent(forecasts)
recyclerView.adapter = adapter recyclerView.adapter = adapter
} }
} }

View File

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

View File

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

View File

@@ -27,18 +27,4 @@
android:contentDescription="@string/image_string" android:contentDescription="@string/image_string"
app:srcCompat="@drawable/ic_baseline_add_24" /> 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> </RelativeLayout>

View File

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

View File

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

View File

@@ -1,20 +1,35 @@
package com.appttude.h_mal.atlas_weather.data.repository package com.appttude.h_mal.atlas_weather.data.repository
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.utils.BaseTest
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doAnswer
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.justRun
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import retrofit2.Response
import java.io.IOException
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
private const val MORE_THAN_FIVE_MINS = 330000L private const val MORE_THAN_FIVE_MINS = 330000L
private const val LESS_THAN_FIVE_MINS = 270000L private const val LESS_THAN_FIVE_MINS = 270000L
class RepositoryImplTest { class RepositoryImplTest : BaseTest() {
lateinit var repository: RepositoryImpl lateinit var repository: RepositoryImpl
@@ -71,4 +86,60 @@ class RepositoryImplTest {
val valid: Boolean = repository.isSearchValid(location) val valid: Boolean = repository.isSearchValid(location)
assertEquals(valid, true) assertEquals(valid, true)
} }
@Test
fun getWeatherFromApi_validLatLong_validSearch() {
//Arrange
val mockResponse = createSuccessfulRetrofitMock<WeatherResponse>()
//Act
//create a successful retrofit response
coEvery { api.getFromApi("", "") }.returns(mockResponse)
// Assert
runBlocking {
val result = repository.getWeatherFromApi("", "")
assertIs<WeatherResponse>(result)
}
}
@Test
fun getWeatherFromApi_validLatLong_invalidResponse() {
//Arrange
val mockResponse = createErrorRetrofitMock<WeatherResponse>()
//Act
//create a successful retrofit response
coEvery { api.getFromApi(any(), any()) } returns (mockResponse)
// Assert
val ioExceptionReturned = assertFailsWith<IOException> {
runBlocking {
repository.getWeatherFromApi("", "")
}
}
assertEquals(ioExceptionReturned.message, "Error Code: 400")
}
@Test
fun loadWeatherList_validResponse() {
// Arrange
val elements = listOf<EntityItem>(
mockk { every { id } returns any() },
mockk { every { id } returns any() },
mockk { every { id } returns any() },
mockk { every { id } returns any() }
)
//Act
coEvery { db.getWeatherDao().getWeatherListWithoutCurrent() } returns elements
// Assert
runBlocking {
val result = repository.loadWeatherList()
assertIs<List<String>>(result)
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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