mirror of
https://github.com/hmalik144/Weather-apps.git
synced 2025-12-10 02:05:20 +00:00
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:
345
app/src/androidTest/assets/valid_response_imperial.json
Normal file
345
app/src/androidTest/assets/valid_response_imperial.json
Normal file
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"lat": 51.51,
|
||||
"lon": -0.13,
|
||||
"timezone": "Europe/London",
|
||||
"timezone_offset": 3600,
|
||||
"current": {
|
||||
"dt": 1691537401,
|
||||
"sunrise": 1691555756,
|
||||
"sunset": 1691609779,
|
||||
"temp": 58.57,
|
||||
"feels_like": 58.5,
|
||||
"pressure": 1012,
|
||||
"humidity": 93,
|
||||
"dew_point": 56.55,
|
||||
"uvi": 0,
|
||||
"clouds": 100,
|
||||
"visibility": 10000,
|
||||
"wind_speed": 5.75,
|
||||
"wind_deg": 280,
|
||||
"weather": [
|
||||
{
|
||||
"id": 804,
|
||||
"main": "Clouds",
|
||||
"description": "overcast clouds",
|
||||
"icon": "04n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"daily": [
|
||||
{
|
||||
"dt": 1691582400,
|
||||
"sunrise": 1691555756,
|
||||
"sunset": 1691609779,
|
||||
"moonrise": 1691620920,
|
||||
"moonset": 1691592600,
|
||||
"moon_phase": 0.78,
|
||||
"temp": {
|
||||
"day": 71.08,
|
||||
"min": 54.81,
|
||||
"max": 75.24,
|
||||
"night": 65.26,
|
||||
"eve": 74.3,
|
||||
"morn": 55.85
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 70.14,
|
||||
"night": 65.3,
|
||||
"eve": 73.92,
|
||||
"morn": 55.04
|
||||
},
|
||||
"pressure": 1018,
|
||||
"humidity": 48,
|
||||
"dew_point": 50.27,
|
||||
"wind_speed": 5.03,
|
||||
"wind_deg": 204,
|
||||
"wind_gust": 11.03,
|
||||
"weather": [
|
||||
{
|
||||
"id": 802,
|
||||
"main": "Clouds",
|
||||
"description": "scattered clouds",
|
||||
"icon": "03d"
|
||||
}
|
||||
],
|
||||
"clouds": 36,
|
||||
"pop": 0,
|
||||
"uvi": 6.76
|
||||
},
|
||||
{
|
||||
"dt": 1691668800,
|
||||
"sunrise": 1691642251,
|
||||
"sunset": 1691696069,
|
||||
"moonrise": 0,
|
||||
"moonset": 1691683560,
|
||||
"moon_phase": 0.82,
|
||||
"temp": {
|
||||
"day": 76.14,
|
||||
"min": 61.27,
|
||||
"max": 79.39,
|
||||
"night": 68.88,
|
||||
"eve": 76.66,
|
||||
"morn": 62.64
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 75.94,
|
||||
"night": 68.99,
|
||||
"eve": 76.6,
|
||||
"morn": 62.31
|
||||
},
|
||||
"pressure": 1019,
|
||||
"humidity": 53,
|
||||
"dew_point": 58.01,
|
||||
"wind_speed": 8.57,
|
||||
"wind_deg": 184,
|
||||
"wind_gust": 15.05,
|
||||
"weather": [
|
||||
{
|
||||
"id": 803,
|
||||
"main": "Clouds",
|
||||
"description": "broken clouds",
|
||||
"icon": "04d"
|
||||
}
|
||||
],
|
||||
"clouds": 68,
|
||||
"pop": 0,
|
||||
"uvi": 6.43
|
||||
},
|
||||
{
|
||||
"dt": 1691755200,
|
||||
"sunrise": 1691728746,
|
||||
"sunset": 1691782357,
|
||||
"moonrise": 1691709180,
|
||||
"moonset": 1691773920,
|
||||
"moon_phase": 0.85,
|
||||
"temp": {
|
||||
"day": 75.67,
|
||||
"min": 63.64,
|
||||
"max": 80.06,
|
||||
"night": 68.27,
|
||||
"eve": 74.07,
|
||||
"morn": 65.26
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 75.9,
|
||||
"night": 68.27,
|
||||
"eve": 74.05,
|
||||
"morn": 65.21
|
||||
},
|
||||
"pressure": 1018,
|
||||
"humidity": 63,
|
||||
"dew_point": 62.37,
|
||||
"wind_speed": 11.59,
|
||||
"wind_deg": 224,
|
||||
"wind_gust": 19.64,
|
||||
"weather": [
|
||||
{
|
||||
"id": 500,
|
||||
"main": "Rain",
|
||||
"description": "light rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"clouds": 100,
|
||||
"pop": 0.32,
|
||||
"rain": 0.12,
|
||||
"uvi": 5.08
|
||||
},
|
||||
{
|
||||
"dt": 1691841600,
|
||||
"sunrise": 1691815241,
|
||||
"sunset": 1691868645,
|
||||
"moonrise": 1691798160,
|
||||
"moonset": 1691863560,
|
||||
"moon_phase": 0.88,
|
||||
"temp": {
|
||||
"day": 74.5,
|
||||
"min": 63.07,
|
||||
"max": 74.5,
|
||||
"night": 63.86,
|
||||
"eve": 70.03,
|
||||
"morn": 63.07
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 73.53,
|
||||
"night": 63.57,
|
||||
"eve": 69.28,
|
||||
"morn": 62.89
|
||||
},
|
||||
"pressure": 1014,
|
||||
"humidity": 40,
|
||||
"dew_point": 48.34,
|
||||
"wind_speed": 14.63,
|
||||
"wind_deg": 225,
|
||||
"wind_gust": 22.73,
|
||||
"weather": [
|
||||
{
|
||||
"id": 803,
|
||||
"main": "Clouds",
|
||||
"description": "broken clouds",
|
||||
"icon": "04d"
|
||||
}
|
||||
],
|
||||
"clouds": 67,
|
||||
"pop": 0.07,
|
||||
"uvi": 5.66
|
||||
},
|
||||
{
|
||||
"dt": 1691928000,
|
||||
"sunrise": 1691901736,
|
||||
"sunset": 1691954930,
|
||||
"moonrise": 1691887860,
|
||||
"moonset": 1691952420,
|
||||
"moon_phase": 0.91,
|
||||
"temp": {
|
||||
"day": 71.26,
|
||||
"min": 61.68,
|
||||
"max": 71.26,
|
||||
"night": 63.75,
|
||||
"eve": 68.59,
|
||||
"morn": 61.68
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 70.52,
|
||||
"night": 63.39,
|
||||
"eve": 67.93,
|
||||
"morn": 61.21
|
||||
},
|
||||
"pressure": 1012,
|
||||
"humidity": 52,
|
||||
"dew_point": 52.92,
|
||||
"wind_speed": 12.08,
|
||||
"wind_deg": 226,
|
||||
"wind_gust": 20.87,
|
||||
"weather": [
|
||||
{
|
||||
"id": 500,
|
||||
"main": "Rain",
|
||||
"description": "light rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"clouds": 99,
|
||||
"pop": 0.22,
|
||||
"rain": 0.16,
|
||||
"uvi": 3.71
|
||||
},
|
||||
{
|
||||
"dt": 1692014400,
|
||||
"sunrise": 1691988232,
|
||||
"sunset": 1692041215,
|
||||
"moonrise": 1691978220,
|
||||
"moonset": 1692040560,
|
||||
"moon_phase": 0.94,
|
||||
"temp": {
|
||||
"day": 73.13,
|
||||
"min": 60.35,
|
||||
"max": 73.92,
|
||||
"night": 63.91,
|
||||
"eve": 69.6,
|
||||
"morn": 60.82
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 72.12,
|
||||
"night": 63.54,
|
||||
"eve": 68.9,
|
||||
"morn": 60.64
|
||||
},
|
||||
"pressure": 1013,
|
||||
"humidity": 42,
|
||||
"dew_point": 48.63,
|
||||
"wind_speed": 12.35,
|
||||
"wind_deg": 237,
|
||||
"wind_gust": 16.15,
|
||||
"weather": [
|
||||
{
|
||||
"id": 801,
|
||||
"main": "Clouds",
|
||||
"description": "few clouds",
|
||||
"icon": "02d"
|
||||
}
|
||||
],
|
||||
"clouds": 16,
|
||||
"pop": 0.1,
|
||||
"uvi": 4
|
||||
},
|
||||
{
|
||||
"dt": 1692100800,
|
||||
"sunrise": 1692074727,
|
||||
"sunset": 1692127498,
|
||||
"moonrise": 1692068820,
|
||||
"moonset": 1692128220,
|
||||
"moon_phase": 0.97,
|
||||
"temp": {
|
||||
"day": 71.24,
|
||||
"min": 58.37,
|
||||
"max": 71.24,
|
||||
"night": 66.47,
|
||||
"eve": 69.06,
|
||||
"morn": 58.37
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 70.23,
|
||||
"night": 65.5,
|
||||
"eve": 68.16,
|
||||
"morn": 57.61
|
||||
},
|
||||
"pressure": 1018,
|
||||
"humidity": 46,
|
||||
"dew_point": 49.62,
|
||||
"wind_speed": 7.38,
|
||||
"wind_deg": 261,
|
||||
"wind_gust": 13.49,
|
||||
"weather": [
|
||||
{
|
||||
"id": 801,
|
||||
"main": "Clouds",
|
||||
"description": "few clouds",
|
||||
"icon": "02d"
|
||||
}
|
||||
],
|
||||
"clouds": 23,
|
||||
"pop": 0,
|
||||
"uvi": 4
|
||||
},
|
||||
{
|
||||
"dt": 1692187200,
|
||||
"sunrise": 1692161223,
|
||||
"sunset": 1692213780,
|
||||
"moonrise": 1692159660,
|
||||
"moonset": 1692215580,
|
||||
"moon_phase": 0,
|
||||
"temp": {
|
||||
"day": 74.35,
|
||||
"min": 60.22,
|
||||
"max": 75.9,
|
||||
"night": 66.33,
|
||||
"eve": 71.44,
|
||||
"morn": 60.69
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 73.42,
|
||||
"night": 65.71,
|
||||
"eve": 70.68,
|
||||
"morn": 59.7
|
||||
},
|
||||
"pressure": 1018,
|
||||
"humidity": 41,
|
||||
"dew_point": 49.51,
|
||||
"wind_speed": 7.92,
|
||||
"wind_deg": 111,
|
||||
"wind_gust": 14.92,
|
||||
"weather": [
|
||||
{
|
||||
"id": 804,
|
||||
"main": "Clouds",
|
||||
"description": "overcast clouds",
|
||||
"icon": "04d"
|
||||
}
|
||||
],
|
||||
"clouds": 91,
|
||||
"pop": 0,
|
||||
"uvi": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
350
app/src/androidTest/assets/valid_response_metric_sydney.json
Normal file
350
app/src/androidTest/assets/valid_response_metric_sydney.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
4
app/src/androidTest/assets/wrong_location_response.json
Normal file
4
app/src/androidTest/assets/wrong_location_response.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"cod": "400",
|
||||
"message": "wrong latitude"
|
||||
}
|
||||
@@ -5,20 +5,30 @@ package com.appttude.h_mal.atlas_weather
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.Root
|
||||
import androidx.test.espresso.UiController
|
||||
import androidx.test.espresso.ViewAction
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
import androidx.test.espresso.matcher.RootMatchers.withDecorView
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.appttude.h_mal.atlas_weather.application.TestAppClass
|
||||
import com.appttude.h_mal.atlas_weather.helpers.BaseCustomMatcher
|
||||
import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
|
||||
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
|
||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.Matcher
|
||||
import org.hamcrest.Matchers
|
||||
import org.hamcrest.TypeSafeMatcher
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -34,6 +44,7 @@ open class BaseTest<A : Activity>(
|
||||
lateinit var scenario: ActivityScenario<A>
|
||||
private lateinit var testApp: TestAppClass
|
||||
private lateinit var testActivity: Activity
|
||||
private lateinit var decorView: View
|
||||
|
||||
@get:Rule
|
||||
var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
@@ -58,19 +69,24 @@ open class BaseTest<A : Activity>(
|
||||
|
||||
scenario = ActivityScenario.launch(startIntent)
|
||||
scenario.onActivity {
|
||||
decorView = it.window.decorView
|
||||
testActivity = it
|
||||
}
|
||||
afterLaunch()
|
||||
}
|
||||
|
||||
fun stubEndpoint(url: String, stub: Stubs) {
|
||||
testApp.stubUrl(url, stub.id)
|
||||
fun stubEndpoint(url: String, stub: Stubs, code: Int = 200) {
|
||||
testApp.stubUrl(url, stub.id, code)
|
||||
}
|
||||
|
||||
fun unstubEndpoint(url: String) {
|
||||
testApp.removeUrlStub(url)
|
||||
}
|
||||
|
||||
fun stubLocation(location: String, lat: Double = 0.00, long: Double = 0.00) {
|
||||
testApp.stubLocation(location, lat, long)
|
||||
}
|
||||
|
||||
fun getActivity() = testActivity
|
||||
|
||||
@After
|
||||
@@ -91,4 +107,13 @@ open class BaseTest<A : Activity>(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun checkToastMessage(message: String) {
|
||||
Espresso.onView(ViewMatchers.withText(message)).inRoot(withDecorView(Matchers.not(decorView)))
|
||||
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
waitFor(3500)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.appttude.h_mal.atlas_weather.application
|
||||
import androidx.room.Room
|
||||
import androidx.test.espresso.IdlingRegistry
|
||||
import androidx.test.espresso.idling.CountingIdlingResource
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.location.MockLocationProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.network.NetworkModule
|
||||
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
|
||||
@@ -18,6 +20,9 @@ class TestAppClass : BaseAppClass() {
|
||||
private val idlingResources = CountingIdlingResource("Data_loader")
|
||||
private val mockingNetworkInterceptor = MockingNetworkInterceptor(idlingResources)
|
||||
|
||||
lateinit var database: AppDatabase
|
||||
lateinit var locationProvider: MockLocationProvider
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
IdlingRegistry.getInstance().register(idlingResources)
|
||||
@@ -32,23 +37,31 @@ class TestAppClass : BaseAppClass() {
|
||||
) as WeatherApi
|
||||
}
|
||||
|
||||
override fun createLocationModule() = MockLocationProvider()
|
||||
|
||||
override fun createRoomDatabase(): AppDatabase {
|
||||
return Room.inMemoryDatabaseBuilder(this, AppDatabase::class.java)
|
||||
.addTypeConverter(Converter(this))
|
||||
.build()
|
||||
override fun createLocationModule(): LocationProvider {
|
||||
locationProvider = MockLocationProvider()
|
||||
return locationProvider
|
||||
}
|
||||
|
||||
fun stubUrl(url: String, rawPath: String) {
|
||||
val id = resources.getIdentifier(rawPath, "raw", packageName)
|
||||
val iStream = resources.openRawResource(id)
|
||||
override fun createRoomDatabase(): AppDatabase {
|
||||
database = Room.inMemoryDatabaseBuilder(this, AppDatabase::class.java)
|
||||
.addTypeConverter(Converter(this))
|
||||
.build()
|
||||
return database
|
||||
}
|
||||
|
||||
fun stubUrl(url: String, rawPath: String, code: Int = 200) {
|
||||
val iStream =
|
||||
InstrumentationRegistry.getInstrumentation().context.assets.open("$rawPath.json")
|
||||
val data = iStream.bufferedReader().use(BufferedReader::readText)
|
||||
mockingNetworkInterceptor.addUrlStub(url = url, data = data)
|
||||
mockingNetworkInterceptor.addUrlStub(url = url, data = data, code = code)
|
||||
}
|
||||
|
||||
fun removeUrlStub(url: String) {
|
||||
mockingNetworkInterceptor.removeUrlStub(url = url)
|
||||
}
|
||||
|
||||
fun stubLocation(location: String, lat: Double = 0.00, long: Double = 0.00) {
|
||||
locationProvider.addLocationToList(location, lat, long)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,17 +3,25 @@ package com.appttude.h_mal.atlas_weather.data.location
|
||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||
|
||||
class MockLocationProvider : LocationProvider {
|
||||
private val latLong = Pair(0.00, 0.00)
|
||||
private var feedMap: MutableMap<String, Pair<Double, Double>> = mutableMapOf()
|
||||
|
||||
private var latLong = Pair(0.00, 0.00)
|
||||
|
||||
override suspend fun getCurrentLatLong() = latLong
|
||||
override fun getLatLongFromLocationName(location: String) = latLong
|
||||
override fun getLatLongFromLocationName(location: String): Pair<Double, Double> {
|
||||
return feedMap[location] ?: Pair(0.00, 0.00)
|
||||
}
|
||||
|
||||
override suspend fun getLocationNameFromLatLong(
|
||||
lat: Double,
|
||||
long: Double,
|
||||
type: LocationType
|
||||
): String {
|
||||
return "Mock Location"
|
||||
return feedMap.filterValues { it.first == lat && it.second == long }.keys.firstOrNull() ?: "Mock Location"
|
||||
}
|
||||
|
||||
fun addLocationToList(name: String, lat: Double, long: Double) {
|
||||
val latLong = Pair(lat, long)
|
||||
feedMap.put(name, latLong)
|
||||
}
|
||||
}
|
||||
@@ -11,23 +11,21 @@ class MockingNetworkInterceptor(
|
||||
private val idlingResource: CountingIdlingResource
|
||||
) : Interceptor {
|
||||
|
||||
private var feedMap: MutableMap<String, String> = mutableMapOf()
|
||||
private var urlCallTracker: MutableMap<String, Int> = mutableMapOf()
|
||||
private var feedMap: MutableMap<String, Pair<String, Int>> = mutableMapOf()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
idlingResource.increment()
|
||||
val original = chain.request()
|
||||
val originalHttpUrl = original.url.toString().split("?")[0]
|
||||
|
||||
urlCallTracker.computeIfPresent(originalHttpUrl) { _, j ->
|
||||
j + 1
|
||||
}
|
||||
feedMap[originalHttpUrl]?.let { responsePair ->
|
||||
val code = responsePair.second
|
||||
val jsonBody = responsePair.first
|
||||
|
||||
feedMap[originalHttpUrl]?.let { jsonPath ->
|
||||
val body = jsonPath.toResponseBody("application/json".toMediaType())
|
||||
val body = jsonBody.toResponseBody("application/json".toMediaType())
|
||||
|
||||
val chainResponseBuilder = Response.Builder()
|
||||
.code(200)
|
||||
.code(code)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.request(original)
|
||||
.message("OK")
|
||||
@@ -40,7 +38,7 @@ class MockingNetworkInterceptor(
|
||||
return chain.proceed(original)
|
||||
}
|
||||
|
||||
fun addUrlStub(url: String, data: String) = feedMap.put(url, data)
|
||||
fun addUrlStub(url: String, data: String, code: Int = 200) = feedMap.put(url, Pair(data, code))
|
||||
fun removeUrlStub(url: String) = feedMap.remove(url)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.appttude.h_mal.atlas_weather.helpers
|
||||
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.TypeSafeMatcher
|
||||
|
||||
open class BaseCustomMatcher<T: Any> : TypeSafeMatcher<T>() {
|
||||
override fun describeTo(description: Description?) = describe(description)
|
||||
override fun matchesSafely(item: T): Boolean = match(item)
|
||||
|
||||
open fun describe(description: Description?) { }
|
||||
open fun match(actual: T): Boolean { return false }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.appttude.h_mal.atlas_weather.testSuite
|
||||
|
||||
import android.os.Build
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.room.Room
|
||||
import androidx.room.util.UUIDUtil
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
||||
import com.appttude.h_mal.atlas_weather.data.room.Converter
|
||||
import com.appttude.h_mal.atlas_weather.data.room.WeatherDao
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
import com.appttude.h_mal.atlas_weather.test.BuildConfig
|
||||
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.Mockito.mock
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
class RoomDatabaseTests {
|
||||
@get:Rule
|
||||
@Suppress("unused")
|
||||
var instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
private lateinit var db: AppDatabase
|
||||
private lateinit var dao: WeatherDao
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.addTypeConverter(Converter(context))
|
||||
.build()
|
||||
dao = db.getWeatherDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenInitializedThenListIsEmpty() {
|
||||
assertTrue(dao.getAllFullWeatherWithoutCurrent().getOrAwaitValue().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenElementAddedThenItemIsInDatabase() {
|
||||
// Arrange
|
||||
val item = createEntity()
|
||||
// Act
|
||||
dao.upsertFullWeather(item)
|
||||
// Assert
|
||||
assertEquals(item, dao.getCurrentFullWeather(item.id).getOrAwaitValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenCurrentElementAddedThenLiveDataIsEmpty() {
|
||||
// Arrange
|
||||
val item = createEntity()
|
||||
// Act
|
||||
dao.upsertFullWeather(item)
|
||||
// Assert
|
||||
assertEquals(0, dao.getAllFullWeatherWithoutCurrent(CURRENT_LOCATION).getOrAwaitValue().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenNewElementAddedThenLiveDataIsNot() {
|
||||
// Arrange
|
||||
val elements = createEntityList()
|
||||
val id = elements.first().id
|
||||
// Act
|
||||
dao.upsertListOfFullWeather(elements)
|
||||
// Assert
|
||||
assertEquals(elements.size - 1, dao.getAllFullWeatherWithoutCurrent(id).getOrAwaitValue().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wheElementDeletedThenContainsIsFalse() {
|
||||
// Arrange
|
||||
val elements = createEntityList()
|
||||
val id = elements.first().id
|
||||
// Act
|
||||
dao.upsertListOfFullWeather(elements)
|
||||
dao.deleteEntry(id)
|
||||
// Assert
|
||||
assertEquals(elements.size - 1, dao.getAllFullWeatherWithoutCurrent(id).getOrAwaitValue().size)
|
||||
assertNull(dao.getCurrentFullWeatherSingle(id))
|
||||
}
|
||||
|
||||
private fun createEntity(id: String = CURRENT_LOCATION): EntityItem {
|
||||
val weather = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
FullWeather()
|
||||
} else {
|
||||
mockk<FullWeather>()
|
||||
}
|
||||
return EntityItem(id, weather)
|
||||
}
|
||||
|
||||
private fun createEntityList(size: Int = 4): List<EntityItem> {
|
||||
return (0.. size).map {
|
||||
val id = UUID.randomUUID().toString()
|
||||
createEntity(id)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package com.appttude.h_mal.atlas_weather.utils
|
||||
enum class Stubs(
|
||||
val id: String
|
||||
) {
|
||||
Valid("valid_response"),
|
||||
Invalid("invalid_response")
|
||||
Metric("valid_response_metric"),
|
||||
Imperial("valid_response_imperial"),
|
||||
WrongLocation("wrong_location_response"),
|
||||
InvalidKey("invalid_api_key_response"),
|
||||
Sydney("valid_response_metric_sydney")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.appttude.h_mal.atlas_weather.utils
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
fun <T> LiveData<T>.getOrAwaitValue(
|
||||
time: Long = 2,
|
||||
timeUnit: TimeUnit = TimeUnit.SECONDS
|
||||
): T {
|
||||
var data: T? = null
|
||||
val latch = CountDownLatch(1)
|
||||
val observer = object : Observer<T> {
|
||||
override fun onChanged(o: T?) {
|
||||
data = o
|
||||
latch.countDown()
|
||||
this@getOrAwaitValue.removeObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
this.observeForever(observer)
|
||||
|
||||
// Don't wait indefinitely if the LiveData is not set.
|
||||
if (!latch.await(time, timeUnit)) {
|
||||
throw TimeoutException("LiveData value was never set.")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data as T
|
||||
}
|
||||
@@ -11,4 +11,8 @@ class HomeScreenRobot : BaseTestRobot() {
|
||||
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
|
||||
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
||||
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
|
||||
fun verifyUnableToRetrieve() {
|
||||
matchText(R.id.header_text, R.string.retrieve_warning)
|
||||
matchText(R.id.body_text, R.string.empty_retrieve_warning)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.appttude.h_mal.atlas_weather.tests
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.BaseTest
|
||||
import com.appttude.h_mal.atlas_weather.ui.MainActivity
|
||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||
import com.appttude.h_mal.atlas_weather.robot.homeScreen
|
||||
import org.junit.Test
|
||||
|
||||
class HomePageNoDataUITest : BaseTest<MainActivity>(MainActivity::class.java) {
|
||||
|
||||
override fun beforeLaunch() {
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.InvalidKey, 400)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadApp_invalidKeyWeatherResponse_returnsEmptyViewPage() {
|
||||
homeScreen {
|
||||
waitFor(2000)
|
||||
// verify empty
|
||||
verifyUnableToRetrieve()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidKeyWeatherResponse_swipeToRefresh_returnsValidPage() {
|
||||
homeScreen {
|
||||
waitFor(2000)
|
||||
// verify empty
|
||||
verifyUnableToRetrieve()
|
||||
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
|
||||
refresh()
|
||||
verifyCurrentTemperature(2)
|
||||
verifyCurrentLocation("Mock Location")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import org.junit.Test
|
||||
class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
|
||||
|
||||
override fun beforeLaunch() {
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.appttude.h_mal.monoWeather.robot
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.BaseTestRobot
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
|
||||
fun addLocation(func: AddLocationScreenRobot.() -> Unit) = AddLocationScreenRobot().apply { func() }
|
||||
class AddLocationScreenRobot : BaseTestRobot() {
|
||||
fun setLocation(location: String) =
|
||||
fillEditText(R.id.location_name_tv, location)
|
||||
|
||||
fun submit() = clickButton(R.id.submit)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.appttude.h_mal.monoWeather.robot
|
||||
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import com.appttude.h_mal.atlas_weather.BaseTestRobot
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.helpers.EspressoHelper
|
||||
|
||||
fun container(func: ContainerRobot.() -> Unit) = ContainerRobot().apply { func() }
|
||||
class ContainerRobot : BaseTestRobot() {
|
||||
|
||||
fun tapTabInBottomBar(tab: Tab) {
|
||||
when (tab) {
|
||||
Tab.WORLD -> EspressoHelper.waitForView(withId(R.id.nav_world))
|
||||
Tab.HOME -> EspressoHelper.waitForView(withId(R.id.nav_home))
|
||||
}.perform(click())
|
||||
}
|
||||
|
||||
enum class Tab {
|
||||
HOME,
|
||||
WORLD
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,17 @@ package com.appttude.h_mal.monoWeather.robot
|
||||
import com.appttude.h_mal.atlas_weather.BaseTestRobot
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
|
||||
fun homeScreen(func: HomeScreenRobot.() -> Unit) = HomeScreenRobot().apply { func() }
|
||||
class HomeScreenRobot : BaseTestRobot() {
|
||||
fun weatherScreen(func: WeatherScreen.() -> Unit) = WeatherScreen().apply { func() }
|
||||
class WeatherScreen : BaseTestRobot() {
|
||||
fun verifyCurrentTemperature(temperature: Int) =
|
||||
matchText(R.id.temp_main_4, temperature.toString())
|
||||
|
||||
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
|
||||
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
||||
fun isDisplayed() = matchViewWaitFor(R.id.temp_main_4)
|
||||
|
||||
fun verifyUnableToRetrieve() {
|
||||
matchText(R.id.header_text, R.string.retrieve_warning)
|
||||
matchText(R.id.body_text, R.string.empty_retrieve_warning)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.appttude.h_mal.monoWeather.robot
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import com.appttude.h_mal.atlas_weather.BaseTestRobot
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.helpers.EspressoHelper
|
||||
|
||||
fun world(func: WorldScreenRobot.() -> Unit) = WorldScreenRobot().apply { func() }
|
||||
class WorldScreenRobot : BaseTestRobot() {
|
||||
fun clickFab() = clickButton(R.id.floatingActionButton)
|
||||
fun clickItemInList(location: String) {
|
||||
EspressoHelper.waitForView(withId(R.id.world_recycler))
|
||||
clickViewInRecycler<RecyclerView.ViewHolder>(R.id.world_recycler, location)
|
||||
}
|
||||
|
||||
fun clickItemInListByPosition(position: Int) =
|
||||
clickViewInRecycler<RecyclerView.ViewHolder>(R.id.world_recycler, position)
|
||||
|
||||
fun emptyViewDisplayed() {
|
||||
matchText(R.id.body_text, R.string.retrieve_warning)
|
||||
matchText(R.id.header_text, R.string.empty_retrieve_warning)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.appttude.h_mal.monoWeather.tests
|
||||
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||
import com.appttude.h_mal.monoWeather.MonoBaseTest
|
||||
import com.appttude.h_mal.monoWeather.robot.weatherScreen
|
||||
import org.junit.Test
|
||||
|
||||
class HomePageNoDataUITest : MonoBaseTest() {
|
||||
|
||||
override fun beforeLaunch() {
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.InvalidKey, 400)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadApp_invalidKeyWeatherResponse_returnsEmptyViewPage() {
|
||||
weatherScreen {
|
||||
// verify empty
|
||||
verifyUnableToRetrieve()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidKeyWeatherResponse_swipeToRefresh_returnsValidPage() {
|
||||
weatherScreen {
|
||||
// verify empty
|
||||
verifyUnableToRetrieve()
|
||||
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
|
||||
refresh()
|
||||
verifyCurrentTemperature(2)
|
||||
verifyCurrentLocation("Mock Location")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,18 @@ package com.appttude.h_mal.monoWeather.tests
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||
import com.appttude.h_mal.monoWeather.MonoBaseTest
|
||||
import com.appttude.h_mal.monoWeather.robot.homeScreen
|
||||
import com.appttude.h_mal.monoWeather.robot.weatherScreen
|
||||
import org.junit.Test
|
||||
|
||||
class HomePageUITest : MonoBaseTest() {
|
||||
|
||||
override fun beforeLaunch() {
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Valid)
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadApp_validWeatherResponse_returnsValidPage() {
|
||||
homeScreen {
|
||||
weatherScreen {
|
||||
isDisplayed()
|
||||
verifyCurrentTemperature(2)
|
||||
verifyCurrentLocation("Mock Location")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.appttude.h_mal.monoWeather.tests
|
||||
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||
import com.appttude.h_mal.monoWeather.MonoBaseTest
|
||||
import com.appttude.h_mal.monoWeather.robot.ContainerRobot.Tab.WORLD
|
||||
import com.appttude.h_mal.monoWeather.robot.addLocation
|
||||
import com.appttude.h_mal.monoWeather.robot.container
|
||||
import com.appttude.h_mal.monoWeather.robot.weatherScreen
|
||||
import com.appttude.h_mal.monoWeather.robot.world
|
||||
import org.junit.Test
|
||||
|
||||
class WorldPageUITest : MonoBaseTest() {
|
||||
|
||||
override fun beforeLaunch() {
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadApp_addNewLocation_returnsValidPage() {
|
||||
container {
|
||||
tapTabInBottomBar(WORLD)
|
||||
}
|
||||
world {
|
||||
clickFab()
|
||||
}
|
||||
addLocation {
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Sydney)
|
||||
stubLocation("Sydney", -33.89, -151.12)
|
||||
setLocation("Sydney")
|
||||
submit()
|
||||
}
|
||||
world {
|
||||
clickItemInList("Sydney")
|
||||
}
|
||||
weatherScreen {
|
||||
isDisplayed()
|
||||
verifyCurrentTemperature(12)
|
||||
verifyCurrentLocation("Sydney")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.home
|
||||
|
||||
import android.Manifest
|
||||
import android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
@@ -9,47 +8,32 @@ import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.Navigation.findNavController
|
||||
import androidx.navigation.ui.onNavDestinationSelected
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
|
||||
import com.appttude.h_mal.atlas_weather.ui.dialog.PermissionsDeclarationDialog
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||
import com.appttude.h_mal.atlas_weather.ui.home.adapter.WeatherRecyclerAdapter
|
||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
||||
import kotlinx.android.synthetic.main.fragment_home.*
|
||||
import org.kodein.di.KodeinAware
|
||||
import org.kodein.di.android.x.kodein
|
||||
|
||||
import org.kodein.di.generic.instance
|
||||
|
||||
|
||||
/**
|
||||
* A simple [Fragment] subclass.
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
private val viewModel by getFragmentViewModel<MainViewModel>()
|
||||
class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
|
||||
private lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
|
||||
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||
navigateToFurtherDetails(it)
|
||||
})
|
||||
|
||||
forecast_listview.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
swipe_refresh.apply {
|
||||
setOnRefreshListener {
|
||||
getPermissionResult(ACCESS_COARSE_LOCATION, LOCATION_PERMISSION_REQUEST) {
|
||||
@@ -59,22 +43,14 @@ 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")
|
||||
@@ -86,6 +62,19 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Any?) {
|
||||
super.onSuccess(data)
|
||||
swipe_refresh.isRefreshing = false
|
||||
if (data is WeatherDisplay) {
|
||||
recyclerAdapter.addCurrent(data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(error: Any?) {
|
||||
super.onFailure(error)
|
||||
swipe_refresh.isRefreshing = false
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun permissionsGranted() {
|
||||
viewModel.fetchData()
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.home.adapter
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
|
||||
class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val icon: ImageView = itemView.findViewById(R.id.icon)
|
||||
var bodyTV: TextView = itemView.findViewById(R.id.body_text)
|
||||
var headerTV: TextView = itemView.findViewById(R.id.header_text)
|
||||
|
||||
fun bindData(
|
||||
@DrawableRes imageRes: Int? = R.drawable.ic_baseline_cloud_off_24,
|
||||
header: String = itemView.resources.getString(R.string.retrieve_warning),
|
||||
body: String = itemView.resources.getString(R.string.empty_retrieve_warning) ){
|
||||
imageRes?.let { icon.setImageResource(it) }
|
||||
headerTV.text = header
|
||||
bodyTV.text = body
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.home.adapter
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.utils.generateView
|
||||
import com.appttude.h_mal.atlas_weather.ui.home.adapter.forecast.ViewHolderForecast
|
||||
import com.appttude.h_mal.atlas_weather.ui.home.adapter.forecastDaily.ViewHolderForecastDaily
|
||||
|
||||
class WeatherRecyclerAdapter(
|
||||
val itemClick: (Forecast) -> Unit
|
||||
private val itemClick: (Forecast) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
var weather: WeatherDisplay? = null
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun addCurrent(current: WeatherDisplay) {
|
||||
weather = current
|
||||
notifyDataSetChanged()
|
||||
@@ -23,7 +22,7 @@ class WeatherRecyclerAdapter(
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (getDataType(viewType)) {
|
||||
is ViewType.Empty -> {
|
||||
val emptyViewHolder = View(parent.context)
|
||||
val emptyViewHolder = parent.generateView(R.layout.empty_state_layout)
|
||||
EmptyViewHolder(emptyViewHolder)
|
||||
}
|
||||
|
||||
@@ -32,8 +31,8 @@ class WeatherRecyclerAdapter(
|
||||
ViewHolderCurrent(viewCurrent)
|
||||
}
|
||||
|
||||
is ViewType.Forecast -> {
|
||||
val viewForecast = parent.generateView(R.layout.list_item_forecast)
|
||||
is ViewType.ForecastHourly -> {
|
||||
val viewForecast = parent.generateView(R.layout.hourly_item_forecast)
|
||||
ViewHolderForecast(viewForecast)
|
||||
}
|
||||
|
||||
@@ -41,13 +40,19 @@ class WeatherRecyclerAdapter(
|
||||
val viewFurther = parent.generateView(R.layout.list_item_further)
|
||||
ViewHolderFurtherDetails(viewFurther)
|
||||
}
|
||||
|
||||
is ViewType.ForecastDaily -> {
|
||||
val viewForecast = parent.generateView(R.layout.list_item_forecast)
|
||||
ViewHolderForecastDaily(viewForecast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ViewType {
|
||||
object Empty : ViewType()
|
||||
object Current : ViewType()
|
||||
object Forecast : ViewType()
|
||||
object ForecastHourly : ViewType()
|
||||
object ForecastDaily : ViewType()
|
||||
object Further : ViewType()
|
||||
}
|
||||
|
||||
@@ -55,19 +60,20 @@ class WeatherRecyclerAdapter(
|
||||
return when (type) {
|
||||
0 -> ViewType.Empty
|
||||
1 -> ViewType.Current
|
||||
2 -> ViewType.Forecast
|
||||
2 -> ViewType.ForecastHourly
|
||||
3 -> ViewType.Further
|
||||
4 -> ViewType.ForecastDaily
|
||||
else -> ViewType.Empty
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
if (weather == null) return 0
|
||||
|
||||
return when (position) {
|
||||
0 -> 1
|
||||
in 1 until itemCount - 2 -> 2
|
||||
itemCount - 1 -> 3
|
||||
1 -> 3
|
||||
2 -> 2
|
||||
in 3 until (itemCount) -> 4
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -75,7 +81,8 @@ class WeatherRecyclerAdapter(
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (getDataType(getItemViewType(position))) {
|
||||
is ViewType.Empty -> {
|
||||
holder as EmptyViewHolder
|
||||
val emptyViewHolder = holder as EmptyViewHolder
|
||||
emptyViewHolder.bindData()
|
||||
}
|
||||
|
||||
is ViewType.Current -> {
|
||||
@@ -83,28 +90,31 @@ class WeatherRecyclerAdapter(
|
||||
viewHolderCurrent.bindData(weather)
|
||||
}
|
||||
|
||||
is ViewType.Forecast -> {
|
||||
val viewHolderForecast = holder as ViewHolderForecast
|
||||
|
||||
weather?.forecast?.get(position - 1)?.let { i ->
|
||||
viewHolderForecast.bindView(i)
|
||||
viewHolderForecast.itemView.setOnClickListener {
|
||||
itemClick(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ViewType.Further -> {
|
||||
val viewHolderCurrent = holder as ViewHolderFurtherDetails
|
||||
viewHolderCurrent.bindData(weather)
|
||||
}
|
||||
|
||||
is ViewType.ForecastHourly -> {
|
||||
val viewHolderForecast = holder as ViewHolderForecast
|
||||
viewHolderForecast.bindView(weather?.hourly)
|
||||
}
|
||||
|
||||
is ViewType.ForecastDaily -> {
|
||||
val viewHolderForecast = holder as ViewHolderForecastDaily
|
||||
weather?.forecast?.getOrNull(position - 3)?.let { f ->
|
||||
viewHolderForecast.bindView(f)
|
||||
viewHolderForecast.itemView.setOnClickListener {
|
||||
itemClick.invoke(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
if (weather == null) return 0
|
||||
return 2 + (weather?.forecast?.size ?: 0)
|
||||
return if (weather == null) 1 else 3 + (weather?.forecast?.size ?: 0)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.appttude.h_mal.monoWeather.ui.home.adapter.forecast
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.Hour
|
||||
import com.appttude.h_mal.atlas_weather.utils.loadImage
|
||||
import com.appttude.h_mal.atlas_weather.utils.toTime
|
||||
|
||||
class GridCellHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
var dayTV: TextView = itemView.findViewById(R.id.widget_item_day)
|
||||
var weatherIV: ImageView = itemView.findViewById(R.id.widget_item_image)
|
||||
var mainTempTV: TextView = itemView.findViewById(R.id.widget_item_temp_high)
|
||||
|
||||
fun bindView(hour: Hour?) {
|
||||
dayTV.text = hour?.dt?.toTime()
|
||||
weatherIV.loadImage(hour?.icon)
|
||||
mainTempTV.text = hour?.temp?.toInt()?.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.home.adapter.forecast
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.Hour
|
||||
import com.appttude.h_mal.atlas_weather.utils.generateView
|
||||
import com.appttude.h_mal.monoWeather.ui.home.adapter.forecast.GridCellHolder
|
||||
|
||||
class GridForecastAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
var weather: MutableList<Hour> = mutableListOf()
|
||||
|
||||
fun addCurrent(current: List<Hour>?) {
|
||||
weather.clear()
|
||||
current?.let { weather.addAll(it) }
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val viewCurrent = parent.generateView(R.layout.hourly_forecast_grid_item)
|
||||
return GridCellHolder(viewCurrent)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val view = holder as GridCellHolder
|
||||
val forecast = weather[position]
|
||||
view.bindView(forecast)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = weather.size
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.home.adapter.forecast
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.Hour
|
||||
import com.appttude.h_mal.atlas_weather.ui.home.adapter.forecast.GridForecastAdapter
|
||||
|
||||
class ViewHolderForecast(
|
||||
itemView: View
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
var recyclerView: RecyclerView = itemView.findViewById(R.id.forecast_recyclerview)
|
||||
|
||||
fun bindView(forecasts: List<Hour>?) {
|
||||
val adapter = GridForecastAdapter()
|
||||
adapter.addCurrent(forecasts)
|
||||
recyclerView.adapter = adapter
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.home.adapter
|
||||
package com.appttude.h_mal.atlas_weather.ui.home.adapter.forecastDaily
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
@@ -8,21 +8,21 @@ import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
|
||||
import com.appttude.h_mal.atlas_weather.utils.loadImage
|
||||
|
||||
class ViewHolderForecast(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
class ViewHolderForecastDaily(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
var dateTV: TextView = itemView.findViewById(R.id.list_date)
|
||||
var dayTV: TextView = itemView.findViewById(R.id.list_day)
|
||||
var conditionTV: TextView = itemView.findViewById(R.id.list_condition)
|
||||
var weatherIV: ImageView = itemView.findViewById(R.id.list_icon)
|
||||
var mainTempTV: TextView = itemView.findViewById(R.id.list_main_temp)
|
||||
var minorTempTV: TextView = itemView.findViewById(R.id.list_minor_temp)
|
||||
var maxTempTV: TextView = itemView.findViewById(R.id.list_main_temp)
|
||||
var minTempTV: TextView = itemView.findViewById(R.id.list_minor_temp)
|
||||
var conditionTV: TextView = itemView.findViewById(R.id.list_condition)
|
||||
|
||||
fun bindView(forecast: Forecast?) {
|
||||
dateTV.text = forecast?.date
|
||||
dayTV.text = forecast?.day
|
||||
conditionTV.text = forecast?.condition
|
||||
weatherIV.loadImage(forecast?.weatherIcon)
|
||||
mainTempTV.text = forecast?.mainTemp
|
||||
minorTempTV.text = forecast?.minorTemp
|
||||
maxTempTV.text = forecast?.mainTemp
|
||||
minTempTV.text = forecast?.minorTemp
|
||||
conditionTV.text = forecast?.condition
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,17 @@
|
||||
package com.appttude.h_mal.atlas_weather.ui.world
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.observe
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||
import com.appttude.h_mal.atlas_weather.utils.displayToast
|
||||
import com.appttude.h_mal.atlas_weather.utils.goBack
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
||||
import kotlinx.android.synthetic.main.activity_add_forecast.*
|
||||
import org.kodein.di.KodeinAware
|
||||
import org.kodein.di.android.x.kodein
|
||||
import org.kodein.di.generic.instance
|
||||
import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
|
||||
import kotlinx.android.synthetic.main.activity_add_forecast.submit
|
||||
|
||||
|
||||
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
||||
|
||||
private val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -31,16 +22,13 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
||||
submit.error = "Location cannot be blank"
|
||||
return@setOnClickListener
|
||||
}
|
||||
viewModel.fetchDataForSingleLocation(locationName)
|
||||
viewModel.fetchDataForSingleLocationSearch(locationName)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
|
||||
viewModel.operationComplete.observe(viewLifecycleOwner) {
|
||||
it?.getContentIfNotHandled()?.let { message ->
|
||||
displayToast(message)
|
||||
}
|
||||
override fun onSuccess(data: Any?) {
|
||||
if (data is String) {
|
||||
displayToast(data)
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ package com.appttude.h_mal.atlas_weather.ui.world
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.observe
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
||||
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.floatingActionButton
|
||||
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.progressBar2
|
||||
import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycler
|
||||
|
||||
|
||||
@@ -18,13 +17,13 @@ import kotlinx.android.synthetic.atlasWeather.fragment_add_location.world_recycl
|
||||
* A simple [Fragment] subclass.
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
|
||||
val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment_add_location) {
|
||||
private lateinit var recyclerAdapter: WorldRecyclerAdapter
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val recyclerAdapter = WorldRecyclerAdapter {
|
||||
recyclerAdapter = WorldRecyclerAdapter {
|
||||
val direction =
|
||||
WorldFragmentDirections.actionWorldFragmentToWorldItemFragment(it)
|
||||
navigateTo(direction)
|
||||
@@ -35,22 +34,18 @@ class WorldFragment : BaseFragment(R.layout.fragment_add_location) {
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
}
|
||||
|
||||
floatingActionButton.setOnClickListener {
|
||||
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar2))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
|
||||
override fun onSuccess(data: Any?) {
|
||||
super.onSuccess(data)
|
||||
if (data is List<*>) recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
viewModel.fetchAllLocations()
|
||||
}
|
||||
|
||||
|
||||
@@ -36,20 +36,4 @@
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
android:visibility="gone">
|
||||
|
||||
<ProgressBar
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
</FrameLayout>
|
||||
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -43,17 +43,5 @@
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
tools:layout="@layout/fragment_home" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_circular"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="0.2dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
||||
@@ -31,15 +31,4 @@
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:srcCompat="@android:drawable/ic_input_add" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar2"
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="visible" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/widget_item_layout"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="3dp"
|
||||
android:layout_marginRight="3dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_item_day"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="#ffffff"
|
||||
tools:text="Dec 1" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widget_item_image"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="center"
|
||||
android:contentDescription="@string/image_string"
|
||||
tools:src="@drawable/cloud_symbol"
|
||||
tools:tint="@color/colour_one" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_item_temp_high"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#ffffff"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="20" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/db_temp_unit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/degrees" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
24
app/src/atlasWeather/res/layout/hourly_item_forecast.xml
Normal file
24
app/src/atlasWeather/res/layout/hourly_item_forecast.xml
Normal 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>
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.appttude.h_mal.atlas_weather.base
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.inflate
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.ViewModelLazy
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.helper.GenericsHelper.getGenericClassAt
|
||||
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||
import com.appttude.h_mal.atlas_weather.utils.displayToast
|
||||
import com.appttude.h_mal.atlas_weather.utils.hide
|
||||
import com.appttude.h_mal.atlas_weather.utils.show
|
||||
import com.appttude.h_mal.atlas_weather.utils.triggerAnimation
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.BaseViewModel
|
||||
import org.kodein.di.KodeinAware
|
||||
import org.kodein.di.android.kodein
|
||||
import org.kodein.di.generic.instance
|
||||
|
||||
abstract class BaseActivity : AppCompatActivity(), KodeinAware {
|
||||
|
||||
private lateinit var loadingView: View
|
||||
|
||||
override val kodein by kodein()
|
||||
|
||||
|
||||
/**
|
||||
* Creates a loading view which to be shown during async operations
|
||||
*
|
||||
* #setOnClickListener(null) is an ugly work around to prevent under being clicked during
|
||||
* loading
|
||||
*/
|
||||
private fun instantiateLoadingView() {
|
||||
loadingView = inflate(this, R.layout.progress_layout, null)
|
||||
loadingView.setOnClickListener(null)
|
||||
addContentView(loadingView, LayoutParams(MATCH_PARENT, MATCH_PARENT))
|
||||
loadingView.hide()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
instantiateLoadingView()
|
||||
}
|
||||
|
||||
fun <A : AppCompatActivity> startActivity(activity: Class<A>) {
|
||||
val intent = Intent(this, activity)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called in case of success or some data emitted from the liveData in viewModel
|
||||
*/
|
||||
open fun onStarted() {
|
||||
loadingView.fadeIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called in case of success or some data emitted from the liveData in viewModel
|
||||
*/
|
||||
open fun onSuccess(data: Any?) {
|
||||
loadingView.fadeOut()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called in case of failure or some error emitted from the liveData in viewModel
|
||||
*/
|
||||
open fun onFailure(error: Any?) {
|
||||
if (error is String) displayToast(error)
|
||||
loadingView.fadeOut()
|
||||
}
|
||||
|
||||
private fun View.fadeIn() = apply {
|
||||
show()
|
||||
triggerAnimation(R.anim.nav_default_enter_anim) {}
|
||||
}
|
||||
|
||||
private fun View.fadeOut() = apply {
|
||||
hide()
|
||||
triggerAnimation(R.anim.nav_default_exit_anim) {}
|
||||
}
|
||||
|
||||
|
||||
override fun onBackPressed() {
|
||||
loadingView.hide()
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
@@ -24,33 +24,32 @@ class RepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
|
||||
db.getSimpleDao().upsertFullWeather(entityItem)
|
||||
db.getWeatherDao().upsertFullWeather(entityItem)
|
||||
}
|
||||
|
||||
override suspend fun saveWeatherListToRoom(
|
||||
list: List<EntityItem>
|
||||
) {
|
||||
db.getSimpleDao().upsertListOfFullWeather(list)
|
||||
db.getWeatherDao().upsertListOfFullWeather(list)
|
||||
}
|
||||
|
||||
override fun loadRoomWeatherLiveData() = db.getSimpleDao().getAllFullWeatherWithoutCurrent()
|
||||
override fun loadRoomWeatherLiveData() = db.getWeatherDao().getAllFullWeatherWithoutCurrent()
|
||||
|
||||
override suspend fun loadWeatherList(): List<String> {
|
||||
return db.getSimpleDao()
|
||||
return db.getWeatherDao()
|
||||
.getWeatherListWithoutCurrent()
|
||||
.map { it.id }
|
||||
}
|
||||
|
||||
override fun loadCurrentWeatherFromRoom(id: String) =
|
||||
db.getSimpleDao().getCurrentFullWeather(id)
|
||||
db.getWeatherDao().getCurrentFullWeather(id)
|
||||
|
||||
override suspend fun loadSingleCurrentWeatherFromRoom(id: String) =
|
||||
db.getSimpleDao().getCurrentFullWeatherSingle(id)
|
||||
db.getWeatherDao().getCurrentFullWeatherSingle(id)
|
||||
|
||||
override fun isSearchValid(locationName: String): Boolean {
|
||||
val lastSaved = prefs
|
||||
.getLastSavedAt("$LOCATION_CONST$locationName")
|
||||
?: return true
|
||||
val difference = System.currentTimeMillis() - lastSaved
|
||||
|
||||
return difference > FALLBACK_TIME
|
||||
@@ -62,7 +61,7 @@ class RepositoryImpl(
|
||||
|
||||
override suspend fun deleteSavedWeatherEntry(locationName: String): Boolean {
|
||||
prefs.deleteLocation(locationName)
|
||||
return db.getSimpleDao().deleteEntry(locationName) > 0
|
||||
return db.getWeatherDao().deleteEntry(locationName) > 0
|
||||
}
|
||||
|
||||
override fun getSavedLocations(): List<String> {
|
||||
@@ -70,7 +69,7 @@ class RepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun getSingleWeather(locationName: String): EntityItem {
|
||||
return db.getSimpleDao().getCurrentFullWeatherSingle(locationName)
|
||||
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
@TypeConverters(Converter::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun getSimpleDao(): WeatherDao
|
||||
abstract fun getWeatherDao(): WeatherDao
|
||||
|
||||
companion object {
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ object GenericsHelper {
|
||||
?.kotlin
|
||||
?: throw IllegalStateException("Can not find class from generic argument")
|
||||
|
||||
// @Suppress("UNCHECKED_CAST")
|
||||
// fun <CLASS : Any> Any.getGenericClassInMethod(position: Int): KClass<CLASS> =
|
||||
// ((javaClass.methods as? ParameterizedType)
|
||||
// ?.actualTypeArguments?.getOrNull(position) as? Class<CLASS>)
|
||||
// ?.kotlin
|
||||
// ?: throw IllegalStateException("Can not find class from generic argument")
|
||||
|
||||
// /**
|
||||
// * Create a view binding out of the the generic [VB]
|
||||
// *
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.appttude.h_mal.atlas_weather.model
|
||||
|
||||
sealed class ViewState {
|
||||
object HasStarted : ViewState()
|
||||
class HasData<T : Any>(val data: T) : ViewState()
|
||||
class HasError<T : Any>(val error: T) : ViewState()
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.appttude.h_mal.monoWeather.ui
|
||||
package com.appttude.h_mal.atlas_weather.ui
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
@@ -9,16 +7,17 @@ import android.view.View
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.fragment.app.createViewModelLazy
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
||||
import com.appttude.h_mal.atlas_weather.base.BaseActivity
|
||||
import com.appttude.h_mal.atlas_weather.helper.GenericsHelper.getGenericClassAt
|
||||
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||
import com.appttude.h_mal.atlas_weather.utils.Event
|
||||
import com.appttude.h_mal.atlas_weather.utils.displayToast
|
||||
import com.appttude.h_mal.atlas_weather.utils.hide
|
||||
import com.appttude.h_mal.atlas_weather.utils.show
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.BaseViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -28,13 +27,18 @@ import org.kodein.di.generic.instance
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
@Suppress("EmptyMethod", "EmptyMethod")
|
||||
abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId),
|
||||
abstract class BaseFragment<V : BaseViewModel>(@LayoutRes contentLayoutId: Int) :
|
||||
Fragment(contentLayoutId),
|
||||
KodeinAware {
|
||||
|
||||
override val kodein by kodein()
|
||||
val factory by instance<ApplicationViewModelFactory>()
|
||||
|
||||
inline fun <reified VM : ViewModel> getFragmentViewModel(): Lazy<VM> = viewModels { factory }
|
||||
val viewModel: V by getFragmentViewModel()
|
||||
|
||||
var mActivity: BaseActivity? = null
|
||||
private fun getFragmentViewModel(): Lazy<V> =
|
||||
createViewModelLazy(getGenericClassAt(0), { viewModelStore }, factoryProducer = { factory })
|
||||
|
||||
private var shortAnimationDuration by Delegates.notNull<Int>()
|
||||
|
||||
@@ -43,25 +47,42 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
|
||||
shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
|
||||
}
|
||||
|
||||
// toggle visibility of progress spinner while async operations are taking place
|
||||
fun progressBarStateObserver(progressBar: View) = Observer<Event<Boolean>> {
|
||||
it.getContentIfNotHandled()?.let { i ->
|
||||
if (i)
|
||||
progressBar.fadeIn()
|
||||
else
|
||||
progressBar.fadeOut()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
mActivity = activity as BaseActivity
|
||||
configureObserver()
|
||||
}
|
||||
|
||||
private fun configureObserver() {
|
||||
viewModel.uiState.observe(viewLifecycleOwner) {
|
||||
when (it) {
|
||||
is ViewState.HasStarted -> onStarted()
|
||||
is ViewState.HasData<*> -> onSuccess(it.data)
|
||||
is ViewState.HasError<*> -> onFailure(it.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// display a toast when operation fails
|
||||
fun errorObserver() = Observer<Event<String>> {
|
||||
it.getContentIfNotHandled()?.let { message ->
|
||||
displayToast(message)
|
||||
}
|
||||
/**
|
||||
* Called in case of starting operation liveData in viewModel
|
||||
*/
|
||||
open fun onStarted() {
|
||||
mActivity?.onStarted()
|
||||
}
|
||||
|
||||
fun refreshObserver(refresher: SwipeRefreshLayout) = Observer<Event<Boolean>> {
|
||||
refresher.isRefreshing = false
|
||||
/**
|
||||
* Called in case of success or some data emitted from the liveData in viewModel
|
||||
*/
|
||||
open fun onSuccess(data: Any?) {
|
||||
mActivity?.onSuccess(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called in case of failure or some error emitted from the liveData in viewModel
|
||||
*/
|
||||
open fun onFailure(error: Any?) {
|
||||
mActivity?.onFailure(error)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,46 +111,6 @@ abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentL
|
||||
}
|
||||
}
|
||||
|
||||
private fun View.fadeIn() {
|
||||
apply {
|
||||
// Set the content view to 0% opacity but visible, so that it is visible
|
||||
// (but fully transparent) during the animation.
|
||||
alpha = 0f
|
||||
hide()
|
||||
|
||||
// Animate the content view to 100% opacity, and clear any animation
|
||||
// listener set on the view.
|
||||
animate()
|
||||
.alpha(1f)
|
||||
.setDuration(shortAnimationDuration.toLong())
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
show()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun View.fadeOut() {
|
||||
apply {
|
||||
// Set the content view to 0% opacity but visible, so that it is visible
|
||||
// (but fully transparent) during the animation.
|
||||
alpha = 1f
|
||||
show()
|
||||
|
||||
// Animate the content view to 100% opacity, and clear any animation
|
||||
// listener set on the view.
|
||||
animate()
|
||||
.alpha(0f)
|
||||
.setDuration(shortAnimationDuration.toLong())
|
||||
.setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
|
||||
@@ -9,10 +9,12 @@ import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupActionBarWithNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.base.BaseActivity
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import kotlinx.android.synthetic.main.activity_main_navigation.toolbar
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
class MainActivity : BaseActivity() {
|
||||
|
||||
lateinit var navHost: NavHostFragment
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.ImageView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.AnimRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.squareup.picasso.Picasso
|
||||
@@ -42,4 +45,14 @@ fun ImageView.loadImage(url: String?) {
|
||||
fun Fragment.hideKeyboard() {
|
||||
val imm = context?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager?
|
||||
imm?.hideSoftInputFromWindow(view?.windowToken, 0)
|
||||
}
|
||||
|
||||
fun View.triggerAnimation(@AnimRes id: Int, complete: (View) -> Unit) {
|
||||
val animation = AnimationUtils.loadAnimation(context, id)
|
||||
animation.setAnimationListener(object : Animation.AnimationListener {
|
||||
override fun onAnimationEnd(animation: Animation?) = complete(this@triggerAnimation)
|
||||
override fun onAnimationStart(a: Animation?) {}
|
||||
override fun onAnimationRepeat(a: Animation?) {}
|
||||
})
|
||||
startAnimation(animation)
|
||||
}
|
||||
@@ -2,13 +2,11 @@ package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.utils.Event
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -19,48 +17,39 @@ class MainViewModel(
|
||||
private val repository: Repository
|
||||
) : WeatherViewModel() {
|
||||
|
||||
val weatherLiveData = MutableLiveData<WeatherDisplay>()
|
||||
|
||||
val operationState = MutableLiveData<Event<Boolean>>()
|
||||
val operationError = MutableLiveData<Event<String>>()
|
||||
val operationRefresh = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
init {
|
||||
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {
|
||||
it?.let {
|
||||
val weather = WeatherDisplay(it)
|
||||
weatherLiveData.postValue(weather)
|
||||
onSuccess(weather)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
fun fetchData() {
|
||||
if (!repository.isSearchValid(CURRENT_LOCATION)) {
|
||||
operationRefresh.postValue(Event(false))
|
||||
return
|
||||
}
|
||||
|
||||
onStart()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
try {
|
||||
// Get location
|
||||
val latLong = locationProvider.getCurrentLatLong()
|
||||
// Get weather from api
|
||||
val weather = repository
|
||||
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
|
||||
val currentLocation =
|
||||
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
|
||||
val fullWeather = createFullWeather(weather, currentLocation)
|
||||
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
|
||||
// Has the search been conducted in the last 5 minutes
|
||||
val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) {
|
||||
// Get location
|
||||
val latLong = locationProvider.getCurrentLatLong()
|
||||
// Get weather from api
|
||||
val weather = repository
|
||||
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
|
||||
val currentLocation =
|
||||
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
|
||||
val fullWeather = createFullWeather(weather, currentLocation)
|
||||
EntityItem(CURRENT_LOCATION, fullWeather)
|
||||
} else {
|
||||
repository.getSingleWeather(CURRENT_LOCATION)
|
||||
}
|
||||
// Save data if not null
|
||||
repository.saveLastSavedAt(CURRENT_LOCATION)
|
||||
repository.saveCurrentWeatherToRoom(entityItem)
|
||||
} catch (e: Exception) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
operationRefresh.postValue(Event(false))
|
||||
onError(e.message!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||
import com.appttude.h_mal.atlas_weather.utils.Event
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels.WeatherViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -20,15 +18,7 @@ class WorldViewModel(
|
||||
private val locationProvider: LocationProvider,
|
||||
private val repository: Repository
|
||||
) : WeatherViewModel() {
|
||||
|
||||
val weatherLiveData = MutableLiveData<List<WeatherDisplay>>()
|
||||
val singleWeatherLiveData = MutableLiveData<WeatherDisplay>()
|
||||
|
||||
val operationState = MutableLiveData<Event<Boolean>>()
|
||||
val operationError = MutableLiveData<Event<String>>()
|
||||
val operationRefresh = MutableLiveData<Event<Boolean>>()
|
||||
|
||||
val operationComplete = MutableLiveData<Event<String>>()
|
||||
private var currentLocation: String? = null
|
||||
|
||||
private val weatherListLiveData = repository.loadRoomWeatherLiveData()
|
||||
|
||||
@@ -37,44 +27,46 @@ class WorldViewModel(
|
||||
val list = it.map { data ->
|
||||
WeatherDisplay(data)
|
||||
}
|
||||
weatherLiveData.postValue(list)
|
||||
onSuccess(list)
|
||||
currentLocation?.let { i -> list.first { j -> j.location == i } }
|
||||
?.let { k -> onSuccess(k) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setLocation(location: String) = run { currentLocation = location }
|
||||
|
||||
fun getSingleLocation(locationName: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val entity = repository.getSingleWeather(locationName)
|
||||
val item = WeatherDisplay(entity)
|
||||
singleWeatherLiveData.postValue(item)
|
||||
onSuccess(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchDataForSingleLocation(locationName: String) {
|
||||
if (!repository.isSearchValid(locationName)) {
|
||||
operationRefresh.postValue(Event(false))
|
||||
return
|
||||
}
|
||||
onStart()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
try {
|
||||
val weatherEntity = createWeatherEntity(locationName)
|
||||
val weatherEntity = if (repository.isSearchValid(locationName)) {
|
||||
createWeatherEntity(locationName)
|
||||
} else {
|
||||
repository.getSingleWeather(locationName)
|
||||
}
|
||||
onSuccess(Unit)
|
||||
repository.saveCurrentWeatherToRoom(weatherEntity)
|
||||
repository.saveLastSavedAt(locationName)
|
||||
repository.saveLastSavedAt(weatherEntity.id)
|
||||
} catch (e: IOException) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
operationRefresh.postValue(Event(false))
|
||||
onError(e.message!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchDataForSingleLocationSearch(locationName: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
onStart()
|
||||
// Check if location exists
|
||||
if (repository.getSavedLocations().contains(locationName)) {
|
||||
operationError.postValue(Event("$locationName already exists"))
|
||||
onError("$locationName already exists")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -89,29 +81,26 @@ class WorldViewModel(
|
||||
LocationType.City
|
||||
)
|
||||
if (repository.getSavedLocations().contains(retrievedLocation)) {
|
||||
operationError.postValue(Event("$retrievedLocation already exists"))
|
||||
onError("$retrievedLocation already exists")
|
||||
return@launch
|
||||
}
|
||||
// Save data if not null
|
||||
repository.saveCurrentWeatherToRoom(entityItem)
|
||||
repository.saveLastSavedAt(retrievedLocation)
|
||||
operationComplete.postValue(Event("$retrievedLocation saved"))
|
||||
|
||||
onSuccess("$retrievedLocation saved")
|
||||
} catch (e: IOException) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
onError(e.message!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchAllLocations() {
|
||||
onStart()
|
||||
if (!repository.isSearchValid(ALL_LOADED)) {
|
||||
operationRefresh.postValue(Event(false))
|
||||
onSuccess(Unit)
|
||||
return
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
try {
|
||||
val list = mutableListOf<EntityItem>()
|
||||
repository.loadWeatherList().forEach { locationName ->
|
||||
@@ -128,25 +117,25 @@ class WorldViewModel(
|
||||
repository.saveWeatherListToRoom(list)
|
||||
repository.saveLastSavedAt(ALL_LOADED)
|
||||
} catch (e: IOException) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
onError(e.message!!)
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
onSuccess(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLocation(locationName: String) {
|
||||
onStart()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
operationState.postValue(Event(true))
|
||||
try {
|
||||
val success = repository.deleteSavedWeatherEntry(locationName)
|
||||
if (!success) {
|
||||
operationError.postValue(Event("Failed to delete"))
|
||||
onError("Failed to delete")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
operationError.postValue(Event(e.message!!))
|
||||
onError(e.message!!)
|
||||
} finally {
|
||||
operationState.postValue(Event(false))
|
||||
onSuccess(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.appttude.h_mal.atlas_weather.viewmodel.baseViewModels
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||
|
||||
open class BaseViewModel: ViewModel() {
|
||||
|
||||
private val _uiState = MutableLiveData<ViewState>()
|
||||
val uiState: LiveData<ViewState> = _uiState
|
||||
|
||||
|
||||
fun onStart() {
|
||||
_uiState.postValue(ViewState.HasStarted)
|
||||
}
|
||||
|
||||
fun <T : Any> onSuccess(result: T) {
|
||||
_uiState.postValue(ViewState.HasData(result))
|
||||
}
|
||||
|
||||
protected fun <E : Any> onError(error: E) {
|
||||
_uiState.postValue(ViewState.HasError(error))
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherRe
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
|
||||
abstract class WeatherViewModel : ViewModel() {
|
||||
abstract class WeatherViewModel : BaseViewModel() {
|
||||
|
||||
fun createFullWeather(
|
||||
weather: WeatherResponse,
|
||||
|
||||
@@ -37,20 +37,4 @@
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
android:visibility="gone">
|
||||
|
||||
<ProgressBar
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
</FrameLayout>
|
||||
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -3,10 +3,11 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="24dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginRight="24dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:background="@color/colorPrimaryDark"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
|
||||
@@ -7,31 +7,17 @@
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_refresh"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/forecast_listview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:backgroundTint="@color/colorPrimaryDark"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:listitem="@layout/db_list_item"></androidx.recyclerview.widget.RecyclerView>
|
||||
tools:listitem="@layout/db_list_item" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
android:visibility="gone"
|
||||
tools:visibility="gone">
|
||||
|
||||
<ProgressBar
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
</FrameLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
13
app/src/main/res/layout/progress_layout.xml
Normal file
13
app/src/main/res/layout/progress_layout.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_circular"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:elevation="0.2dp" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -30,4 +30,5 @@
|
||||
<string name="loading_nforecast">Loading \nforecast…</string>
|
||||
<string name="retrieve_warning">Unable to retrieve weather</string>
|
||||
<string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string>
|
||||
<string name="no_weather_to_display">No weather to display</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,10 +12,12 @@ class EmptyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
var bodyTV: TextView = itemView.findViewById(R.id.body_text)
|
||||
var headerTV: TextView = itemView.findViewById(R.id.header_text)
|
||||
|
||||
fun bindData(@DrawableRes imageRes: Int?, header: String, body: String) {
|
||||
imageRes?.let { icon.setImageResource(it) }
|
||||
headerTV.text = header
|
||||
bodyTV.text = body
|
||||
|
||||
}
|
||||
fun bindData(
|
||||
@DrawableRes imageRes: Int? = R.drawable.ic_baseline_cloud_off_24,
|
||||
header: String = itemView.resources.getString(R.string.retrieve_warning),
|
||||
body: String = itemView.resources.getString(R.string.empty_retrieve_warning) ){
|
||||
imageRes?.let { icon.setImageResource(it) }
|
||||
headerTV.text = header
|
||||
bodyTV.text = body
|
||||
}
|
||||
}
|
||||
@@ -3,43 +3,38 @@ package com.appttude.h_mal.monoWeather.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_home.forecast_listview
|
||||
import kotlinx.android.synthetic.main.fragment_home.progressBar
|
||||
import kotlinx.android.synthetic.main.fragment_home.swipe_refresh
|
||||
|
||||
|
||||
class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
|
||||
class WorldItemFragment : BaseFragment<WorldViewModel>(R.layout.fragment_home) {
|
||||
|
||||
private val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
private var param1: String? = null
|
||||
|
||||
private lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
param1 = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName
|
||||
param1?.let { viewModel.setLocation(it) }
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val recyclerAdapter = WeatherRecyclerAdapter {
|
||||
recyclerAdapter = WeatherRecyclerAdapter {
|
||||
val directions =
|
||||
WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it)
|
||||
navigateTo(directions)
|
||||
}
|
||||
|
||||
param1?.let { viewModel.getSingleLocation(it) }
|
||||
|
||||
viewModel.singleWeatherLiveData.observe(viewLifecycleOwner, Observer {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
swipe_refresh.isRefreshing = false
|
||||
})
|
||||
|
||||
forecast_listview.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = recyclerAdapter
|
||||
@@ -54,9 +49,19 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) {
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
|
||||
param1?.let { viewModel.getSingleLocation(it) }
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Any?) {
|
||||
if (data is WeatherDisplay) {
|
||||
recyclerAdapter.addCurrent(data)
|
||||
}
|
||||
super.onSuccess(data)
|
||||
swipe_refresh.isRefreshing = false
|
||||
}
|
||||
|
||||
override fun onFailure(error: Any?) {
|
||||
super.onFailure(error)
|
||||
swipe_refresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,11 @@ import androidx.navigation.ui.onNavDestinationSelected
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.application.LOCATION_PERMISSION_REQUEST
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.Forecast
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel
|
||||
import com.appttude.h_mal.monoWeather.dialog.PermissionsDeclarationDialog
|
||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
||||
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||
import com.appttude.h_mal.monoWeather.ui.home.adapter.WeatherRecyclerAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_home.*
|
||||
|
||||
@@ -25,22 +26,16 @@ import kotlinx.android.synthetic.main.fragment_home.*
|
||||
* A simple [Fragment] subclass.
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
|
||||
private val viewModel by getFragmentViewModel<MainViewModel>()
|
||||
class HomeFragment : BaseFragment<MainViewModel>(R.layout.fragment_home) {
|
||||
|
||||
lateinit var dialog: PermissionsDeclarationDialog
|
||||
lateinit var recyclerAdapter: WeatherRecyclerAdapter
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
|
||||
val recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||
navigateToFurtherDetails(it)
|
||||
})
|
||||
|
||||
forecast_listview.adapter = recyclerAdapter
|
||||
dialog = PermissionsDeclarationDialog(requireContext())
|
||||
|
||||
swipe_refresh.apply {
|
||||
@@ -52,13 +47,11 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
}
|
||||
recyclerAdapter = WeatherRecyclerAdapter(itemClick = {
|
||||
navigateToFurtherDetails(it)
|
||||
})
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
viewModel.operationRefresh.observe(viewLifecycleOwner, refreshObserver(swipe_refresh))
|
||||
forecast_listview.adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@@ -76,6 +69,20 @@ class HomeFragment : BaseFragment(R.layout.fragment_home) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Any?) {
|
||||
super.onSuccess(data)
|
||||
swipe_refresh.isRefreshing = false
|
||||
|
||||
if (data is WeatherDisplay) {
|
||||
recyclerAdapter.addCurrent(data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(error: Any?) {
|
||||
super.onFailure(error)
|
||||
swipe_refresh.isRefreshing = false
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun permissionsGranted() {
|
||||
viewModel.fetchData()
|
||||
|
||||
@@ -83,7 +83,8 @@ class WeatherRecyclerAdapter(
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (getDataType(getItemViewType(position))) {
|
||||
is ViewType.Empty -> {
|
||||
holder as EmptyViewHolder
|
||||
val emptyViewHolder = holder as EmptyViewHolder
|
||||
emptyViewHolder.bindData()
|
||||
}
|
||||
|
||||
is ViewType.Current -> {
|
||||
@@ -115,7 +116,7 @@ class WeatherRecyclerAdapter(
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return if (weather == null) 0 else 3 + (weather?.forecast?.size ?: 0)
|
||||
return if (weather == null) 1 else 3 + (weather?.forecast?.size ?: 0)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,6 +15,5 @@ class ViewHolderForecast(
|
||||
val adapter = GridForecastAdapter()
|
||||
adapter.addCurrent(forecasts)
|
||||
recyclerView.adapter = adapter
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,21 @@ package com.appttude.h_mal.monoWeather.ui.world
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.observe
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||
import com.appttude.h_mal.atlas_weather.utils.displayToast
|
||||
import com.appttude.h_mal.atlas_weather.utils.goBack
|
||||
import com.appttude.h_mal.atlas_weather.utils.hideKeyboard
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
||||
import kotlinx.android.synthetic.main.activity_add_forecast.location_name_tv
|
||||
import kotlinx.android.synthetic.main.activity_add_forecast.progressBar
|
||||
import kotlinx.android.synthetic.main.activity_add_forecast.submit
|
||||
|
||||
|
||||
class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
||||
class AddLocationFragment : BaseFragment<WorldViewModel>(R.layout.activity_add_forecast) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
|
||||
submit.setOnClickListener {
|
||||
val locationName = location_name_tv.text?.trim()?.toString()
|
||||
if (locationName.isNullOrBlank()) {
|
||||
@@ -30,14 +26,12 @@ class AddLocationFragment : BaseFragment(R.layout.activity_add_forecast) {
|
||||
viewModel.fetchDataForSingleLocationSearch(locationName)
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
|
||||
viewModel.operationComplete.observe(viewLifecycleOwner) {
|
||||
it?.getContentIfNotHandled()?.let { message ->
|
||||
displayToast(message)
|
||||
}
|
||||
override fun onSuccess(data: Any?) {
|
||||
super.onSuccess(data)
|
||||
if (data is String) {
|
||||
displayToast(data)
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,36 +3,35 @@ package com.appttude.h_mal.monoWeather.ui.world
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.observe
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.ui.BaseFragment
|
||||
import com.appttude.h_mal.atlas_weather.utils.navigateTo
|
||||
import com.appttude.h_mal.atlas_weather.viewmodel.WorldViewModel
|
||||
import com.appttude.h_mal.monoWeather.ui.BaseFragment
|
||||
import com.appttude.h_mal.monoWeather.ui.world.WorldFragmentDirections.actionWorldFragmentToWorldItemFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.android.synthetic.main.fragment__two.floatingActionButton
|
||||
import kotlinx.android.synthetic.main.fragment__two.progressBar
|
||||
import kotlinx.android.synthetic.main.fragment__two.world_recycler
|
||||
import kotlinx.android.synthetic.monoWeather.fragment__two.floatingActionButton
|
||||
import kotlinx.android.synthetic.monoWeather.fragment__two.world_recycler
|
||||
|
||||
|
||||
/**
|
||||
* A simple [Fragment] subclass.
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class WorldFragment : BaseFragment(R.layout.fragment__two) {
|
||||
private val viewModel by getFragmentViewModel<WorldViewModel>()
|
||||
class WorldFragment : BaseFragment<WorldViewModel>(R.layout.fragment__two) {
|
||||
|
||||
lateinit var recyclerAdapter: WorldRecyclerAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.fetchAllLocations()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val recyclerAdapter = WorldRecyclerAdapter({
|
||||
recyclerAdapter = WorldRecyclerAdapter({
|
||||
val direction =
|
||||
actionWorldFragmentToWorldItemFragment(it.location)
|
||||
navigateTo(direction)
|
||||
@@ -53,17 +52,19 @@ class WorldFragment : BaseFragment(R.layout.fragment__two) {
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
viewModel.weatherLiveData.observe(viewLifecycleOwner) {
|
||||
recyclerAdapter.addCurrent(it)
|
||||
}
|
||||
|
||||
floatingActionButton.setOnClickListener {
|
||||
navigateTo(R.id.action_worldFragment_to_addLocationFragment)
|
||||
}
|
||||
|
||||
viewModel.operationState.observe(viewLifecycleOwner, progressBarStateObserver(progressBar))
|
||||
viewModel.operationError.observe(viewLifecycleOwner, errorObserver())
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun onSuccess(data: Any?) {
|
||||
super.onSuccess(data)
|
||||
|
||||
if (data is List<*>) {
|
||||
recyclerAdapter.addCurrent(data as List<WeatherDisplay>)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,18 +27,4 @@
|
||||
android:contentDescription="@string/image_string"
|
||||
app:srcCompat="@drawable/ic_baseline_add_24" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible">
|
||||
|
||||
<ProgressBar
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
</FrameLayout>
|
||||
</RelativeLayout>
|
||||
@@ -3,7 +3,8 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/colorPrimaryDark">
|
||||
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="24dp">
|
||||
android:layout_margin="24dp"
|
||||
android:background="@color/colorPrimaryDark">
|
||||
|
||||
<GridView
|
||||
android:id="@+id/grid_mono"
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
package com.appttude.h_mal.atlas_weather.data.repository
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.data.network.WeatherApi
|
||||
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
|
||||
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||
import com.nhaarman.mockitokotlin2.any
|
||||
import com.nhaarman.mockitokotlin2.doAnswer
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.justRun
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
|
||||
private const val MORE_THAN_FIVE_MINS = 330000L
|
||||
private const val LESS_THAN_FIVE_MINS = 270000L
|
||||
|
||||
class RepositoryImplTest {
|
||||
class RepositoryImplTest : BaseTest() {
|
||||
|
||||
lateinit var repository: RepositoryImpl
|
||||
|
||||
@@ -71,4 +86,60 @@ class RepositoryImplTest {
|
||||
val valid: Boolean = repository.isSearchValid(location)
|
||||
assertEquals(valid, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getWeatherFromApi_validLatLong_validSearch() {
|
||||
//Arrange
|
||||
val mockResponse = createSuccessfulRetrofitMock<WeatherResponse>()
|
||||
|
||||
//Act
|
||||
//create a successful retrofit response
|
||||
coEvery { api.getFromApi("", "") }.returns(mockResponse)
|
||||
|
||||
// Assert
|
||||
runBlocking {
|
||||
val result = repository.getWeatherFromApi("", "")
|
||||
assertIs<WeatherResponse>(result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getWeatherFromApi_validLatLong_invalidResponse() {
|
||||
//Arrange
|
||||
val mockResponse = createErrorRetrofitMock<WeatherResponse>()
|
||||
|
||||
//Act
|
||||
//create a successful retrofit response
|
||||
coEvery { api.getFromApi(any(), any()) } returns (mockResponse)
|
||||
|
||||
// Assert
|
||||
val ioExceptionReturned = assertFailsWith<IOException> {
|
||||
runBlocking {
|
||||
repository.getWeatherFromApi("", "")
|
||||
}
|
||||
}
|
||||
assertEquals(ioExceptionReturned.message, "Error Code: 400")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadWeatherList_validResponse() {
|
||||
// Arrange
|
||||
val elements = listOf<EntityItem>(
|
||||
mockk { every { id } returns any() },
|
||||
mockk { every { id } returns any() },
|
||||
mockk { every { id } returns any() },
|
||||
mockk { every { id } returns any() }
|
||||
)
|
||||
|
||||
//Act
|
||||
coEvery { db.getWeatherDao().getWeatherListWithoutCurrent() } returns elements
|
||||
|
||||
// Assert
|
||||
runBlocking {
|
||||
val result = repository.loadWeatherList()
|
||||
assertIs<List<String>>(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
import com.google.gson.Gson
|
||||
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
@@ -18,9 +18,7 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
class ServicesHelperTest {
|
||||
|
||||
private val gson = Gson()
|
||||
class ServicesHelperTest : BaseTest() {
|
||||
|
||||
lateinit var helper: ServicesHelper
|
||||
|
||||
@@ -40,8 +38,7 @@ class ServicesHelperTest {
|
||||
MockKAnnotations.init(this)
|
||||
helper = ServicesHelper(repository, settingsRepository, locationProvider)
|
||||
|
||||
val json = this::class.java.classLoader!!.getResource("weather_sample.json").readText()
|
||||
weatherResponse = gson.fromJson(json, WeatherResponse::class.java)
|
||||
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
{
|
||||
"lat": 51.5,
|
||||
"lon": -0.12,
|
||||
"timezone": "Europe/London",
|
||||
"timezone_offset": 0,
|
||||
"current": {
|
||||
"dt": 1608391380,
|
||||
"sunrise": 1608364972,
|
||||
"sunset": 1608393158,
|
||||
"temp": 10.53,
|
||||
"feels_like": 5.17,
|
||||
"pressure": 1006,
|
||||
"humidity": 71,
|
||||
"dew_point": 5.5,
|
||||
"uvi": 0.12,
|
||||
"clouds": 39,
|
||||
"visibility": 10000,
|
||||
"wind_speed": 6.2,
|
||||
"wind_deg": 210,
|
||||
"weather": [
|
||||
{
|
||||
"id": 500,
|
||||
"main": "Rain",
|
||||
"description": "light rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"rain": {
|
||||
"1h": 0.19
|
||||
}
|
||||
},
|
||||
"daily": [
|
||||
{
|
||||
"dt": 1608375600,
|
||||
"sunrise": 1608364972,
|
||||
"sunset": 1608393158,
|
||||
"temp": {
|
||||
"day": 11.78,
|
||||
"min": 9.1,
|
||||
"max": 12.31,
|
||||
"night": 9.1,
|
||||
"eve": 10.27,
|
||||
"morn": 10.8
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 6.46,
|
||||
"night": 5.08,
|
||||
"eve": 5.58,
|
||||
"morn": 4.63
|
||||
},
|
||||
"pressure": 1005,
|
||||
"humidity": 69,
|
||||
"dew_point": 6.42,
|
||||
"wind_speed": 6.37,
|
||||
"wind_deg": 217,
|
||||
"weather": [
|
||||
{
|
||||
"id": 500,
|
||||
"main": "Rain",
|
||||
"description": "light rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"clouds": 97,
|
||||
"pop": 0.98,
|
||||
"rain": 1.69,
|
||||
"uvi": 0.53
|
||||
},
|
||||
{
|
||||
"dt": 1608462000,
|
||||
"sunrise": 1608451406,
|
||||
"sunset": 1608479581,
|
||||
"temp": {
|
||||
"day": 9.9,
|
||||
"min": 7.41,
|
||||
"max": 10.52,
|
||||
"night": 7.41,
|
||||
"eve": 8.83,
|
||||
"morn": 8.59
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 4.19,
|
||||
"night": 3.99,
|
||||
"eve": 4.55,
|
||||
"morn": 4.79
|
||||
},
|
||||
"pressure": 1013,
|
||||
"humidity": 64,
|
||||
"dew_point": 3.48,
|
||||
"wind_speed": 6.12,
|
||||
"wind_deg": 226,
|
||||
"weather": [
|
||||
{
|
||||
"id": 500,
|
||||
"main": "Rain",
|
||||
"description": "light rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"clouds": 8,
|
||||
"pop": 0.58,
|
||||
"rain": 0.65,
|
||||
"uvi": 0.45
|
||||
},
|
||||
{
|
||||
"dt": 1608548400,
|
||||
"sunrise": 1608537838,
|
||||
"sunset": 1608566008,
|
||||
"temp": {
|
||||
"day": 11.06,
|
||||
"min": 7.01,
|
||||
"max": 13.57,
|
||||
"night": 13.2,
|
||||
"eve": 12.68,
|
||||
"morn": 8.59
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 6.32,
|
||||
"night": 9.99,
|
||||
"eve": 9.75,
|
||||
"morn": 4.64
|
||||
},
|
||||
"pressure": 1005,
|
||||
"humidity": 91,
|
||||
"dew_point": 9.69,
|
||||
"wind_speed": 6.7,
|
||||
"wind_deg": 185,
|
||||
"weather": [
|
||||
{
|
||||
"id": 501,
|
||||
"main": "Rain",
|
||||
"description": "moderate rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"clouds": 100,
|
||||
"pop": 1,
|
||||
"rain": 7.85,
|
||||
"uvi": 0.21
|
||||
},
|
||||
{
|
||||
"dt": 1608634800,
|
||||
"sunrise": 1608624266,
|
||||
"sunset": 1608652438,
|
||||
"temp": {
|
||||
"day": 12.97,
|
||||
"min": 11.7,
|
||||
"max": 13.21,
|
||||
"night": 11.7,
|
||||
"eve": 12.37,
|
||||
"morn": 12.93
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 11.39,
|
||||
"night": 10.49,
|
||||
"eve": 10.96,
|
||||
"morn": 9.65
|
||||
},
|
||||
"pressure": 1012,
|
||||
"humidity": 83,
|
||||
"dew_point": 10.31,
|
||||
"wind_speed": 2.38,
|
||||
"wind_deg": 214,
|
||||
"weather": [
|
||||
{
|
||||
"id": 500,
|
||||
"main": "Rain",
|
||||
"description": "light rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"clouds": 100,
|
||||
"pop": 1,
|
||||
"rain": 3.25,
|
||||
"uvi": 0.34
|
||||
},
|
||||
{
|
||||
"dt": 1608721200,
|
||||
"sunrise": 1608710690,
|
||||
"sunset": 1608738871,
|
||||
"temp": {
|
||||
"day": 12.28,
|
||||
"min": 10.12,
|
||||
"max": 12.62,
|
||||
"night": 10.12,
|
||||
"eve": 10.12,
|
||||
"morn": 11.73
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 7.48,
|
||||
"night": 6.73,
|
||||
"eve": 6.6,
|
||||
"morn": 8.15
|
||||
},
|
||||
"pressure": 1006,
|
||||
"humidity": 64,
|
||||
"dew_point": 5.76,
|
||||
"wind_speed": 5.45,
|
||||
"wind_deg": 224,
|
||||
"weather": [
|
||||
{
|
||||
"id": 500,
|
||||
"main": "Rain",
|
||||
"description": "light rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"clouds": 97,
|
||||
"pop": 0.94,
|
||||
"rain": 2.52,
|
||||
"uvi": 0.52
|
||||
},
|
||||
{
|
||||
"dt": 1608811200,
|
||||
"sunrise": 1608797112,
|
||||
"sunset": 1608825307,
|
||||
"temp": {
|
||||
"day": 7.3,
|
||||
"min": 4.66,
|
||||
"max": 8.32,
|
||||
"night": 4.66,
|
||||
"eve": 5.76,
|
||||
"morn": 5.85
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 0.36,
|
||||
"night": -1.25,
|
||||
"eve": -0.31,
|
||||
"morn": -2.46
|
||||
},
|
||||
"pressure": 1020,
|
||||
"humidity": 60,
|
||||
"dew_point": 0.15,
|
||||
"wind_speed": 7.09,
|
||||
"wind_deg": 5,
|
||||
"weather": [
|
||||
{
|
||||
"id": 500,
|
||||
"main": "Rain",
|
||||
"description": "light rain",
|
||||
"icon": "10d"
|
||||
}
|
||||
],
|
||||
"clouds": 85,
|
||||
"pop": 0.52,
|
||||
"rain": 0.6,
|
||||
"uvi": 1
|
||||
},
|
||||
{
|
||||
"dt": 1608897600,
|
||||
"sunrise": 1608883530,
|
||||
"sunset": 1608911747,
|
||||
"temp": {
|
||||
"day": 4.12,
|
||||
"min": 2.2,
|
||||
"max": 4.63,
|
||||
"night": 2.97,
|
||||
"eve": 3.44,
|
||||
"morn": 2.28
|
||||
},
|
||||
"feels_like": {
|
||||
"day": -0.33,
|
||||
"night": -0.43,
|
||||
"eve": -0.1,
|
||||
"morn": -2.82
|
||||
},
|
||||
"pressure": 1033,
|
||||
"humidity": 70,
|
||||
"dew_point": -3.09,
|
||||
"wind_speed": 3.35,
|
||||
"wind_deg": 334,
|
||||
"weather": [
|
||||
{
|
||||
"id": 800,
|
||||
"main": "Clear",
|
||||
"description": "clear sky",
|
||||
"icon": "01d"
|
||||
}
|
||||
],
|
||||
"clouds": 0,
|
||||
"pop": 0,
|
||||
"uvi": 1
|
||||
},
|
||||
{
|
||||
"dt": 1608984000,
|
||||
"sunrise": 1608969944,
|
||||
"sunset": 1608998190,
|
||||
"temp": {
|
||||
"day": 6.03,
|
||||
"min": 2.76,
|
||||
"max": 6.92,
|
||||
"night": 6.92,
|
||||
"eve": 6.45,
|
||||
"morn": 3.59
|
||||
},
|
||||
"feels_like": {
|
||||
"day": 0.49,
|
||||
"night": -0.96,
|
||||
"eve": -0.28,
|
||||
"morn": -0.44
|
||||
},
|
||||
"pressure": 1024,
|
||||
"humidity": 69,
|
||||
"dew_point": 0.84,
|
||||
"wind_speed": 5.25,
|
||||
"wind_deg": 251,
|
||||
"weather": [
|
||||
{
|
||||
"id": 804,
|
||||
"main": "Clouds",
|
||||
"description": "overcast clouds",
|
||||
"icon": "04d"
|
||||
}
|
||||
],
|
||||
"clouds": 100,
|
||||
"pop": 0,
|
||||
"uvi": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.appttude.h_mal.atlas_weather.utils
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
|
||||
|
||||
open class BaseTest {
|
||||
private val gson by lazy { Gson() }
|
||||
|
||||
fun <T : Any> getTestData(resourceName: String): T {
|
||||
val json = this::class.java.classLoader!!.getResource(resourceName).readText()
|
||||
val typeToken = object : TypeToken<T>() {}.type
|
||||
return gson.fromJson<T>(json, typeToken)
|
||||
}
|
||||
|
||||
fun <T : Any> getTestData(resourceName: String, cls: Class<T>): T {
|
||||
val json = this::class.java.classLoader!!.getResource(resourceName).readText()
|
||||
return gson.fromJson<T>(json, cls)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> createSuccessfulRetrofitMock(): Response<T> {
|
||||
val mockResponse = mockk<T>()
|
||||
return Response.success(mockResponse)
|
||||
}
|
||||
|
||||
fun <T: Any> createErrorRetrofitMock(code: Int = 400): Response<T> {
|
||||
val responseBody = mockk<ResponseBody>(relaxed = true)
|
||||
val rawResponse = mockk<okhttp3.Response>().also {
|
||||
every { it.code } returns code
|
||||
}
|
||||
return Response.error<T>(code, responseBody)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.appttude.h_mal.atlas_weather.utils
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
fun <T> LiveData<T>.getOrAwaitValue(
|
||||
time: Long = 2,
|
||||
timeUnit: TimeUnit = TimeUnit.SECONDS
|
||||
): T {
|
||||
var data: T? = null
|
||||
val latch = CountDownLatch(1)
|
||||
val observer = object : Observer<T> {
|
||||
override fun onChanged(o: T?) {
|
||||
data = o
|
||||
latch.countDown()
|
||||
this@getOrAwaitValue.removeObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
this.observeForever(observer)
|
||||
|
||||
// Don't wait indefinitely if the LiveData is not set.
|
||||
if (!latch.await(time, timeUnit)) {
|
||||
throw TimeoutException("LiveData value was never set.")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data as T
|
||||
}
|
||||
|
||||
fun sleep(millis: Long = 1000) {
|
||||
runBlocking(Dispatchers.Default) { delay(millis) }
|
||||
}
|
||||
@@ -1,26 +1,35 @@
|
||||
package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
|
||||
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
|
||||
import com.appttude.h_mal.atlas_weather.utils.sleep
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertIs
|
||||
|
||||
|
||||
class WorldViewModelTest : BaseTest() {
|
||||
|
||||
@Suppress("unused")
|
||||
class WorldViewModelTest {
|
||||
@get:Rule
|
||||
val rule = InstantTaskExecutorRule()
|
||||
|
||||
@InjectMockKs
|
||||
lateinit var viewModel: WorldViewModel
|
||||
|
||||
@MockK(relaxed = true)
|
||||
@@ -29,46 +38,102 @@ class WorldViewModelTest {
|
||||
@MockK
|
||||
lateinit var locationProvider: LocationProviderImpl
|
||||
|
||||
private lateinit var weatherResponse: WeatherResponse
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this)
|
||||
viewModel = WorldViewModel(locationProvider, repository)
|
||||
|
||||
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchDataForSingleLocation_validLocation_validReturn() {
|
||||
// Arrange
|
||||
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
|
||||
temperatureUnit = "°C"
|
||||
locationString = CURRENT_LOCATION
|
||||
})
|
||||
|
||||
// Act
|
||||
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
|
||||
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
|
||||
weatherResponse.lat,
|
||||
weatherResponse.lon
|
||||
)
|
||||
coEvery {
|
||||
repository.getWeatherFromApi(
|
||||
weatherResponse.lat.toString(),
|
||||
weatherResponse.lon.toString()
|
||||
)
|
||||
}.returns(weatherResponse)
|
||||
coEvery {
|
||||
locationProvider.getLocationNameFromLatLong(
|
||||
weatherResponse.lat,
|
||||
weatherResponse.lon,
|
||||
LocationType.City
|
||||
)
|
||||
}.returns(CURRENT_LOCATION)
|
||||
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
|
||||
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
|
||||
|
||||
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||
|
||||
// Assert
|
||||
viewModel.uiState.observeForever {
|
||||
println(it.javaClass.name)
|
||||
}
|
||||
|
||||
sleep(3000)
|
||||
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
|
||||
// Arrange
|
||||
val location = CURRENT_LOCATION
|
||||
|
||||
// Act
|
||||
every { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } throws IOException("Unable to get location")
|
||||
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
|
||||
coEvery {
|
||||
repository.getWeatherFromApi(
|
||||
weatherResponse.lat.toString(),
|
||||
weatherResponse.lon.toString()
|
||||
)
|
||||
}.returns(weatherResponse)
|
||||
|
||||
viewModel.fetchDataForSingleLocation(location)
|
||||
|
||||
assertEquals(viewModel.operationRefresh.getOrAwaitValue()?.getContentIfNotHandled(), false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun <T> LiveData<T>.getOrAwaitValue(
|
||||
time: Long = 2,
|
||||
timeUnit: TimeUnit = TimeUnit.SECONDS
|
||||
): T {
|
||||
var data: T? = null
|
||||
val latch = CountDownLatch(1)
|
||||
val observer = object : Observer<T> {
|
||||
override fun onChanged(o: T?) {
|
||||
data = o
|
||||
latch.countDown()
|
||||
this@getOrAwaitValue.removeObserver(this)
|
||||
}
|
||||
// Assert
|
||||
sleep(300)
|
||||
assertIs<ViewState.HasError<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
|
||||
this.observeForever(observer)
|
||||
@Test
|
||||
fun searchAboveFallbackTime_validLocation_validReturn() {
|
||||
// Arrange
|
||||
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
|
||||
temperatureUnit = "°C"
|
||||
locationString = CURRENT_LOCATION
|
||||
})
|
||||
|
||||
// Don't wait indefinitely if the LiveData is not set.
|
||||
if (!latch.await(time, timeUnit)) {
|
||||
throw TimeoutException("LiveData value was never set.")
|
||||
// Act
|
||||
coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem)
|
||||
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false)
|
||||
coEvery {
|
||||
repository.getWeatherFromApi(
|
||||
weatherResponse.lat.toString(),
|
||||
weatherResponse.lon.toString()
|
||||
)
|
||||
}.returns(weatherResponse)
|
||||
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
|
||||
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
|
||||
|
||||
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||
|
||||
// Assert
|
||||
sleep(300)
|
||||
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data as T
|
||||
}
|
||||
Reference in New Issue
Block a user