├── .github └── workflows │ ├── android_build.yml │ ├── apk_release.yml │ └── ci_ktlint.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ └── com.mayokunadeniyi.instantweather.data.source.local.WeatherDatabase │ │ └── 1.json └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── mayokunadeniyi │ │ └── instantweather │ │ ├── data │ │ └── source │ │ │ └── local │ │ │ ├── WeatherLocalDataSourceTest.kt │ │ │ └── dao │ │ │ └── WeatherDaoTest.kt │ │ └── utils │ │ ├── DataBindingIdlingResource.kt │ │ └── EspressoIdlingResource.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_instant_weather-playstore.png │ ├── ic_launcher-playstore.png │ ├── instant_weather_new-playstore.png │ ├── java │ │ └── com │ │ │ └── mayokunadeniyi │ │ │ └── instantweather │ │ │ ├── App.kt │ │ │ ├── InstantWeatherApplication.kt │ │ │ ├── ViewModelFactory.kt │ │ │ ├── data │ │ │ ├── model │ │ │ │ ├── City.kt │ │ │ │ ├── LocationModel.kt │ │ │ │ ├── NetworkWeather.kt │ │ │ │ ├── NetworkWeatherCondition.kt │ │ │ │ ├── NetworkWeatherDescription.kt │ │ │ │ ├── NetworkWeatherForecast.kt │ │ │ │ ├── NetworkWeatherForecastResponse.kt │ │ │ │ ├── SearchResult.kt │ │ │ │ ├── Weather.kt │ │ │ │ ├── WeatherForecast.kt │ │ │ │ └── Wind.kt │ │ │ └── source │ │ │ │ ├── local │ │ │ │ ├── WeatherDatabase.kt │ │ │ │ ├── WeatherLocalDataSource.kt │ │ │ │ ├── WeatherLocalDataSourceImpl.kt │ │ │ │ ├── dao │ │ │ │ │ └── WeatherDao.kt │ │ │ │ └── entity │ │ │ │ │ ├── DBWeather.kt │ │ │ │ │ └── DBWeatherForecast.kt │ │ │ │ ├── remote │ │ │ │ ├── WeatherRemoteDataSource.kt │ │ │ │ ├── WeatherRemoteDataSourceImpl.kt │ │ │ │ └── retrofit │ │ │ │ │ └── WeatherApiService.kt │ │ │ │ └── repository │ │ │ │ ├── WeatherRepository.kt │ │ │ │ └── WeatherRepositoryImpl.kt │ │ │ ├── di │ │ │ ├── key │ │ │ │ └── ViewModelKey.kt │ │ │ ├── module │ │ │ │ ├── AppModule.kt │ │ │ │ ├── DataSourcesModule.kt │ │ │ │ ├── DatabaseModule.kt │ │ │ │ ├── DispatcherModule.kt │ │ │ │ ├── NetworkModule.kt │ │ │ │ ├── RepositoryModule.kt │ │ │ │ └── ViewModelModule.kt │ │ │ └── scope │ │ │ │ └── DispatcherScopes.kt │ │ │ ├── initializers │ │ │ └── DeferredComponentInitializer.kt │ │ │ ├── mapper │ │ │ ├── BaseMapper.kt │ │ │ ├── WeatherForecastMapperLocal.kt │ │ │ ├── WeatherForecastMapperRemote.kt │ │ │ ├── WeatherMapperLocal.kt │ │ │ └── WeatherMapperRemote.kt │ │ │ ├── ui │ │ │ ├── BaseFragment.kt │ │ │ ├── MainActivity.kt │ │ │ ├── forecast │ │ │ │ ├── ForecastFragment.kt │ │ │ │ ├── ForecastFragmentViewModel.kt │ │ │ │ └── WeatherForecastAdapter.kt │ │ │ ├── home │ │ │ │ ├── HomeFragment.kt │ │ │ │ └── HomeFragmentViewModel.kt │ │ │ ├── search │ │ │ │ ├── SearchFragment.kt │ │ │ │ ├── SearchFragmentViewModel.kt │ │ │ │ └── SearchResultAdapter.kt │ │ │ └── settings │ │ │ │ └── SettingsFragment.kt │ │ │ ├── utils │ │ │ ├── BaseBottomSheetDialog.kt │ │ │ ├── BindingAdapter.kt │ │ │ ├── Constants.kt │ │ │ ├── DateUtils.kt │ │ │ ├── GpsUtil.kt │ │ │ ├── LiveDataUtils.kt │ │ │ ├── LocationLiveData.kt │ │ │ ├── NotificationHelper.kt │ │ │ ├── Result.kt │ │ │ ├── SharedPreferenceHelper.kt │ │ │ ├── TemperatureUtils.kt │ │ │ ├── ThemeManager.kt │ │ │ ├── UserInteractionAwareCallback.kt │ │ │ ├── WeatherIconGenerator.kt │ │ │ └── typeconverters │ │ │ │ └── ListNetworkWeatherDescriptionConverter.kt │ │ │ └── worker │ │ │ ├── MyWorkerFactory.kt │ │ │ └── UpdateWeatherWorker.kt │ └── res │ │ ├── anim │ │ ├── fade_out.xml │ │ ├── slide_down.xml │ │ ├── slide_out_left.xml │ │ ├── slide_out_right.xml │ │ ├── slide_up.xml │ │ ├── zoom_in.xml │ │ └── zoom_out.xml │ │ ├── drawable-v24 │ │ ├── ic_big_cloud.xml │ │ └── ic_cloud.xml │ │ ├── drawable │ │ ├── bottom_sheet_dialog_top_background.xml │ │ ├── circle_blue_solid_background.xml │ │ ├── circle_orange_solid_background.xml │ │ ├── circle_orange_stroke_background.xml │ │ ├── ic_arrow_left.xml │ │ ├── ic_arrow_right.xml │ │ ├── ic_baseline_star_24.xml │ │ ├── ic_baseline_title_24.xml │ │ ├── ic_cached.xml │ │ ├── ic_close.xml │ │ ├── ic_format_list.xml │ │ ├── ic_home.xml │ │ ├── ic_humidity.xml │ │ ├── ic_insert_chart.xml │ │ ├── ic_instant_weather_foreground.xml │ │ ├── ic_palette.xml │ │ ├── ic_pressure.xml │ │ ├── ic_search.xml │ │ ├── ic_settings.xml │ │ ├── ic_wb_sunny_black_24dp.xml │ │ ├── ic_wind.xml │ │ ├── launch_screen.xml │ │ └── launcher_bitmap.png │ │ ├── font │ │ └── googlesans.ttf │ │ ├── layout-land-night │ │ └── fragment_forecast.xml │ │ ├── layout-land │ │ └── fragment_forecast.xml │ │ ├── layout-night │ │ ├── fragment_forecast.xml │ │ ├── fragment_home.xml │ │ ├── fragment_search_detail.xml │ │ └── weather_item.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── fragment_forecast.xml │ │ ├── fragment_home.xml │ │ ├── fragment_search.xml │ │ ├── fragment_search_detail.xml │ │ ├── item_search_result.xml │ │ └── weather_item.xml │ │ ├── menu │ │ └── home_bottom_nav.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── instant_weather_new.xml │ │ └── instant_weather_new_round.xml │ │ ├── mipmap-hdpi │ │ ├── instant_weather_new.png │ │ ├── instant_weather_new_foreground.png │ │ └── instant_weather_new_round.png │ │ ├── mipmap-mdpi │ │ ├── instant_weather_new.png │ │ ├── instant_weather_new_foreground.png │ │ └── instant_weather_new_round.png │ │ ├── mipmap-xhdpi │ │ ├── instant_weather_new.png │ │ ├── instant_weather_new_foreground.png │ │ └── instant_weather_new_round.png │ │ ├── mipmap-xxhdpi │ │ ├── instant_weather_new.png │ │ ├── instant_weather_new_foreground.png │ │ └── instant_weather_new_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── instant_weather_new.png │ │ ├── instant_weather_new_foreground.png │ │ └── instant_weather_new_round.png │ │ ├── navigation │ │ └── nav_graph.xml │ │ ├── values-night │ │ └── colors.xml │ │ ├── values │ │ ├── arrays.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── ic_instant_weather_background.xml │ │ ├── ic_launcher_background.xml │ │ ├── instant_weather_new_background.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── preferences.xml │ ├── sharedTest │ └── java │ │ └── com │ │ └── mayokunadeniyi │ │ └── instantweather │ │ ├── CoroutineTestRule.kt │ │ ├── FakeRepositorySuccess.kt │ │ ├── LiveDataTestUtil.kt │ │ ├── MainCoroutineRule.kt │ │ ├── RecyclerViewMatcher.kt │ │ └── TestUtils.kt │ └── test │ └── java │ └── com │ └── mayokunadeniyi │ └── instantweather │ ├── data │ └── source │ │ └── repository │ │ └── WeatherRepositoryTest.kt │ └── ui │ ├── forecast │ └── ForecastFragmentViewModelTest.kt │ ├── home │ └── HomeFragmentViewModelTest.kt │ └── search │ └── SearchFragmentViewModelTest.kt ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── Dependencies.kt ├── fastlane ├── Appfile └── Fastfile ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── media ├── final-architecture.png └── instant_weather_github.png └── settings.gradle.kts /.github/workflows/android_build.yml: -------------------------------------------------------------------------------- 1 | name: Android Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Create Properties File 13 | env: 14 | API_KEY: ${{ secrets.APIKEY }} 15 | ALGOLIA_API_KEY: ${{ secrets.ALGOLIAAPIKEY }} 16 | ALGOLIA_APP_ID: ${{ secrets.ALGOLIAAPPID }} 17 | ALGOLIA_INDEX_NAME: ${{ secrets.ALGOLIA_INDEX_NAME }} 18 | run: | 19 | touch local.properties 20 | echo "API_KEY=$API_KEY" >> local.properties 21 | echo "ALGOLIA_API_KEY=$ALGOLIA_API_KEY" >> local.properties 22 | echo "ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> local.properties 23 | echo "ALGOLIA_INDEX_NAME=$ALGOLIA_INDEX_NAME" >> local.properties 24 | 25 | - name: Set up JDK 11 26 | uses: actions/setup-java@v2 27 | with: 28 | distribution: temurin 29 | java-version: 11 30 | cache: gradle 31 | 32 | - name: Run Tests 33 | run: ./gradlew test 34 | 35 | - name: Build Project 36 | run: ./gradlew assemble -------------------------------------------------------------------------------- /.github/workflows/apk_release.yml: -------------------------------------------------------------------------------- 1 | name: Generate APK 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | apk: 13 | name: Generate APK 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | 20 | - name: Create Properties File 21 | env: 22 | API_KEY: ${{ secrets.APIKEY }} 23 | ALGOLIA_API_KEY: ${{ secrets.ALGOLIAAPIKEY }} 24 | ALGOLIA_APP_ID: ${{ secrets.ALGOLIAAPPID }} 25 | ALGOLIA_INDEX_NAME: ${{ secrets.ALGOLIA_INDEX_NAME }} 26 | run: | 27 | touch local.properties 28 | echo "API_KEY=$API_KEY" >> local.properties 29 | echo "ALGOLIA_API_KEY=$ALGOLIA_API_KEY" >> local.properties 30 | echo "ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> local.properties 31 | echo "ALGOLIA_INDEX_NAME=$ALGOLIA_INDEX_NAME" >> local.properties 32 | 33 | - name: Set up JDK 11 34 | uses: actions/setup-java@v2 35 | with: 36 | distribution: temurin 37 | java-version: 11 38 | cache: gradle 39 | - name: Build debug APK 40 | run: bash ./gradlew assembleDebug --stacktrace 41 | - name: Upload APK 42 | uses: actions/upload-artifact@v2 43 | with: 44 | name: app 45 | path: app/build/outputs/apk/debug/app-debug.apk -------------------------------------------------------------------------------- /.github/workflows/ci_ktlint.yml: -------------------------------------------------------------------------------- 1 | name: Ktlint 2 | 3 | on: 4 | push: 5 | branches: [master] # Just in case master was not up to date while merging PR 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | run: 11 | continue-on-error: true 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Create APIKEY.PROPERTIES File 20 | env: 21 | API_KEY: ${{ secrets.APIKEY }} 22 | ALGOLIA_API_KEY: ${{ secrets.ALGOLIAAPIKEY }} 23 | ALGOLIA_APP_ID: ${{ secrets.ALGOLIAAPPID }} 24 | run: | 25 | touch apikey.properties 26 | echo "API_KEY=$API_KEY" >> apikey.properties 27 | echo "ALGOLIA_API_KEY=$ALGOLIA_API_KEY" >> apikey.properties 28 | echo "ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> apikey.properties 29 | 30 | - name: Set up JDK 11 31 | uses: actions/setup-java@v2 32 | with: 33 | distribution: temurin 34 | java-version: 11 35 | cache: gradle 36 | 37 | - name: ktlint 38 | run: ./gradlew ktlintCheck 39 | 40 | - uses: actions/upload-artifact@v2 41 | with: 42 | name: ktlint-report 43 | path: ./**/build/reports/ktlint/ -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mayokun Adeniyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/schemas/com.mayokunadeniyi.instantweather.data.source.local.WeatherDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "aa12f03c9c80ac9fbe944f980cc21992", 6 | "entities": [ 7 | { 8 | "tableName": "weather_table", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`unique_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `city_id` INTEGER NOT NULL, `city_name` TEXT NOT NULL, `weather_details` TEXT NOT NULL, `speed` REAL NOT NULL, `deg` INTEGER NOT NULL, `temp` REAL NOT NULL, `pressure` REAL NOT NULL, `humidity` REAL NOT NULL)", 10 | "fields": [ 11 | { 12 | "fieldPath": "uId", 13 | "columnName": "unique_id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "cityId", 19 | "columnName": "city_id", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "cityName", 25 | "columnName": "city_name", 26 | "affinity": "TEXT", 27 | "notNull": true 28 | }, 29 | { 30 | "fieldPath": "networkWeatherDescription", 31 | "columnName": "weather_details", 32 | "affinity": "TEXT", 33 | "notNull": true 34 | }, 35 | { 36 | "fieldPath": "wind.speed", 37 | "columnName": "speed", 38 | "affinity": "REAL", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "wind.deg", 43 | "columnName": "deg", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "networkWeatherCondition.temp", 49 | "columnName": "temp", 50 | "affinity": "REAL", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "networkWeatherCondition.pressure", 55 | "columnName": "pressure", 56 | "affinity": "REAL", 57 | "notNull": true 58 | }, 59 | { 60 | "fieldPath": "networkWeatherCondition.humidity", 61 | "columnName": "humidity", 62 | "affinity": "REAL", 63 | "notNull": true 64 | } 65 | ], 66 | "primaryKey": { 67 | "columnNames": [ 68 | "unique_id" 69 | ], 70 | "autoGenerate": true 71 | }, 72 | "indices": [], 73 | "foreignKeys": [] 74 | }, 75 | { 76 | "tableName": "weather_forecast", 77 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `weather_description` TEXT NOT NULL, `speed` REAL NOT NULL, `deg` INTEGER NOT NULL, `temp` REAL NOT NULL, `pressure` REAL NOT NULL, `humidity` REAL NOT NULL)", 78 | "fields": [ 79 | { 80 | "fieldPath": "id", 81 | "columnName": "id", 82 | "affinity": "INTEGER", 83 | "notNull": true 84 | }, 85 | { 86 | "fieldPath": "date", 87 | "columnName": "date", 88 | "affinity": "TEXT", 89 | "notNull": true 90 | }, 91 | { 92 | "fieldPath": "networkWeatherDescriptions", 93 | "columnName": "weather_description", 94 | "affinity": "TEXT", 95 | "notNull": true 96 | }, 97 | { 98 | "fieldPath": "wind.speed", 99 | "columnName": "speed", 100 | "affinity": "REAL", 101 | "notNull": true 102 | }, 103 | { 104 | "fieldPath": "wind.deg", 105 | "columnName": "deg", 106 | "affinity": "INTEGER", 107 | "notNull": true 108 | }, 109 | { 110 | "fieldPath": "networkWeatherCondition.temp", 111 | "columnName": "temp", 112 | "affinity": "REAL", 113 | "notNull": true 114 | }, 115 | { 116 | "fieldPath": "networkWeatherCondition.pressure", 117 | "columnName": "pressure", 118 | "affinity": "REAL", 119 | "notNull": true 120 | }, 121 | { 122 | "fieldPath": "networkWeatherCondition.humidity", 123 | "columnName": "humidity", 124 | "affinity": "REAL", 125 | "notNull": true 126 | } 127 | ], 128 | "primaryKey": { 129 | "columnNames": [ 130 | "id" 131 | ], 132 | "autoGenerate": true 133 | }, 134 | "indices": [], 135 | "foreignKeys": [] 136 | } 137 | ], 138 | "views": [], 139 | "setupQueries": [ 140 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 141 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aa12f03c9c80ac9fbe944f980cc21992')" 142 | ] 143 | } 144 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/mayokunadeniyi/instantweather/data/source/local/WeatherLocalDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.local 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.room.Room 5 | import androidx.test.core.app.ApplicationProvider 6 | import androidx.test.espresso.matcher.ViewMatchers.assertThat 7 | import androidx.test.ext.junit.runners.AndroidJUnit4 8 | import androidx.test.filters.MediumTest 9 | import com.mayokunadeniyi.instantweather.MainCoroutineRule 10 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeather 11 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeatherForecast 12 | import com.mayokunadeniyi.instantweather.fakeDbWeatherEntity 13 | import com.mayokunadeniyi.instantweather.fakeDbWeatherForecast 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.ExperimentalCoroutinesApi 16 | import kotlinx.coroutines.test.runBlockingTest 17 | import org.hamcrest.CoreMatchers.`is` 18 | import org.hamcrest.CoreMatchers.notNullValue 19 | import org.hamcrest.CoreMatchers.nullValue 20 | import org.junit.After 21 | import org.junit.Before 22 | import org.junit.Rule 23 | import org.junit.Test 24 | import org.junit.runner.RunWith 25 | import java.util.Optional.empty 26 | 27 | /** 28 | * Created by Mayokun Adeniyi on 03/08/2020. 29 | */ 30 | @RunWith(AndroidJUnit4::class) 31 | @MediumTest 32 | @ExperimentalCoroutinesApi 33 | class WeatherLocalDataSourceTest { 34 | 35 | //region constants 36 | 37 | //endregion constants 38 | 39 | //region helper fields 40 | private lateinit var database: WeatherDatabase 41 | //endregion helper fields 42 | 43 | private lateinit var systemUnderTest: WeatherLocalDataSourceImpl 44 | 45 | @get:Rule 46 | var mainCoroutineRule = MainCoroutineRule() 47 | 48 | @get:Rule 49 | var instantTaskExecutor = InstantTaskExecutorRule() 50 | 51 | @Before 52 | fun setUp() { 53 | database = Room.inMemoryDatabaseBuilder( 54 | ApplicationProvider.getApplicationContext(), 55 | WeatherDatabase::class.java 56 | ).allowMainThreadQueries().build() 57 | 58 | systemUnderTest = WeatherLocalDataSourceImpl(database.weatherDao, Dispatchers.Main) 59 | } 60 | 61 | @After 62 | fun cleanUp() { 63 | database.close() 64 | } 65 | 66 | @Test 67 | fun saveWeather_getWeather_returnWeatherDbEntity() = mainCoroutineRule.runBlockingTest { 68 | systemUnderTest.saveWeather(fakeDbWeatherEntity) 69 | 70 | val returnedWeather = systemUnderTest.getWeather() 71 | 72 | assertThat( 73 | returnedWeather as DBWeather, 74 | `is`(notNullValue(DBWeather::class.java)) 75 | ) 76 | 77 | assertThat(returnedWeather, `is`(fakeDbWeatherEntity)) 78 | assertThat(returnedWeather.cityId, `is`(fakeDbWeatherEntity.cityId)) 79 | assertThat(returnedWeather.cityName, `is`(fakeDbWeatherEntity.cityName)) 80 | assertThat(returnedWeather.uId, `is`(fakeDbWeatherEntity.uId)) 81 | } 82 | 83 | @Test 84 | fun saveForecastWeather_getForecastWeather_returnForecastWeatherDbEntity() = 85 | mainCoroutineRule.runBlockingTest { 86 | systemUnderTest.saveForecastWeather(fakeDbWeatherForecast) 87 | 88 | val returnedForecastWeather = systemUnderTest.getForecastWeather()?.first() 89 | 90 | assertThat( 91 | returnedForecastWeather as DBWeatherForecast, 92 | `is`(notNullValue(DBWeatherForecast::class.java)) 93 | ) 94 | 95 | assertThat(returnedForecastWeather.date, `is`(fakeDbWeatherForecast.date)) 96 | assertThat(returnedForecastWeather.id, `is`(fakeDbWeatherForecast.id)) 97 | assertThat(returnedForecastWeather.wind, `is`(fakeDbWeatherForecast.wind)) 98 | } 99 | 100 | @Test 101 | fun deleteWeather_getWeatherReturnsNull() = mainCoroutineRule.runBlockingTest { 102 | systemUnderTest.saveWeather(fakeDbWeatherEntity) 103 | systemUnderTest.deleteWeather() 104 | 105 | val result = systemUnderTest.getWeather() 106 | assertThat(result, `is`(nullValue())) 107 | } 108 | 109 | @Test 110 | fun deleteForecastWeather_getForecastWeatherReturnsNull() = mainCoroutineRule.runBlockingTest { 111 | systemUnderTest.saveForecastWeather(fakeDbWeatherForecast) 112 | systemUnderTest.deleteForecastWeather() 113 | 114 | val result = systemUnderTest.getForecastWeather() 115 | assertThat(result, `is`(empty())) 116 | } 117 | 118 | // region helper methods 119 | 120 | // endregion helper methods 121 | 122 | // region helper classes 123 | 124 | // endregion helper classes 125 | } 126 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/mayokunadeniyi/instantweather/utils/DataBindingIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import android.view.View 4 | import androidx.databinding.DataBindingUtil 5 | import androidx.databinding.ViewDataBinding 6 | import androidx.fragment.app.Fragment 7 | import androidx.fragment.app.FragmentActivity 8 | import androidx.fragment.app.testing.FragmentScenario 9 | import androidx.test.core.app.ActivityScenario 10 | import androidx.test.espresso.IdlingResource 11 | import java.util.UUID 12 | 13 | /** 14 | * Created by Mayokun Adeniyi on 25/07/2020. 15 | */ 16 | 17 | class DataBindingIdlingResource : IdlingResource { 18 | // List of registered callbacks 19 | private val idlingCallbacks = mutableListOf() 20 | // Give it a unique id to work around an Espresso bug where you cannot register/unregister 21 | // an idling resource with the same name. 22 | private val id = UUID.randomUUID().toString() 23 | // Holds whether isIdle was called and the result was false. We track this to avoid calling 24 | // onTransitionToIdle callbacks if Espresso never thought we were idle in the first place. 25 | private var wasNotIdle = false 26 | 27 | lateinit var activity: FragmentActivity 28 | 29 | override fun getName() = "DataBinding $id" 30 | 31 | override fun isIdleNow(): Boolean { 32 | val idle = !getBindings().any { it.hasPendingBindings() } 33 | @Suppress("LiftReturnOrAssignment") 34 | if (idle) { 35 | if (wasNotIdle) { 36 | // Notify observers to avoid Espresso race detector. 37 | idlingCallbacks.forEach { it.onTransitionToIdle() } 38 | } 39 | wasNotIdle = false 40 | } else { 41 | wasNotIdle = true 42 | // Check next frame. 43 | activity.findViewById(android.R.id.content).postDelayed( 44 | { 45 | isIdleNow 46 | }, 47 | 16 48 | ) 49 | } 50 | return idle 51 | } 52 | 53 | override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) { 54 | idlingCallbacks.add(callback) 55 | } 56 | 57 | /** 58 | * Find all binding classes in all currently available fragments. 59 | */ 60 | private fun getBindings(): List { 61 | val fragments = (activity as? FragmentActivity) 62 | ?.supportFragmentManager 63 | ?.fragments 64 | 65 | val bindings = 66 | fragments?.mapNotNull { 67 | it.view?.getBinding() 68 | } ?: emptyList() 69 | val childrenBindings = fragments?.flatMap { it.childFragmentManager.fragments } 70 | ?.mapNotNull { it.view?.getBinding() } ?: emptyList() 71 | 72 | return bindings + childrenBindings 73 | } 74 | } 75 | 76 | private fun View.getBinding(): ViewDataBinding? = DataBindingUtil.getBinding(this) 77 | 78 | /** 79 | * Sets the activity from an [ActivityScenario] to be used from [DataBindingIdlingResource]. 80 | */ 81 | fun DataBindingIdlingResource.monitorActivity( 82 | activityScenario: ActivityScenario 83 | ) { 84 | activityScenario.onActivity { 85 | this.activity = it 86 | } 87 | } 88 | 89 | /** 90 | * Sets the fragment from a [FragmentScenario] to be used from [DataBindingIdlingResource]. 91 | */ 92 | fun DataBindingIdlingResource.monitorFragment(fragmentScenario: FragmentScenario) { 93 | fragmentScenario.onFragment { 94 | this.activity = it.requireActivity() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/mayokunadeniyi/instantweather/utils/EspressoIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import androidx.test.espresso.idling.CountingIdlingResource 4 | 5 | /** 6 | * Created by Mayokun Adeniyi on 10/07/2020. 7 | */ 8 | 9 | object EspressoIdlingResource { 10 | private const val RESOURCE = "GLOBAL" 11 | 12 | @JvmField 13 | val countingIdlingResource = CountingIdlingResource(RESOURCE) 14 | 15 | fun increment() { 16 | countingIdlingResource.increment() 17 | } 18 | 19 | fun decrement() { 20 | if (!countingIdlingResource.isIdleNow) { 21 | countingIdlingResource.decrement() 22 | } 23 | } 24 | } 25 | 26 | inline fun wrapEspressoIdlingResource(function: () -> T): T { 27 | // Espresso does not work well with coroutines yet. See 28 | // https://github.com/Kotlin/kotlinx.coroutines/issues/982 29 | EspressoIdlingResource.increment() // Set app as busy. 30 | return try { 31 | function() 32 | } finally { 33 | EspressoIdlingResource.decrement() // Set app as idle. 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/ic_instant_weather-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/ic_instant_weather-playstore.png -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/instant_weather_new-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/instant_weather_new-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/App.kt: -------------------------------------------------------------------------------- 1 | // App.kt 2 | override fun onCreate() { 3 | super.onCreate() 4 | 5 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 6 | val deferredComponentInitializer = DeferredComponentInitializer(this) 7 | deferredComponentInitializer.initialize() 8 | } else { 9 | initializeComponents() 10 | } 11 | } 12 | 13 | private fun initializeComponents() { 14 | initWorkManager() 15 | initCrashlytics() 16 | initAnalytics() 17 | } 18 | 19 | // DeferredComponentInitializer.kt 20 | class DeferredComponentInitializer(private val app: Application) { 21 | 22 | fun initialize() { 23 | val componentInitializer = ComponentInitializer(app) 24 | val initializeMessage = when { 25 | isColdStart() -> "Deferred initialization from cold start" 26 | else -> "Deferred initialization from warm start" 27 | } 28 | WorkManager.getInstance(app) 29 | .beginUniqueWork( 30 | "DeferredInitialization", 31 | ExistingWorkPolicy.KEEP, 32 | OneTimeWorkRequestBuilder() 33 | .setInputData(workDataOf(DeferredWorker.KEY_INITIALIZE to initializeMessage)) 34 | .build() 35 | ) 36 | .enqueue() 37 | } 38 | 39 | private fun isColdStart(): Boolean { 40 | return ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.INITIALIZED 41 | } 42 | 43 | private class DeferredWorker( 44 | context: Context, 45 | workerParams: WorkerParameters 46 | ) : Worker(context, workerParams) { 47 | override fun doWork(): Result { 48 | val initializeMessage = inputData.getString(KEY_INITIALIZE) 49 | initializeMessage?.let { 50 | Log.d("DeferredInit", it) 51 | } 52 | ComponentInitializer(applicationContext).initialize() 53 | return Result.success() 54 | } 55 | 56 | companion object { 57 | const val KEY_INITIALIZE = "KEY_INITIALIZE" 58 | } 59 | } 60 | 61 | private class ComponentInitializer(private val app: Application) { 62 | fun initialize() { 63 | initCrashlytics() 64 | initAnalytics() 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/InstantWeatherApplication.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather 2 | 3 | import android.app.Application 4 | import android.util.Log 5 | import androidx.preference.PreferenceManager 6 | import androidx.work.Configuration 7 | import androidx.work.DelegatingWorkerFactory 8 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepository 9 | import com.mayokunadeniyi.instantweather.utils.ThemeManager 10 | import com.mayokunadeniyi.instantweather.worker.MyWorkerFactory 11 | import dagger.hilt.android.HiltAndroidApp 12 | import timber.log.Timber 13 | import javax.inject.Inject 14 | 15 | /** 16 | * Created by Mayokun Adeniyi on 2020-01-25. 17 | */ 18 | 19 | @HiltAndroidApp 20 | class InstantWeatherApplication : Application(), Configuration.Provider { 21 | 22 | @Inject 23 | lateinit var weatherRepository: WeatherRepository 24 | 25 | override fun onCreate() { 26 | super.onCreate() 27 | if (BuildConfig.DEBUG) { 28 | Timber.plant(Timber.DebugTree()) 29 | } 30 | initTheme() 31 | } 32 | 33 | private fun initTheme() { 34 | val preferences = PreferenceManager.getDefaultSharedPreferences(this) 35 | runCatching { 36 | ThemeManager.applyTheme(requireNotNull(preferences.getString("theme_key", ""))) 37 | }.onFailure { exception -> 38 | Timber.e("Theme Manager: $exception") 39 | } 40 | } 41 | 42 | override fun getWorkManagerConfiguration(): Configuration { 43 | val myWorkerFactory = DelegatingWorkerFactory() 44 | myWorkerFactory.addFactory(MyWorkerFactory(weatherRepository)) 45 | // Add here other factories that you may need in this application 46 | 47 | return Configuration.Builder() 48 | .setMinimumLoggingLevel(Log.INFO) 49 | .setWorkerFactory(myWorkerFactory) 50 | .build() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import javax.inject.Inject 6 | import javax.inject.Provider 7 | import javax.inject.Singleton 8 | 9 | /** 10 | * Created by Mayokun Adeniyi on 20/07/2020. 11 | */ 12 | 13 | /** 14 | * Factory for all ViewModels. 15 | * reference : https://github.com/googlesamples/android-architecture-components 16 | */ 17 | @Singleton 18 | class ViewModelFactory @Inject constructor( 19 | private val viewModelMap: Map, @JvmSuppressWildcards Provider> 20 | ) : ViewModelProvider.Factory { 21 | 22 | @Suppress("UNCHECKED_CAST") 23 | override fun create(modelClass: Class): T { 24 | var viewModel = viewModelMap[modelClass] 25 | 26 | if (viewModel == null) { 27 | for (entry in viewModelMap) { 28 | if (modelClass.isAssignableFrom(entry.key)) { 29 | viewModel = entry.value 30 | break 31 | } 32 | } 33 | } 34 | 35 | if (viewModel == null) throw IllegalArgumentException("Unknown model class $modelClass") 36 | return viewModel.get() as T 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/City.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | /** 4 | * Created by Mayokun Adeniyi on 16/03/2020. 5 | */ 6 | 7 | data class City( 8 | val name: String, 9 | val country: String 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/LocationModel.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | /** 4 | * Created by Mayokun Adeniyi on 17/03/2020. 5 | */ 6 | 7 | data class LocationModel( 8 | val longitude: Double, 9 | val latitude: Double 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/NetworkWeather.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | /** 6 | * Created by Mayokun Adeniyi on 2020-01-25. 7 | */ 8 | 9 | data class NetworkWeather( 10 | 11 | val uId: Int, 12 | 13 | @SerializedName("id") 14 | val cityId: Int, 15 | 16 | val name: String, 17 | 18 | val wind: Wind, 19 | 20 | @SerializedName("weather") 21 | val networkWeatherDescriptions: List, 22 | 23 | @SerializedName("main") 24 | val networkWeatherCondition: NetworkWeatherCondition 25 | ) 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/NetworkWeatherCondition.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | @Parcelize 7 | data class NetworkWeatherCondition( 8 | var temp: Double, 9 | val pressure: Double, 10 | val humidity: Double 11 | ) : Parcelable 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/NetworkWeatherDescription.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | @Parcelize 7 | data class NetworkWeatherDescription( 8 | val id: Long, 9 | val main: String?, 10 | val description: String?, 11 | val icon: String? 12 | ) : Parcelable 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/NetworkWeatherForecast.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class NetworkWeatherForecast( 6 | 7 | val id: Int, 8 | 9 | @SerializedName("dt_txt") 10 | val date: String, 11 | 12 | val wind: Wind, 13 | 14 | @SerializedName("weather") 15 | val networkWeatherDescription: List, 16 | 17 | @SerializedName("main") 18 | val networkWeatherCondition: NetworkWeatherCondition 19 | ) 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/NetworkWeatherForecastResponse.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | /** 6 | * Created by Mayokun Adeniyi on 13/03/2020. 7 | */ 8 | 9 | data class NetworkWeatherForecastResponse( 10 | 11 | @SerializedName("list") 12 | val weathers: List, 13 | 14 | val city: City 15 | ) 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/SearchResult.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | /** 4 | * Created by Mayokun Adeniyi on 28/04/2020. 5 | */ 6 | 7 | data class SearchResult( 8 | val name: String, 9 | val country: String, 10 | val subcountry: String 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/Weather.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | /** 7 | * Created by Mayokun Adeniyi on 27/02/2020. 8 | */ 9 | 10 | @Parcelize 11 | data class Weather( 12 | val uId: Int, 13 | val cityId: Int, 14 | val name: String, 15 | val wind: Wind, 16 | val networkWeatherDescription: List, 17 | val networkWeatherCondition: NetworkWeatherCondition 18 | ) : Parcelable 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/WeatherForecast.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | /** 4 | * Created by Mayokun Adeniyi on 11/03/2020. 5 | */ 6 | 7 | data class WeatherForecast( 8 | val uID: Int, 9 | 10 | var date: String, 11 | 12 | val wind: Wind, 13 | 14 | val networkWeatherDescription: List, 15 | 16 | val networkWeatherCondition: NetworkWeatherCondition 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/model/Wind.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | /** 7 | * Created by Mayokun Adeniyi on 16/03/2020. 8 | */ 9 | 10 | @Parcelize 11 | data class Wind( 12 | val speed: Double, 13 | val deg: Int 14 | ) : Parcelable 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/local/WeatherDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.local 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.TypeConverters 6 | import com.mayokunadeniyi.instantweather.data.source.local.dao.WeatherDao 7 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeather 8 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeatherForecast 9 | import com.mayokunadeniyi.instantweather.utils.typeconverters.ListNetworkWeatherDescriptionConverter 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 2020-01-27. 13 | */ 14 | 15 | @Database(entities = [DBWeather::class, DBWeatherForecast::class], version = 1, exportSchema = true) 16 | @TypeConverters( 17 | ListNetworkWeatherDescriptionConverter::class 18 | ) 19 | abstract class WeatherDatabase : RoomDatabase() { 20 | 21 | abstract val weatherDao: WeatherDao 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/local/WeatherLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.local 2 | 3 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeather 4 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeatherForecast 5 | 6 | /** 7 | * Created by Mayokun Adeniyi on 13/07/2020. 8 | */ 9 | 10 | interface WeatherLocalDataSource { 11 | 12 | suspend fun getWeather(): DBWeather? 13 | 14 | suspend fun saveWeather(weather: DBWeather) 15 | 16 | suspend fun deleteWeather() 17 | 18 | suspend fun getForecastWeather(): List? 19 | 20 | suspend fun saveForecastWeather(weatherForecast: DBWeatherForecast) 21 | 22 | suspend fun deleteForecastWeather() 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/local/WeatherLocalDataSourceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.local 2 | 3 | import com.mayokunadeniyi.instantweather.data.source.local.dao.WeatherDao 4 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeather 5 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeatherForecast 6 | import com.mayokunadeniyi.instantweather.di.scope.IoDispatcher 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.withContext 9 | import javax.inject.Inject 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 13/07/2020. 13 | */ 14 | 15 | class WeatherLocalDataSourceImpl @Inject constructor( 16 | private val weatherDao: WeatherDao, 17 | @IoDispatcher private val ioDispatcher: CoroutineDispatcher 18 | ) : WeatherLocalDataSource { 19 | override suspend fun getWeather(): DBWeather = withContext(ioDispatcher) { 20 | return@withContext weatherDao.getWeather() 21 | } 22 | 23 | override suspend fun saveWeather(weather: DBWeather) = withContext(ioDispatcher) { 24 | weatherDao.insertWeather(weather) 25 | } 26 | 27 | override suspend fun deleteWeather() = withContext(ioDispatcher) { 28 | weatherDao.deleteAllWeather() 29 | } 30 | 31 | override suspend fun getForecastWeather(): List? = 32 | withContext(ioDispatcher) { 33 | return@withContext weatherDao.getAllWeatherForecast() 34 | } 35 | 36 | override suspend fun saveForecastWeather(weatherForecast: DBWeatherForecast) = 37 | withContext(ioDispatcher) { 38 | weatherDao.insertForecastWeather(weatherForecast) 39 | } 40 | 41 | override suspend fun deleteForecastWeather() = withContext(ioDispatcher) { 42 | weatherDao.deleteAllWeatherForecast() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/local/dao/WeatherDao.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.local.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeather 8 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeatherForecast 9 | 10 | /** 11 | * Created by Mayokun Adeniyi on 2020-01-27. 12 | */ 13 | 14 | @Dao 15 | interface WeatherDao { 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) 18 | suspend fun insertWeather(vararg dbWeather: DBWeather) 19 | 20 | @Query("SELECT * FROM weather_table ORDER BY unique_id DESC LIMIT 1") 21 | suspend fun getWeather(): DBWeather 22 | 23 | @Query("SELECT * FROM weather_table ORDER BY unique_id DESC") 24 | suspend fun getAllWeather(): List 25 | 26 | @Query("DELETE FROM weather_table") 27 | suspend fun deleteAllWeather() 28 | 29 | @Insert(onConflict = OnConflictStrategy.REPLACE) 30 | suspend fun insertForecastWeather(vararg dbWeatherForecast: DBWeatherForecast) 31 | 32 | @Query("SELECT * FROM weather_forecast ORDER BY id") 33 | suspend fun getAllWeatherForecast(): List 34 | 35 | @Query("DELETE FROM weather_forecast") 36 | suspend fun deleteAllWeatherForecast() 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/local/entity/DBWeather.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.local.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Embedded 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherCondition 8 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherDescription 9 | import com.mayokunadeniyi.instantweather.data.model.Wind 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 2020-01-27. 13 | */ 14 | 15 | // This class represents the Database DTO 16 | @Entity(tableName = "weather_table") 17 | data class DBWeather( 18 | 19 | @ColumnInfo(name = "unique_id") 20 | @PrimaryKey(autoGenerate = true) 21 | var uId: Int = 0, 22 | 23 | @ColumnInfo(name = "city_id") 24 | val cityId: Int, 25 | 26 | @ColumnInfo(name = "city_name") 27 | val cityName: String, 28 | 29 | @Embedded 30 | val wind: Wind, 31 | 32 | @ColumnInfo(name = "weather_details") 33 | val networkWeatherDescription: List, 34 | 35 | @Embedded 36 | val networkWeatherCondition: NetworkWeatherCondition 37 | ) 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/local/entity/DBWeatherForecast.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.local.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Embedded 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherCondition 8 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherDescription 9 | import com.mayokunadeniyi.instantweather.data.model.Wind 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 2020-01-28. 13 | */ 14 | 15 | @Entity(tableName = "weather_forecast") 16 | class DBWeatherForecast( 17 | 18 | @PrimaryKey(autoGenerate = true) 19 | val id: Int = 0, 20 | 21 | val date: String, 22 | 23 | @Embedded 24 | val wind: Wind, 25 | 26 | @ColumnInfo(name = "weather_description") 27 | val networkWeatherDescriptions: List, 28 | 29 | @Embedded 30 | val networkWeatherCondition: NetworkWeatherCondition 31 | ) 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/remote/WeatherRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.remote 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.LocationModel 4 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeather 5 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherForecast 6 | import com.mayokunadeniyi.instantweather.utils.Result 7 | 8 | /** 9 | * Created by Mayokun Adeniyi on 13/07/2020. 10 | */ 11 | 12 | interface WeatherRemoteDataSource { 13 | suspend fun getWeather(location: LocationModel): Result 14 | 15 | suspend fun getWeatherForecast(cityId: Int): Result> 16 | 17 | suspend fun getSearchWeather(query: String): Result 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/remote/WeatherRemoteDataSourceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.remote 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.LocationModel 4 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeather 5 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherForecast 6 | import com.mayokunadeniyi.instantweather.data.source.remote.retrofit.WeatherApiService 7 | import com.mayokunadeniyi.instantweather.di.scope.IoDispatcher 8 | import com.mayokunadeniyi.instantweather.utils.Result 9 | import kotlinx.coroutines.CoroutineDispatcher 10 | import kotlinx.coroutines.withContext 11 | import javax.inject.Inject 12 | 13 | /** 14 | * Created by Mayokun Adeniyi on 13/07/2020. 15 | */ 16 | 17 | class WeatherRemoteDataSourceImpl @Inject constructor( 18 | @IoDispatcher private val ioDispatcher: CoroutineDispatcher, 19 | private val apiService: WeatherApiService 20 | ) : WeatherRemoteDataSource { 21 | override suspend fun getWeather(location: LocationModel): Result = 22 | withContext(ioDispatcher) { 23 | return@withContext try { 24 | val result = apiService.getCurrentWeather( 25 | location.latitude, location.longitude 26 | ) 27 | if (result.isSuccessful) { 28 | val networkWeather = result.body() 29 | Result.Success(networkWeather) 30 | } else { 31 | Result.Success(null) 32 | } 33 | } catch (exception: Exception) { 34 | Result.Error(exception) 35 | } 36 | } 37 | 38 | override suspend fun getWeatherForecast(cityId: Int): Result> = 39 | withContext(ioDispatcher) { 40 | return@withContext try { 41 | val result = apiService.getWeatherForecast(cityId) 42 | if (result.isSuccessful) { 43 | val networkWeatherForecast = result.body()?.weathers 44 | Result.Success(networkWeatherForecast) 45 | } else { 46 | Result.Success(null) 47 | } 48 | } catch (exception: Exception) { 49 | Result.Error(exception) 50 | } 51 | } 52 | 53 | override suspend fun getSearchWeather(query: String): Result = 54 | withContext(ioDispatcher) { 55 | return@withContext try { 56 | val result = apiService.getSpecificWeather(query) 57 | if (result.isSuccessful) { 58 | val networkWeather = result.body() 59 | Result.Success(networkWeather) 60 | } else { 61 | Result.Success(null) 62 | } 63 | } catch (exception: Exception) { 64 | Result.Error(exception) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/remote/retrofit/WeatherApiService.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.remote.retrofit 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeather 4 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherForecastResponse 5 | import retrofit2.Response 6 | import retrofit2.http.GET 7 | import retrofit2.http.Query 8 | 9 | /** 10 | * Created by Mayokun Adeniyi on 23/05/2020. 11 | */ 12 | interface WeatherApiService { 13 | 14 | /** 15 | * This function gets the [NetworkWeather] for the [location] the 16 | * user searched for. 17 | */ 18 | @GET("/data/2.5/weather") 19 | suspend fun getSpecificWeather( 20 | @Query("q") location: String 21 | ): Response 22 | 23 | // This function gets the weather information for the user's location. 24 | @GET("/data/2.5/weather") 25 | suspend fun getCurrentWeather( 26 | @Query("lat") latitude: Double, 27 | @Query("lon") longitude: Double 28 | ): Response 29 | 30 | // This function gets the weather forecast information for the user's location. 31 | @GET("data/2.5/forecast") 32 | suspend fun getWeatherForecast( 33 | @Query("id") cityId: Int 34 | ): Response 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/data/source/repository/WeatherRepository.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.data.source.repository 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.LocationModel 4 | import com.mayokunadeniyi.instantweather.data.model.Weather 5 | import com.mayokunadeniyi.instantweather.data.model.WeatherForecast 6 | import com.mayokunadeniyi.instantweather.utils.Result 7 | 8 | /** 9 | * Created by Mayokun Adeniyi on 13/07/2020. 10 | */ 11 | interface WeatherRepository { 12 | 13 | suspend fun getWeather(location: LocationModel, refresh: Boolean): Result 14 | 15 | suspend fun getForecastWeather(cityId: Int, refresh: Boolean): Result?> 16 | 17 | suspend fun getSearchWeather(location: String): Result 18 | 19 | suspend fun storeWeatherData(weather: Weather) 20 | 21 | suspend fun storeForecastData(forecasts: List) 22 | 23 | suspend fun deleteWeatherData() 24 | 25 | suspend fun deleteForecastData() 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/key/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.key 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | /** 8 | * Created by Mayokun Adeniyi on 02/02/2021. 9 | */ 10 | 11 | @MustBeDocumented 12 | @MapKey 13 | @Retention(AnnotationRetention.RUNTIME) 14 | @Target( 15 | AnnotationTarget.FUNCTION, 16 | AnnotationTarget.PROPERTY_GETTER, 17 | AnnotationTarget.PROPERTY_SETTER 18 | ) 19 | internal annotation class ViewModelKey(val value: KClass) 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/module/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.module 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import com.google.gson.Gson 6 | import com.mayokunadeniyi.instantweather.BuildConfig 7 | import com.mayokunadeniyi.instantweather.utils.LocationLiveData 8 | import com.mayokunadeniyi.instantweather.utils.SharedPreferenceHelper 9 | import com.readystatesoftware.chuck.ChuckInterceptor 10 | import dagger.Lazy 11 | import dagger.Module 12 | import dagger.Provides 13 | import dagger.hilt.InstallIn 14 | import dagger.hilt.components.SingletonComponent 15 | import okhttp3.OkHttpClient 16 | import okhttp3.logging.HttpLoggingInterceptor 17 | import retrofit2.Retrofit 18 | import retrofit2.converter.gson.GsonConverterFactory 19 | import timber.log.Timber 20 | import java.util.concurrent.TimeUnit 21 | import javax.inject.Singleton 22 | 23 | /** 24 | * Created by Mayokun Adeniyi on 1/18/21. 25 | */ 26 | 27 | @InstallIn(SingletonComponent::class) 28 | @Module 29 | class AppModule { 30 | 31 | @Provides 32 | @Singleton 33 | fun provideContext(application: Application): Context { 34 | return application.applicationContext 35 | } 36 | 37 | @Provides 38 | @Singleton 39 | fun provideSharedPreferencesHelper(context: Context): SharedPreferenceHelper { 40 | return SharedPreferenceHelper.getInstance(context) 41 | } 42 | 43 | @Provides 44 | @Singleton 45 | fun provideLocationLiveData(context: Context): LocationLiveData { 46 | return LocationLiveData(context) 47 | } 48 | 49 | @Provides 50 | @Singleton 51 | fun provideOkHttpClient(interceptor: HttpLoggingInterceptor): OkHttpClient { 52 | return OkHttpClient.Builder().addInterceptor(interceptor).build() 53 | } 54 | 55 | @Provides 56 | @Singleton 57 | fun provideLoggingInterceptor(): HttpLoggingInterceptor { 58 | return HttpLoggingInterceptor().apply { 59 | level = if (BuildConfig.DEBUG) { 60 | HttpLoggingInterceptor.Level.BODY 61 | } else { 62 | HttpLoggingInterceptor.Level.NONE 63 | } 64 | } 65 | } 66 | 67 | @Provides 68 | @Singleton 69 | fun provideGson(): Gson = Gson() 70 | 71 | @Provides 72 | @Singleton 73 | fun provideGsonConverterFactory(gson: Gson): GsonConverterFactory { 74 | return GsonConverterFactory.create(gson) 75 | } 76 | 77 | @Provides 78 | @Singleton 79 | fun provideRetrofitBuilder( 80 | client: Lazy, 81 | converterFactory: GsonConverterFactory, 82 | context: Context 83 | ): Retrofit { 84 | val retrofitBuilder = Retrofit.Builder() 85 | .baseUrl(BuildConfig.BASE_URL) 86 | .client(client.get()) 87 | .addConverterFactory(converterFactory) 88 | 89 | val okHttpClientBuilder = OkHttpClient.Builder() 90 | .addInterceptor { chain -> 91 | 92 | val original = chain.request() 93 | val originalHttpUrl = original.url 94 | 95 | val url = originalHttpUrl.newBuilder() 96 | .addQueryParameter("appid", BuildConfig.API_KEY) 97 | .build() 98 | 99 | Timber.d("Started making network call") 100 | 101 | val requestBuilder = original.newBuilder() 102 | .url(url) 103 | 104 | val request = requestBuilder.build() 105 | return@addInterceptor chain.proceed(request) 106 | } 107 | .readTimeout(60, TimeUnit.SECONDS) 108 | if (BuildConfig.DEBUG) { 109 | okHttpClientBuilder.addInterceptor(ChuckInterceptor(context)) 110 | } 111 | return retrofitBuilder.client(okHttpClientBuilder.build()).build() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/module/DataSourcesModule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.module 2 | 3 | import com.mayokunadeniyi.instantweather.data.source.local.WeatherLocalDataSource 4 | import com.mayokunadeniyi.instantweather.data.source.local.WeatherLocalDataSourceImpl 5 | import com.mayokunadeniyi.instantweather.data.source.remote.WeatherRemoteDataSource 6 | import com.mayokunadeniyi.instantweather.data.source.remote.WeatherRemoteDataSourceImpl 7 | import dagger.Binds 8 | import dagger.Module 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | 12 | /** 13 | * Created by Mayokun Adeniyi on 02/02/2021. 14 | */ 15 | 16 | @InstallIn(SingletonComponent::class) 17 | @Module 18 | abstract class DataSourcesModule { 19 | 20 | @Binds 21 | abstract fun bindLocalDataSource(localDataSourceImpl: WeatherLocalDataSourceImpl): WeatherLocalDataSource 22 | 23 | @Binds 24 | abstract fun bindRemoteDataSource(remoteDataSourceImpl: WeatherRemoteDataSourceImpl): WeatherRemoteDataSource 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/module/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.module 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.mayokunadeniyi.instantweather.data.source.local.WeatherDatabase 6 | import com.mayokunadeniyi.instantweather.data.source.local.dao.WeatherDao 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import javax.inject.Singleton 12 | 13 | /** 14 | * Created by Mayokun Adeniyi on 02/02/2021. 15 | */ 16 | 17 | @InstallIn(SingletonComponent::class) 18 | @Module 19 | class DatabaseModule { 20 | 21 | @Singleton 22 | @Provides 23 | fun provideDatabase(context: Context): WeatherDatabase { 24 | return Room.databaseBuilder( 25 | context, 26 | WeatherDatabase::class.java, "InstantWeather.db" 27 | ).build() 28 | } 29 | 30 | @Singleton 31 | @Provides 32 | fun provideWeatherDao(database: WeatherDatabase): WeatherDao { 33 | return database.weatherDao 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/module/DispatcherModule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.module 2 | 3 | import com.mayokunadeniyi.instantweather.di.scope.DefaultDispatcher 4 | import com.mayokunadeniyi.instantweather.di.scope.IoDispatcher 5 | import com.mayokunadeniyi.instantweather.di.scope.MainDispatcher 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | import kotlinx.coroutines.CoroutineDispatcher 11 | import kotlinx.coroutines.Dispatchers 12 | 13 | /** 14 | * Created by Mayokun Adeniyi on 02/02/2021. 15 | */ 16 | 17 | @InstallIn(SingletonComponent::class) 18 | @Module 19 | object DispatcherModule { 20 | 21 | @Provides 22 | @DefaultDispatcher 23 | fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default 24 | 25 | @Provides 26 | @IoDispatcher 27 | fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO 28 | 29 | @Provides 30 | @MainDispatcher 31 | fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/module/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.module 2 | 3 | import com.mayokunadeniyi.instantweather.data.source.remote.retrofit.WeatherApiService 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import retrofit2.Retrofit 9 | import javax.inject.Singleton 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 02/02/2021. 13 | */ 14 | 15 | @InstallIn(SingletonComponent::class) 16 | @Module 17 | class NetworkModule { 18 | 19 | @Provides 20 | @Singleton 21 | fun provideInstantWeatherApiService(retrofit: Retrofit): WeatherApiService { 22 | return retrofit.create(WeatherApiService::class.java) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/module/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.module 2 | 3 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepository 4 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepositoryImpl 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | 10 | /** 11 | * Created by Mayokun Adeniyi on 02/02/2021. 12 | */ 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | abstract class RepositoryModule { 17 | 18 | @Binds 19 | abstract fun bindRepository(weatherRepositoryImpl: WeatherRepositoryImpl): WeatherRepository 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/module/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.module 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.mayokunadeniyi.instantweather.ViewModelFactory 6 | import com.mayokunadeniyi.instantweather.di.key.ViewModelKey 7 | import com.mayokunadeniyi.instantweather.ui.forecast.ForecastFragmentViewModel 8 | import com.mayokunadeniyi.instantweather.ui.home.HomeFragmentViewModel 9 | import com.mayokunadeniyi.instantweather.ui.search.SearchFragmentViewModel 10 | import dagger.Binds 11 | import dagger.Module 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.components.SingletonComponent 14 | import dagger.multibindings.IntoMap 15 | 16 | /** 17 | * Created by Mayokun Adeniyi on 02/02/2021. 18 | */ 19 | 20 | @InstallIn(SingletonComponent::class) 21 | @Module 22 | abstract class ViewModelModule { 23 | 24 | @Binds 25 | abstract fun bindViewModelFactory(viewModelFactory: ViewModelFactory): ViewModelProvider.Factory 26 | 27 | @IntoMap 28 | @Binds 29 | @ViewModelKey(HomeFragmentViewModel::class) 30 | abstract fun bindHomeFragmentViewModel(viewModel: HomeFragmentViewModel): ViewModel 31 | 32 | @IntoMap 33 | @Binds 34 | @ViewModelKey(ForecastFragmentViewModel::class) 35 | abstract fun bindForecastFragmentViewModel(viewModel: ForecastFragmentViewModel): ViewModel 36 | 37 | @IntoMap 38 | @Binds 39 | @ViewModelKey(SearchFragmentViewModel::class) 40 | abstract fun bindSearchFragmentViewModel(viewModel: SearchFragmentViewModel): ViewModel 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/di/scope/DispatcherScopes.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.di.scope 2 | 3 | import javax.inject.Qualifier 4 | 5 | /** 6 | * Created by Mayokun Adeniyi on 02/02/2021. 7 | */ 8 | 9 | @Retention(AnnotationRetention.BINARY) 10 | @Qualifier 11 | annotation class DefaultDispatcher 12 | 13 | @Retention(AnnotationRetention.BINARY) 14 | @Qualifier 15 | annotation class IoDispatcher 16 | 17 | @Retention(AnnotationRetention.BINARY) 18 | @Qualifier 19 | annotation class MainDispatcher 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/initializers/DeferredComponentInitializer.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.initializers 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.os.Build 6 | import android.util.Log 7 | import androidx.lifecycle.Lifecycle 8 | import androidx.lifecycle.ProcessLifecycleOwner 9 | import androidx.work.ExistingWorkPolicy 10 | import androidx.work.OneTimeWorkRequestBuilder 11 | import androidx.work.WorkManager 12 | import androidx.work.Worker 13 | import androidx.work.WorkerParameters 14 | import androidx.work.workDataOf 15 | 16 | class DeferredComponentInitializer(private val app: Application) { 17 | 18 | fun initialize() { 19 | val componentInitializer = ComponentInitializer(app) 20 | val initializeMessage = when { 21 | isColdStart() -> "Deferred initialization from cold start" 22 | else -> "Deferred initialization from warm start" 23 | } 24 | WorkManager.getInstance(app) 25 | .beginUniqueWork( 26 | "DeferredInitialization", 27 | ExistingWorkPolicy.KEEP, 28 | OneTimeWorkRequestBuilder() 29 | .setInputData(workDataOf(DeferredWorker.KEY_INITIALIZE to initializeMessage)) 30 | .build() 31 | ) 32 | .enqueue() 33 | } 34 | 35 | private fun isColdStart(): Boolean { 36 | return ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.INITIALIZED 37 | } 38 | 39 | private class DeferredWorker( 40 | context: Context, 41 | workerParams: WorkerParameters 42 | ) : Worker(context, workerParams) { 43 | override fun doWork(): Result { 44 | val initializeMessage = inputData.getString(KEY_INITIALIZE) 45 | initializeMessage?.let { 46 | Log.d("DeferredInit", it) 47 | } 48 | ComponentInitializer(applicationContext).initialize() 49 | return Result.success() 50 | } 51 | 52 | companion object { 53 | const val KEY_INITIALIZE = "KEY_INITIALIZE" 54 | } 55 | } 56 | 57 | private class ComponentInitializer(private val app: Application) { 58 | fun initialize() { 59 | initCrashlytics() 60 | initAnalytics() 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/mapper/BaseMapper.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.mapper 2 | 3 | /** 4 | * Created by Mayokun Adeniyi on 10/03/2020. 5 | */ 6 | 7 | interface BaseMapper { 8 | 9 | fun transformToDomain(type: E): D 10 | 11 | fun transformToDto(type: D): E 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/mapper/WeatherForecastMapperLocal.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.mapper 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.WeatherForecast 4 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeatherForecast 5 | 6 | /** 7 | * Created by Mayokun Adeniyi on 15/03/2020. 8 | */ 9 | 10 | class WeatherForecastMapperLocal : 11 | BaseMapper, List> { 12 | override fun transformToDomain(type: List): List { 13 | return type.map { dbWeatherForecast -> 14 | WeatherForecast( 15 | dbWeatherForecast.id, 16 | dbWeatherForecast.date, 17 | dbWeatherForecast.wind, 18 | dbWeatherForecast.networkWeatherDescriptions, 19 | dbWeatherForecast.networkWeatherCondition 20 | ) 21 | } 22 | } 23 | 24 | override fun transformToDto(type: List): List { 25 | return type.map { weatherForecast -> 26 | DBWeatherForecast( 27 | weatherForecast.uID, 28 | weatherForecast.date, 29 | weatherForecast.wind, 30 | weatherForecast.networkWeatherDescription, 31 | weatherForecast.networkWeatherCondition 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/mapper/WeatherForecastMapperRemote.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.mapper 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherForecast 4 | import com.mayokunadeniyi.instantweather.data.model.WeatherForecast 5 | 6 | /** 7 | * Created by Mayokun Adeniyi on 15/03/2020. 8 | */ 9 | 10 | class WeatherForecastMapperRemote : 11 | BaseMapper, List> { 12 | override fun transformToDomain(type: List): List { 13 | return type.map { networkWeatherForecast -> 14 | WeatherForecast( 15 | networkWeatherForecast.id, 16 | networkWeatherForecast.date, 17 | networkWeatherForecast.wind, 18 | networkWeatherForecast.networkWeatherDescription, 19 | networkWeatherForecast.networkWeatherCondition 20 | ) 21 | } 22 | } 23 | 24 | override fun transformToDto(type: List): List { 25 | return type.map { weatherForecast -> 26 | NetworkWeatherForecast( 27 | weatherForecast.uID, 28 | weatherForecast.date, 29 | weatherForecast.wind, 30 | weatherForecast.networkWeatherDescription, 31 | weatherForecast.networkWeatherCondition 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/mapper/WeatherMapperLocal.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.mapper 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.Weather 4 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeather 5 | 6 | /** 7 | * Created by Mayokun Adeniyi on 10/03/2020. 8 | */ 9 | 10 | class WeatherMapperLocal : BaseMapper { 11 | override fun transformToDomain(type: DBWeather): Weather = Weather( 12 | uId = type.uId, 13 | cityId = type.cityId, 14 | name = type.cityName, 15 | wind = type.wind, 16 | networkWeatherDescription = type.networkWeatherDescription, 17 | networkWeatherCondition = type.networkWeatherCondition 18 | ) 19 | 20 | override fun transformToDto(type: Weather): DBWeather = DBWeather( 21 | uId = type.uId, 22 | cityId = type.cityId, 23 | cityName = type.name, 24 | wind = type.wind, 25 | networkWeatherDescription = type.networkWeatherDescription, 26 | networkWeatherCondition = type.networkWeatherCondition 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/mapper/WeatherMapperRemote.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.mapper 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeather 4 | import com.mayokunadeniyi.instantweather.data.model.Weather 5 | 6 | /** 7 | * Created by Mayokun Adeniyi on 10/03/2020. 8 | */ 9 | 10 | class WeatherMapperRemote : BaseMapper { 11 | override fun transformToDomain(type: NetworkWeather): Weather = Weather( 12 | uId = type.uId, 13 | cityId = type.cityId, 14 | name = type.name, 15 | wind = type.wind, 16 | networkWeatherDescription = type.networkWeatherDescriptions, 17 | networkWeatherCondition = type.networkWeatherCondition 18 | ) 19 | 20 | override fun transformToDto(type: Weather): NetworkWeather = NetworkWeather( 21 | uId = type.uId, 22 | cityId = type.cityId, 23 | name = type.name, 24 | wind = type.wind, 25 | networkWeatherDescriptions = type.networkWeatherDescription, 26 | networkWeatherCondition = type.networkWeatherCondition 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ui/BaseFragment.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.google.android.material.snackbar.Snackbar 6 | import javax.inject.Inject 7 | 8 | /** 9 | * Created by Mayokun Adeniyi on 02/02/2021. 10 | */ 11 | 12 | abstract class BaseFragment : Fragment() { 13 | 14 | @Inject 15 | lateinit var viewModelFactoryProvider: ViewModelProvider.Factory 16 | 17 | fun showShortSnackBar(message: String) { 18 | Snackbar.make(requireView(), message, Snackbar.LENGTH_SHORT).show() 19 | } 20 | 21 | fun showLongSnackBar(message: String) { 22 | Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.databinding.DataBindingUtil 6 | import androidx.navigation.findNavController 7 | import androidx.navigation.ui.setupActionBarWithNavController 8 | import androidx.navigation.ui.setupWithNavController 9 | import com.mayokunadeniyi.instantweather.R 10 | import com.mayokunadeniyi.instantweather.databinding.ActivityMainBinding 11 | import dagger.hilt.android.AndroidEntryPoint 12 | 13 | @AndroidEntryPoint 14 | class MainActivity : AppCompatActivity() { 15 | 16 | private lateinit var binding: ActivityMainBinding 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | setTheme(R.style.AppTheme) 20 | super.onCreate(savedInstanceState) 21 | binding = DataBindingUtil.setContentView(this, R.layout.activity_main) 22 | setupNavigation() 23 | } 24 | 25 | private fun setupNavigation() { 26 | val navController = findNavController(R.id.mainNavFragment) 27 | setupActionBarWithNavController(navController) 28 | binding.bottomNavBar.setupWithNavController(navController) 29 | } 30 | 31 | override fun onSupportNavigateUp() = findNavController(R.id.mainNavFragment).navigateUp() 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ui/forecast/ForecastFragment.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui.forecast 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.view.isVisible 8 | import androidx.fragment.app.viewModels 9 | import com.mayokunadeniyi.instantweather.R 10 | import com.mayokunadeniyi.instantweather.databinding.FragmentForecastBinding 11 | import com.mayokunadeniyi.instantweather.ui.BaseFragment 12 | import com.mayokunadeniyi.instantweather.ui.forecast.WeatherForecastAdapter.ForecastOnClickListener 13 | import com.mayokunadeniyi.instantweather.utils.SharedPreferenceHelper 14 | import com.mayokunadeniyi.instantweather.utils.convertCelsiusToFahrenheit 15 | import com.shrikanthravi.collapsiblecalendarview.widget.CollapsibleCalendar 16 | import dagger.hilt.android.AndroidEntryPoint 17 | import timber.log.Timber 18 | import javax.inject.Inject 19 | 20 | @AndroidEntryPoint 21 | class ForecastFragment : BaseFragment(), ForecastOnClickListener { 22 | private lateinit var binding: FragmentForecastBinding 23 | 24 | private val viewModel by viewModels { viewModelFactoryProvider } 25 | 26 | private val weatherForecastAdapter by lazy { WeatherForecastAdapter(this) } 27 | 28 | @Inject 29 | lateinit var prefs: SharedPreferenceHelper 30 | 31 | override fun onCreateView( 32 | inflater: LayoutInflater, 33 | container: ViewGroup?, 34 | savedInstanceState: Bundle? 35 | ): View? { 36 | binding = FragmentForecastBinding.inflate(layoutInflater) 37 | return binding.root 38 | } 39 | 40 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 41 | super.onViewCreated(view, savedInstanceState) 42 | 43 | setupCalendar() 44 | binding.forecastRecyclerview.adapter = weatherForecastAdapter 45 | viewModel.getWeatherForecast(prefs.getCityId()) 46 | observeMoreViewModels() 47 | } 48 | 49 | private fun observeMoreViewModels() { 50 | with(viewModel) { 51 | forecast.observe(viewLifecycleOwner) { weatherForecast -> 52 | weatherForecast?.let { list -> 53 | weatherForecast.forEach { 54 | if (prefs.getSelectedTemperatureUnit() == requireActivity().resources.getString(R.string.temp_unit_fahrenheit)) 55 | it.networkWeatherCondition.temp = convertCelsiusToFahrenheit(it.networkWeatherCondition.temp) 56 | } 57 | weatherForecastAdapter.submitList(list) 58 | } 59 | } 60 | 61 | dataFetchState.observe(viewLifecycleOwner) { state -> 62 | binding.apply { 63 | forecastRecyclerview.isVisible = state 64 | forecastErrorText?.isVisible = !state 65 | } 66 | } 67 | 68 | isLoading.observe(viewLifecycleOwner) { state -> 69 | binding.forecastProgressBar.isVisible = state 70 | } 71 | 72 | filteredForecast.observe(viewLifecycleOwner) { 73 | weatherForecastAdapter.submitList(it) 74 | binding.emptyListText.isVisible = it.isEmpty() 75 | } 76 | } 77 | 78 | binding.forecastSwipeRefresh.setOnRefreshListener { 79 | initiateRefresh() 80 | } 81 | } 82 | 83 | private fun initiateRefresh() { 84 | binding.forecastErrorText?.visibility = View.GONE 85 | binding.forecastProgressBar.visibility = View.VISIBLE 86 | binding.forecastRecyclerview.visibility = View.GONE 87 | viewModel.refreshForecastData(prefs.getCityId()) 88 | binding.forecastSwipeRefresh.isRefreshing = false 89 | } 90 | 91 | private fun setupCalendar() { 92 | binding.calendarView.setCalendarListener(object : CollapsibleCalendar.CalendarListener { 93 | override fun onClickListener() { 94 | } 95 | 96 | override fun onDataUpdate() { 97 | } 98 | 99 | override fun onDayChanged() { 100 | } 101 | 102 | override fun onDaySelect() { 103 | binding.emptyListText.visibility = View.GONE 104 | runCatching { 105 | val selectedDay = binding.calendarView.selectedDay 106 | val list = viewModel.forecast.value 107 | viewModel.updateWeatherForecast(requireNotNull(selectedDay), requireNotNull(list)) 108 | }.onFailure { 109 | Timber.d(it) 110 | } 111 | } 112 | 113 | override fun onItemClick(v: View) { 114 | } 115 | 116 | override fun onMonthChange() { 117 | } 118 | 119 | override fun onWeekChange(position: Int) { 120 | } 121 | }) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ui/forecast/WeatherForecastAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui.forecast 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.DiffUtil 6 | import androidx.recyclerview.widget.ListAdapter 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.mayokunadeniyi.instantweather.data.model.WeatherForecast 9 | import com.mayokunadeniyi.instantweather.databinding.WeatherItemBinding 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 15/03/2020. 13 | */ 14 | 15 | class WeatherForecastAdapter(private val clickListener: ForecastOnClickListener) : ListAdapter(WeatherForecastDiffCallBack()) { 16 | 17 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 18 | return ViewHolder.from(parent) 19 | } 20 | 21 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 22 | val weatherForecast = getItem(position) 23 | holder.bind(weatherForecast) 24 | holder.itemView.setOnClickListener { 25 | clickListener.onClick() 26 | } 27 | } 28 | 29 | class ViewHolder(private val binding: WeatherItemBinding) : RecyclerView.ViewHolder(binding.root) { 30 | 31 | // Binds the WeatherForecast in the layout 32 | fun bind(weatherForecast: WeatherForecast) { 33 | binding.weatherForecast = weatherForecast 34 | val weatherDescription = weatherForecast.networkWeatherDescription.first() 35 | binding.weatherForecastDescription = weatherDescription 36 | binding.executePendingBindings() 37 | } 38 | companion object { 39 | fun from(parent: ViewGroup): ViewHolder { 40 | val layoutInflater = LayoutInflater.from(parent.context) 41 | val binding = WeatherItemBinding.inflate(layoutInflater, parent, false) 42 | return ViewHolder(binding) 43 | } 44 | } 45 | } 46 | 47 | /** 48 | * A utility class [DiffUtil] that helps to calculate updates for a [RecyclerView] Adapter. 49 | */ 50 | class WeatherForecastDiffCallBack : DiffUtil.ItemCallback() { 51 | override fun areItemsTheSame(oldItem: WeatherForecast, newItem: WeatherForecast): Boolean { 52 | return oldItem.uID == newItem.uID 53 | } 54 | 55 | override fun areContentsTheSame( 56 | oldItem: WeatherForecast, 57 | newItem: WeatherForecast 58 | ): Boolean { 59 | return oldItem == newItem 60 | } 61 | } 62 | 63 | interface ForecastOnClickListener { 64 | fun onClick() {} 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ui/home/HomeFragmentViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui.home 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.mayokunadeniyi.instantweather.data.model.LocationModel 8 | import com.mayokunadeniyi.instantweather.data.model.Weather 9 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepository 10 | import com.mayokunadeniyi.instantweather.utils.LocationLiveData 11 | import com.mayokunadeniyi.instantweather.utils.Result 12 | import com.mayokunadeniyi.instantweather.utils.asLiveData 13 | import com.mayokunadeniyi.instantweather.utils.convertKelvinToCelsius 14 | import kotlinx.coroutines.launch 15 | import java.text.SimpleDateFormat 16 | import java.util.Date 17 | import javax.inject.Inject 18 | 19 | /** 20 | * Created by Mayokun Adeniyi on 2020-01-25. 21 | */ 22 | class HomeFragmentViewModel @Inject constructor( 23 | private val repository: WeatherRepository 24 | ) : 25 | ViewModel() { 26 | 27 | @Inject 28 | lateinit var locationLiveData: LocationLiveData 29 | 30 | init { 31 | currentSystemTime() 32 | } 33 | 34 | private val _isLoading = MutableLiveData() 35 | val isLoading = _isLoading.asLiveData() 36 | 37 | private val _dataFetchState = MutableLiveData() 38 | val dataFetchState = _dataFetchState.asLiveData() 39 | 40 | private val _weather = MutableLiveData() 41 | val weather = _weather.asLiveData() 42 | 43 | val time = currentSystemTime() 44 | 45 | fun fetchLocationLiveData() = locationLiveData 46 | 47 | /** 48 | *This attempts to get the [Weather] from the local data source, 49 | * if the result is null, it gets from the remote source. 50 | * @see refreshWeather 51 | */ 52 | fun getWeather(location: LocationModel) { 53 | _isLoading.postValue(true) 54 | viewModelScope.launch { 55 | when (val result = repository.getWeather(location, false)) { 56 | is Result.Success -> { 57 | _isLoading.value = false 58 | if (result.data != null) { 59 | val weather = result.data 60 | _dataFetchState.value = true 61 | _weather.value = weather 62 | } else { 63 | refreshWeather(location) 64 | } 65 | } 66 | is Result.Error -> { 67 | _isLoading.value = false 68 | _dataFetchState.value = false 69 | } 70 | 71 | is Result.Loading -> _isLoading.postValue(true) 72 | } 73 | } 74 | } 75 | 76 | @SuppressLint("SimpleDateFormat") 77 | fun currentSystemTime(): String { 78 | val currentTime = System.currentTimeMillis() 79 | val date = Date(currentTime) 80 | val dateFormat = SimpleDateFormat("EEEE MMM d, hh:mm aaa") 81 | return dateFormat.format(date) 82 | } 83 | 84 | /** 85 | * This is called when the user swipes down to refresh. 86 | * This enables the [Weather] for the current [location] to be received. 87 | */ 88 | fun refreshWeather(location: LocationModel) { 89 | _isLoading.value = true 90 | viewModelScope.launch { 91 | when (val result = repository.getWeather(location, true)) { 92 | is Result.Success -> { 93 | _isLoading.value = false 94 | if (result.data != null) { 95 | val weather = result.data.apply { 96 | this.networkWeatherCondition.temp = convertKelvinToCelsius(this.networkWeatherCondition.temp) 97 | } 98 | _dataFetchState.value = true 99 | _weather.value = weather 100 | 101 | repository.deleteWeatherData() 102 | repository.storeWeatherData(weather) 103 | } else { 104 | _weather.postValue(null) 105 | _dataFetchState.postValue(false) 106 | } 107 | } 108 | is Result.Error -> { 109 | _isLoading.value = false 110 | _dataFetchState.value = false 111 | } 112 | is Result.Loading -> _isLoading.postValue(true) 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ui/search/SearchFragmentViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui.search 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import androidx.paging.LivePagedListBuilder 8 | import androidx.paging.PagedList 9 | import com.algolia.instantsearch.core.connection.ConnectionHandler 10 | import com.algolia.instantsearch.helper.android.list.SearcherSingleIndexDataSource 11 | import com.algolia.instantsearch.helper.android.searchbox.SearchBoxConnectorPagedList 12 | import com.algolia.instantsearch.helper.searcher.SearcherSingleIndex 13 | import com.algolia.instantsearch.helper.stats.StatsConnector 14 | import com.algolia.search.client.ClientSearch 15 | import com.algolia.search.model.APIKey 16 | import com.algolia.search.model.ApplicationID 17 | import com.algolia.search.model.IndexName 18 | import com.mayokunadeniyi.instantweather.BuildConfig 19 | import com.mayokunadeniyi.instantweather.data.model.SearchResult 20 | import com.mayokunadeniyi.instantweather.data.model.Weather 21 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepository 22 | import com.mayokunadeniyi.instantweather.utils.Result 23 | import com.mayokunadeniyi.instantweather.utils.asLiveData 24 | import kotlinx.coroutines.launch 25 | import kotlinx.serialization.json.jsonPrimitive 26 | import timber.log.Timber 27 | import javax.inject.Inject 28 | 29 | /** 30 | * Created by Mayokun Adeniyi on 27/04/2020. 31 | */ 32 | 33 | class SearchFragmentViewModel @Inject constructor(private val repository: WeatherRepository) : 34 | ViewModel() { 35 | 36 | private val applicationID = BuildConfig.ALGOLIA_APP_ID 37 | private val algoliaAPIKey = BuildConfig.ALGOLIA_API_KEY 38 | private val algoliaIndexName = BuildConfig.ALGOLIA_INDEX_NAME 39 | private val client = ClientSearch( 40 | ApplicationID(applicationID), 41 | APIKey(algoliaAPIKey) 42 | ) 43 | private val index = client.initIndex(IndexName(algoliaIndexName)) 44 | private val searcher = SearcherSingleIndex(index) 45 | 46 | private val dataSourceFactory = SearcherSingleIndexDataSource.Factory(searcher) { hit -> 47 | SearchResult( 48 | name = hit["name"]?.jsonPrimitive?.content ?: "", 49 | subcountry = hit["subcountry"]?.jsonPrimitive?.content ?: "", 50 | country = hit["country"]?.jsonPrimitive?.content ?: "" 51 | ) 52 | } 53 | 54 | private val pagedListConfig = PagedList.Config.Builder().setPageSize(50).build() 55 | val locations: LiveData> = 56 | LivePagedListBuilder(dataSourceFactory, pagedListConfig).build() 57 | 58 | val searchBox = SearchBoxConnectorPagedList(searcher, listOf(locations)) 59 | val stats = StatsConnector(searcher) 60 | private val connection = ConnectionHandler() 61 | 62 | init { 63 | connection += searchBox 64 | connection += stats 65 | } 66 | 67 | private val _weatherInfo = MutableLiveData() 68 | val weatherInfo = _weatherInfo.asLiveData() 69 | 70 | private val _isLoading = MutableLiveData() 71 | val isLoading = _isLoading.asLiveData() 72 | 73 | private val _dataFetchState = MutableLiveData() 74 | val dataFetchState = _dataFetchState.asLiveData() 75 | 76 | /** 77 | * Gets the [Weather] information for the user selected location[name] 78 | * @param name value of the location whose [Weather] data is to be fetched. 79 | */ 80 | fun getSearchWeather(name: String) { 81 | _isLoading.postValue(true) 82 | viewModelScope.launch { 83 | when (val result = repository.getSearchWeather(name)) { 84 | is Result.Success -> { 85 | _isLoading.value = false 86 | if (result.data != null) { 87 | Timber.i("Mayokun Result ${result.data}") 88 | _dataFetchState.value = true 89 | _weatherInfo.postValue(result.data) 90 | } else { 91 | _weatherInfo.postValue(null) 92 | _dataFetchState.postValue(false) 93 | } 94 | } 95 | is Result.Error -> { 96 | _isLoading.value = false 97 | _dataFetchState.value = false 98 | } 99 | } 100 | } 101 | } 102 | 103 | override fun onCleared() { 104 | super.onCleared() 105 | searcher.cancel() 106 | connection.disconnect() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ui/search/SearchResultAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui.search 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.paging.PagedListAdapter 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.mayokunadeniyi.instantweather.data.model.SearchResult 9 | import com.mayokunadeniyi.instantweather.databinding.ItemSearchResultBinding 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 28/04/2020. 13 | */ 14 | 15 | class SearchResultAdapter(private val delegate: OnItemClickedListener) : PagedListAdapter(SearchResultDiffCallBack()) { 16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 17 | return ViewHolder.from(parent) 18 | } 19 | 20 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 21 | val searchResult = getItem(position) 22 | if (searchResult != null) { 23 | holder.itemView.setOnClickListener { 24 | delegate.onSearchResultClicked(searchResult) 25 | } 26 | holder.bind(searchResult) 27 | } 28 | } 29 | 30 | class ViewHolder(private val binding: ItemSearchResultBinding) : RecyclerView.ViewHolder(binding.root) { 31 | fun bind(searchResult: SearchResult) { 32 | binding.searchResult = searchResult 33 | binding.executePendingBindings() 34 | } 35 | companion object { 36 | fun from(parent: ViewGroup): ViewHolder { 37 | val layoutInflater = LayoutInflater.from(parent.context) 38 | val binding = ItemSearchResultBinding.inflate(layoutInflater, parent, false) 39 | return ViewHolder(binding) 40 | } 41 | } 42 | } 43 | 44 | class SearchResultDiffCallBack : DiffUtil.ItemCallback() { 45 | override fun areItemsTheSame(oldItem: SearchResult, newItem: SearchResult): Boolean { 46 | return oldItem.name == newItem.name 47 | } 48 | 49 | override fun areContentsTheSame(oldItem: SearchResult, newItem: SearchResult): Boolean { 50 | return oldItem == newItem 51 | } 52 | } 53 | 54 | interface OnItemClickedListener { 55 | fun onSearchResultClicked(searchResult: SearchResult) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/ui/settings/SettingsFragment.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui.settings 2 | 3 | import android.content.SharedPreferences 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatDelegate 6 | import androidx.preference.Preference 7 | import androidx.preference.PreferenceFragmentCompat 8 | import com.mayokunadeniyi.instantweather.R 9 | import com.mayokunadeniyi.instantweather.utils.SharedPreferenceHelper 10 | 11 | class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener { 12 | private lateinit var sharedPreferenceHelper: SharedPreferenceHelper 13 | override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 14 | setPreferencesFromResource(R.xml.preferences, rootKey) 15 | sharedPreferenceHelper = SharedPreferenceHelper.getInstance(requireContext()) 16 | init() 17 | } 18 | 19 | override fun onResume() { 20 | super.onResume() 21 | preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this) 22 | } 23 | 24 | override fun onPause() { 25 | super.onPause() 26 | preferenceScreen.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) 27 | } 28 | 29 | private fun init() { 30 | val themePreferenceKey = PREFERENCE_KEY_THEME 31 | val themePreference = findPreference(themePreferenceKey) 32 | val selectedOption = sharedPreferenceHelper.getSelectedThemePref() 33 | themePreference?.summary = selectedOption 34 | 35 | val cachePreferenceKey = PREFERENCE_KEY_CACHE 36 | val cachePreference = findPreference(cachePreferenceKey) 37 | val setDuration = sharedPreferenceHelper.getUserSetCacheDuration() 38 | cachePreference?.summary = setDuration 39 | 40 | val unitPreferenceKey = PREFERENCE_KEY_TEMPERATURE_UNIT 41 | val unitPreference = findPreference(unitPreferenceKey) 42 | val selectedUnit = sharedPreferenceHelper.getSelectedTemperatureUnit() 43 | unitPreference?.summary = selectedUnit 44 | } 45 | 46 | override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) { 47 | val themePreferenceKey = PREFERENCE_KEY_THEME 48 | if (key == themePreferenceKey) { 49 | val themePreference = findPreference(themePreferenceKey) 50 | val selectedOption = sharedPreferenceHelper.getSelectedThemePref() 51 | themePreference?.summary = selectedOption 52 | 53 | when (selectedOption) { 54 | getString(R.string.light_theme_value) -> setTheme(AppCompatDelegate.MODE_NIGHT_NO) 55 | getString(R.string.dark_theme_value) -> setTheme(AppCompatDelegate.MODE_NIGHT_YES) 56 | getString(R.string.auto_battery_value) -> setTheme(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY) 57 | getString(R.string.follow_system_value) -> setTheme(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) 58 | } 59 | } 60 | 61 | val cachePreferenceKey = PREFERENCE_KEY_CACHE 62 | if (key == cachePreferenceKey) { 63 | val cachePreference = findPreference(cachePreferenceKey) 64 | val setDuration = sharedPreferenceHelper.getUserSetCacheDuration() 65 | cachePreference?.summary = setDuration 66 | } 67 | 68 | val unitPreferenceKey = PREFERENCE_KEY_TEMPERATURE_UNIT 69 | if (key == unitPreferenceKey) { 70 | val unitPreference = findPreference(unitPreferenceKey) 71 | val selectedUnit = sharedPreferenceHelper.getSelectedTemperatureUnit() 72 | unitPreference?.summary = selectedUnit 73 | } 74 | } 75 | 76 | private fun setTheme(mode: Int) { 77 | AppCompatDelegate.setDefaultNightMode(mode) 78 | } 79 | 80 | companion object { 81 | private const val PREFERENCE_KEY_THEME = "theme_key" 82 | private const val PREFERENCE_KEY_CACHE = "cache_key" 83 | private const val PREFERENCE_KEY_TEMPERATURE_UNIT = "unit_key" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/BaseBottomSheetDialog.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import android.app.Activity 4 | import com.google.android.material.bottomsheet.BottomSheetDialog 5 | 6 | /** 7 | * Created by Mayokun Adeniyi on 7/24/21. 8 | */ 9 | 10 | class BaseBottomSheetDialog(private val activity: Activity, theme: Int) : 11 | BottomSheetDialog(activity, theme) { 12 | 13 | override fun onStart() { 14 | super.onStart() 15 | this.window?.let { 16 | it.callback = UserInteractionAwareCallback(it.callback, activity) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/BindingAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import android.widget.TextView 4 | import androidx.databinding.BindingAdapter 5 | import com.github.pwittchen.weathericonview.WeatherIconView 6 | import com.mayokunadeniyi.instantweather.R 7 | 8 | /** 9 | * Created by Mayokun Adeniyi on 2020-01-28. 10 | */ 11 | 12 | /** 13 | * This function helps to dynamically set the icon for the [WeatherIconView] 14 | * @see WeatherIconGenerator 15 | */ 16 | @BindingAdapter("setIcon") 17 | fun WeatherIconView.showIcon(condition: String?) { 18 | val context = this.context 19 | WeatherIconGenerator.getIconResources(context, this, condition) 20 | } 21 | 22 | @BindingAdapter("setTemperature") 23 | fun TextView.setTemperature(double: Double) { 24 | val context = this.context 25 | if (SharedPreferenceHelper.getInstance(context).getSelectedTemperatureUnit() == context.getString( 26 | R.string.temp_unit_fahrenheit 27 | ) 28 | ) 29 | this.text = double.toString() + context.resources.getString(R.string.temp_symbol_fahrenheit) 30 | else 31 | this.text = double.toString() + context.resources.getString(R.string.temp_symbol_celsius) 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | /** 4 | * Created by Mayokun Adeniyi on 18/03/2020. 5 | */ 6 | 7 | const val GPS_REQUEST_CHECK_SETTINGS = 102 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/DateUtils.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import java.text.SimpleDateFormat 4 | 5 | /** 6 | * Created by Mayokun Adeniyi on 7/24/21. 7 | */ 8 | 9 | fun String.formatDate(): String { 10 | val parser = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") 11 | val formatter = SimpleDateFormat("d MMM y, h:mma") 12 | return formatter.format(parser.parse(this)) 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/GpsUtil.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.location.LocationManager 6 | import android.widget.Toast 7 | import com.google.android.gms.common.api.ApiException 8 | import com.google.android.gms.common.api.ResolvableApiException 9 | import com.google.android.gms.location.LocationServices 10 | import com.google.android.gms.location.LocationSettingsRequest 11 | import com.google.android.gms.location.LocationSettingsStatusCodes 12 | import com.google.android.gms.location.SettingsClient 13 | import timber.log.Timber 14 | 15 | /** 16 | * Created by Mayokun Adeniyi on 27/03/2020. 17 | */ 18 | class GpsUtil(private val context: Context) { 19 | private val settingsClient: SettingsClient = LocationServices.getSettingsClient(context) 20 | private val locationSettingsRequest: LocationSettingsRequest? 21 | private val locationManager = 22 | context.getSystemService(Context.LOCATION_SERVICE) as LocationManager 23 | 24 | init { 25 | val builder = LocationSettingsRequest.Builder() 26 | .addLocationRequest(LocationLiveData.locationRequest) 27 | locationSettingsRequest = builder.build() 28 | builder.setAlwaysShow(true) 29 | } 30 | 31 | fun turnGPSOn(OnGpsListener: OnGpsListener?) { 32 | if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { 33 | OnGpsListener?.gpsStatus(true) 34 | } else { 35 | settingsClient 36 | .checkLocationSettings(locationSettingsRequest) 37 | .addOnSuccessListener(context as Activity) { 38 | OnGpsListener?.gpsStatus(true) 39 | }.addOnFailureListener(context) { exception -> 40 | 41 | when ((exception as ApiException).statusCode) { 42 | LocationSettingsStatusCodes.RESOLUTION_REQUIRED -> { 43 | try { 44 | val resApiException = exception as ResolvableApiException 45 | resApiException.startResolutionForResult(context, GPS_REQUEST_CHECK_SETTINGS) 46 | } catch (sendIntentException: Exception) { 47 | sendIntentException.printStackTrace() 48 | Timber.i("PendingIntent unable to execute request. ") 49 | } 50 | } 51 | LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE -> { 52 | val errorMessage = 53 | "Location settings are inadequate, and cannot be " + "fixed here. Fix in Settings." 54 | Timber.e(errorMessage) 55 | 56 | Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show() 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | interface OnGpsListener { 64 | fun gpsStatus(isGPSEnabled: Boolean) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/LiveDataUtils.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.Observer 7 | 8 | /** 9 | * Created by Mayokun Adeniyi on 30/03/2020. 10 | */ 11 | 12 | /** 13 | * This functions helps in transforming a [MutableLiveData] of type [T] 14 | * to a [LiveData] of type [T] 15 | */ 16 | fun MutableLiveData.asLiveData() = this as LiveData 17 | 18 | /** 19 | * This function helps to observe a [LiveData] once 20 | */ 21 | fun LiveData.observeOnce(lifecycleOwner: LifecycleOwner, observer: Observer) { 22 | observe( 23 | lifecycleOwner, 24 | object : Observer { 25 | override fun onChanged(t: T?) { 26 | observer.onChanged(t) 27 | removeObserver(this) 28 | } 29 | } 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/LocationLiveData.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.location.Location 6 | import androidx.lifecycle.LiveData 7 | import com.google.android.gms.location.LocationCallback 8 | import com.google.android.gms.location.LocationRequest 9 | import com.google.android.gms.location.LocationResult 10 | import com.google.android.gms.location.LocationServices 11 | import com.mayokunadeniyi.instantweather.data.model.LocationModel 12 | 13 | class LocationLiveData(context: Context) : LiveData() { 14 | 15 | private var fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) 16 | 17 | override fun onInactive() { 18 | super.onInactive() 19 | fusedLocationClient.removeLocationUpdates(locationCallback) 20 | } 21 | 22 | @SuppressLint("MissingPermission") 23 | override fun onActive() { 24 | super.onActive() 25 | fusedLocationClient.lastLocation 26 | .addOnSuccessListener { location: Location? -> 27 | location?.also { 28 | setLocationData(it) 29 | } 30 | } 31 | startLocationUpdates() 32 | } 33 | 34 | @SuppressLint("MissingPermission") 35 | private fun startLocationUpdates() { 36 | fusedLocationClient.requestLocationUpdates( 37 | locationRequest, 38 | locationCallback, 39 | null 40 | ) 41 | } 42 | 43 | private val locationCallback = object : LocationCallback() { 44 | override fun onLocationResult(locationResult: LocationResult?) { 45 | locationResult ?: return 46 | for (location in locationResult.locations) { 47 | setLocationData(location) 48 | } 49 | } 50 | } 51 | 52 | private fun setLocationData(location: Location) { 53 | value = LocationModel( 54 | longitude = location.longitude, 55 | latitude = location.latitude 56 | ) 57 | } 58 | 59 | companion object { 60 | val locationRequest: LocationRequest = LocationRequest.create().apply { 61 | interval = 10000 62 | fastestInterval = 5000 63 | priority = LocationRequest.PRIORITY_HIGH_ACCURACY 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/NotificationHelper.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.app.PendingIntent 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.os.Build 9 | import androidx.core.app.NotificationCompat 10 | import androidx.core.app.NotificationManagerCompat 11 | import com.mayokunadeniyi.instantweather.R 12 | import com.mayokunadeniyi.instantweather.ui.MainActivity 13 | 14 | /** 15 | * Created by Mayokun Adeniyi on 12/06/2020. 16 | */ 17 | 18 | class NotificationHelper(private val message: String, private val context: Context) { 19 | 20 | private val CHANNEL_ID = "Instant Weather Channel ID" 21 | private val NOTIFICATION_ID = 123 22 | 23 | fun createNotification() { 24 | createNotificationChannel() 25 | 26 | val intent = Intent(context, MainActivity::class.java).apply { 27 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 28 | } 29 | 30 | val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) 31 | 32 | val notification = NotificationCompat.Builder(context, CHANNEL_ID) 33 | .setSmallIcon(R.drawable.launcher_bitmap) 34 | .setContentTitle(message) 35 | .setContentText("Check out the latest weather information in your location!") 36 | .setPriority(NotificationCompat.PRIORITY_DEFAULT) 37 | .setContentIntent(pendingIntent) 38 | .build() 39 | 40 | NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) 41 | } 42 | 43 | private fun createNotificationChannel() { 44 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 45 | val name = CHANNEL_ID 46 | val descriptionText = "Weather Update" 47 | val importance = NotificationManager.IMPORTANCE_DEFAULT 48 | val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { 49 | description = descriptionText 50 | } 51 | val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 52 | notificationManager.createNotificationChannel(channel) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/Result.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | /** 4 | * Created by Mayokun Adeniyi on 23/05/2020. 5 | */ 6 | 7 | /** 8 | * A generic class that holds a value with its loading status. 9 | * @param 10 | */ 11 | sealed class Result { 12 | 13 | data class Success(val data: T?) : Result() 14 | data class Error(val exception: Exception) : Result() 15 | object Loading : Result() 16 | 17 | override fun toString(): String { 18 | return when (this) { 19 | is Success<*> -> "Success[data=$data]" 20 | is Error -> "Error[exception=$exception]" 21 | is Loading -> "Loading" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/SharedPreferenceHelper.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import androidx.core.content.edit 6 | import androidx.preference.PreferenceManager 7 | import com.google.gson.Gson 8 | import com.mayokunadeniyi.instantweather.data.model.LocationModel 9 | 10 | /** 11 | * Created by Mayokun Adeniyi on 2020-01-28. 12 | */ 13 | 14 | class SharedPreferenceHelper { 15 | 16 | companion object { 17 | 18 | private const val WEATHER_PREF_TIME = "Weather pref time" 19 | private const val WEATHER_FORECAST_PREF_TIME = "Forecast pref time" 20 | private const val CITY_ID = "City ID" 21 | private var prefs: SharedPreferences? = null 22 | private const val LOCATION = "LOCATION" 23 | 24 | @Volatile 25 | private var instance: SharedPreferenceHelper? = null 26 | 27 | /** 28 | * This checks if there is an existing instance of the [SharedPreferences] in the 29 | * specified [context] and creates one if there isn't or else, it returns the 30 | * already existing instance. This function ensures that the [SharedPreferences] is 31 | * accessed at any instance by a single thread. 32 | */ 33 | fun getInstance(context: Context): SharedPreferenceHelper { 34 | synchronized(this) { 35 | val _instance = instance 36 | if (_instance == null) { 37 | prefs = PreferenceManager.getDefaultSharedPreferences(context) 38 | instance = _instance 39 | } 40 | return SharedPreferenceHelper() 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * This function saves the initial time [System.nanoTime] at which the weather information 47 | * at the user's location is accessed. 48 | * @param time the value of [System.nanoTime] when the weather information is received. 49 | */ 50 | fun saveTimeOfInitialWeatherFetch(time: Long) { 51 | prefs?.edit(commit = true) { 52 | putLong(WEATHER_PREF_TIME, time) 53 | } 54 | } 55 | 56 | /** 57 | * This function returns the saved value of [System.nanoTime] when the weather information 58 | * at the user's location was accessed. 59 | * @see saveTimeOfInitialWeatherFetch 60 | */ 61 | fun getTimeOfInitialWeatherFetch() = prefs?.getLong(WEATHER_PREF_TIME, 0L) 62 | 63 | /** 64 | * This function saves the initial time [System.nanoTime] at which the weather forecast 65 | * at the user's location is accessed. 66 | * @param time the value of [System.nanoTime] when the weather forecast is received. 67 | */ 68 | fun saveTimeOfInitialWeatherForecastFetch(time: Long) { 69 | prefs?.edit(commit = true) { 70 | putLong(WEATHER_FORECAST_PREF_TIME, time) 71 | } 72 | } 73 | 74 | /** 75 | * This function returns the saved value of [System.nanoTime] when the weather forecast 76 | * at the user's location was accessed. 77 | * @see saveTimeOfInitialWeatherForecastFetch 78 | */ 79 | fun getTimeOfInitialWeatherForecastFetch() = prefs?.getLong(WEATHER_FORECAST_PREF_TIME, 0L) 80 | 81 | /** 82 | * This function saves the [cityId] of the location whose weather information has been 83 | * received. 84 | * @param cityId the id of the location whose weather has been received 85 | */ 86 | fun saveCityId(cityId: Int) { 87 | prefs?.edit(commit = true) { 88 | putInt(CITY_ID, cityId) 89 | } 90 | } 91 | 92 | /** 93 | * This function returns the id of the location whose weather information has been received. 94 | * @see saveCityId 95 | */ 96 | fun getCityId() = prefs?.getInt(CITY_ID, 0) 97 | 98 | /** 99 | * This function gets the value of the cache duration the user set in the 100 | * Settings Fragment. 101 | */ 102 | fun getUserSetCacheDuration() = prefs?.getString("cache_key", "0") 103 | 104 | /** 105 | * This function gets the value of the app theme the user set in the 106 | * Settings Fragment. 107 | */ 108 | fun getSelectedThemePref() = prefs?.getString("theme_key", "") 109 | 110 | /** 111 | * This function gets the value of the temperature unit the user set in the 112 | * Settings Fragment. 113 | */ 114 | fun getSelectedTemperatureUnit() = prefs?.getString("unit_key", "") 115 | 116 | /** 117 | * This function saves a [LocationModel] 118 | */ 119 | fun saveLocation(location: LocationModel) { 120 | prefs?.edit(commit = true) { 121 | val gson = Gson() 122 | val json = gson.toJson(location) 123 | putString(LOCATION, json) 124 | } 125 | } 126 | 127 | /** 128 | * This function gets the value of the saved [LocationModel] 129 | */ 130 | fun getLocation(): LocationModel { 131 | val gson = Gson() 132 | val json = prefs?.getString(LOCATION, null) 133 | return gson.fromJson(json, LocationModel::class.java) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/TemperatureUtils.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import java.text.DecimalFormat 4 | 5 | /** 6 | * Created by Mayokun Adeniyi on 7/24/21. 7 | */ 8 | 9 | /** 10 | * This function converts a [number] from Kelvin to Celsius by using [DecimalFormat] and 11 | * converting it to a [Double] then subtracting 273 from it. 12 | * @param number the number to be converted to Celsius. 13 | */ 14 | fun convertKelvinToCelsius(number: Number): Double { 15 | return DecimalFormat().run { 16 | applyPattern(".##") 17 | parse(format(number.toDouble().minus(273))).toDouble() 18 | } 19 | } 20 | 21 | fun convertCelsiusToFahrenheit(celsius: Double): Double { 22 | return DecimalFormat().run { 23 | applyPattern(".##") 24 | parse(format(celsius.times(1.8).plus(32))).toDouble() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/ThemeManager.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import androidx.appcompat.app.AppCompatDelegate 4 | 5 | /** 6 | * Created by Mayokun Adeniyi on 02/05/2020. 7 | */ 8 | object ThemeManager { 9 | private const val LIGHT_MODE = "Light" 10 | private const val DARK_MODE = "Dark" 11 | private const val AUTO_BATTERY_MODE = "Auto-battery" 12 | private const val FOLLOW_SYSTEM_MODE = "System" 13 | 14 | /** 15 | * This function helps persist the theme set by the user by getting the [themePreference] on initial startup 16 | * of the application. 17 | */ 18 | fun applyTheme(themePreference: String) { 19 | when (themePreference) { 20 | LIGHT_MODE -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) 21 | DARK_MODE -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) 22 | AUTO_BATTERY_MODE -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY) 23 | FOLLOW_SYSTEM_MODE -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/UserInteractionAwareCallback.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils 2 | 3 | import android.annotation.TargetApi 4 | import android.app.Activity 5 | import android.os.Build 6 | import android.view.ActionMode 7 | import android.view.KeyEvent 8 | import android.view.Menu 9 | import android.view.MenuItem 10 | import android.view.MotionEvent 11 | import android.view.SearchEvent 12 | import android.view.View 13 | import android.view.Window 14 | import android.view.WindowManager 15 | import android.view.accessibility.AccessibilityEvent 16 | import androidx.annotation.Nullable 17 | 18 | /** 19 | * Created by Mayokun Adeniyi on 22/02/2021. 20 | */ 21 | 22 | class UserInteractionAwareCallback( 23 | private val originalCallback: Window.Callback, 24 | private val activity: Activity? 25 | ) : Window.Callback { 26 | 27 | override fun dispatchKeyEvent(event: KeyEvent): Boolean { 28 | return originalCallback.dispatchKeyEvent(event) 29 | } 30 | 31 | override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean { 32 | return originalCallback.dispatchKeyShortcutEvent(event) 33 | } 34 | 35 | override fun dispatchTouchEvent(event: MotionEvent): Boolean { 36 | when (event.action) { 37 | MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP -> activity?.onUserInteraction() 38 | } 39 | return originalCallback.dispatchTouchEvent(event) 40 | } 41 | 42 | override fun dispatchTrackballEvent(event: MotionEvent): Boolean { 43 | return originalCallback.dispatchTrackballEvent(event) 44 | } 45 | 46 | override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { 47 | return originalCallback.dispatchGenericMotionEvent(event) 48 | } 49 | 50 | override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean { 51 | return originalCallback.dispatchPopulateAccessibilityEvent(event) 52 | } 53 | 54 | @Nullable 55 | override fun onCreatePanelView(featureId: Int): View? { 56 | return originalCallback.onCreatePanelView(featureId) 57 | } 58 | 59 | override fun onCreatePanelMenu(featureId: Int, menu: Menu): Boolean { 60 | return originalCallback.onCreatePanelMenu(featureId, menu) 61 | } 62 | 63 | override fun onPreparePanel(featureId: Int, view: View?, menu: Menu): Boolean { 64 | return originalCallback.onPreparePanel(featureId, view, menu) 65 | } 66 | 67 | override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { 68 | return originalCallback.onMenuOpened(featureId, menu) 69 | } 70 | 71 | override fun onMenuItemSelected(featureId: Int, item: MenuItem): Boolean { 72 | return originalCallback.onMenuItemSelected(featureId, item) 73 | } 74 | 75 | override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams) { 76 | originalCallback.onWindowAttributesChanged(attrs) 77 | } 78 | 79 | override fun onContentChanged() { 80 | originalCallback.onContentChanged() 81 | } 82 | 83 | override fun onWindowFocusChanged(hasFocus: Boolean) { 84 | originalCallback.onWindowFocusChanged(hasFocus) 85 | } 86 | 87 | override fun onAttachedToWindow() { 88 | originalCallback.onAttachedToWindow() 89 | } 90 | 91 | override fun onDetachedFromWindow() { 92 | originalCallback.onDetachedFromWindow() 93 | } 94 | 95 | override fun onPanelClosed(featureId: Int, menu: Menu) { 96 | originalCallback.onPanelClosed(featureId, menu) 97 | } 98 | 99 | override fun onSearchRequested(): Boolean { 100 | return originalCallback.onSearchRequested() 101 | } 102 | 103 | @TargetApi(Build.VERSION_CODES.M) 104 | override fun onSearchRequested(searchEvent: SearchEvent): Boolean { 105 | return originalCallback.onSearchRequested(searchEvent) 106 | } 107 | 108 | @Nullable 109 | override fun onWindowStartingActionMode(callback: ActionMode.Callback): ActionMode? { 110 | return originalCallback.onWindowStartingActionMode(callback) 111 | } 112 | 113 | @TargetApi(Build.VERSION_CODES.M) 114 | @Nullable 115 | override fun onWindowStartingActionMode(callback: ActionMode.Callback, type: Int): ActionMode? { 116 | return originalCallback.onWindowStartingActionMode(callback, type) 117 | } 118 | 119 | override fun onActionModeStarted(mode: ActionMode) { 120 | originalCallback.onActionModeStarted(mode) 121 | } 122 | 123 | override fun onActionModeFinished(mode: ActionMode) { 124 | originalCallback.onActionModeFinished(mode) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/utils/typeconverters/ListNetworkWeatherDescriptionConverter.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.utils.typeconverters 2 | 3 | import androidx.room.TypeConverter 4 | import com.google.gson.Gson 5 | import com.google.gson.reflect.TypeToken 6 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherDescription 7 | import java.lang.reflect.Type 8 | 9 | /** 10 | * Created by Mayokun Adeniyi on 2020-01-28. 11 | */ 12 | class ListNetworkWeatherDescriptionConverter { 13 | val gson = Gson() 14 | 15 | val type: Type = object : TypeToken?>() {}.type 16 | 17 | /** 18 | * Converts a listOf[NetworkWeatherDescription] to a [String] 19 | */ 20 | @TypeConverter 21 | fun fromWeatherDtoList(list: List?): String { 22 | return gson.toJson(list, type) 23 | } 24 | 25 | /** 26 | * Converts a [String] to a listOf[NetworkWeatherDescription] 27 | */ 28 | @TypeConverter 29 | fun toWeatherDtoList(json: String?): List { 30 | return gson.fromJson(json, type) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/worker/MyWorkerFactory.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.worker 2 | 3 | import android.content.Context 4 | import androidx.work.ListenableWorker 5 | import androidx.work.WorkerFactory 6 | import androidx.work.WorkerParameters 7 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepository 8 | 9 | /** 10 | * Created by Mayokun Adeniyi on 16/06/2020. 11 | */ 12 | 13 | class MyWorkerFactory(private val repository: WeatherRepository) : WorkerFactory() { 14 | override fun createWorker( 15 | appContext: Context, 16 | workerClassName: String, 17 | workerParameters: WorkerParameters 18 | ): ListenableWorker? { 19 | 20 | return when (workerClassName) { 21 | UpdateWeatherWorker::class.java.name -> { 22 | UpdateWeatherWorker(appContext, workerParameters, repository) 23 | } 24 | 25 | else -> 26 | // Return null, so that the base class can delegate to the default WorkerFactory. 27 | null 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/mayokunadeniyi/instantweather/worker/UpdateWeatherWorker.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.worker 2 | 3 | import android.content.Context 4 | import androidx.work.CoroutineWorker 5 | import androidx.work.WorkerParameters 6 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepository 7 | import com.mayokunadeniyi.instantweather.utils.NotificationHelper 8 | import com.mayokunadeniyi.instantweather.utils.Result.Success 9 | import com.mayokunadeniyi.instantweather.utils.SharedPreferenceHelper 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 12/06/2020. 13 | */ 14 | 15 | class UpdateWeatherWorker( 16 | context: Context, 17 | params: WorkerParameters, 18 | private val repository: WeatherRepository 19 | ) : CoroutineWorker(context, params) { 20 | private val notificationHelper = NotificationHelper("Weather Update", context) 21 | private val sharedPrefs = SharedPreferenceHelper.getInstance(context) 22 | 23 | override suspend fun doWork(): Result { 24 | val location = sharedPrefs.getLocation() 25 | return when (val result = repository.getWeather(location, true)) { 26 | is Success -> { 27 | if (result.data != null) { 28 | when ( 29 | val foreResult = 30 | repository.getForecastWeather(result.data.cityId, true) 31 | ) { 32 | is Success -> { 33 | if (foreResult.data != null) { 34 | notificationHelper.createNotification() 35 | Result.success() 36 | } else { 37 | Result.failure() 38 | } 39 | } 40 | else -> Result.failure() 41 | } 42 | } else { 43 | Result.failure() 44 | } 45 | } 46 | else -> Result.failure() 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_down.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | android:interpolator="@android:anim/linear_interpolator"> 4 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_up.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | android:interpolator="@android:anim/linear_interpolator"> 4 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/anim/zoom_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | android:interpolator="@android:anim/decelerate_interpolator"> 4 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/anim/zoom_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | android:interpolator="@android:anim/accelerate_interpolator"> 4 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_big_cloud.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_cloud.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/bottom_sheet_dialog_top_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/circle_blue_solid_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/circle_orange_solid_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/circle_orange_stroke_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_left.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_right.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_star_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_title_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cached.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_close.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_format_list.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_home.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_humidity.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 34 | 41 | 48 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_insert_chart.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_instant_weather_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 16 | 22 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_palette.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pressure.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 34 | 41 | 48 | 55 | 62 | 69 | 76 | 83 | 84 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_search.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_settings.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_wb_sunny_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_wind.xml: -------------------------------------------------------------------------------- 1 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/launch_screen.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/launcher_bitmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/drawable/launcher_bitmap.png -------------------------------------------------------------------------------- /app/src/main/res/font/googlesans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/font/googlesans.ttf -------------------------------------------------------------------------------- /app/src/main/res/layout-land-night/fragment_forecast.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 14 | 15 | 16 | 24 | 25 | 48 | 49 | 61 | 62 | 71 | 72 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /app/src/main/res/layout-land/fragment_forecast.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 14 | 15 | 23 | 24 | 45 | 46 | 58 | 59 | 68 | 69 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /app/src/main/res/layout-night/fragment_forecast.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 14 | 15 | 36 | 37 | 51 | 52 | 61 | 62 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 11 | 22 | 23 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_forecast.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 11 | 15 | 16 | 35 | 36 | 50 | 51 | 60 | 61 | 71 | 72 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_search.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 11 | 12 | 23 | 24 | 25 | 34 | 35 | 44 | 45 | 55 | 56 | 71 | 72 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_search_result.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 18 | 19 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/res/menu/home_bottom_nav.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 13 | 14 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/instant_weather_new.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/instant_weather_new_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/instant_weather_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-hdpi/instant_weather_new.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/instant_weather_new_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-hdpi/instant_weather_new_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/instant_weather_new_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-hdpi/instant_weather_new_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/instant_weather_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-mdpi/instant_weather_new.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/instant_weather_new_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-mdpi/instant_weather_new_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/instant_weather_new_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-mdpi/instant_weather_new_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/instant_weather_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xhdpi/instant_weather_new.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/instant_weather_new_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xhdpi/instant_weather_new_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/instant_weather_new_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xhdpi/instant_weather_new_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/instant_weather_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xxhdpi/instant_weather_new.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/instant_weather_new_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xxhdpi/instant_weather_new_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/instant_weather_new_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xxhdpi/instant_weather_new_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/instant_weather_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xxxhdpi/instant_weather_new.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/instant_weather_new_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xxxhdpi/instant_weather_new_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/instant_weather_new_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/app/src/main/res/mipmap-xxxhdpi/instant_weather_new_round.png -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 16 | 17 | 21 | 26 | 29 | 30 | 35 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | 5 | #0F0F0F 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/arrays.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @string/light_theme_name 5 | @string/dark_theme_name 6 | @string/auto_battery_name 7 | @string/follow_system_name 8 | 9 | 10 | 11 | @string/light_theme_value 12 | @string/dark_theme_value 13 | @string/auto_battery_value 14 | @string/follow_system_value 15 | 16 | 17 | 18 | @string/temp_unit_celsius 19 | @string/temp_unit_fahrenheit 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1976D2 4 | #63A4FF 5 | #004ba0 6 | #FFFFFF 7 | #E1E2E1 8 | 9 | #FFFFFF 10 | #000000 11 | 12 | #1976d2 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24dp 5 | 24dp 6 | 120dp 7 | 1dp 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_instant_weather_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1976D2 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1976D2 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/instant_weather_new_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #1976D2 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Instant Weather 3 | 4 | Home 5 | Forecast 6 | Settings 7 | Oops! An error occurred. \n\n Please check your internet connection. 8 | \n\n Swipe down to retry. 9 | Pressure 10 | Humidity 11 | Weather History 12 | Sort by: 13 | Cache duration (seconds) 14 | Fetching weather 15 | Weather in 16 | , 17 | \u2103 18 | \u2109 19 | Forecast up to 5days. Select end date for forecast. 20 | Weather Forecast 21 | Wind speed 22 | m/s 23 | hPa 24 | % 25 | Instant Weather 26 | Weather forecast for selected day is unavailable. 27 | Search 28 | Enter the name of the city. 29 | Couldn\'t find your city. \n\n Go ahead and hit the search button. 30 | Please enable your GPS! 31 | 32 | 33 | Theme 34 | 35 | Light theme 36 | Dark theme 37 | Auto battery 38 | Follow system 39 | 40 | Light 41 | Dark 42 | Auto-battery 43 | System 44 | Your device is in Flight Mode, disable and restart the application. 45 | Permission was not granted! \n\n Enable permission manually in this application\'s settings and restart the application. 46 | An error occurred when getting your location! \n\n Ensure your device is connected to the internet. \n\n Swipe down to refresh. 47 | Location Optimization Permission was not granted! \n\n Enable permission manually in application settings \n and restart the application. 48 | An error occurred in getting your location! \n\n Ensure your device is connected to the internet \n\n Swipe down to refresh. 49 | Enable your GPS and restart! 50 | Location Permission 51 | This application requires access to your location to function! 52 | No 53 | Ask me 54 | GPS is required for this application to function! 55 | 56 | Temperature Unit 57 | Celsius/°C 58 | Fahrenheit/°F 59 | Rate this app 60 | 61 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 16 | 17 | 24 | 25 | 31 | 32 | 35 | 36 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/res/xml/preferences.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 14 | 15 | 19 | 20 | 25 | 26 | 27 | 28 | 32 | 33 | 38 | 39 | 40 | 43 | 46 | 47 | -------------------------------------------------------------------------------- /app/src/sharedTest/java/com/mayokunadeniyi/instantweather/CoroutineTestRule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineDispatcher 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.setMain 8 | import org.junit.rules.TestWatcher 9 | import org.junit.runner.Description 10 | 11 | /** 12 | * Created by Mayokun Adeniyi on 08/01/2022. 13 | */ 14 | @ExperimentalCoroutinesApi 15 | class CoroutineTestRule constructor(val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : 16 | TestWatcher() { 17 | 18 | override fun starting(description: Description?) { 19 | super.starting(description) 20 | Dispatchers.setMain(dispatcher) 21 | } 22 | 23 | override fun finished(description: Description?) { 24 | super.finished(description) 25 | Dispatchers.resetMain() 26 | dispatcher.cleanupTestCoroutines() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/sharedTest/java/com/mayokunadeniyi/instantweather/FakeRepositorySuccess.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.LocationModel 4 | import com.mayokunadeniyi.instantweather.data.model.Weather 5 | import com.mayokunadeniyi.instantweather.data.model.WeatherForecast 6 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepository 7 | import com.mayokunadeniyi.instantweather.utils.Result 8 | 9 | /** 10 | * Created by Mayokun Adeniyi on 06/08/2020. 11 | */ 12 | 13 | class FakeRepositorySuccess : WeatherRepository { 14 | /** 15 | * @param refresh in this case is used to toggle if we want to test for 16 | * when the data returned is of type [Result.Success] or [Result.Error] 17 | */ 18 | override suspend fun getWeather(location: LocationModel, refresh: Boolean): Result { 19 | return Result.Success(fakeWeather) 20 | } 21 | 22 | /** 23 | * @param refresh in this case is used to toggle if we want to test for 24 | * when the data returned is of type [Result.Success] or [Result.Error] 25 | */ 26 | override suspend fun getForecastWeather( 27 | cityId: Int, 28 | refresh: Boolean 29 | ): Result?> { 30 | return Result.Success( 31 | listOf( 32 | fakeWeatherForecast, 33 | fakeWeatherForecast, 34 | fakeWeatherForecast, 35 | fakeWeatherForecast 36 | ) 37 | ) 38 | } 39 | 40 | override suspend fun getSearchWeather(location: String): Result { 41 | TODO("Not yet implemented") 42 | } 43 | 44 | override suspend fun storeWeatherData(weather: Weather) { 45 | TODO("Not yet implemented") 46 | } 47 | 48 | override suspend fun storeForecastData(forecasts: List) { 49 | TODO("Not yet implemented") 50 | } 51 | 52 | override suspend fun deleteWeatherData() { 53 | TODO("Not yet implemented") 54 | } 55 | 56 | override suspend fun deleteForecastData() { 57 | TODO("Not yet implemented") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/sharedTest/java/com/mayokunadeniyi/instantweather/LiveDataTestUtil.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.Observer 6 | import java.util.concurrent.CountDownLatch 7 | import java.util.concurrent.TimeUnit 8 | import java.util.concurrent.TimeoutException 9 | 10 | /** 11 | * Gets the value of a [LiveData] or waits for it to have one, with a timeout. 12 | * 13 | * Use this extension from host-side (JVM) tests. It's recommended to use it alongside 14 | * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously. 15 | */ 16 | @VisibleForTesting(otherwise = VisibleForTesting.NONE) 17 | fun LiveData.getOrAwaitValue( 18 | time: Long = 2, 19 | timeUnit: TimeUnit = TimeUnit.SECONDS, 20 | afterObserve: () -> Unit = {} 21 | ): T { 22 | var data: T? = null 23 | val latch = CountDownLatch(1) 24 | val observer = object : Observer { 25 | override fun onChanged(o: T?) { 26 | data = o 27 | latch.countDown() 28 | this@getOrAwaitValue.removeObserver(this) 29 | } 30 | } 31 | this.observeForever(observer) 32 | 33 | try { 34 | afterObserve.invoke() 35 | 36 | // Don't wait indefinitely if the LiveData is not set. 37 | if (!latch.await(time, timeUnit)) { 38 | this.removeObserver(observer) 39 | throw TimeoutException("LiveData value was never set.") 40 | } 41 | } finally { 42 | this.removeObserver(observer) 43 | } 44 | @Suppress("UNCHECKED_CAST") 45 | return data as T 46 | } 47 | 48 | /** 49 | * Observes a [LiveData] until the `block` is done executing. 50 | */ 51 | fun LiveData.observeForTesting(block: () -> Unit) { 52 | val observer = Observer { } 53 | try { 54 | observeForever(observer) 55 | block() 56 | } finally { 57 | removeObserver(observer) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/sharedTest/java/com/mayokunadeniyi/instantweather/MainCoroutineRule.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineDispatcher 6 | import kotlinx.coroutines.test.TestCoroutineScope 7 | import kotlinx.coroutines.test.resetMain 8 | import kotlinx.coroutines.test.setMain 9 | import org.junit.rules.TestWatcher 10 | import org.junit.runner.Description 11 | 12 | /** 13 | * Created by Mayokun Adeniyi on 08/07/2020. 14 | */ 15 | 16 | @ExperimentalCoroutinesApi 17 | class MainCoroutineRule(val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : 18 | TestWatcher(), 19 | TestCoroutineScope by TestCoroutineScope(dispatcher) { 20 | override fun starting(description: Description?) { 21 | super.starting(description) 22 | Dispatchers.setMain(dispatcher) 23 | } 24 | 25 | override fun finished(description: Description?) { 26 | super.finished(description) 27 | cleanupTestCoroutines() 28 | Dispatchers.resetMain() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/sharedTest/java/com/mayokunadeniyi/instantweather/RecyclerViewMatcher.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather 2 | 3 | import android.content.res.Resources 4 | import android.view.View 5 | import androidx.recyclerview.widget.RecyclerView 6 | import org.hamcrest.Description 7 | import org.hamcrest.Matcher 8 | import org.hamcrest.TypeSafeMatcher 9 | 10 | class RecyclerViewMatcher(recyclerViewId: Int) { 11 | private val recyclerViewId: Int 12 | fun atPosition(position: Int): Matcher? { 13 | return atPositionOnView(position, -1) 14 | } 15 | 16 | fun atPositionOnView(position: Int, targetViewId: Int): Matcher? { 17 | return object : TypeSafeMatcher() { 18 | var resources: Resources? = null 19 | var childView: View? = null 20 | override fun describeTo(description: Description?) { 21 | var idDescription = Integer.toString(recyclerViewId) 22 | if (resources != null) { 23 | idDescription = try { 24 | resources!!.getResourceName(recyclerViewId) 25 | } catch (var4: Resources.NotFoundException) { 26 | String.format( 27 | "%s (resource name not found)", 28 | *arrayOf(Integer.valueOf(recyclerViewId)) 29 | ) 30 | } 31 | } 32 | description?.appendText("with id: $idDescription") 33 | } 34 | 35 | override fun matchesSafely(view: View?): Boolean { 36 | resources = view?.resources 37 | if (childView == null) { 38 | val recyclerView = 39 | view?.rootView?.findViewById(recyclerViewId) as RecyclerView 40 | childView = if (recyclerView != null && recyclerView.id == recyclerViewId) { 41 | recyclerView.findViewHolderForAdapterPosition(position)!!.itemView 42 | } else { 43 | return false 44 | } 45 | } 46 | return if (targetViewId == -1) { 47 | view === childView 48 | } else { 49 | val targetView: View = childView!!.findViewById(targetViewId) 50 | view === targetView 51 | } 52 | } 53 | } 54 | } 55 | 56 | init { 57 | this.recyclerViewId = recyclerViewId 58 | } 59 | } 60 | 61 | fun withRecyclerView(recyclerViewId: Int): RecyclerViewMatcher? { 62 | return RecyclerViewMatcher(recyclerViewId) 63 | } 64 | -------------------------------------------------------------------------------- /app/src/sharedTest/java/com/mayokunadeniyi/instantweather/TestUtils.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather 2 | 3 | import com.mayokunadeniyi.instantweather.data.model.LocationModel 4 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeather 5 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherCondition 6 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherDescription 7 | import com.mayokunadeniyi.instantweather.data.model.NetworkWeatherForecast 8 | import com.mayokunadeniyi.instantweather.data.model.Weather 9 | import com.mayokunadeniyi.instantweather.data.model.WeatherForecast 10 | import com.mayokunadeniyi.instantweather.data.model.Wind 11 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeather 12 | import com.mayokunadeniyi.instantweather.data.source.local.entity.DBWeatherForecast 13 | 14 | /** 15 | * Created by Mayokun Adeniyi on 02/08/2020. 16 | */ 17 | 18 | val fakeDbWeatherEntity = DBWeather( 19 | 1, 20 | 123, 21 | "Lagos", 22 | Wind(32.5, 24), 23 | listOf(NetworkWeatherDescription(1L, "Main", "Cloudy", "icon")), 24 | NetworkWeatherCondition(324.43, 1234.32, 32.5) 25 | ) 26 | 27 | val fakeDbWeatherForecast = DBWeatherForecast( 28 | 1, "Date", Wind(22.2, 21), 29 | listOf( 30 | NetworkWeatherDescription(1L, "Main", "Desc", "Icon") 31 | ), 32 | NetworkWeatherCondition(22.3, 22.2, 22.2) 33 | ) 34 | 35 | val dummyLocation = LocationModel(12.2, 23.4) 36 | 37 | val fakeNetworkWeather = NetworkWeather( 38 | 1, 39 | 123, 40 | "Lagos", 41 | Wind(32.5, 24), 42 | listOf(NetworkWeatherDescription(1L, "Main", "Cloudy", "icon")), 43 | NetworkWeatherCondition(324.43, 1234.32, 32.5) 44 | ) 45 | 46 | val fakeNetworkWeatherForecast = NetworkWeatherForecast( 47 | 1, "Date", Wind(22.2, 21), 48 | listOf( 49 | NetworkWeatherDescription(1L, "Main", "Desc", "Icon") 50 | ), 51 | NetworkWeatherCondition(22.3, 22.2, 22.2) 52 | ) 53 | 54 | val fakeWeather = Weather( 55 | 1, 56 | 123, 57 | "Lagos", 58 | Wind(32.5, 24), 59 | listOf(NetworkWeatherDescription(1L, "Main", "Cloudy", "cloud")), 60 | NetworkWeatherCondition(324.43, 1234.32, 32.5) 61 | ) 62 | 63 | val fakeWeatherForecast = WeatherForecast( 64 | 1, "2021-07-25 14:22:10", Wind(22.2, 21), 65 | listOf( 66 | NetworkWeatherDescription(1L, "Main", "Desc", "Icon") 67 | ), 68 | NetworkWeatherCondition(22.3, 22.2, 22.2) 69 | ) 70 | 71 | fun createNewWeatherForecast(date: String): WeatherForecast { 72 | return WeatherForecast( 73 | 1, date, Wind(22.2, 21), 74 | listOf( 75 | NetworkWeatherDescription(1L, "Main", "Desc", "Icon") 76 | ), 77 | NetworkWeatherCondition(22.3, 22.2, 22.2) 78 | ) 79 | } 80 | 81 | val fakeWeatherForecastList = listOf( 82 | createNewWeatherForecast("3 Jan 2022, 2:00pm"), 83 | createNewWeatherForecast("4 Jan 2022, 12:00am"), 84 | createNewWeatherForecast("9 Jan 2022, 12:00am"), 85 | createNewWeatherForecast("9 Jan 2022, 12:00am"), 86 | createNewWeatherForecast("9 Jan 2022, 12:00am") 87 | ) 88 | 89 | val invalidDataException = Exception("Invalid Data") 90 | const val queryLocation = "Lagos" 91 | const val cityId = 1234 92 | -------------------------------------------------------------------------------- /app/src/test/java/com/mayokunadeniyi/instantweather/ui/search/SearchFragmentViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.mayokunadeniyi.instantweather.ui.search 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import com.mayokunadeniyi.instantweather.MainCoroutineRule 5 | import com.mayokunadeniyi.instantweather.data.source.repository.WeatherRepository 6 | import com.mayokunadeniyi.instantweather.fakeWeather 7 | import com.mayokunadeniyi.instantweather.getOrAwaitValue 8 | import com.mayokunadeniyi.instantweather.invalidDataException 9 | import com.mayokunadeniyi.instantweather.queryLocation 10 | import com.mayokunadeniyi.instantweather.utils.Result 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.test.runBlockingTest 13 | import org.hamcrest.MatcherAssert.assertThat 14 | import org.hamcrest.Matchers.`is` 15 | import org.hamcrest.Matchers.nullValue 16 | import org.junit.Before 17 | import org.junit.Rule 18 | import org.junit.Test 19 | import org.junit.runner.RunWith 20 | import org.mockito.Mock 21 | import org.mockito.Mockito.times 22 | import org.mockito.Mockito.verify 23 | import org.mockito.Mockito.`when` 24 | import org.mockito.junit.MockitoJUnitRunner 25 | 26 | /** 27 | * Created by Mayokun Adeniyi on 07/08/2020. 28 | */ 29 | @RunWith(MockitoJUnitRunner::class) 30 | @ExperimentalCoroutinesApi 31 | class SearchFragmentViewModelTest { 32 | 33 | //region constants 34 | 35 | //endregion constants 36 | 37 | //region helper fields 38 | @Mock 39 | private lateinit var repository: WeatherRepository 40 | //endregion helper fields 41 | 42 | private lateinit var systemUnderTest: SearchFragmentViewModel 43 | 44 | @get:Rule 45 | var mainCoroutineRule = MainCoroutineRule() 46 | 47 | @get:Rule 48 | var instantTaskExecutorRule = InstantTaskExecutorRule() 49 | 50 | @Before 51 | fun setUp() { 52 | systemUnderTest = SearchFragmentViewModel(repository) 53 | } 54 | 55 | @Test 56 | fun `assert that getSearchWeather returns the weather result successfully from the repository`() = 57 | mainCoroutineRule.runBlockingTest { 58 | `when`(repository.getSearchWeather(queryLocation)).thenReturn( 59 | Result.Success( 60 | fakeWeather 61 | ) 62 | ) 63 | 64 | systemUnderTest.getSearchWeather(queryLocation) 65 | 66 | verify(repository, times(1)).getSearchWeather(queryLocation) 67 | 68 | assertThat(systemUnderTest.weatherInfo.getOrAwaitValue(), `is`(fakeWeather)) 69 | assertThat(systemUnderTest.isLoading.getOrAwaitValue(), `is`(false)) 70 | assertThat(systemUnderTest.dataFetchState.getOrAwaitValue(), `is`(true)) 71 | } 72 | 73 | @Test 74 | fun `assert that getSearchWeather returns a null result from the repository`() = 75 | mainCoroutineRule.runBlockingTest { 76 | `when`(repository.getSearchWeather(queryLocation)).thenReturn( 77 | Result.Success( 78 | null 79 | ) 80 | ) 81 | 82 | systemUnderTest.getSearchWeather(queryLocation) 83 | 84 | verify(repository, times(1)).getSearchWeather(queryLocation) 85 | 86 | assertThat(systemUnderTest.weatherInfo.getOrAwaitValue(), `is`(nullValue())) 87 | assertThat(systemUnderTest.isLoading.getOrAwaitValue(), `is`(false)) 88 | assertThat(systemUnderTest.dataFetchState.getOrAwaitValue(), `is`(false)) 89 | } 90 | 91 | @Test 92 | fun `assert that getSearchWeather returns an error from the repository`() = 93 | mainCoroutineRule.runBlockingTest { 94 | `when`(repository.getSearchWeather(queryLocation)).thenReturn( 95 | Result.Error( 96 | invalidDataException 97 | ) 98 | ) 99 | 100 | systemUnderTest.getSearchWeather(queryLocation) 101 | 102 | verify(repository, times(1)).getSearchWeather(queryLocation) 103 | 104 | assertThat(systemUnderTest.isLoading.getOrAwaitValue(), `is`(false)) 105 | assertThat(systemUnderTest.dataFetchState.getOrAwaitValue(), `is`(false)) 106 | } 107 | 108 | // region helper methods 109 | 110 | // endregion helper methods 111 | 112 | // region helper classes 113 | 114 | // endregion helper classes 115 | } 116 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath(Plugins.kotlinGradlePlugin) 10 | classpath(Plugins.gradleAndroid) 11 | classpath(Plugins.safeArgs) 12 | classpath(Plugins.crashlyticsPlugin) 13 | classpath(Dagger.hiltGradlePlugin) 14 | // NOTE: Do not place your application dependencies here; they belong 15 | // in the individual module build.gradle files 16 | } 17 | } 18 | 19 | plugins { 20 | id("org.jlleitschuh.gradle.ktlint") version ("10.2.1") 21 | } 22 | 23 | allprojects { 24 | repositories { 25 | google() 26 | mavenCentral() 27 | maven(url = "https://jitpack.io") 28 | } 29 | apply(plugin = "org.jlleitschuh.gradle.ktlint") 30 | } 31 | 32 | tasks.register("clean").configure { 33 | delete("build") 34 | } 35 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | } -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one 2 | package_name("com.mayokunadeniyi.instantweather") # e.g. com.krausefx.app 3 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:android) 17 | 18 | platform :android do 19 | desc "Runs all the tests" 20 | lane :test do 21 | gradle(task: "test") 22 | end 23 | 24 | desc "Submit a new Beta Build to Crashlytics Beta" 25 | lane :beta do 26 | gradle(task: "clean assembleRelease") 27 | crashlytics 28 | 29 | # sh "your_script.sh" 30 | # You can also use other beta testing services here 31 | end 32 | 33 | desc "Deploy a new version to the Google Play" 34 | lane :deploy do 35 | gradle(task: "clean assembleRelease") 36 | upload_to_play_store 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | #Enable incremental annotation processing 23 | kapt.incremental.apt=true 24 | # m1 chip support 25 | org.gradle.jvmargs=-Xmx1536M -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -Dfile.encoding=UTF-8 -Dapple.awt.UIElement=true 26 | 27 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Nov 13 14:42:55 GMT 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /media/final-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/media/final-architecture.png -------------------------------------------------------------------------------- /media/instant_weather_github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayokunadeniyi/Instant-Weather/52cc36b3bd17faf121fa72e1dd3e68eb6f58795d/media/instant_weather_github.png -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | include(":app") 2 | rootProject.name = "Instant Weather" 3 | --------------------------------------------------------------------------------