From 1fa34764df3e21553f41f8cce02a22c8e16e9117 Mon Sep 17 00:00:00 2001 From: hmalik144 Date: Sat, 12 Aug 2023 18:39:20 +0100 Subject: [PATCH] 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 --- .gitignore | 3 +- .idea/androidTestResultsUserPreferences.xml | 126 ------- Gemfile.lock | 218 +++++++++++ .../assets/invalid_api_key_response.json} | 0 .../assets/valid_response_imperial.json | 345 +++++++++++++++++ .../assets/valid_response_metric.json} | 0 .../assets/valid_response_metric_sydney.json | 350 ++++++++++++++++++ .../assets/wrong_location_response.json | 4 + .../appttude/h_mal/atlas_weather/BaseTest.kt | 29 +- .../atlas_weather/application/TestAppClass.kt | 33 +- .../data/location/MockLocationProvider.kt | 14 +- .../interceptors/MockingNetworkInterceptor.kt | 16 +- .../helpers/BaseCustomMatcher.kt | 12 + .../testSuite/RoomDatabaseTests.kt | 117 ++++++ .../h_mal/atlas_weather/utils/Stubs.kt | 7 +- .../h_mal/atlas_weather/utils/TestUtils.kt | 32 ++ .../atlas_weather/robot/HomeScreenRobot.kt | 4 + .../tests/HomePageNoDataUITest.kt | 37 ++ .../atlas_weather/tests/HomePageUITest.kt | 2 +- .../robot/AddLocationScreenRobot.kt | 12 + .../h_mal/monoWeather/robot/ContainerRobot.kt | 23 ++ .../{HomeScreenRobot.kt => WeatherScreen.kt} | 9 +- .../monoWeather/robot/WorldScreenRobot.kt | 24 ++ .../monoWeather/tests/HomePageNoDataUITest.kt | 35 ++ .../h_mal/monoWeather/tests/HomePageUITest.kt | 6 +- .../monoWeather/tests/WorldPageUITest.kt | 42 +++ .../atlas_weather/ui/home/HomeFragment.kt | 57 ++- .../ui/home/adapter/EmptyViewHolder.kt | 19 +- .../ui/home/adapter/WeatherRecyclerAdapter.kt | 62 ++-- .../home/adapter/forecast/GridCellHolder.kt | 23 ++ .../adapter/forecast/GridForecastAdapter.kt | 32 ++ .../adapter/forecast/ViewHolderForecast.kt | 20 + .../ViewHolderForecastDaily.kt} | 16 +- .../ui/world/AddLocationFragment.kt | 30 +- .../atlas_weather/ui/world/WorldFragment.kt | 23 +- .../res/layout/activity_add_forecast.xml | 16 - .../atlasWeather/res/layout/activity_main.xml | 12 - .../res/layout/fragment_add_location.xml | 11 - .../res/layout/hourly_forecast_grid_item.xml | 53 +++ .../res/layout/hourly_item_forecast.xml | 24 ++ .../h_mal/atlas_weather/base/BaseActivity.kt | 91 +++++ .../data/repository/RepositoryImpl.kt | 17 +- .../atlas_weather/data/room/AppDatabase.kt | 2 +- .../atlas_weather/helper/GenericsHelper.kt | 7 + .../h_mal/atlas_weather/model/ViewState.kt | 7 + .../h_mal/atlas_weather/ui/BaseFragment.kt | 107 +++--- .../h_mal/atlas_weather/ui/MainActivity.kt | 4 +- .../h_mal/atlas_weather/utils/ViewUtils.kt | 13 + .../atlas_weather/viewmodel/MainViewModel.kt | 45 +-- .../atlas_weather/viewmodel/WorldViewModel.kt | 69 ++-- .../viewmodel/baseViewModels/BaseViewModel.kt | 25 ++ .../baseViewModels/WeatherViewModel.kt | 2 +- .../main/res/layout/activity_add_forecast.xml | 16 - app/src/main/res/layout/db_list_item.xml | 9 +- app/src/main/res/layout/fragment_home.xml | 22 +- app/src/main/res/layout/progress_layout.xml | 13 + app/src/main/res/values/strings.xml | 1 + .../h_mal/monoWeather/ui/EmptyViewHolder.kt | 14 +- .../h_mal/monoWeather/ui/WorldItemFragment.kt | 35 +- .../h_mal/monoWeather/ui/home/HomeFragment.kt | 37 +- .../ui/home/adapter/WeatherRecyclerAdapter.kt | 5 +- .../adapter/forecast/ViewHolderForecast.kt | 1 - .../ui/world/AddLocationFragment.kt | 20 +- .../monoWeather/ui/world/WorldFragment.kt | 31 +- .../res/layout/fragment__two.xml | 14 - .../monoWeather/res/layout/mono_item_one.xml | 3 +- .../monoWeather/res/layout/mono_item_two.xml | 3 +- .../data/repository/RepositoryImplTest.kt | 73 +++- .../helper/ServicesHelperTest.kt | 9 +- .../atlas_weather/helper/weather_sample.json | 320 ---------------- .../h_mal/atlas_weather/utils/BaseTest.kt | 37 ++ .../h_mal/atlas_weather/utils/testUtils.kt | 39 ++ .../viewmodel/WorldViewModelTest.kt | 131 +++++-- settings.gradle | 1 + 74 files changed, 2194 insertions(+), 927 deletions(-) delete mode 100644 .idea/androidTestResultsUserPreferences.xml create mode 100644 Gemfile.lock rename app/src/{main/res/raw/invalid_response.json => androidTest/assets/invalid_api_key_response.json} (100%) create mode 100644 app/src/androidTest/assets/valid_response_imperial.json rename app/src/{main/res/raw/valid_response.json => androidTest/assets/valid_response_metric.json} (100%) create mode 100644 app/src/androidTest/assets/valid_response_metric_sydney.json create mode 100644 app/src/androidTest/assets/wrong_location_response.json create mode 100644 app/src/androidTest/java/com/appttude/h_mal/atlas_weather/helpers/BaseCustomMatcher.kt create mode 100644 app/src/androidTest/java/com/appttude/h_mal/atlas_weather/testSuite/RoomDatabaseTests.kt create mode 100644 app/src/androidTest/java/com/appttude/h_mal/atlas_weather/utils/TestUtils.kt create mode 100644 app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/tests/HomePageNoDataUITest.kt create mode 100644 app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/AddLocationScreenRobot.kt create mode 100644 app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/ContainerRobot.kt rename app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/{HomeScreenRobot.kt => WeatherScreen.kt} (60%) create mode 100644 app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/WorldScreenRobot.kt create mode 100644 app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageNoDataUITest.kt create mode 100644 app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/WorldPageUITest.kt create mode 100644 app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/GridCellHolder.kt create mode 100644 app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/GridForecastAdapter.kt create mode 100644 app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/ViewHolderForecast.kt rename app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/{ViewHolderForecast.kt => forecastDaily/ViewHolderForecastDaily.kt} (66%) create mode 100644 app/src/atlasWeather/res/layout/hourly_forecast_grid_item.xml create mode 100644 app/src/atlasWeather/res/layout/hourly_item_forecast.xml create mode 100644 app/src/main/java/com/appttude/h_mal/atlas_weather/base/BaseActivity.kt create mode 100644 app/src/main/java/com/appttude/h_mal/atlas_weather/model/ViewState.kt create mode 100644 app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/baseViewModels/BaseViewModel.kt create mode 100644 app/src/main/res/layout/progress_layout.xml rename app/src/{main => monoWeather}/res/layout/fragment__two.xml (72%) delete mode 100644 app/src/test/java/com/appttude/h_mal/atlas_weather/helper/weather_sample.json create mode 100644 app/src/test/java/com/appttude/h_mal/atlas_weather/utils/BaseTest.kt create mode 100644 app/src/test/java/com/appttude/h_mal/atlas_weather/utils/testUtils.kt diff --git a/.gitignore b/.gitignore index 0ea4711..6b8a6e1 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ gen-external-apklibs /out/ # User-specific configurations +.idea/androidTestResultsUserPreferences.xml .idea/caches/ .idea/libraries/ .idea/shelf/ @@ -94,5 +95,3 @@ gen-external-apklibs /fastlane/report.xml # Google play files /google-play-key.json - -/.idea/androidTestResultsUserPreferences.xml diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml deleted file mode 100644 index 66e1e63..0000000 --- a/.idea/androidTestResultsUserPreferences.xml +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..c7706ea --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,218 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.6) + rexml + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + artifactory (3.0.15) + atomos (0.1.3) + aws-eventstream (1.2.0) + aws-partitions (1.798.0) + aws-sdk-core (3.180.1) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.5) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.71.0) + aws-sdk-core (~> 3, >= 3.177.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.132.0) + aws-sdk-core (~> 3, >= 3.179.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.100.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.2.7) + fastlane (2.214.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (~> 0.1.1) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (>= 1.4.5, < 2.0.0) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.46.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.1) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + webrick + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.19.0) + google-apis-core (>= 0.9.0, < 2.a) + google-cloud-core (1.6.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.1) + google-cloud-storage (1.44.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.19.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.7.0) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.6.3) + jwt (2.7.1) + memoist (0.16.2) + mini_magick (4.12.0) + mini_mime (1.1.2) + multi_json (1.15.0) + multipart-post (2.3.0) + nanaimo (0.3.0) + naturally (2.2.1) + optparse (0.1.1) + os (1.1.4) + plist (3.7.0) + public_suffix (5.0.3) + rake (13.0.6) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.6) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.3) + signet (0.17.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (1.8.0) + webrick (1.8.1) + word_wrap (1.0.0) + xcodeproj (1.22.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.3.5 diff --git a/app/src/main/res/raw/invalid_response.json b/app/src/androidTest/assets/invalid_api_key_response.json similarity index 100% rename from app/src/main/res/raw/invalid_response.json rename to app/src/androidTest/assets/invalid_api_key_response.json diff --git a/app/src/androidTest/assets/valid_response_imperial.json b/app/src/androidTest/assets/valid_response_imperial.json new file mode 100644 index 0000000..96cd02d --- /dev/null +++ b/app/src/androidTest/assets/valid_response_imperial.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/app/src/main/res/raw/valid_response.json b/app/src/androidTest/assets/valid_response_metric.json similarity index 100% rename from app/src/main/res/raw/valid_response.json rename to app/src/androidTest/assets/valid_response_metric.json diff --git a/app/src/androidTest/assets/valid_response_metric_sydney.json b/app/src/androidTest/assets/valid_response_metric_sydney.json new file mode 100644 index 0000000..cd7dd3b --- /dev/null +++ b/app/src/androidTest/assets/valid_response_metric_sydney.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/app/src/androidTest/assets/wrong_location_response.json b/app/src/androidTest/assets/wrong_location_response.json new file mode 100644 index 0000000..dcb8e52 --- /dev/null +++ b/app/src/androidTest/assets/wrong_location_response.json @@ -0,0 +1,4 @@ +{ + "cod": "400", + "message": "wrong latitude" +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTest.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTest.kt index b525a59..af29076 100644 --- a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTest.kt +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTest.kt @@ -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( lateinit var scenario: ActivityScenario 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( 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( } }) } + + @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) + } + } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/application/TestAppClass.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/application/TestAppClass.kt index fc5f264..1118703 100644 --- a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/application/TestAppClass.kt +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/application/TestAppClass.kt @@ -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) + } + } \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/location/MockLocationProvider.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/location/MockLocationProvider.kt index 6115ef1..ea101a8 100644 --- a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/location/MockLocationProvider.kt +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/location/MockLocationProvider.kt @@ -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> = 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 { + 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) + } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/network/interceptors/MockingNetworkInterceptor.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/network/interceptors/MockingNetworkInterceptor.kt index a1b8638..94397c4 100644 --- a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/network/interceptors/MockingNetworkInterceptor.kt +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/network/interceptors/MockingNetworkInterceptor.kt @@ -11,23 +11,21 @@ class MockingNetworkInterceptor( private val idlingResource: CountingIdlingResource ) : Interceptor { - private var feedMap: MutableMap = mutableMapOf() - private var urlCallTracker: MutableMap = mutableMapOf() + private var feedMap: MutableMap> = 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) } \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/helpers/BaseCustomMatcher.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/helpers/BaseCustomMatcher.kt new file mode 100644 index 0000000..afe9dc4 --- /dev/null +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/helpers/BaseCustomMatcher.kt @@ -0,0 +1,12 @@ +package com.appttude.h_mal.atlas_weather.helpers + +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher + +open class BaseCustomMatcher : TypeSafeMatcher() { + 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 } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/testSuite/RoomDatabaseTests.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/testSuite/RoomDatabaseTests.kt new file mode 100644 index 0000000..5fe68d9 --- /dev/null +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/testSuite/RoomDatabaseTests.kt @@ -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() + } + return EntityItem(id, weather) + } + + private fun createEntityList(size: Int = 4): List { + return (0.. size).map { + val id = UUID.randomUUID().toString() + createEntity(id) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/utils/Stubs.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/utils/Stubs.kt index 59c6885..e3cb20e 100644 --- a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/utils/Stubs.kt +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/utils/Stubs.kt @@ -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") } \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/utils/TestUtils.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/utils/TestUtils.kt new file mode 100644 index 0000000..2305ea8 --- /dev/null +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/utils/TestUtils.kt @@ -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 LiveData.getOrAwaitValue( + time: Long = 2, + timeUnit: TimeUnit = TimeUnit.SECONDS +): T { + var data: T? = null + val latch = CountDownLatch(1) + val observer = object : Observer { + 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 +} \ No newline at end of file diff --git a/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/robot/HomeScreenRobot.kt b/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/robot/HomeScreenRobot.kt index e4b19de..b863c40 100644 --- a/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/robot/HomeScreenRobot.kt +++ b/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/robot/HomeScreenRobot.kt @@ -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) + } } \ No newline at end of file diff --git a/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/tests/HomePageNoDataUITest.kt b/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/tests/HomePageNoDataUITest.kt new file mode 100644 index 0000000..4ff1669 --- /dev/null +++ b/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/tests/HomePageNoDataUITest.kt @@ -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::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") + } + } +} diff --git a/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/tests/HomePageUITest.kt b/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/tests/HomePageUITest.kt index 789eeaf..5d23f0f 100644 --- a/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/tests/HomePageUITest.kt +++ b/app/src/androidTestAtlasWeather/java/com/appttude/h_mal/atlas_weather/tests/HomePageUITest.kt @@ -10,7 +10,7 @@ import org.junit.Test class HomePageUITest : BaseTest(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 diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/AddLocationScreenRobot.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/AddLocationScreenRobot.kt new file mode 100644 index 0000000..87e79e2 --- /dev/null +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/AddLocationScreenRobot.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/ContainerRobot.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/ContainerRobot.kt new file mode 100644 index 0000000..5003a2f --- /dev/null +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/ContainerRobot.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/HomeScreenRobot.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/WeatherScreen.kt similarity index 60% rename from app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/HomeScreenRobot.kt rename to app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/WeatherScreen.kt index 834397a..7f4034f 100644 --- a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/HomeScreenRobot.kt +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/WeatherScreen.kt @@ -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) + } } \ No newline at end of file diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/WorldScreenRobot.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/WorldScreenRobot.kt new file mode 100644 index 0000000..6df917e --- /dev/null +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/WorldScreenRobot.kt @@ -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(R.id.world_recycler, location) + } + + fun clickItemInListByPosition(position: Int) = + clickViewInRecycler(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) + } +} \ No newline at end of file diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageNoDataUITest.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageNoDataUITest.kt new file mode 100644 index 0000000..58e319b --- /dev/null +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageNoDataUITest.kt @@ -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") + } + } +} diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageUITest.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageUITest.kt index 6f27b73..271e978 100644 --- a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageUITest.kt +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageUITest.kt @@ -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") diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/WorldPageUITest.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/WorldPageUITest.kt new file mode 100644 index 0000000..18bdcf7 --- /dev/null +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/WorldPageUITest.kt @@ -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") + } + } +} diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/HomeFragment.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/HomeFragment.kt index d61fd7f..31240c4 100644 --- a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/HomeFragment.kt +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/HomeFragment.kt @@ -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() +class HomeFragment : BaseFragment(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() diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/EmptyViewHolder.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/EmptyViewHolder.kt index 01f5388..cb44208 100644 --- a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/EmptyViewHolder.kt +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/EmptyViewHolder.kt @@ -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) \ No newline at end of file +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 + } +} \ No newline at end of file diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/WeatherRecyclerAdapter.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/WeatherRecyclerAdapter.kt index 9d07194..0844835 100644 --- a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/WeatherRecyclerAdapter.kt +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/WeatherRecyclerAdapter.kt @@ -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() { 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) } } \ No newline at end of file diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/GridCellHolder.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/GridCellHolder.kt new file mode 100644 index 0000000..f1ce9c5 --- /dev/null +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/GridCellHolder.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/GridForecastAdapter.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/GridForecastAdapter.kt new file mode 100644 index 0000000..e6197db --- /dev/null +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/GridForecastAdapter.kt @@ -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() { + var weather: MutableList = mutableListOf() + + fun addCurrent(current: List?) { + 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 + +} \ No newline at end of file diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/ViewHolderForecast.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/ViewHolderForecast.kt new file mode 100644 index 0000000..d1770e8 --- /dev/null +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecast/ViewHolderForecast.kt @@ -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?) { + val adapter = GridForecastAdapter() + adapter.addCurrent(forecasts) + recyclerView.adapter = adapter + } +} \ No newline at end of file diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/ViewHolderForecast.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecastDaily/ViewHolderForecastDaily.kt similarity index 66% rename from app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/ViewHolderForecast.kt rename to app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecastDaily/ViewHolderForecastDaily.kt index a95efd7..d97732f 100644 --- a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/ViewHolderForecast.kt +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/home/adapter/forecastDaily/ViewHolderForecastDaily.kt @@ -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 } } \ No newline at end of file diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/world/AddLocationFragment.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/world/AddLocationFragment.kt index e8441d0..2c79c5d 100644 --- a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/world/AddLocationFragment.kt +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/world/AddLocationFragment.kt @@ -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() +class AddLocationFragment : BaseFragment(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() } } diff --git a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/world/WorldFragment.kt b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/world/WorldFragment.kt index 8b0e787..fd9743d 100644 --- a/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/world/WorldFragment.kt +++ b/app/src/atlasWeather/java/com/appttude/h_mal/atlas_weather/ui/world/WorldFragment.kt @@ -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() +class WorldFragment : BaseFragment(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) } override fun onResume() { super.onResume() - viewModel.fetchAllLocations() } diff --git a/app/src/atlasWeather/res/layout/activity_add_forecast.xml b/app/src/atlasWeather/res/layout/activity_add_forecast.xml index 8152f4b..7d8c3f3 100644 --- a/app/src/atlasWeather/res/layout/activity_add_forecast.xml +++ b/app/src/atlasWeather/res/layout/activity_add_forecast.xml @@ -36,20 +36,4 @@ android:textStyle="bold" /> - - - - - - - \ No newline at end of file diff --git a/app/src/atlasWeather/res/layout/activity_main.xml b/app/src/atlasWeather/res/layout/activity_main.xml index 19566a4..25a8d00 100644 --- a/app/src/atlasWeather/res/layout/activity_main.xml +++ b/app/src/atlasWeather/res/layout/activity_main.xml @@ -43,17 +43,5 @@ app:layout_constraintTop_toBottomOf="@id/toolbar" tools:layout="@layout/fragment_home" /> - - diff --git a/app/src/atlasWeather/res/layout/fragment_add_location.xml b/app/src/atlasWeather/res/layout/fragment_add_location.xml index 7c57ce1..dcc23d7 100644 --- a/app/src/atlasWeather/res/layout/fragment_add_location.xml +++ b/app/src/atlasWeather/res/layout/fragment_add_location.xml @@ -31,15 +31,4 @@ app:layout_constraintRight_toRightOf="parent" app:srcCompat="@android:drawable/ic_input_add" /> - \ No newline at end of file diff --git a/app/src/atlasWeather/res/layout/hourly_forecast_grid_item.xml b/app/src/atlasWeather/res/layout/hourly_forecast_grid_item.xml new file mode 100644 index 0000000..874aba3 --- /dev/null +++ b/app/src/atlasWeather/res/layout/hourly_forecast_grid_item.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/atlasWeather/res/layout/hourly_item_forecast.xml b/app/src/atlasWeather/res/layout/hourly_item_forecast.xml new file mode 100644 index 0000000..32e5151 --- /dev/null +++ b/app/src/atlasWeather/res/layout/hourly_item_forecast.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/base/BaseActivity.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/BaseActivity.kt new file mode 100644 index 0000000..b463b90 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/BaseActivity.kt @@ -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 startActivity(activity: Class) { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImpl.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImpl.kt index 71715b6..ebfa238 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImpl.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImpl.kt @@ -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 ) { - 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 { - 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 { @@ -70,7 +69,7 @@ class RepositoryImpl( } override suspend fun getSingleWeather(locationName: String): EntityItem { - return db.getSimpleDao().getCurrentFullWeatherSingle(locationName) + return db.getWeatherDao().getCurrentFullWeatherSingle(locationName) } } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/room/AppDatabase.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/room/AppDatabase.kt index 0a3ea62..78aae7f 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/room/AppDatabase.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/room/AppDatabase.kt @@ -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 { diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/helper/GenericsHelper.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/helper/GenericsHelper.kt index 23840f6..ff91eb5 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/helper/GenericsHelper.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/helper/GenericsHelper.kt @@ -11,6 +11,13 @@ object GenericsHelper { ?.kotlin ?: throw IllegalStateException("Can not find class from generic argument") +// @Suppress("UNCHECKED_CAST") +// fun Any.getGenericClassInMethod(position: Int): KClass = +// ((javaClass.methods as? ParameterizedType) +// ?.actualTypeArguments?.getOrNull(position) as? Class) +// ?.kotlin +// ?: throw IllegalStateException("Can not find class from generic argument") + // /** // * Create a view binding out of the the generic [VB] // * diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/model/ViewState.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/model/ViewState.kt new file mode 100644 index 0000000..e258785 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/model/ViewState.kt @@ -0,0 +1,7 @@ +package com.appttude.h_mal.atlas_weather.model + +sealed class ViewState { + object HasStarted : ViewState() + class HasData(val data: T) : ViewState() + class HasError(val error: T) : ViewState() +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/BaseFragment.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/BaseFragment.kt index 25d0c3a..f0cd6c1 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/BaseFragment.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/BaseFragment.kt @@ -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(@LayoutRes contentLayoutId: Int) : + Fragment(contentLayoutId), KodeinAware { override val kodein by kodein() val factory by instance() - inline fun getFragmentViewModel(): Lazy = viewModels { factory } + val viewModel: V by getFragmentViewModel() + + var mActivity: BaseActivity? = null + private fun getFragmentViewModel(): Lazy = + createViewModelLazy(getGenericClassAt(0), { viewModelStore }, factoryProducer = { factory }) private var shortAnimationDuration by Delegates.notNull() @@ -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> { - 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> { - 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> { - 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, diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/MainActivity.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/MainActivity.kt index c992220..748233d 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/MainActivity.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/MainActivity.kt @@ -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 diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/utils/ViewUtils.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/utils/ViewUtils.kt index b5e2c2a..344aff5 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/utils/ViewUtils.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/utils/ViewUtils.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/MainViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/MainViewModel.kt index b8874db..99cb2cd 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/MainViewModel.kt @@ -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() - - val operationState = MutableLiveData>() - val operationError = MutableLiveData>() - val operationRefresh = MutableLiveData>() - 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!!) } } } diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModel.kt index 7fde5d0..30fd381 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModel.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModel.kt @@ -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>() - val singleWeatherLiveData = MutableLiveData() - - val operationState = MutableLiveData>() - val operationError = MutableLiveData>() - val operationRefresh = MutableLiveData>() - - val operationComplete = MutableLiveData>() + 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() 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) } } } diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/baseViewModels/BaseViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/baseViewModels/BaseViewModel.kt new file mode 100644 index 0000000..a2ecbc7 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/baseViewModels/BaseViewModel.kt @@ -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() + val uiState: LiveData = _uiState + + + fun onStart() { + _uiState.postValue(ViewState.HasStarted) + } + + fun onSuccess(result: T) { + _uiState.postValue(ViewState.HasData(result)) + } + + protected fun onError(error: E) { + _uiState.postValue(ViewState.HasError(error)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/baseViewModels/WeatherViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/baseViewModels/WeatherViewModel.kt index 791f549..519b53b 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/baseViewModels/WeatherViewModel.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/baseViewModels/WeatherViewModel.kt @@ -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, diff --git a/app/src/main/res/layout/activity_add_forecast.xml b/app/src/main/res/layout/activity_add_forecast.xml index 209a3ca..135df78 100644 --- a/app/src/main/res/layout/activity_add_forecast.xml +++ b/app/src/main/res/layout/activity_add_forecast.xml @@ -37,20 +37,4 @@ android:textStyle="bold" /> - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/db_list_item.xml b/app/src/main/res/layout/db_list_item.xml index a1e4c0f..4677d45 100644 --- a/app/src/main/res/layout/db_list_item.xml +++ b/app/src/main/res/layout/db_list_item.xml @@ -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"> + android:layout_width="match_parent" + android:layout_height="match_parent"> + tools:listitem="@layout/db_list_item" /> - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/progress_layout.xml b/app/src/main/res/layout/progress_layout.xml new file mode 100644 index 0000000..53d4a7b --- /dev/null +++ b/app/src/main/res/layout/progress_layout.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93081fc..a00ebe6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,4 +30,5 @@ Loading \nforecast… Unable to retrieve weather Make sure you are connected to the internet and have location permissions granted + No weather to display diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/EmptyViewHolder.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/EmptyViewHolder.kt index 3f9d3a8..fa229d4 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/EmptyViewHolder.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/EmptyViewHolder.kt @@ -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 + } } \ No newline at end of file diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/WorldItemFragment.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/WorldItemFragment.kt index 1b4e625..4c7e6d7 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/WorldItemFragment.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/WorldItemFragment.kt @@ -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(R.layout.fragment_home) { - private val viewModel by getFragmentViewModel() 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 + } } \ No newline at end of file diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/HomeFragment.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/HomeFragment.kt index 5157d78..6eec52d 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/HomeFragment.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/HomeFragment.kt @@ -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() +class HomeFragment : BaseFragment(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() diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/adapter/WeatherRecyclerAdapter.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/adapter/WeatherRecyclerAdapter.kt index 2c2bcb9..e914d98 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/adapter/WeatherRecyclerAdapter.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/adapter/WeatherRecyclerAdapter.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/adapter/forecast/ViewHolderForecast.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/adapter/forecast/ViewHolderForecast.kt index 0a8594d..5a8c90a 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/adapter/forecast/ViewHolderForecast.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/home/adapter/forecast/ViewHolderForecast.kt @@ -15,6 +15,5 @@ class ViewHolderForecast( val adapter = GridForecastAdapter() adapter.addCurrent(forecasts) recyclerView.adapter = adapter - } } \ No newline at end of file diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/world/AddLocationFragment.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/world/AddLocationFragment.kt index 19a57de..a17dcad 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/world/AddLocationFragment.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/world/AddLocationFragment.kt @@ -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(R.layout.activity_add_forecast) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val viewModel by getFragmentViewModel() - 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() } } diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/world/WorldFragment.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/world/WorldFragment.kt index a7080ca..691343e 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/world/WorldFragment.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/world/WorldFragment.kt @@ -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() +class WorldFragment : BaseFragment(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) + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment__two.xml b/app/src/monoWeather/res/layout/fragment__two.xml similarity index 72% rename from app/src/main/res/layout/fragment__two.xml rename to app/src/monoWeather/res/layout/fragment__two.xml index b18ceea..2ed58e2 100644 --- a/app/src/main/res/layout/fragment__two.xml +++ b/app/src/monoWeather/res/layout/fragment__two.xml @@ -27,18 +27,4 @@ android:contentDescription="@string/image_string" app:srcCompat="@drawable/ic_baseline_add_24" /> - - - - diff --git a/app/src/monoWeather/res/layout/mono_item_one.xml b/app/src/monoWeather/res/layout/mono_item_one.xml index 4f66376..e6cbe7f 100644 --- a/app/src/monoWeather/res/layout/mono_item_one.xml +++ b/app/src/monoWeather/res/layout/mono_item_one.xml @@ -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"> + android:layout_margin="24dp" + android:background="@color/colorPrimaryDark"> () + + //Act + //create a successful retrofit response + coEvery { api.getFromApi("", "") }.returns(mockResponse) + + // Assert + runBlocking { + val result = repository.getWeatherFromApi("", "") + assertIs(result) + } + } + + @Test + fun getWeatherFromApi_validLatLong_invalidResponse() { + //Arrange + val mockResponse = createErrorRetrofitMock() + + //Act + //create a successful retrofit response + coEvery { api.getFromApi(any(), any()) } returns (mockResponse) + + // Assert + val ioExceptionReturned = assertFailsWith { + runBlocking { + repository.getWeatherFromApi("", "") + } + } + assertEquals(ioExceptionReturned.message, "Error Code: 400") + } + + @Test + fun loadWeatherList_validResponse() { + // Arrange + val elements = listOf( + 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>(result) + } + } + + } \ No newline at end of file diff --git a/app/src/test/java/com/appttude/h_mal/atlas_weather/helper/ServicesHelperTest.kt b/app/src/test/java/com/appttude/h_mal/atlas_weather/helper/ServicesHelperTest.kt index e1e55d0..adb8cc8 100644 --- a/app/src/test/java/com/appttude/h_mal/atlas_weather/helper/ServicesHelperTest.kt +++ b/app/src/test/java/com/appttude/h_mal/atlas_weather/helper/ServicesHelperTest.kt @@ -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 diff --git a/app/src/test/java/com/appttude/h_mal/atlas_weather/helper/weather_sample.json b/app/src/test/java/com/appttude/h_mal/atlas_weather/helper/weather_sample.json deleted file mode 100644 index 8356c51..0000000 --- a/app/src/test/java/com/appttude/h_mal/atlas_weather/helper/weather_sample.json +++ /dev/null @@ -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 - } - ] -} \ No newline at end of file diff --git a/app/src/test/java/com/appttude/h_mal/atlas_weather/utils/BaseTest.kt b/app/src/test/java/com/appttude/h_mal/atlas_weather/utils/BaseTest.kt new file mode 100644 index 0000000..e56274f --- /dev/null +++ b/app/src/test/java/com/appttude/h_mal/atlas_weather/utils/BaseTest.kt @@ -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 getTestData(resourceName: String): T { + val json = this::class.java.classLoader!!.getResource(resourceName).readText() + val typeToken = object : TypeToken() {}.type + return gson.fromJson(json, typeToken) + } + + fun getTestData(resourceName: String, cls: Class): T { + val json = this::class.java.classLoader!!.getResource(resourceName).readText() + return gson.fromJson(json, cls) + } + + inline fun createSuccessfulRetrofitMock(): Response { + val mockResponse = mockk() + return Response.success(mockResponse) + } + + fun createErrorRetrofitMock(code: Int = 400): Response { + val responseBody = mockk(relaxed = true) + val rawResponse = mockk().also { + every { it.code } returns code + } + return Response.error(code, responseBody) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/appttude/h_mal/atlas_weather/utils/testUtils.kt b/app/src/test/java/com/appttude/h_mal/atlas_weather/utils/testUtils.kt new file mode 100644 index 0000000..7eb9191 --- /dev/null +++ b/app/src/test/java/com/appttude/h_mal/atlas_weather/utils/testUtils.kt @@ -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 LiveData.getOrAwaitValue( + time: Long = 2, + timeUnit: TimeUnit = TimeUnit.SECONDS +): T { + var data: T? = null + val latch = CountDownLatch(1) + val observer = object : Observer { + 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) } +} \ No newline at end of file diff --git a/app/src/test/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModelTest.kt b/app/src/test/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModelTest.kt index a67f220..5b311e2 100644 --- a/app/src/test/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModelTest.kt +++ b/app/src/test/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModelTest.kt @@ -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>(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 LiveData.getOrAwaitValue( - time: Long = 2, - timeUnit: TimeUnit = TimeUnit.SECONDS -): T { - var data: T? = null - val latch = CountDownLatch(1) - val observer = object : Observer { - override fun onChanged(o: T?) { - data = o - latch.countDown() - this@getOrAwaitValue.removeObserver(this) - } + // Assert + sleep(300) + assertIs>(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>(viewModel.uiState.getOrAwaitValue()) } - - @Suppress("UNCHECKED_CAST") - return data as T } \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 8457659..6e4fe3f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,3 +20,4 @@ dependencyResolutionManagement { } rootProject.name = "Atlas Weather" include ':app' +include ':test-resources'