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/
# User-specific configurations
.idea/androidTestResultsUserPreferences.xml
.idea/caches/
.idea/libraries/
.idea/shelf/
@@ -94,5 +95,3 @@ gen-external-apklibs
/fastlane/report.xml
# Google play files
/google-play-key.json
/.idea/androidTestResultsUserPreferences.xml

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

View File

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

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
class MockLocationProvider : LocationProvider {
private val latLong = Pair(0.00, 0.00)
private var feedMap: MutableMap<String, Pair<Double, Double>> = mutableMapOf()
private var latLong = Pair(0.00, 0.00)
override suspend fun getCurrentLatLong() = latLong
override fun getLatLongFromLocationName(location: String) = latLong
override fun getLatLongFromLocationName(location: String): Pair<Double, Double> {
return feedMap[location] ?: Pair(0.00, 0.00)
}
override suspend fun getLocationNameFromLatLong(
lat: Double,
long: Double,
type: LocationType
): String {
return "Mock Location"
return feedMap.filterValues { it.first == lat && it.second == long }.keys.firstOrNull() ?: "Mock Location"
}
fun addLocationToList(name: String, lat: Double, long: Double) {
val latLong = Pair(lat, long)
feedMap.put(name, latLong)
}
}

View File

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

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(
val id: String
) {
Valid("valid_response"),
Invalid("invalid_response")
Metric("valid_response_metric"),
Imperial("valid_response_imperial"),
WrongLocation("wrong_location_response"),
InvalidKey("invalid_api_key_response"),
Sydney("valid_response_metric_sydney")
}

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 refresh() = pullToRefresh(R.id.swipe_refresh)
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
fun verifyUnableToRetrieve() {
matchText(R.id.header_text, R.string.retrieve_warning)
matchText(R.id.body_text, R.string.empty_retrieve_warning)
}
}

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) {
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

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

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

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

View File

@@ -1,6 +1,23 @@
package com.appttude.h_mal.atlas_weather.ui.home.adapter
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.recyclerview.widget.RecyclerView
import com.appttude.h_mal.atlas_weather.R
class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val icon: ImageView = itemView.findViewById(R.id.icon)
var bodyTV: TextView = itemView.findViewById(R.id.body_text)
var headerTV: TextView = itemView.findViewById(R.id.header_text)
fun bindData(
@DrawableRes imageRes: Int? = R.drawable.ic_baseline_cloud_off_24,
header: String = itemView.resources.getString(R.string.retrieve_warning),
body: String = itemView.resources.getString(R.string.empty_retrieve_warning) ){
imageRes?.let { icon.setImageResource(it) }
headerTV.text = header
bodyTV.text = body
}
}

View File

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

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

View File

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

View File

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

View File

@@ -36,20 +36,4 @@
android:textStyle="bold" />
</LinearLayout>
<FrameLayout
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:visibility="gone">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</RelativeLayout>

View File

@@ -43,17 +43,5 @@
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:layout="@layout/fragment_home" />
<ProgressBar
android:id="@+id/progress_circular"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:elevation="0.2dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -31,15 +31,4 @@
app:layout_constraintRight_toRightOf="parent"
app:srcCompat="@android:drawable/ic_input_add" />
<ProgressBar
android:id="@+id/progressBar2"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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.model.weather.FullWeather
abstract class WeatherViewModel : ViewModel() {
abstract class WeatherViewModel : BaseViewModel() {
fun createFullWeather(
weather: WeatherResponse,

View File

@@ -37,20 +37,4 @@
android:textStyle="bold" />
</LinearLayout>
<FrameLayout
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:visibility="gone">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</RelativeLayout>

View File

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

View File

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

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="retrieve_warning">Unable to retrieve weather</string>
<string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string>
<string name="no_weather_to_display">No weather to display</string>
</resources>

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,18 +27,4 @@
android:contentDescription="@string/image_string"
app:srcCompat="@drawable/ic_baseline_add_24" />
<FrameLayout
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:visibility="gone"
tools:visibility="visible">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
</RelativeLayout>

View File

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

View File

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

View File

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

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

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

View File

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