├── results ├── data_layer.png ├── ui_layer.png ├── architecture.png ├── current_weather_screen.png ├── hourly_forecast_screen.png └── android_architect_image.png ├── app ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── drawable │ │ │ │ ├── ic_baseline_arrow_upward_24.xml │ │ │ │ ├── ic_baseline_arrow_downward_24.xml │ │ │ │ ├── ic_baseline_arrow_forward_24.xml │ │ │ │ ├── ic_moon.xml │ │ │ │ ├── ic_sun.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ ├── strings_current_weather.xml │ │ │ │ ├── strings_daily_forecast.xml │ │ │ │ └── themes.xml │ │ │ ├── layout │ │ │ │ ├── activity_my_weather.xml │ │ │ │ ├── daily_forecast_item.xml │ │ │ │ ├── hourly_forecast_item.xml │ │ │ │ ├── fragment_daily_forecast.xml │ │ │ │ └── fragment_current_weather.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── anim │ │ │ │ ├── fade_in.xml │ │ │ │ ├── fade_out.xml │ │ │ │ ├── slide_out_right.xml │ │ │ │ ├── slide_in_left.xml │ │ │ │ ├── slide_in_right.xml │ │ │ │ └── slide_out_left.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── navigation │ │ │ │ └── nav_graph.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── vsaytech │ │ │ │ └── mvvmweather │ │ │ │ ├── extensions │ │ │ │ ├── StringExtensions.kt │ │ │ │ ├── IntegerExtensions.kt │ │ │ │ ├── DoubleExtensions.kt │ │ │ │ ├── ViewModelExtensions.kt │ │ │ │ └── FragmentExtension.kt │ │ │ │ ├── data │ │ │ │ ├── model │ │ │ │ │ ├── ForecastWS.kt │ │ │ │ │ ├── CurrentWeatherWS.kt │ │ │ │ │ ├── ForecastDayWS.kt │ │ │ │ │ ├── ConditionWS.kt │ │ │ │ │ ├── ConditionXWS.kt │ │ │ │ │ ├── LocationWS.kt │ │ │ │ │ ├── AstroWS.kt │ │ │ │ │ ├── ConditionXXWS.kt │ │ │ │ │ ├── DayWS.kt │ │ │ │ │ ├── CurrentWS.kt │ │ │ │ │ └── HourWS.kt │ │ │ │ ├── network │ │ │ │ │ ├── WeatherService.kt │ │ │ │ │ └── DataTransformObjects.kt │ │ │ │ ├── database │ │ │ │ │ ├── DatabaseEntities.kt │ │ │ │ │ ├── Room.kt │ │ │ │ │ └── Converters.kt │ │ │ │ └── repository │ │ │ │ │ └── currentweather │ │ │ │ │ └── CurrentWeatherRepository.kt │ │ │ │ ├── ui │ │ │ │ ├── currentweather │ │ │ │ │ ├── LastLocationListener.kt │ │ │ │ │ ├── DailyForecastAdapter.kt │ │ │ │ │ ├── CurrentWeatherViewModel.kt │ │ │ │ │ └── CurrentWeatherFragment.kt │ │ │ │ ├── uistate │ │ │ │ │ └── NetworkResult.kt │ │ │ │ ├── MyWeatherActivity.kt │ │ │ │ ├── domain │ │ │ │ │ └── CurrentWeatherModels.kt │ │ │ │ └── dailyforecast │ │ │ │ │ ├── HourlyForecastAdapter.kt │ │ │ │ │ └── DailyForecastFragment.kt │ │ │ │ ├── util │ │ │ │ ├── MyDataStore.kt │ │ │ │ └── TimeUtils.kt │ │ │ │ ├── di │ │ │ │ ├── DatabaseModule.kt │ │ │ │ └── NetworkModule.kt │ │ │ │ ├── work │ │ │ │ └── RefreshWeatherDataWorker.kt │ │ │ │ └── MyWeatherApplication.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── vsaytech │ │ │ └── mvvmweather │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── vsaytech │ │ └── mvvmweather │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── gradle.properties ├── gradlew.bat ├── README.md └── gradlew /results/data_layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/results/data_layer.png -------------------------------------------------------------------------------- /results/ui_layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/results/ui_layer.png -------------------------------------------------------------------------------- /results/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/results/architecture.png -------------------------------------------------------------------------------- /results/current_weather_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/results/current_weather_screen.png -------------------------------------------------------------------------------- /results/hourly_forecast_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/results/hourly_forecast_screen.png -------------------------------------------------------------------------------- /results/android_architect_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/results/android_architect_image.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsay01/MVVMWeather/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/extensions/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.extensions 2 | 3 | fun String?.ifNull(): String { 4 | return this ?: "" 5 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/ForecastWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | data class ForecastWS( 4 | val forecastday: List 5 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/extensions/IntegerExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.extensions 2 | 3 | import kotlin.Int.Companion.MIN_VALUE 4 | 5 | fun Int?.ifNull(): Int { 6 | return this ?: MIN_VALUE 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/extensions/DoubleExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.extensions 2 | 3 | import kotlin.Double.Companion.MIN_VALUE 4 | 5 | fun Double?.ifNull(): Double { 6 | return this ?: MIN_VALUE 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/CurrentWeatherWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | data class CurrentWeatherWS( 4 | val current: CurrentWS, 5 | val forecast: ForecastWS, 6 | val location: LocationWS 7 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/ForecastDayWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | data class ForecastDayWS( 4 | val astro: AstroWS, 5 | val date: String, 6 | val date_epoch: Int, 7 | val day: DayWS, 8 | val hour: List 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/ConditionWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | data class ConditionWS( 4 | val code: Int, 5 | val icon: String, 6 | val text: String 7 | ) { 8 | fun getCurrentConditionIcon() = "https://" + icon.removePrefix("//") 9 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/currentweather/LastLocationListener.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui.currentweather 2 | 3 | import android.location.Location 4 | 5 | interface LastLocationListener { 6 | fun onLastLocationFound(location: Location) 7 | fun onCheckPermissonFailed() 8 | } -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 3 | repositories { 4 | google() 5 | mavenCentral() 6 | jcenter() // Warning: this repository is going to shut down soon 7 | } 8 | } 9 | rootProject.name = "MVVMWeather" 10 | include ':app' 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/ConditionXWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | import com.vsaytech.mvvmweather.extensions.ifNull 4 | 5 | data class ConditionXWS( 6 | val code: Int?, 7 | val icon: String?, 8 | val text: String? 9 | ) { 10 | fun getConditionIcon() = "https://" + icon.ifNull().removePrefix("//") 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/LocationWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | data class LocationWS( 4 | val country: String?, 5 | val lat: Double?, 6 | val localtime: String?, 7 | val localtime_epoch: Int?, 8 | val lon: Double?, 9 | val name: String?, 10 | val region: String?, 11 | val tz_id: String? 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/uistate/NetworkResult.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui.uistate 2 | 3 | sealed class NetworkResult { 4 | class Loading : NetworkResult() 5 | class Success(val data: T) : NetworkResult() 6 | class Error(val message: String, val cause: Exception? = null) : NetworkResult() 7 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_arrow_upward_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_arrow_downward_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/AstroWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | data class AstroWS( 8 | val moon_illumination: String?, 9 | val moon_phase: String?, 10 | val moonrise: String?, 11 | val moonset: String?, 12 | val sunrise: String?, 13 | val sunset: String? 14 | ): Parcelable -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/test/java/com/vsaytech/mvvmweather/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/ConditionXXWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | import android.os.Parcelable 4 | import com.vsaytech.mvvmweather.extensions.ifNull 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | data class ConditionXXWS( 9 | val code: Int?, 10 | val icon: String?, 11 | val text: String? 12 | ) : Parcelable { 13 | fun getHourlyConditionIcon() = "https://" + icon.ifNull().removePrefix("//") 14 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_moon.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings_current_weather.xml: -------------------------------------------------------------------------------- 1 | 2 | MVVMWeather 3 | %1$s° F 4 | %1$s %2$s %3$s 5 | Feels like: 6 | Weather condition 7 | Daily Forecast 8 | 3 days 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/network/WeatherService.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.network 2 | 3 | import com.vsaytech.mvvmweather.data.model.CurrentWeatherWS 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | 7 | interface WeatherService { 8 | @GET("forecast.json") 9 | suspend fun getCurrentWeatherByLocation( 10 | @Query("key") apiKey: String, 11 | @Query("q") location: String, 12 | @Query("days") days: Int = 10 13 | ): CurrentWeatherWS 14 | } -------------------------------------------------------------------------------- /app/src/main/res/values/strings_daily_forecast.xml: -------------------------------------------------------------------------------- 1 | 2 | Hourly Forecast - %1$s 3 | Humidity 4 | Wind 5 | %1$s mph 6 | Sun Rise: %1$s 7 | Sun Set: %1$s 8 | Moon Rise: %1$s 9 | Moon Set: %1$s 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/util/MyDataStore.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.util 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.stringPreferencesKey 7 | import androidx.datastore.preferences.preferencesDataStore 8 | 9 | const val LOCATION_KEY = "location" 10 | 11 | val LOCATION_PREFERENCE_KEY = stringPreferencesKey(LOCATION_KEY) 12 | 13 | val Context.locationDataStore: DataStore by preferencesDataStore(name = LOCATION_KEY) 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/extensions/ViewModelExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.extensions 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MediatorLiveData 5 | import androidx.lifecycle.MutableLiveData 6 | 7 | /** 8 | * Transforms a [LiveData] into [MutableLiveData] 9 | * 10 | * @param T type 11 | * @return [MutableLiveData] emitting the same values 12 | */ 13 | fun LiveData.toMutableLiveData(): MutableLiveData { 14 | val mediatorLiveData = MediatorLiveData() 15 | mediatorLiveData.addSource(this) { 16 | mediatorLiveData.value = it 17 | } 18 | return mediatorLiveData 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/MyWeatherActivity.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.vsaytech.mvvmweather.databinding.ActivityMyWeatherBinding 6 | import dagger.hilt.android.AndroidEntryPoint 7 | 8 | @AndroidEntryPoint 9 | class MyWeatherActivity : AppCompatActivity() { 10 | 11 | private lateinit var binding: ActivityMyWeatherBinding 12 | 13 | override fun onCreate(savedInstanceState: Bundle?) { 14 | super.onCreate(savedInstanceState) 15 | binding = ActivityMyWeatherBinding.inflate(layoutInflater) 16 | val view = binding.root 17 | setContentView(view) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/DayWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | data class DayWS( 4 | val avghumidity: Double, 5 | val avgtemp_c: Double, 6 | val avgtemp_f: Double, 7 | val avgvis_km: Double, 8 | val avgvis_miles: Double, 9 | val condition: ConditionXWS, 10 | val daily_chance_of_rain: Int, 11 | val daily_chance_of_snow: Int, 12 | val daily_will_it_rain: Int, 13 | val daily_will_it_snow: Int, 14 | val maxtemp_c: Double, 15 | val maxtemp_f: Double, 16 | val maxwind_kph: Double, 17 | val maxwind_mph: Double, 18 | val mintemp_c: Double, 19 | val mintemp_f: Double, 20 | val totalprecip_in: Double, 21 | val totalprecip_mm: Double, 22 | val uv: Double 23 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/database/DatabaseEntities.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.database 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import com.vsaytech.mvvmweather.data.model.CurrentWS 7 | import com.vsaytech.mvvmweather.data.model.ForecastWS 8 | import com.vsaytech.mvvmweather.data.model.LocationWS 9 | 10 | /** 11 | * Database entities go in this file. These are responsible for reading and writing from the 12 | * database. 13 | */ 14 | 15 | @Entity 16 | data class CurrentWeatherDB( 17 | @PrimaryKey(autoGenerate = true) val id: Int? = 0, 18 | @Embedded val current: CurrentWS, 19 | @Embedded val forecast: ForecastWS, 20 | @Embedded val location: LocationWS 21 | ) -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_my_weather.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/vsaytech/mvvmweather/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.vsaytech.mvvmweather", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /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. 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 -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/CurrentWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | data class CurrentWS( 4 | val cloud: Int?, 5 | val condition: ConditionWS?, 6 | val feelslike_c: Double?, 7 | val feelslike_f: Double?, 8 | val gust_kph: Double?, 9 | val gust_mph: Double?, 10 | val humidity: Int?, 11 | val is_day: Int?, 12 | val last_updated: String?, 13 | val last_updated_epoch: Int?, 14 | val precip_in: Double?, 15 | val precip_mm: Double?, 16 | val pressure_in: Double?, 17 | val pressure_mb: Double?, 18 | val temp_c: Double?, 19 | val temp_f: Double?, 20 | val uv: Double?, 21 | val vis_km: Double?, 22 | val vis_miles: Double?, 23 | val wind_degree: Int?, 24 | val wind_dir: String?, 25 | val wind_kph: Double?, 26 | val wind_mph: Double? 27 | ) -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/database/Room.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.database 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Database 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import androidx.room.RoomDatabase 9 | import androidx.room.TypeConverters 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | @Dao 13 | interface CurrentWeatherDao { 14 | @Query("select * from currentweatherdb LIMIT 1") 15 | fun getCurrentWeather(): Flow 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) 18 | fun insertCurrentWeather(currentWeatherDB: CurrentWeatherDB) 19 | } 20 | 21 | @Database(entities = [CurrentWeatherDB::class], version = 1) 22 | @TypeConverters(Converters::class) 23 | abstract class CurrentWeatherDatabase : RoomDatabase() { 24 | abstract val currentWeatherDao: CurrentWeatherDao 25 | } -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_in.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_out.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_right.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_left.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_right.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_left.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/di/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.di 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.vsaytech.mvvmweather.data.database.CurrentWeatherDao 6 | import com.vsaytech.mvvmweather.data.database.CurrentWeatherDatabase 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import javax.inject.Singleton 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | object DatabaseModule { 17 | @Provides 18 | @Singleton 19 | fun provideAppDatabase(@ApplicationContext appContext: Context): CurrentWeatherDatabase { 20 | return Room.databaseBuilder( 21 | appContext, 22 | CurrentWeatherDatabase::class.java, 23 | "currentWeatherDB" 24 | ).fallbackToDestructiveMigration().build() 25 | } 26 | 27 | @Provides 28 | fun provideCurrentWeatherDao(currentWeatherDatabase: CurrentWeatherDatabase): CurrentWeatherDao { 29 | return currentWeatherDatabase.currentWeatherDao 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/model/HourWS.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | data class HourWS( 8 | val chance_of_rain: Int?, 9 | val chance_of_snow: Int?, 10 | val cloud: Int?, 11 | val condition: ConditionXXWS?, 12 | val dewpoint_c: Double?, 13 | val dewpoint_f: Double?, 14 | val feelslike_c: Double?, 15 | val feelslike_f: Double?, 16 | val gust_kph: Double?, 17 | val gust_mph: Double?, 18 | val heatindex_c: Double?, 19 | val heatindex_f: Double?, 20 | val humidity: Int?, 21 | val is_day: Int?, 22 | val precip_in: Double?, 23 | val precip_mm: Double?, 24 | val pressure_in: Double?, 25 | val pressure_mb: Double?, 26 | val temp_c: Double?, 27 | val temp_f: Double?, 28 | val time: String?, 29 | val time_epoch: Int?, 30 | val uv: Double?, 31 | val vis_km: Double?, 32 | val vis_miles: Double?, 33 | val will_it_rain: Int?, 34 | val will_it_snow: Int?, 35 | val wind_degree: Int?, 36 | val wind_dir: String?, 37 | val wind_kph: Double?, 38 | val wind_mph: Double?, 39 | val windchill_c: Double?, 40 | val windchill_f: Double? 41 | ): Parcelable -------------------------------------------------------------------------------- /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=-Xmx2048m -Dfile.encoding=UTF-8 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 | 23 | # Need to add this line if use Hilt and Moshi 24 | # https://stackoverflow.com/questions/64087297/records-requires-asm8 25 | android.jetifier.ignorelist=moshi-1.13.0 -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/database/Converters.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.database 2 | 3 | import androidx.room.TypeConverter 4 | import com.squareup.moshi.JsonAdapter 5 | import com.squareup.moshi.Moshi 6 | import com.squareup.moshi.Types 7 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 8 | import com.vsaytech.mvvmweather.data.model.ConditionWS 9 | import com.vsaytech.mvvmweather.data.model.ForecastDayWS 10 | import java.lang.reflect.ParameterizedType 11 | 12 | 13 | val moshi: Moshi = Moshi.Builder() 14 | .addLast(KotlinJsonAdapterFactory()) 15 | .build() 16 | val typeForecastDayWSList: ParameterizedType = Types.newParameterizedType(MutableList::class.java, ForecastDayWS::class.java) 17 | val jsonAdapter: JsonAdapter> = moshi.adapter(typeForecastDayWSList) 18 | val jsonAdapterConditionWS: JsonAdapter = moshi.adapter(ConditionWS::class.java) 19 | 20 | class Converters { 21 | @TypeConverter 22 | fun listForecastDayWSToJsonString(value: List?): String = jsonAdapter.toJson(value) 23 | 24 | @TypeConverter 25 | fun jsonForecastDayWSStringToList(value: String) = jsonAdapter.fromJson(value) 26 | 27 | @TypeConverter 28 | fun ConditionWSToJsonString(value: ConditionWS?): String = jsonAdapterConditionWS.toJson(value) 29 | 30 | @TypeConverter 31 | fun jsonStringToConditionWS(value: String) = jsonAdapterConditionWS.fromJson(value) 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/domain/CurrentWeatherModels.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui.domain 2 | 3 | import android.os.Parcelable 4 | import com.vsaytech.mvvmweather.data.model.AstroWS 5 | import com.vsaytech.mvvmweather.data.model.HourWS 6 | import kotlinx.parcelize.Parcelize 7 | 8 | /** 9 | * Domain objects are plain Kotlin data classes that represent the things in our app. These are the 10 | * objects that should be displayed on screen, or manipulated by the app. 11 | * 12 | * @see database for objects that are mapped to the database 13 | * @see network for objects that parse or prepare network calls 14 | */ 15 | data class CurrentWeather( 16 | val name: String, 17 | val region: String, 18 | val country: String, 19 | val currentDayTime: String, 20 | val temp_c: Double, 21 | val temp_f: Double, 22 | val feelslike_c: Double, 23 | val feelslike_f: Double, 24 | val wind_mph: Double, 25 | val wind_kph: Double, 26 | val humidity: Int, 27 | val conditionText: String, 28 | val conditionIcon: String 29 | ) 30 | 31 | @Parcelize 32 | data class CurrentWeatherDailyForecast( 33 | val day: String, 34 | val monthDay: String, 35 | val conditionText: String, 36 | val conditionIcon: String, 37 | val maxtemp_c: Double, 38 | val maxtemp_f: Double, 39 | val mintemp_c: Double, 40 | val mintemp_f: Double, 41 | val astro: AstroWS, 42 | val hourList: List 43 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/work/RefreshWeatherDataWorker.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.work 2 | 3 | import android.content.Context 4 | import androidx.hilt.work.HiltWorker 5 | import androidx.work.CoroutineWorker 6 | import androidx.work.WorkerParameters 7 | import com.vsaytech.mvvmweather.data.repository.currentweather.CurrentWeatherRepository 8 | import com.vsaytech.mvvmweather.util.LOCATION_KEY 9 | import dagger.assisted.Assisted 10 | import dagger.assisted.AssistedInject 11 | import retrofit2.HttpException 12 | import timber.log.Timber 13 | 14 | @HiltWorker 15 | class RefreshWeatherDataWorker @AssistedInject constructor( 16 | @Assisted appContext: Context, 17 | @Assisted params: WorkerParameters, 18 | private val currentWeatherRepository: CurrentWeatherRepository 19 | ) : 20 | CoroutineWorker(appContext, params) { 21 | override suspend fun doWork(): Result { 22 | val location = inputData.getString(LOCATION_KEY) 23 | 24 | try { 25 | location?.let { 26 | currentWeatherRepository.getCurrentWeather(location) 27 | } ?: kotlin.run { 28 | Timber.d("Location is null or empty") 29 | } 30 | Timber.d("Work request for sync is run") 31 | } catch (e: HttpException) { 32 | return Result.retry() 33 | } 34 | return Result.success() 35 | } 36 | 37 | companion object { 38 | const val WORK_NAME = "com.vsaytech.mvvmweather.work.RefreshWeatherDataWorker" 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_sun.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 27 | 30 | 33 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/repository/currentweather/CurrentWeatherRepository.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.repository.currentweather 2 | 3 | import com.vsaytech.mvvmweather.BuildConfig 4 | import com.vsaytech.mvvmweather.data.database.CurrentWeatherDB 5 | import com.vsaytech.mvvmweather.data.database.CurrentWeatherDatabase 6 | import com.vsaytech.mvvmweather.data.network.WeatherService 7 | import com.vsaytech.mvvmweather.data.network.asDatabaseModel 8 | import com.vsaytech.mvvmweather.ui.domain.CurrentWeather 9 | import com.vsaytech.mvvmweather.ui.uistate.NetworkResult 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.delay 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.flow 14 | import kotlinx.coroutines.withContext 15 | import timber.log.Timber 16 | import javax.inject.Inject 17 | 18 | /** 19 | * Repository for fetching current weather from the network 20 | */ 21 | class CurrentWeatherRepository @Inject constructor( 22 | private val database: CurrentWeatherDatabase, 23 | private val weatherService: WeatherService 24 | ) { 25 | 26 | //Use this if you want to return Flow to ViewModel 27 | val currentWeather: Flow = database.currentWeatherDao.getCurrentWeather() 28 | 29 | suspend fun getCurrentWeather(location: String) { 30 | withContext(Dispatchers.IO) { 31 | Timber.d("current weather is called") 32 | val currentWeather = weatherService.getCurrentWeatherByLocation(BuildConfig.WEATHER_API_KEY, location) 33 | database.currentWeatherDao.insertCurrentWeather(currentWeather.asDatabaseModel()) 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.di 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 5 | import com.vsaytech.mvvmweather.BuildConfig 6 | import com.vsaytech.mvvmweather.data.network.WeatherService 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import okhttp3.OkHttpClient 12 | import okhttp3.logging.HttpLoggingInterceptor 13 | import retrofit2.Retrofit 14 | import retrofit2.converter.moshi.MoshiConverterFactory 15 | import javax.inject.Singleton 16 | 17 | @Module 18 | @InstallIn(SingletonComponent::class) 19 | object NetworkModule { 20 | 21 | @Singleton 22 | @Provides 23 | fun provideMoshi(): Moshi = Moshi.Builder() 24 | .addLast(KotlinJsonAdapterFactory()) 25 | .build() 26 | 27 | @Singleton 28 | @Provides 29 | fun provideOkHttpClient() = if (BuildConfig.DEBUG) { 30 | val loggingInterceptor = HttpLoggingInterceptor() 31 | loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY) 32 | OkHttpClient.Builder() 33 | .addInterceptor(loggingInterceptor) 34 | .build() 35 | } else { 36 | OkHttpClient 37 | .Builder() 38 | .build() 39 | } 40 | 41 | @Singleton 42 | @Provides 43 | fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() 44 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 45 | .baseUrl(BuildConfig.WEATHER_BASE_URL) 46 | .client(okHttpClient) 47 | .build() 48 | 49 | @Provides 50 | @Singleton 51 | fun provideWeatherService(retrofit: Retrofit): WeatherService = 52 | retrofit.create(WeatherService::class.java) 53 | 54 | } -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | 23 | 28 | 31 | 32 | 36 | 37 | 40 | 41 | 46 | 49 | 53 | 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/util/TimeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.util 2 | 3 | import java.text.SimpleDateFormat 4 | import java.util.* 5 | 6 | const val DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm" 7 | const val DATE_TIME_FORMAT_SECONDARY = "yyyy-MM-dd" 8 | const val DAY_FORMAT = "EEEE" 9 | const val MONTH_DAY_FORMAT = "MMM/dd" 10 | const val TIME_FORMAT = "hh:mm aa" 11 | const val TODAY = "Today" 12 | 13 | fun getDayNameFromDateTimeString(dateTime: String): String? { 14 | val format = SimpleDateFormat(DATE_TIME_FORMAT, Locale.getDefault()) 15 | val date = format.parse(dateTime) 16 | val day = SimpleDateFormat(DAY_FORMAT, Locale.getDefault()) 17 | return date?.let { 18 | if (isDateCurrentDay(day.format(it))) return TODAY else return day.format(it) 19 | } 20 | } 21 | 22 | fun getDayNameFromDateString(dateTime: String): String? { 23 | val format = SimpleDateFormat(DATE_TIME_FORMAT_SECONDARY, Locale.getDefault()) 24 | val date = format.parse(dateTime) 25 | val day = SimpleDateFormat(DAY_FORMAT, Locale.getDefault()) 26 | return date?.let { 27 | if (isDateCurrentDay(day.format(it))) return TODAY else return day.format(it) 28 | } 29 | } 30 | 31 | fun getMonthDayFromDateTimeString(dateTime: String): String? { 32 | val format = SimpleDateFormat(DATE_TIME_FORMAT, Locale.getDefault()) 33 | val date = format.parse(dateTime) 34 | val mothDay = SimpleDateFormat(MONTH_DAY_FORMAT, Locale.getDefault()) 35 | return date?.let { mothDay.format(it) } 36 | } 37 | 38 | fun getMonthDayFromDateString(dateTime: String): String? { 39 | val format = SimpleDateFormat(DATE_TIME_FORMAT_SECONDARY, Locale.getDefault()) 40 | val date = format.parse(dateTime) 41 | val mothDay = SimpleDateFormat(MONTH_DAY_FORMAT, Locale.getDefault()) 42 | return date?.let { mothDay.format(it) } 43 | } 44 | 45 | fun getTimeFromDateString(dateTime: String): String? { 46 | val format = SimpleDateFormat(DATE_TIME_FORMAT, Locale.getDefault()) 47 | val date = format.parse(dateTime) 48 | val time = SimpleDateFormat(TIME_FORMAT, Locale.getDefault()) 49 | return date?.let { time.format(it) } 50 | } 51 | 52 | private fun isDateCurrentDay(day: String): Boolean { 53 | val calendar = Calendar.getInstance() 54 | val sdf = SimpleDateFormat(DAY_FORMAT, Locale.getDefault()) 55 | val dayOfWeek = sdf.format(calendar.time) 56 | if (dayOfWeek.contentEquals(day, true)) { 57 | return true 58 | } 59 | return false 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/extensions/FragmentExtension.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.extensions 2 | 3 | import android.Manifest 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.pm.PackageManager 7 | import android.location.Location 8 | import android.location.LocationManager 9 | import android.provider.Settings 10 | import android.widget.Toast 11 | import androidx.core.content.ContextCompat 12 | import androidx.fragment.app.Fragment 13 | import com.google.android.gms.location.FusedLocationProviderClient 14 | import com.vsaytech.mvvmweather.ui.currentweather.LastLocationListener 15 | 16 | val locationPermissions = arrayOf( 17 | Manifest.permission.ACCESS_NETWORK_STATE, 18 | Manifest.permission.ACCESS_FINE_LOCATION, 19 | Manifest.permission.ACCESS_COARSE_LOCATION 20 | ) 21 | 22 | private fun Fragment.checkAppPermission(): Boolean { 23 | locationPermissions.forEach { permission -> 24 | if (ContextCompat.checkSelfPermission(requireContext(), permission) == 25 | PackageManager.PERMISSION_DENIED 26 | ) { 27 | return false 28 | } 29 | } 30 | return true 31 | } 32 | 33 | //Request Location 34 | fun Fragment.getLastLocation( 35 | fusedLocationClient: FusedLocationProviderClient, 36 | lastLocationListener: LastLocationListener 37 | ) { 38 | if (checkAppPermission()) { 39 | activity?.let { 40 | if (isLocationEnabled()) { 41 | fusedLocationClient.lastLocation.addOnCompleteListener(it) { task -> 42 | val location: Location? = task.result 43 | if (location != null) { 44 | lastLocationListener.onLastLocationFound(location) 45 | //viewModel.refreshCurrentWeatherFromRepository("${location.latitude},${location.longitude}") 46 | } 47 | } 48 | } else { 49 | Toast.makeText(it, "Please turn on location", Toast.LENGTH_LONG).show() 50 | val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) 51 | startActivity(intent) 52 | } 53 | } 54 | } else { 55 | lastLocationListener.onCheckPermissonFailed() 56 | } 57 | } 58 | 59 | private fun Fragment.isLocationEnabled(): Boolean { 60 | val locationManager: LocationManager = 61 | activity?.getSystemService(Context.LOCATION_SERVICE) as LocationManager 62 | return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled( 63 | LocationManager.NETWORK_PROVIDER 64 | ) 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/data/network/DataTransformObjects.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.data.network 2 | 3 | import com.vsaytech.mvvmweather.data.database.CurrentWeatherDB 4 | import com.vsaytech.mvvmweather.data.model.CurrentWeatherWS 5 | import com.vsaytech.mvvmweather.data.model.ForecastWS 6 | import com.vsaytech.mvvmweather.extensions.ifNull 7 | import com.vsaytech.mvvmweather.ui.domain.CurrentWeather 8 | import com.vsaytech.mvvmweather.ui.domain.CurrentWeatherDailyForecast 9 | 10 | /** 11 | * DataTransferObjects go in this file. These are responsible for parsing responses from the server 12 | * or formatting objects to send to the server. You should convert these to domain objects before 13 | * using them. 14 | */ 15 | 16 | /** 17 | * Convert database results CurrentWeatherDB to CurrentWeather UI domain object 18 | */ 19 | fun CurrentWeatherDB.asCurrentWeatherDomainModel(): CurrentWeather { 20 | return CurrentWeather( 21 | name = location.name.ifNull(), 22 | region = location.region.ifNull(), 23 | country = location.country.ifNull(), 24 | currentDayTime = location.localtime.ifNull(), 25 | temp_c = current.temp_c.ifNull(), 26 | temp_f = current.temp_f.ifNull(), 27 | feelslike_c = current.feelslike_c.ifNull(), 28 | feelslike_f = current.feelslike_f.ifNull(), 29 | wind_mph = current.wind_mph.ifNull(), 30 | wind_kph = current.wind_kph.ifNull(), 31 | humidity = current.humidity.ifNull(), 32 | conditionText = current.condition?.text.ifNull(), 33 | conditionIcon = current.condition?.getCurrentConditionIcon().ifNull() 34 | ) 35 | } 36 | 37 | /** 38 | * Convert CurrentWeatherDB to CurrentWeatherDailyForecast UI domain object 39 | */ 40 | fun ForecastWS.asCurrentWeatherDailyForecastDomainModel(): List { 41 | return forecastday.map { 42 | CurrentWeatherDailyForecast( 43 | day = it.date, 44 | monthDay = it.date, 45 | conditionText = it.day.condition.text.ifNull(), 46 | conditionIcon = it.day.condition.getConditionIcon(), 47 | maxtemp_c = it.day.maxtemp_c, 48 | maxtemp_f = it.day.maxtemp_f, 49 | mintemp_c = it.day.maxtemp_c, 50 | mintemp_f = it.day.maxtemp_f, 51 | astro = it.astro, 52 | hourList = it.hour 53 | ) 54 | } 55 | } 56 | 57 | /** 58 | * Convert Network results CurrentWeatherWS to database objects CurrentWeatherDB 59 | */ 60 | fun CurrentWeatherWS.asDatabaseModel(): CurrentWeatherDB { 61 | return CurrentWeatherDB( 62 | current = current, 63 | forecast = forecast, 64 | location = location 65 | ) 66 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/dailyforecast/HourlyForecastAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui.dailyforecast 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import com.bumptech.glide.Glide 7 | import com.vsaytech.mvvmweather.R 8 | import com.vsaytech.mvvmweather.data.model.HourWS 9 | import com.vsaytech.mvvmweather.databinding.HourlyForecastItemBinding 10 | import com.vsaytech.mvvmweather.util.getTimeFromDateString 11 | import dagger.hilt.android.scopes.FragmentScoped 12 | import javax.inject.Inject 13 | 14 | @FragmentScoped 15 | class HourlyForecastAdapter @Inject constructor() : RecyclerView.Adapter() { 16 | /** 17 | * The WeatherHourlyForecastList that our Adapter will show 18 | */ 19 | var currentHourlyForecastList: List = emptyList() 20 | set(value) { 21 | field = value 22 | // For an extra challenge, update this to use the paging library. 23 | 24 | // Notify any registered observers that the data set has changed. This will cause every 25 | // element in our RecyclerView to be invalidated. 26 | notifyDataSetChanged() 27 | } 28 | 29 | /** 30 | * Called when RecyclerView needs a new {@link ViewHolder} of the given type to represent 31 | * an item. 32 | */ 33 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HourlyForecastViewHolder { 34 | val itemBinding = HourlyForecastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) 35 | return HourlyForecastViewHolder(itemBinding) 36 | } 37 | 38 | override fun getItemCount() = currentHourlyForecastList.size 39 | 40 | /** 41 | * Called by RecyclerView to display the data at the specified position. This method should 42 | * update the contents of the {@link ViewHolder#itemView} to reflect the item at the given 43 | * position. 44 | */ 45 | override fun onBindViewHolder(holder: HourlyForecastViewHolder, position: Int) { 46 | val paymentBean: HourWS = currentHourlyForecastList[position] 47 | holder.bind(paymentBean) 48 | } 49 | } 50 | 51 | /** 52 | * ViewHolder for WeatherHourlyForecast items. 53 | */ 54 | class HourlyForecastViewHolder(private val itemBinding: HourlyForecastItemBinding) : 55 | RecyclerView.ViewHolder(itemBinding.root) { 56 | fun bind(hourlyForecast: HourWS) { 57 | itemBinding.apply { 58 | tvHourlyTime.text = hourlyForecast.time?.let { getTimeFromDateString(it) } 59 | Glide.with(ivHourlyConditionIcon.context).load(hourlyForecast.condition?.getHourlyConditionIcon()).into(ivHourlyConditionIcon) 60 | tvHourlyWeather.text = tvHourlyWeather.context.getString(R.string.current_weather_temp, hourlyForecast.temp_f.toString()) 61 | tvHourlyCondition.text = hourlyForecast.condition?.text 62 | tvHourlyHumidityValue.text = hourlyForecast.humidity.toString() 63 | tvHourlyWindValue.text = tvHourlyWindValue.context.getString(R.string.hour_wind_value, hourlyForecast.wind_mph.toString()) 64 | tvHourlyTempFeelLike.text = tvHourlyTempFeelLike.context.getString(R.string.current_weather_temp, hourlyForecast.feelslike_f.toString()) 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/daily_forecast_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 18 | 19 | 27 | 28 | 38 | 39 | 48 | 49 | 58 | 59 | 68 | 69 | 77 | 78 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/dailyforecast/DailyForecastFragment.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui.dailyforecast 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.fragment.app.Fragment 9 | import androidx.navigation.fragment.navArgs 10 | import androidx.recyclerview.widget.DividerItemDecoration 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import com.bumptech.glide.Glide 13 | import com.vsaytech.mvvmweather.R 14 | import com.vsaytech.mvvmweather.databinding.FragmentDailyForecastBinding 15 | import com.vsaytech.mvvmweather.ui.currentweather.DailyForecastAdapter 16 | import dagger.hilt.android.AndroidEntryPoint 17 | import javax.inject.Inject 18 | 19 | @AndroidEntryPoint 20 | class DailyForecastFragment : Fragment() { 21 | 22 | private var _binding: FragmentDailyForecastBinding? = null 23 | 24 | // This property is only valid between onCreateView and 25 | // onDestroyView. 26 | private val binding get() = _binding!! 27 | 28 | @Inject 29 | lateinit var hourlyAdapter: HourlyForecastAdapter 30 | 31 | private val args: DailyForecastFragmentArgs by navArgs() 32 | 33 | override fun onCreateView( 34 | inflater: LayoutInflater, container: ViewGroup?, 35 | savedInstanceState: Bundle? 36 | ): View { 37 | // Inflate the layout for this fragment 38 | _binding = FragmentDailyForecastBinding.inflate(inflater, container, false) 39 | return binding.root 40 | } 41 | 42 | override fun onResume() { 43 | super.onResume() 44 | (activity as AppCompatActivity).supportActionBar?.show() 45 | } 46 | 47 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 48 | super.onViewCreated(view, savedInstanceState) 49 | initView() 50 | } 51 | 52 | private fun initView() { 53 | val currentWeatherDailyForecast = args.currentWeatherDailyForecast 54 | val locationName = args.currentLocationName 55 | (activity as AppCompatActivity).supportActionBar?.title = getString(R.string.hour_forecast, locationName) 56 | 57 | if (currentWeatherDailyForecast.hourList.isNotEmpty()) { 58 | binding.apply { 59 | rvHourlyForecast.apply { 60 | addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) 61 | layoutManager = LinearLayoutManager(context) 62 | adapter = hourlyAdapter 63 | } 64 | hourlyAdapter?.currentHourlyForecastList = currentWeatherDailyForecast.hourList 65 | 66 | Glide.with(ivMoon).load(R.drawable.ic_moon).into(ivMoon) 67 | Glide.with(ivSun).load(R.drawable.ic_sun).into(ivSun) 68 | 69 | tvSunRise.text = getString(R.string.sun_rise, currentWeatherDailyForecast.astro.sunrise) 70 | tvSunSet.text = getString(R.string.sun_set, currentWeatherDailyForecast.astro.sunset) 71 | 72 | tvMoonRise.text = getString(R.string.moon_rise, currentWeatherDailyForecast.astro.moonrise) 73 | tvMoonSet.text = getString(R.string.moon_set, currentWeatherDailyForecast.astro.moonset) 74 | } 75 | } 76 | } 77 | 78 | override fun onDestroyView() { 79 | super.onDestroyView() 80 | _binding = null 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/currentweather/DailyForecastAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui.currentweather 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import com.bumptech.glide.Glide 7 | import com.vsaytech.mvvmweather.databinding.DailyForecastItemBinding 8 | import com.vsaytech.mvvmweather.ui.domain.CurrentWeatherDailyForecast 9 | import com.vsaytech.mvvmweather.util.getDayNameFromDateString 10 | import com.vsaytech.mvvmweather.util.getMonthDayFromDateString 11 | import dagger.hilt.android.scopes.FragmentScoped 12 | import javax.inject.Inject 13 | 14 | @FragmentScoped 15 | class DailyForecastAdapter @Inject constructor() : RecyclerView.Adapter() { 16 | 17 | private var onDailyForecastItemClickLister: ((CurrentWeatherDailyForecast) -> Unit)? = null 18 | fun setOnDailyForecastItemClickLister(listener: (CurrentWeatherDailyForecast) -> Unit) { 19 | onDailyForecastItemClickLister = listener 20 | } 21 | 22 | /** 23 | * The WeatherDailyForecastList that our Adapter will show 24 | */ 25 | var currentWeatherDailyForecastList: List = emptyList() 26 | set(value) { 27 | field = value 28 | // For an extra challenge, update this to use the paging library. 29 | 30 | // Notify any registered observers that the data set has changed. This will cause every 31 | // element in our RecyclerView to be invalidated. 32 | notifyDataSetChanged() 33 | } 34 | 35 | /** 36 | * Called when RecyclerView needs a new {@link ViewHolder} of the given type to represent 37 | * an item. 38 | */ 39 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DailyForecastViewHolder { 40 | val itemBinding = DailyForecastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) 41 | return DailyForecastViewHolder(itemBinding) 42 | } 43 | 44 | override fun getItemCount() = currentWeatherDailyForecastList.size 45 | 46 | /** 47 | * Called by RecyclerView to display the data at the specified position. This method should 48 | * update the contents of the {@link ViewHolder#itemView} to reflect the item at the given 49 | * position. 50 | */ 51 | override fun onBindViewHolder(holder: DailyForecastViewHolder, position: Int) { 52 | val paymentBean: CurrentWeatherDailyForecast = currentWeatherDailyForecastList[position] 53 | holder.bind(paymentBean, onDailyForecastItemClickLister) 54 | } 55 | } 56 | 57 | /** 58 | * ViewHolder for WeatherDailyForecast items. 59 | */ 60 | class DailyForecastViewHolder(private val itemBinding: DailyForecastItemBinding) : 61 | RecyclerView.ViewHolder(itemBinding.root) { 62 | fun bind(currentWeatherDailyForecast: CurrentWeatherDailyForecast, onDailyForecastItemClickLister: ((CurrentWeatherDailyForecast) -> Unit)?) { 63 | itemBinding.apply { 64 | clDailyForecast.setOnClickListener { 65 | onDailyForecastItemClickLister?.let { 66 | it(currentWeatherDailyForecast) 67 | } 68 | } 69 | tvDay.text = getDayNameFromDateString(currentWeatherDailyForecast.day) 70 | tvDate.text = getMonthDayFromDateString(currentWeatherDailyForecast.monthDay) 71 | tvCondition.text = currentWeatherDailyForecast.conditionText 72 | Glide.with(ivConditionIcon.context).load(currentWeatherDailyForecast.conditionIcon).into(ivConditionIcon) 73 | tvTempMax.text = currentWeatherDailyForecast.maxtemp_f.toString() 74 | tvTempMin.text = currentWeatherDailyForecast.mintemp_f.toString() 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/currentweather/CurrentWeatherViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui.currentweather 2 | 3 | import android.app.Application 4 | import androidx.datastore.preferences.core.edit 5 | import androidx.lifecycle.AndroidViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.vsaytech.mvvmweather.data.network.asCurrentWeatherDailyForecastDomainModel 8 | import com.vsaytech.mvvmweather.data.network.asCurrentWeatherDomainModel 9 | import com.vsaytech.mvvmweather.data.repository.currentweather.CurrentWeatherRepository 10 | import com.vsaytech.mvvmweather.ui.domain.CurrentWeather 11 | import com.vsaytech.mvvmweather.ui.domain.CurrentWeatherDailyForecast 12 | import com.vsaytech.mvvmweather.ui.uistate.NetworkResult 13 | import com.vsaytech.mvvmweather.util.LOCATION_PREFERENCE_KEY 14 | import com.vsaytech.mvvmweather.util.locationDataStore 15 | import dagger.hilt.android.lifecycle.HiltViewModel 16 | import kotlinx.coroutines.flow.MutableStateFlow 17 | import kotlinx.coroutines.flow.StateFlow 18 | import kotlinx.coroutines.launch 19 | import java.io.IOException 20 | import javax.inject.Inject 21 | 22 | @HiltViewModel 23 | class CurrentWeatherViewModel @Inject constructor( 24 | private val app: Application, 25 | private val currentWeatherRepository: CurrentWeatherRepository 26 | ) : AndroidViewModel(app) { 27 | 28 | /** 29 | * A currentWeather displayed on the screen. 30 | */ 31 | // publicly exposed live data, not mutable 32 | val currentWeatherStateFlow: StateFlow> 33 | get() = currentWeatherMutableStateFlow 34 | 35 | private val currentWeatherMutableStateFlow: MutableStateFlow> = MutableStateFlow(NetworkResult.Loading()) 36 | 37 | /** 38 | * A dailyForecastWeather displayed on the screen. 39 | */ 40 | // publicly exposed live data, not mutable 41 | val dailyForecastWeatherListStateFlow: StateFlow>> 42 | get() = dailyForecastWeatherListMutableStateFlow 43 | 44 | private val dailyForecastWeatherListMutableStateFlow: MutableStateFlow>> = 45 | MutableStateFlow(NetworkResult.Loading()) 46 | 47 | init { 48 | viewModelScope.launch { 49 | currentWeatherRepository.currentWeather 50 | // Update View with the latest currentWeatherDB 51 | // Writes to the value property of MutableStateFlow, 52 | // adding a new element to the flow and updating all of its collectors 53 | .collect { currentWeather -> 54 | currentWeatherMutableStateFlow.value = NetworkResult.Success(currentWeather.asCurrentWeatherDomainModel()) 55 | 56 | dailyForecastWeatherListMutableStateFlow.value = 57 | NetworkResult.Success(currentWeather.forecast.asCurrentWeatherDailyForecastDomainModel()) 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * Refresh data from the repository. Use a coroutine launch to run in a 64 | * background thread. 65 | */ 66 | fun refreshCurrentWeatherFromRepository(location: String) { 67 | viewModelScope.launch { 68 | try { 69 | currentWeatherMutableStateFlow.value = NetworkResult.Loading() 70 | saveLocationDataStore(location) 71 | currentWeatherRepository.getCurrentWeather(location) 72 | } catch (networkError: IOException) { 73 | // Show a Toast error message and hide the progress bar. 74 | currentWeatherMutableStateFlow.value = NetworkResult.Error("Refresh current weather from repository", networkError) 75 | } 76 | } 77 | } 78 | 79 | private suspend fun saveLocationDataStore(locationParams: String) { 80 | app.locationDataStore.edit { location -> 81 | location[LOCATION_PREFERENCE_KEY] = locationParams 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | id 'androidx.navigation.safeargs' 6 | id 'kotlin-parcelize' 7 | // Hilt 8 | id("dagger.hilt.android.plugin") 9 | } 10 | 11 | def apikeyPropertiesFile = rootProject.file("apikey.properties") 12 | def apikeyProperties = new Properties() 13 | if (apikeyPropertiesFile.exists()) { 14 | apikeyProperties.load(new FileInputStream(apikeyPropertiesFile)) 15 | } 16 | 17 | android { 18 | compileSdk 31 19 | 20 | defaultConfig { 21 | applicationId "com.vsaytech.mvvmweather" 22 | minSdk 21 23 | targetSdk 31 24 | versionCode 1 25 | versionName "1.0" 26 | 27 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 28 | buildConfigField("String", "WEATHER_API_KEY", apikeyProperties['WEATHER_API_KEY']) 29 | buildConfigField("String", "WEATHER_BASE_URL", apikeyProperties['WEATHER_BASE_URL']) 30 | } 31 | 32 | buildTypes { 33 | release { 34 | minifyEnabled false 35 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 36 | } 37 | } 38 | compileOptions { 39 | sourceCompatibility JavaVersion.VERSION_11 40 | targetCompatibility JavaVersion.VERSION_11 41 | } 42 | kotlinOptions { 43 | jvmTarget = '1.8' 44 | } 45 | buildFeatures { 46 | viewBinding = true 47 | } 48 | } 49 | 50 | dependencies { 51 | 52 | implementation 'androidx.core:core-ktx:1.8.0' 53 | implementation 'androidx.fragment:fragment-ktx:1.4.1' 54 | 55 | implementation 'androidx.appcompat:appcompat:1.4.2' 56 | implementation 'com.google.android.material:material:1.6.1' 57 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 58 | 59 | testImplementation 'junit:junit:4.13.2' 60 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 61 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 62 | 63 | // navigation 64 | def nav_version = "1.0.0" 65 | implementation "android.arch.navigation:navigation-fragment-ktx:1.0.0" 66 | implementation "android.arch.navigation:navigation-ui-ktx:$nav_version" 67 | 68 | // coroutines for getting off the UI thread 69 | def coroutines = "1.0.1" 70 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2" 71 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2" 72 | 73 | // retrofit for networking 74 | implementation 'com.squareup.retrofit2:retrofit:2.9.0' 75 | implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2' 76 | implementation 'com.squareup.retrofit2:converter-moshi:2.9.0' 77 | implementation 'com.squareup.okhttp3:logging-interceptor:4.7.2' 78 | 79 | // moshi for parsing the JSON format 80 | def moshi_version = "1.9.3" 81 | implementation "com.squareup.moshi:moshi:1.13.0" 82 | implementation "com.squareup.moshi:moshi-kotlin:1.13.0" 83 | kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0" 84 | 85 | // joda time library for dealing with time 86 | implementation 'joda-time:joda-time:2.10.14' 87 | 88 | // arch components 89 | // ViewModel and LiveData 90 | def lifecycle_version = "2.2.0" 91 | implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" 92 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" 93 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1" 94 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.0-rc02" 95 | 96 | // logging 97 | implementation 'com.jakewharton.timber:timber:5.0.1' 98 | 99 | // glide for images 100 | implementation 'com.github.bumptech.glide:glide:4.13.2' 101 | kapt 'com.github.bumptech.glide:compiler:4.13.2' 102 | 103 | // Room dependency 104 | def room_version = "2.1.0-alpha06" 105 | implementation "androidx.room:room-runtime:2.4.2" 106 | kapt "androidx.room:room-compiler:2.4.2" 107 | implementation "androidx.room:room-ktx:2.4.2" 108 | 109 | // WorkManager dependency 110 | def work_version = "1.0.1" 111 | implementation "android.arch.work:work-runtime-ktx:$work_version" 112 | 113 | implementation "com.google.android.gms:play-services-location:20.0.0" 114 | implementation "androidx.work:work-runtime-ktx:2.7.1" 115 | 116 | // DataStore 117 | implementation("androidx.datastore:datastore-preferences:1.0.0") 118 | 119 | // Hilt 120 | implementation("com.google.dagger:hilt-android:2.41") 121 | kapt("com.google.dagger:hilt-android-compiler:2.41") 122 | 123 | // Hilt workmanager 124 | implementation("androidx.hilt:hilt-work:1.0.0") 125 | kapt("androidx.hilt:hilt-compiler:1.0.0") 126 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MVVMWeather 2 | 3 | ## :scroll: Description 4 | Mini weather application that shows the weather of the current location. It also shows the hourly forecast of that location as well. 5 | The goal of this project is to show how we can follow Model-view-viewmodel (MVVM) architecture. 6 | 7 | 8 | 9 | ## :camera_flash: Screenshots 10 | 11 | 12 | ## :scroll: (IMPORTANT) Add weather API Key 13 | 14 | - Go to https://www.weatherapi.com/ 15 | - Sign up with free plan 16 | - Create apikey.properties file, and add following lines 17 | 18 | ``` 19 | WEATHER_API_KEY="" 20 | WEATHER_BASE_URL="https://api.weatherapi.com/v1/" 21 | ``` 22 | 23 | ## :bulb: Motivation 24 | In this sample project, it focus on: 25 | - Model–view–viewmodel (MVVM) architecture 26 | - MVVM with network call only 27 | - MVVM with network call and database (Room) 28 | - Navigation with Android architecture component 29 | - WorkManager 30 | - DataStore 31 | - Use kotlin Flow in MVVM 32 | 33 | ## Dependencies 34 | - Navigation 35 | - Coroutine 36 | - Retrofit 37 | - Moshi 38 | - ViewModel 39 | - LiveData 40 | - Glide 41 | - Room 42 | - DataStore 43 | - Flow 44 | 45 | ## Branches 46 | 47 | | Branches | What do we have? | 48 | | ------------- |:-------------------------------------------------------------------------------------:| 49 | | `master` | MVVM with network call and database | 50 | | `remoteonly` | MVVM with network call only | 51 | | `datasource` | MVVM with network call and database (Room) | 52 | | `workmanager` | Add a work manager to get the current weather with the saved location from DataStore | 53 | | `flow` | Show how to use kotlin flow in MVVM | 54 | | `di` | Android Hilt and Kotlin full Flow integrated | 55 | 56 | ## Recommended app architecture 57 | 58 | Note: The recommendations and template only allow projects to scale, improve quality and robustness, and make them easier to test. 59 | However, this is only just guidelines, and we should adapt them to our requirements as needed. 60 | 61 | [Drive UI from data models](https://developer.android.com/topic/architecture) 62 | The important principle is that you should drive your UI from data models, preferably persistent models (room). 63 | Data models represent the data of an app. They're independent from the UI elements and other components in your app. 64 | This means that they are not tied to the UI and app component lifecycle, but will still be destroyed when the OS decides to remove the app's process from memory. 65 | Persistent models are ideal for the following reasons: 66 | - Your users don't lose data if the Android OS destroys your app to free up resources. 67 | - Your app continues to work in cases when a network connection is flaky or not available. 68 | - If you base your app architecture on data model classes, you make your app more testable and robust. 69 | 70 | 71 | 72 | There are three layers: 73 | - UI Layer 74 | - Domain Layer (optional) 75 | - Data Layer 76 | 77 | 78 | 79 | 80 | 81 | ## Model–view–viewmodel (MVVM) with network call only (TBD) 82 | 83 | ## Model–view–viewmodel (MVVM) with network call with database (TBD) 84 | 85 | ## Navigation with Android architecture component (TBD) 86 | 87 | ## WorkManager and DataStore (TBD) 88 | 89 | ## Kotlin Flow in Room and Repository (Still use LiveData in ViewModel and UI) (TBD) 90 | - Make use of NetworkResult sealed class for LOADING, SUCCESS, ERROR states 91 | 92 | ## Kotlin full Flow integrated (TBD) 93 | 94 | ## Android Hilt integrated (TBD) 95 | - Inject viewModel 96 | - Inject repository 97 | - Inject database 98 | - Inject network (Retrofit) 99 | - Inject RecyclerView adapter 100 | - Hilt and WorkManager 101 | 102 | ``` 103 | Copyright 2022 The Android Open Source Project 104 | 105 | Licensed under the Apache License, Version 2.0 (the "License"); 106 | you may not use this file except in compliance with the License. 107 | You may obtain a copy of the License at 108 | 109 | https://www.apache.org/licenses/LICENSE-2.0 110 | 111 | Unless required by applicable law or agreed to in writing, software 112 | distributed under the License is distributed on an "AS IS" BASIS, 113 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 114 | See the License for the specific language governing permissions and 115 | limitations under the License. 116 | ``` 117 | -------------------------------------------------------------------------------- /app/src/main/res/layout/hourly_forecast_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | 28 | 29 | 38 | 39 | 47 | 48 | 57 | 58 | 67 | 68 | 77 | 78 | 88 | 89 | 98 | 99 | 110 | 111 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/MyWeatherApplication.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather 2 | 3 | import android.app.Application 4 | import androidx.datastore.preferences.core.emptyPreferences 5 | import androidx.hilt.work.HiltWorkerFactory 6 | import androidx.work.Configuration 7 | import androidx.work.Configuration.Provider 8 | import androidx.work.Constraints 9 | import androidx.work.Data 10 | import androidx.work.OneTimeWorkRequestBuilder 11 | import androidx.work.WorkManager 12 | import com.vsaytech.mvvmweather.util.LOCATION_KEY 13 | import com.vsaytech.mvvmweather.util.LOCATION_PREFERENCE_KEY 14 | import com.vsaytech.mvvmweather.util.locationDataStore 15 | import com.vsaytech.mvvmweather.work.RefreshWeatherDataWorker 16 | import dagger.hilt.android.HiltAndroidApp 17 | import kotlinx.coroutines.CoroutineScope 18 | import kotlinx.coroutines.Dispatchers 19 | import kotlinx.coroutines.flow.catch 20 | import kotlinx.coroutines.flow.first 21 | import kotlinx.coroutines.flow.map 22 | import kotlinx.coroutines.launch 23 | import timber.log.Timber 24 | import java.io.IOException 25 | import javax.inject.Inject 26 | 27 | /** 28 | * Override application to setup background work via WorkManager 29 | */ 30 | @HiltAndroidApp 31 | class MyWeatherApplication : Application(), Provider { 32 | private val applicationScope = CoroutineScope(Dispatchers.Default) 33 | 34 | @Inject 35 | lateinit var workerFactory: HiltWorkerFactory 36 | 37 | /** 38 | * onCreate is called before the first screen is shown to the user. 39 | * 40 | * Use it to setup any background tasks, running expensive setup operations in a background 41 | * thread to avoid delaying app start. 42 | */ 43 | override fun onCreate() { 44 | super.onCreate() 45 | Timber.plant(Timber.DebugTree()) 46 | delayedInit() 47 | } 48 | 49 | /** 50 | * Setup WorkManager background job to 'fetch' new network 51 | */ 52 | private fun setupRecurringWork() { 53 | /*Use this if you want to schedule periodic work manager 54 | val constraints = Constraints.Builder() 55 | .setRequiresBatteryNotLow(true) 56 | .build() 57 | 58 | val inputLocationData = Data.Builder() 59 | .putString(LOCATION_KEY, "30.267153,-97.743057") 60 | .build() 61 | 62 | //MIN_PERIODIC_INTERVAL_MILLIS the min interval for work manager 15 minutes 63 | val repeatingRequest = PeriodicWorkRequestBuilder(MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS) 64 | .setInputData(inputLocationData) 65 | .setConstraints(constraints) 66 | .build() 67 | 68 | Timber.d("Periodic Work request for sync is scheduled") 69 | WorkManager.getInstance(applicationContext).enqueueUniquePeriodicWork( 70 | RefreshWeatherDataWorker.WORK_NAME, 71 | ExistingPeriodicWorkPolicy.KEEP, 72 | repeatingRequest 73 | )*/ 74 | 75 | //Read location from data store 76 | val dataStoreLocation = applicationContext.locationDataStore.data 77 | .catch { exception -> 78 | if (exception is IOException) { 79 | Timber.d("Error reading preferences: ") 80 | emit(emptyPreferences()) 81 | } else { 82 | Timber.d("Error reading preferences: ") 83 | throw exception 84 | } 85 | }.map { preferences -> 86 | preferences[LOCATION_PREFERENCE_KEY] ?: "" 87 | } 88 | 89 | applicationScope.launch { 90 | if (dataStoreLocation.first().isEmpty()) { 91 | Timber.d("Datastore preferences[LOCATION_PREFERENCE_KEY] is null or empty") 92 | } else { 93 | val constraints = Constraints.Builder() 94 | .setRequiresBatteryNotLow(true) 95 | .build() 96 | 97 | val inputLocationData = Data.Builder() 98 | .putString(LOCATION_KEY, dataStoreLocation.first()) 99 | .build() 100 | 101 | val repeatingRequest = OneTimeWorkRequestBuilder() 102 | .setInputData(inputLocationData) 103 | .setConstraints(constraints) 104 | .build() 105 | 106 | Timber.d("Periodic Work request for sync is scheduled") 107 | WorkManager.getInstance(applicationContext).enqueue( 108 | repeatingRequest 109 | ) 110 | } 111 | } 112 | } 113 | 114 | private fun delayedInit() { 115 | applicationScope.launch { 116 | Timber.plant(Timber.DebugTree()) 117 | setupRecurringWork() 118 | } 119 | } 120 | 121 | override fun getWorkManagerConfiguration() = 122 | Configuration.Builder() 123 | .setWorkerFactory(workerFactory) 124 | .setMinimumLoggingLevel(android.util.Log.DEBUG) 125 | .build() 126 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_daily_forecast.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 15 | 16 | 24 | 25 | 37 | 38 | 51 | 52 | 62 | 63 | 75 | 76 | 89 | 90 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_current_weather.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 20 | 21 | 31 | 32 | 41 | 42 | 53 | 54 | 65 | 66 | 76 | 77 | 89 | 90 | 100 | 101 | 110 | 111 | 120 | 121 | 131 | 132 | 140 | 141 | 148 | 149 | 160 | 161 | 173 | 174 | 186 | 187 | -------------------------------------------------------------------------------- /app/src/main/java/com/vsaytech/mvvmweather/ui/currentweather/CurrentWeatherFragment.kt: -------------------------------------------------------------------------------- 1 | package com.vsaytech.mvvmweather.ui.currentweather 2 | 3 | import android.location.Location 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import android.widget.Toast 9 | import androidx.activity.result.contract.ActivityResultContracts 10 | import androidx.appcompat.app.AppCompatActivity 11 | import androidx.constraintlayout.motion.widget.Debug.getLocation 12 | import androidx.core.app.ActivityCompat 13 | import androidx.fragment.app.Fragment 14 | import androidx.fragment.app.viewModels 15 | import androidx.lifecycle.Lifecycle 16 | import androidx.lifecycle.lifecycleScope 17 | import androidx.lifecycle.repeatOnLifecycle 18 | import androidx.navigation.fragment.findNavController 19 | import androidx.navigation.navOptions 20 | import androidx.recyclerview.widget.DividerItemDecoration 21 | import androidx.recyclerview.widget.LinearLayoutManager 22 | import com.bumptech.glide.Glide 23 | import com.google.android.gms.location.FusedLocationProviderClient 24 | import com.google.android.gms.location.LocationServices 25 | import com.vsaytech.mvvmweather.R 26 | import com.vsaytech.mvvmweather.databinding.FragmentCurrentWeatherBinding 27 | import com.vsaytech.mvvmweather.extensions.getLastLocation 28 | import com.vsaytech.mvvmweather.extensions.locationPermissions 29 | import com.vsaytech.mvvmweather.ui.uistate.NetworkResult 30 | import com.vsaytech.mvvmweather.util.getDayNameFromDateTimeString 31 | import com.vsaytech.mvvmweather.util.getMonthDayFromDateTimeString 32 | import com.vsaytech.mvvmweather.util.getTimeFromDateString 33 | import dagger.hilt.android.AndroidEntryPoint 34 | import kotlinx.coroutines.launch 35 | import javax.inject.Inject 36 | 37 | @AndroidEntryPoint 38 | class CurrentWeatherFragment : Fragment() { 39 | private val requestLocationMultiplePermissions = 40 | registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> 41 | permissions.entries.forEach { 42 | val granted = it.value 43 | val permission = it.key 44 | if (!granted) { 45 | val neverAskAgain = !ActivityCompat.shouldShowRequestPermissionRationale( 46 | requireActivity(), 47 | permission 48 | ) 49 | if (neverAskAgain) { 50 | //user click "never ask again" 51 | } else { 52 | //show explain dialog 53 | } 54 | return@registerForActivityResult 55 | } else { 56 | getLocation() 57 | } 58 | } 59 | } 60 | 61 | 62 | private lateinit var fusedLocationClient: FusedLocationProviderClient 63 | 64 | /** 65 | * RecyclerView Adapter for converting a list of DailyForecast. 66 | */ 67 | @Inject 68 | lateinit var dailyForecastAdapter: DailyForecastAdapter 69 | 70 | /** 71 | * One way to delay creation of the viewModel until an appropriate lifecycle method is to use 72 | * lazy. This requires that viewModel not be referenced before onActivityCreated, which we 73 | * do in this Fragment. 74 | */ 75 | private val viewModel by viewModels() 76 | 77 | private var _binding: FragmentCurrentWeatherBinding? = null 78 | 79 | // This property is only valid between onCreateView and 80 | // onDestroyView. 81 | private val binding get() = _binding!! 82 | 83 | override fun onCreateView( 84 | inflater: LayoutInflater, container: ViewGroup?, 85 | savedInstanceState: Bundle? 86 | ): View { 87 | // Inflate the layout for this fragment 88 | _binding = FragmentCurrentWeatherBinding.inflate(inflater, container, false) 89 | return binding.root 90 | } 91 | 92 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 93 | super.onViewCreated(view, savedInstanceState) 94 | manageLastLocation() 95 | initView() 96 | } 97 | 98 | override fun onResume() { 99 | super.onResume() 100 | (activity as AppCompatActivity).supportActionBar?.hide() 101 | } 102 | 103 | private fun manageLastLocation() { 104 | activity?.let { fusedLocationClient = LocationServices.getFusedLocationProviderClient(it) } 105 | getLastLocation(fusedLocationClient, object : LastLocationListener { 106 | override fun onLastLocationFound(location: Location) { 107 | viewModel.refreshCurrentWeatherFromRepository("${location.latitude},${location.longitude}") 108 | } 109 | 110 | override fun onCheckPermissonFailed() { 111 | requestLocationMultiplePermissions.launch(locationPermissions) 112 | } 113 | }) 114 | } 115 | 116 | private fun initView() { 117 | // Start a coroutine in the lifecycle scope 118 | viewLifecycleOwner.lifecycleScope.launch { 119 | // repeatOnLifecycle launches the block in a new coroutine every time the 120 | // lifecycle is in the STARTED state (or above) and cancels it when it's STOPPED. 121 | viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { 122 | launch { 123 | observeCurrentWeatherStateFlow() 124 | } 125 | 126 | launch { 127 | observeDailyWeatherForecastListStateFlow() 128 | } 129 | } 130 | } 131 | 132 | binding.rvDailyForecast.apply { 133 | addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) 134 | layoutManager = LinearLayoutManager(context) 135 | adapter = dailyForecastAdapter 136 | 137 | dailyForecastAdapter.setOnDailyForecastItemClickLister { currentWeatherDailyForecast -> 138 | val options = navOptions { 139 | anim { 140 | enter = R.anim.slide_in_right 141 | exit = R.anim.slide_out_left 142 | popEnter = R.anim.slide_in_left 143 | popExit = R.anim.slide_out_right 144 | } 145 | } 146 | this@CurrentWeatherFragment.findNavController() 147 | .navigate( 148 | CurrentWeatherFragmentDirections.actionCurrentWeatherToDailyForecastFragment(currentWeatherDailyForecast) 149 | .setCurrentLocationName(binding.tvCity.text.toString()), 150 | options 151 | ) 152 | } 153 | } 154 | } 155 | 156 | private suspend fun observeDailyWeatherForecastListStateFlow() { 157 | // Trigger the flow and start listening for values. 158 | // Note that this happens when lifecycle is STARTED and stops 159 | // collecting when the lifecycle is STOPPED 160 | viewModel.dailyForecastWeatherListStateFlow.collect { resultNetwork -> 161 | when (resultNetwork) { 162 | is NetworkResult.Success -> { 163 | resultNetwork.data.let { currentWeatherDailyForecast -> 164 | binding.pbLoadingSpinner.visibility = View.GONE 165 | currentWeatherDailyForecast.apply { 166 | binding.apply { 167 | if (currentWeatherDailyForecast.isNotEmpty()) { 168 | tvTempMin.text = getString(R.string.current_weather_temp, get(0).mintemp_f.toString()) 169 | tvTempMax.text = getString(R.string.current_weather_temp, get(0).maxtemp_f.toString()) 170 | dailyForecastAdapter.currentWeatherDailyForecastList = currentWeatherDailyForecast 171 | tvDailyForecastLabel.visibility = View.VISIBLE 172 | tvDailyForecastDay.visibility = View.VISIBLE 173 | } 174 | } 175 | } 176 | } 177 | } 178 | is NetworkResult.Error -> { 179 | //show error message 180 | binding.pbLoadingSpinner.visibility = View.GONE 181 | Toast.makeText( 182 | requireContext(), 183 | resultNetwork.message, 184 | Toast.LENGTH_SHORT 185 | ).show() 186 | } 187 | 188 | is NetworkResult.Loading<*> -> { 189 | //show loader, shimmer effect etc 190 | binding.pbLoadingSpinner.visibility = View.VISIBLE 191 | binding.gpWeatherTopSection.visibility = View.GONE 192 | } 193 | } 194 | } 195 | } 196 | 197 | private suspend fun observeCurrentWeatherStateFlow() { 198 | // Trigger the flow and start listening for values. 199 | // Note that this happens when lifecycle is STARTED and stops 200 | // collecting when the lifecycle is STOPPED 201 | viewModel.currentWeatherStateFlow.collect { resultNetwork -> 202 | when (resultNetwork) { 203 | is NetworkResult.Success -> { 204 | binding.pbLoadingSpinner.visibility = View.GONE 205 | resultNetwork.data.let { currentWeather -> 206 | currentWeather.apply { 207 | binding.apply { 208 | tvCity.text = name 209 | tvDayTime.text = getString( 210 | R.string.current_weather_day_month_time, 211 | getDayNameFromDateTimeString(currentDayTime), 212 | getMonthDayFromDateTimeString(currentDayTime), 213 | getTimeFromDateString(currentDayTime) 214 | ) 215 | tvCondition.text = conditionText 216 | activity?.let { Glide.with(it).load(conditionIcon).into(ivCondition) } 217 | tvCurrentTemp.text = getString(R.string.current_weather_temp, temp_f.toString()) 218 | tvTempFeelLike.text = getString(R.string.current_weather_temp, feelslike_f.toString()) 219 | gpWeatherTopSection.visibility = View.VISIBLE 220 | pbLoadingSpinner.visibility = View.GONE 221 | } 222 | } 223 | } 224 | } 225 | is NetworkResult.Error -> { 226 | //show error message 227 | binding.pbLoadingSpinner.visibility = View.GONE 228 | Toast.makeText( 229 | requireContext(), 230 | resultNetwork.message, 231 | Toast.LENGTH_SHORT 232 | ).show() 233 | } 234 | 235 | is NetworkResult.Loading<*> -> { 236 | //show loader, shimmer effect etc 237 | binding.pbLoadingSpinner.visibility = View.VISIBLE 238 | binding.gpWeatherTopSection.visibility = View.GONE 239 | } 240 | } 241 | } 242 | } 243 | 244 | override fun onDestroyView() { 245 | super.onDestroyView() 246 | _binding = null 247 | } 248 | } --------------------------------------------------------------------------------