diff --git a/.idea/assetWizardSettings.xml b/.idea/assetWizardSettings.xml deleted file mode 100644 index a6aa5f0..0000000 --- a/.idea/assetWizardSettings.xml +++ /dev/null @@ -1,407 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTest.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTest.kt index af29076..02c3347 100644 --- a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTest.kt +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTest.kt @@ -8,10 +8,9 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.view.View -import android.view.WindowManager import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso -import androidx.test.espresso.Root import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.assertion.ViewAssertions @@ -20,15 +19,12 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.appttude.h_mal.atlas_weather.application.TestAppClass -import com.appttude.h_mal.atlas_weather.helpers.BaseCustomMatcher -import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction +import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule import com.appttude.h_mal.atlas_weather.utils.Stubs import kotlinx.coroutines.runBlocking -import org.hamcrest.Description import org.hamcrest.Matcher import org.hamcrest.Matchers -import org.hamcrest.TypeSafeMatcher import org.junit.After import org.junit.Before import org.junit.Rule @@ -46,6 +42,8 @@ open class BaseTest( private lateinit var testActivity: Activity private lateinit var decorView: View + private val prefs by lazy { PreferenceProvider(ApplicationProvider.getApplicationContext()) } + @get:Rule var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION) @@ -87,6 +85,8 @@ open class BaseTest( testApp.stubLocation(location, lat, long) } + fun clearPrefs() = prefs.clearPrefs() + fun getActivity() = testActivity @After diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTestRobot.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTestRobot.kt index b62750f..8f367eb 100644 --- a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTestRobot.kt +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/BaseTestRobot.kt @@ -5,6 +5,7 @@ import android.view.View import android.widget.DatePicker import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView.ViewHolder +import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onView import androidx.test.espresso.UiController @@ -28,6 +29,8 @@ import org.hamcrest.Matcher @SuppressWarnings("unused") open class BaseTestRobot { + fun goBack() = Espresso.pressBack() + fun fillEditText(resId: Int, text: String?): ViewInteraction = onView(withId(resId)).perform( ViewActions.replaceText(text), @@ -151,4 +154,8 @@ open class BaseTestRobot { ) ) } + + fun openMenuItem() { + matchView(R.id.settings_fragment).perform(click()) + } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/prefs/PreferenceProviderTest.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/prefs/PreferenceProviderTest.kt new file mode 100644 index 0000000..9af037b --- /dev/null +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/data/prefs/PreferenceProviderTest.kt @@ -0,0 +1,81 @@ +package com.appttude.h_mal.atlas_weather.data.prefs + +import androidx.test.core.app.ApplicationProvider +import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION +import com.appttude.h_mal.atlas_weather.model.types.UnitType +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import java.lang.Thread.sleep +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PreferenceProviderTest { + + lateinit var preferenceProvider: PreferenceProvider + + @Before + fun setUp() { + preferenceProvider = PreferenceProvider(ApplicationProvider.getApplicationContext()) + } + + @Test + fun saveAndGetLastSavedAt() { + // Arrange + val input = anyString() + + // Act + preferenceProvider.saveLastSavedAt(input) + + // Assert + val result = preferenceProvider.getLastSavedAt(input) + runBlocking { sleep(100) } + assert(result < System.currentTimeMillis()) + } + + @Test + fun getAllKeysAndDeleteKeys() { + // Arrange + val listOfLocations = listOf(CURRENT_LOCATION, "sydney", "london", "berlin", "dublin") + + // Act + listOfLocations.forEach { preferenceProvider.saveLastSavedAt(it) } + + // Assert + val result = preferenceProvider.getAllKeysExcludingCurrent() + assert(result.size > 0) + assertFalse { result.contains(CURRENT_LOCATION) } + + // Act + listOfLocations.forEach{ preferenceProvider.deleteLocation(it)} + + // Assert + val deletedResults = preferenceProvider.getAllKeysExcludingCurrent() + assertFalse { deletedResults.containsAll(listOfLocations) } + } + + @Test + fun setAndGetFirstTimeRun() { + // Act + preferenceProvider.setFirstTimeRun() + runBlocking { sleep(100) } + + // Assert + val result = preferenceProvider.getFirstTimeRun() + assertFalse(result) + } + + @Test + fun setAndGetUnitsType() { + // Arrange + val input = UnitType.values()[kotlin.random.Random.nextInt(UnitType.values().size)] + + // Act + preferenceProvider.setUnitsType(input) + + // Assert + val result = preferenceProvider.getUnitsType() + assertEquals(result, input) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/testSuite/RoomDatabaseTests.kt b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/testSuite/RoomDatabaseTests.kt index 5fe68d9..6bfdeea 100644 --- a/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/testSuite/RoomDatabaseTests.kt +++ b/app/src/androidTest/java/com/appttude/h_mal/atlas_weather/testSuite/RoomDatabaseTests.kt @@ -3,7 +3,6 @@ package com.appttude.h_mal.atlas_weather.testSuite import android.os.Build import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room -import androidx.room.util.UUIDUtil import androidx.test.platform.app.InstrumentationRegistry import com.appttude.h_mal.atlas_weather.data.room.AppDatabase import com.appttude.h_mal.atlas_weather.data.room.Converter @@ -11,18 +10,13 @@ import com.appttude.h_mal.atlas_weather.data.room.WeatherDao import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.model.weather.FullWeather -import com.appttude.h_mal.atlas_weather.test.BuildConfig import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue -import io.mockk.every import io.mockk.mockk import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.Assert.* -import org.mockito.ArgumentMatchers.any -import org.mockito.ArgumentMatchers.anyString -import org.mockito.Mockito.mock import java.util.UUID diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/SettingsRobot.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/SettingsRobot.kt new file mode 100644 index 0000000..ee50ee6 --- /dev/null +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/robot/SettingsRobot.kt @@ -0,0 +1,48 @@ +package com.appttude.h_mal.monoWeather.robot + +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.appttude.h_mal.atlas_weather.BaseTestRobot +import com.appttude.h_mal.atlas_weather.R +import com.appttude.h_mal.atlas_weather.model.types.UnitType + + +fun settingsScreen(func: SettingsScreen.() -> Unit) = SettingsScreen().apply { func() } +class SettingsScreen : BaseTestRobot() { + + fun selectWeatherUnits(unitType: UnitType) { + onView(withId(androidx.preference.R.id.recycler_view)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(withText(R.string.weather_units)), + click())) + val label = when (unitType) { + UnitType.METRIC -> "Metric" + UnitType.IMPERIAL -> "Imperial" + } + + onView(withText(label)) + .inRoot(isDialog()) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + .perform(click()) + } + + + fun verifyCurrentTemperature(temperature: Int) = + matchText(R.id.temp_main_4, temperature.toString()) + + fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location) + fun refresh() = pullToRefresh(R.id.swipe_refresh) + + fun verifyUnableToRetrieve() { + matchText(R.id.header_text, R.string.retrieve_warning) + matchText(R.id.body_text, R.string.empty_retrieve_warning) + } +} \ No newline at end of file diff --git a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageUITest.kt b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageUITest.kt index 284a7f1..1bc556e 100644 --- a/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageUITest.kt +++ b/app/src/androidTestMonoWeather/java/com/appttude/h_mal/monoWeather/tests/HomePageUITest.kt @@ -2,8 +2,10 @@ package com.appttude.h_mal.monoWeather.tests import com.appttude.h_mal.atlas_weather.BaseTest +import com.appttude.h_mal.atlas_weather.model.types.UnitType import com.appttude.h_mal.atlas_weather.ui.MainActivity import com.appttude.h_mal.atlas_weather.utils.Stubs +import com.appttude.h_mal.monoWeather.robot.settingsScreen import com.appttude.h_mal.monoWeather.robot.weatherScreen import org.junit.Test @@ -11,6 +13,7 @@ class HomePageUITest : BaseTest(MainActivity::class.java) { override fun beforeLaunch() { stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric) + clearPrefs() } @Test @@ -21,4 +24,25 @@ class HomePageUITest : BaseTest(MainActivity::class.java) { verifyCurrentLocation("Mock Location") } } + + @Test + fun loadApp_changeToImperial_returnsValidPage() { + weatherScreen { + isDisplayed() + verifyCurrentTemperature(2) + verifyCurrentLocation("Mock Location") + stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Imperial) + openMenuItem() + } + settingsScreen { + selectWeatherUnits(UnitType.IMPERIAL) + goBack() + } + weatherScreen { + isDisplayed() + refresh() + verifyCurrentTemperature(58) + verifyCurrentLocation("Mock Location") + } + } } diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/application/BaseAppClass.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/application/BaseAppClass.kt index ebd54a1..98b2a84 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/application/BaseAppClass.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/application/BaseAppClass.kt @@ -1,6 +1,7 @@ package com.appttude.h_mal.atlas_weather.application import android.app.Application +import com.appttude.h_mal.atlas_weather.data.WeatherSource import com.appttude.h_mal.atlas_weather.data.location.LocationProvider import com.appttude.h_mal.atlas_weather.data.network.WeatherApi import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider @@ -33,7 +34,8 @@ abstract class BaseAppClass : Application(), KodeinAware { bind() from singleton { RepositoryImpl(instance(), instance(), instance()) } bind() from singleton { SettingsRepositoryImpl(instance()) } bind() from singleton { ServicesHelper(instance(), instance(), instance()) } - bind() from provider { ApplicationViewModelFactory(instance(), instance()) } + bind() from singleton { WeatherSource(instance(), instance()) } + bind() from provider { ApplicationViewModelFactory(this@BaseAppClass, instance(), instance(),instance()) } } abstract fun createNetworkModule(): WeatherApi diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/base/BasePreferencesFragment.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/BasePreferencesFragment.kt new file mode 100644 index 0000000..9fec7e9 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/BasePreferencesFragment.kt @@ -0,0 +1,87 @@ +package com.appttude.h_mal.atlas_weather.base + +import android.os.Bundle +import android.view.View +import androidx.annotation.XmlRes +import androidx.fragment.app.createViewModelLazy +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceManager +import com.appttude.h_mal.atlas_weather.R +import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseAndroidViewModel +import com.appttude.h_mal.atlas_weather.helper.GenericsHelper.getGenericClassAt +import com.appttude.h_mal.atlas_weather.model.ViewState +import com.appttude.h_mal.atlas_weather.viewmodel.ApplicationViewModelFactory +import org.kodein.di.KodeinAware +import org.kodein.di.android.x.kodein +import org.kodein.di.generic.instance +import kotlin.properties.Delegates + +@Suppress("EmptyMethod", "EmptyMethod") +abstract class BasePreferencesFragment(@XmlRes private val preferencesResId: Int) : + PreferenceFragmentCompat(), + KodeinAware { + + override val kodein by kodein() + private val factory by instance() + + val viewModel: V by getFragmentViewModel() + + var mActivity: BaseActivity? = null + private fun getFragmentViewModel(): Lazy = + createViewModelLazy(getGenericClassAt(0), { viewModelStore }, factoryProducer = { factory }) + + private var shortAnimationDuration by Delegates.notNull() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + shortAnimationDuration = resources.getInteger(android.R.integer.config_shortAnimTime) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + mActivity = activity as BaseActivity + configureObserver() + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(preferencesResId, rootKey) + + val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + prefs.registerOnSharedPreferenceChangeListener { _, s -> + preferenceChanged(s) + } + } + + private fun configureObserver() { + viewModel.uiState.observe(viewLifecycleOwner) { + when (it) { + is ViewState.HasStarted -> onStarted() + is ViewState.HasData<*> -> onSuccess(it.data) + is ViewState.HasError<*> -> onFailure(it.error) + } + } + } + + open fun preferenceChanged(key: String) { } + + /** + * Called in case of starting operation liveData in viewModel + */ + open fun onStarted() { + mActivity?.onStarted() + } + + /** + * Called in case of success or some data emitted from the liveData in viewModel + */ + open fun onSuccess(data: Any?) { + mActivity?.onSuccess(data) + } + + /** + * Called in case of failure or some error emitted from the liveData in viewModel + */ + open fun onFailure(error: Any?) { + mActivity?.onFailure(error) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/BaseAndroidViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/BaseAndroidViewModel.kt new file mode 100644 index 0000000..8db3155 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/BaseAndroidViewModel.kt @@ -0,0 +1,42 @@ +package com.appttude.h_mal.atlas_weather.base.baseViewModels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.appttude.h_mal.atlas_weather.model.ViewState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch + +open class BaseAndroidViewModel(application: Application) : AndroidViewModel(application) { + + private val _uiState = MutableLiveData() + val uiState: LiveData = _uiState + + + fun onStart() { + _uiState.postValue(ViewState.HasStarted) + } + + fun onSuccess(result: T) { + _uiState.postValue(ViewState.HasData(result)) + } + + protected fun onError(error: E) { + _uiState.postValue(ViewState.HasError(error)) + } + + protected var job: Job? = null + + fun cancelOperation() { + CoroutineScope(Dispatchers.IO).launch { + job?.run { + cancelAndJoin() + onSuccess(Unit) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/BaseViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/BaseViewModel.kt index c4895c5..c207add 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/BaseViewModel.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/BaseViewModel.kt @@ -4,8 +4,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.appttude.h_mal.atlas_weather.model.ViewState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch -open class BaseViewModel: ViewModel() { +open class BaseViewModel : ViewModel() { private val _uiState = MutableLiveData() val uiState: LiveData = _uiState @@ -22,4 +27,15 @@ open class BaseViewModel: ViewModel() { protected fun onError(error: E) { _uiState.postValue(ViewState.HasError(error)) } + + protected var job: Job? = null + + fun cancelOperation() { + CoroutineScope(Dispatchers.IO).launch { + job?.run { + cancelAndJoin() + onSuccess(Unit) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/WeatherViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/WeatherViewModel.kt deleted file mode 100644 index be19ec3..0000000 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/base/baseViewModels/WeatherViewModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.appttude.h_mal.atlas_weather.base.baseViewModels - -import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse -import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem -import com.appttude.h_mal.atlas_weather.model.weather.FullWeather - -abstract class WeatherViewModel : BaseViewModel() { - - fun createFullWeather( - weather: WeatherResponse, - location: String - ): FullWeather { - return FullWeather(weather).apply { - temperatureUnit = "°C" - locationString = location - } - } - - fun createWeatherEntity( - locationId: String, - weather: FullWeather - ): EntityItem { - weather.apply { - locationString = locationId - } - - return EntityItem(locationId, weather) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/WeatherSource.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/WeatherSource.kt new file mode 100644 index 0000000..b19e568 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/WeatherSource.kt @@ -0,0 +1,72 @@ +package com.appttude.h_mal.atlas_weather.data + +import com.appttude.h_mal.atlas_weather.data.location.LocationProvider +import com.appttude.h_mal.atlas_weather.data.repository.Repository +import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION +import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem +import com.appttude.h_mal.atlas_weather.model.types.LocationType +import com.appttude.h_mal.atlas_weather.model.types.UnitType +import com.appttude.h_mal.atlas_weather.model.weather.FullWeather +import java.io.IOException + +class WeatherSource( + val repository: Repository, + private val locationProvider: LocationProvider +) { + + @Throws(IOException::class) + suspend fun getWeather( + latLon: Pair, + locationName: String? = null, + locationType: LocationType = LocationType.Town + ): FullWeather { + val location = locationName ?: CURRENT_LOCATION + + // Has the search been conducted in the last 5 minutes + return if (repository.isSearchValid(location)) { + fetchWeather(latLon, location, locationType) + } else { + val weather = repository.getSingleWeather(location) + repository.saveCurrentWeatherToRoom(weather) + weather.weather + } + } + + @Throws(IOException::class) + suspend fun forceFetchWeather(latLon: Pair, + locationType: LocationType = LocationType.Town): FullWeather { + // get data from database + val weatherEntity = repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION) + // check unit type - if same do nothing + val units = if (repository.getUnitType() == UnitType.METRIC) "°C" else "°F" + if (weatherEntity.weather.temperatureUnit == units) return weatherEntity.weather + // load data for forced + return fetchWeather( + Pair(latLon.first, latLon.second), + CURRENT_LOCATION, locationType + ) + } + + private suspend fun fetchWeather( + latLon: Pair, + locationName: String, + locationType: LocationType = LocationType.Town + ): FullWeather { + // Get weather from api + val weather = repository + .getWeatherFromApi(latLon.first.toString(), latLon.second.toString()) + val currentLocation = + locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon, locationType) + val unit = repository.getUnitType() + val fullWeather = FullWeather(weather).apply { + temperatureUnit = if (unit == UnitType.METRIC) "°C" else "°F" + locationString = currentLocation + } + val entityItem = EntityItem(locationName, fullWeather) + // Save data if not null + repository.saveLastSavedAt(locationName) + repository.saveCurrentWeatherToRoom(entityItem) + + return fullWeather + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/location/LocationProvider.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/location/LocationProvider.kt index 8b17a98..0a72264 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/location/LocationProvider.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/location/LocationProvider.kt @@ -1,8 +1,11 @@ package com.appttude.h_mal.atlas_weather.data.location +import android.Manifest +import androidx.annotation.RequiresPermission import com.appttude.h_mal.atlas_weather.model.types.LocationType interface LocationProvider { + @RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION) suspend fun getCurrentLatLong(): Pair fun getLatLongFromLocationName(location: String): Pair suspend fun getLocationNameFromLatLong( diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/location/LocationProviderImpl.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/location/LocationProviderImpl.kt index a79c826..a809c08 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/location/LocationProviderImpl.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/location/LocationProviderImpl.kt @@ -5,13 +5,8 @@ import android.annotation.SuppressLint import android.content.Context import android.location.Geocoder import android.location.Location -import android.location.LocationManager -import android.os.HandlerThread import androidx.annotation.RequiresPermission import com.appttude.h_mal.atlas_weather.model.types.LocationType -import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.tasks.CancellationToken @@ -19,21 +14,23 @@ import com.google.android.gms.tasks.OnTokenCanceledListener import kotlinx.coroutines.tasks.await import java.io.IOException import java.util.* -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine class LocationProviderImpl( private val applicationContext: Context ) : LocationProvider, LocationHelper(applicationContext) { - private var locationManager = - applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager? private val client = LocationServices.getFusedLocationProviderClient(applicationContext) private val geoCoder: Geocoder by lazy { Geocoder(applicationContext, Locale.getDefault()) } @RequiresPermission(value = ACCESS_COARSE_LOCATION) override suspend fun getCurrentLatLong(): Pair { - val location = client.lastLocation.await() ?: getAFreshLocation() + val lastLocation = client.lastLocation.await() + lastLocation?.let { + val delta = it.time - System.currentTimeMillis() + if (delta < 300000) return it.getLatLonPair() + } + + val location = getAFreshLocation() return location?.getLatLonPair() ?: throw IOException("Unable to get location") } @@ -68,57 +65,16 @@ class LocationProviderImpl( @SuppressLint("MissingPermission") private suspend fun getAFreshLocation(): Location? { - return client.getCurrentLocation(Priority.PRIORITY_LOW_POWER, object : CancellationToken() { - override fun isCancellationRequested(): Boolean = false - override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken = this - }).await() + return client.getCurrentLocation( + Priority.PRIORITY_HIGH_ACCURACY, + cancellationToken + ).await() } - @SuppressLint("MissingPermission") - private suspend fun requestFreshLocation(): Location? { - val handlerThread = HandlerThread("MyHandlerThread") - handlerThread.start() - // Now get the Looper from the HandlerThread - // NOTE: This call will block until the HandlerThread gets control and initializes its Looper - val looper = handlerThread.looper - - return suspendCoroutine { cont -> - val callback = object : LocationCallback() { - override fun onLocationResult(p0: LocationResult) { - client.removeLocationUpdates(this) - cont.resume(p0.lastLocation) - } - } - - with(locationManager!!) { - when { - isProviderEnabled(LocationManager.GPS_PROVIDER) -> { - client.requestLocationUpdates( - createLocationRequest(Priority.PRIORITY_HIGH_ACCURACY), - callback, - looper - ) - } - - isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> { - client.requestLocationUpdates( - createLocationRequest(Priority.PRIORITY_LOW_POWER), - callback, - looper - ) - } - - else -> { - cont.resume(null) - } - } - } - - } + private val cancellationToken = object : CancellationToken() { + override fun isCancellationRequested(): Boolean = false + override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken = + this } - private fun createLocationRequest(priority: Int) = LocationRequest.create() - .setPriority(priority) - .setNumUpdates(1) - .setExpirationDuration(1000) } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/prefs/PreferencesProvider.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/prefs/PreferencesProvider.kt index 693191f..bbea858 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/prefs/PreferencesProvider.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/prefs/PreferencesProvider.kt @@ -2,13 +2,16 @@ package com.appttude.h_mal.atlas_weather.data.prefs import android.content.Context import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting import androidx.preference.PreferenceManager import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION +import com.appttude.h_mal.atlas_weather.model.types.UnitType /** * Shared preferences to save & load last timestamp */ const val LOCATION_CONST = "location_" +const val UNIT_CONST = "UnitType" class PreferenceProvider( context: Context @@ -30,7 +33,7 @@ class PreferenceProvider( return preference.getLong(locationName, 0L) } - fun getAllKeys() = preference.all.keys.apply { + fun getAllKeysExcludingCurrent() = preference.all.keys.apply { remove(CURRENT_LOCATION) } @@ -50,4 +53,18 @@ class PreferenceProvider( return preference.getBoolean("widget_black_background", false) } + fun setUnitsType(type: UnitType) { + preference.edit().putString(UNIT_CONST, type.name).apply() + } + + fun getUnitsType(): UnitType { + val unit = preference.getString(UNIT_CONST, UnitType.METRIC.name) + return UnitType.getByName(unit) ?: UnitType.METRIC + } + + @VisibleForTesting + fun clearPrefs() { + preference.edit().clear().apply() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/Repository.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/Repository.kt index ce0c3d9..43a20d4 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/Repository.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/Repository.kt @@ -3,6 +3,7 @@ package com.appttude.h_mal.atlas_weather.data.repository import androidx.lifecycle.LiveData import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem +import com.appttude.h_mal.atlas_weather.model.types.UnitType interface Repository { @@ -18,4 +19,5 @@ interface Repository { suspend fun deleteSavedWeatherEntry(locationName: String): Boolean fun getSavedLocations(): List suspend fun getSingleWeather(locationName: String): EntityItem + fun getUnitType() : UnitType } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImpl.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImpl.kt index ebfa238..bf12df3 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImpl.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImpl.kt @@ -7,6 +7,7 @@ import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider import com.appttude.h_mal.atlas_weather.data.room.AppDatabase import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem +import com.appttude.h_mal.atlas_weather.model.types.UnitType import com.appttude.h_mal.atlas_weather.utils.FALLBACK_TIME @@ -20,7 +21,7 @@ class RepositoryImpl( lat: String, long: String ): WeatherResponse { - return responseUnwrap { api.getFromApi(lat, long) } + return responseUnwrap { api.getFromApi(lat, long, units = prefs.getUnitsType().name.lowercase()) } } override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) { @@ -65,11 +66,15 @@ class RepositoryImpl( } override fun getSavedLocations(): List { - return prefs.getAllKeys().toList() + return prefs.getAllKeysExcludingCurrent().toList() } override suspend fun getSingleWeather(locationName: String): EntityItem { return db.getWeatherDao().getCurrentFullWeatherSingle(locationName) } + override fun getUnitType(): UnitType { + return prefs.getUnitsType() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/SettingsRepository.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/SettingsRepository.kt index 48dd734..103c2c5 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/SettingsRepository.kt @@ -1,8 +1,12 @@ package com.appttude.h_mal.atlas_weather.data.repository +import com.appttude.h_mal.atlas_weather.model.types.UnitType + interface SettingsRepository { fun isNotificationsEnabled(): Boolean fun setFirstTime() fun getFirstTime(): Boolean fun isBlackBackground(): Boolean + fun saveUnitType(unitType: UnitType) + fun getUnitType(): UnitType } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/SettingsRepositoryImpl.kt index 5764118..8f387a3 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/data/repository/SettingsRepositoryImpl.kt @@ -1,6 +1,7 @@ package com.appttude.h_mal.atlas_weather.data.repository import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider +import com.appttude.h_mal.atlas_weather.model.types.UnitType class SettingsRepositoryImpl( private val prefs: PreferenceProvider @@ -12,4 +13,13 @@ class SettingsRepositoryImpl( override fun getFirstTime(): Boolean = prefs.getFirstTimeRun() override fun isBlackBackground() = prefs.isWidgetBackground() + + + override fun saveUnitType(unitType: UnitType) { + prefs.setUnitsType(unitType) + } + + override fun getUnitType(): UnitType { + return prefs.getUnitsType() + } } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/helper/ServicesHelper.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/helper/ServicesHelper.kt index 5485fdc..e336231 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/helper/ServicesHelper.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/helper/ServicesHelper.kt @@ -5,15 +5,19 @@ import android.graphics.Bitmap import android.graphics.Color import android.graphics.drawable.Drawable import androidx.annotation.RequiresPermission +import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.data.location.LocationProvider import com.appttude.h_mal.atlas_weather.data.repository.Repository import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem +import com.appttude.h_mal.atlas_weather.model.types.UnitType import com.appttude.h_mal.atlas_weather.model.weather.FullWeather import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetCellData import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetData import com.appttude.h_mal.atlas_weather.model.widget.WidgetData +import com.appttude.h_mal.atlas_weather.model.widget.WidgetError +import com.appttude.h_mal.atlas_weather.model.widget.WidgetState import com.appttude.h_mal.atlas_weather.model.widget.WidgetWeatherCollection import com.appttude.h_mal.atlas_weather.utils.toSmallDayName import com.squareup.picasso.Picasso @@ -58,6 +62,77 @@ class ServicesHelper( } } + @RequiresPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + suspend fun fetchWidgetData(): WidgetState { + // Data was loaded within last 5 minutes - no need to retrieve again + if (!repository.isSearchValid(CURRENT_LOCATION)) { + val data = getWidgetWeatherCollection() + data?.let { + return WidgetState.HasData(it) + } + } + + // Try and retrieve location + val latLong = try { + locationProvider.getCurrentLatLong() + } catch (e: IOException) { + val data = getWidgetWeatherCollection() + data?.let { + return WidgetState.HasData(it) + } + + val error = WidgetError( + icon = R.drawable.ic_baseline_cloud_off_24, + errorMessage = "Failed to retrieve location, check location" + ) + return WidgetState.HasError(error) + } + + // Get weather from api + val weather = try { + repository + .getWeatherFromApi(latLong.first.toString(), latLong.second.toString()) + } catch (e: IOException) { + val data = getWidgetWeatherCollection() + data?.let { + return WidgetState.HasData(it) + } + + val error = WidgetError( + icon = R.drawable.ic_baseline_cloud_off_24, + errorMessage = "Failed to retrieve weather data, check connection" + ) + return WidgetState.HasError(error) + } + + val currentLocation = try { + locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon) + } catch (e: IOException) { + val data = getWidgetWeatherCollection() + data?.let { + return WidgetState.HasData(it) + } + + val error = WidgetError( + icon = R.drawable.ic_baseline_cloud_off_24, + errorMessage = "Failed to retrieve location name" + ) + return WidgetState.HasError(error) + } + + val fullWeather = FullWeather(weather).apply { + temperatureUnit = if (repository.getUnitType() == UnitType.METRIC) "°C" else "°F" + locationString = currentLocation + } + val entityItem = EntityItem(CURRENT_LOCATION, fullWeather) + // Save data to database + repository.saveLastSavedAt(CURRENT_LOCATION) + repository.saveCurrentWeatherToRoom(entityItem) + + val data = createWidgetWeatherCollection(entityItem, currentLocation) + return WidgetState.HasData(data) + } + suspend fun getWidgetWeather(): WidgetData? { return try { val result = repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION) @@ -127,6 +202,33 @@ class ServicesHelper( } } + private fun createWidgetWeatherCollection( + result: EntityItem, + locationName: String + ): WidgetWeatherCollection { + val widgetData = result.weather.let { + val bitmap = it.current?.icon + val temp = it.current?.temp?.toInt().toString() + val epoc = System.currentTimeMillis() + + WidgetData(locationName, bitmap, temp, epoc) + } + + val list = mutableListOf() + + result.weather.daily?.drop(1)?.dropLast(2)?.forEach { dailyWeather -> + val day = dailyWeather.dt?.toSmallDayName() + val icon = dailyWeather.icon + val temp = dailyWeather.max?.toInt().toString() + + val item = InnerWidgetCellData(day, icon, temp) + list.add(item) + } + list.toList() + + return WidgetWeatherCollection(widgetData, list) + } + private suspend fun getBitmapFromUrl(imageAddress: String?): Bitmap? { return suspendCoroutine { cont -> Picasso.get().load(imageAddress).into(object : Target { diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/model/types/UnitType.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/model/types/UnitType.kt new file mode 100644 index 0000000..295d7af --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/model/types/UnitType.kt @@ -0,0 +1,18 @@ +package com.appttude.h_mal.atlas_weather.model.types + +import java.util.Locale + +enum class UnitType { + METRIC, + IMPERIAL; + + companion object { + fun getByName(name: String?): UnitType? { + return values().firstOrNull { + it.name.lowercase(Locale.ROOT) == name?.lowercase( + Locale.ROOT + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/model/widget/WidgetError.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/model/widget/WidgetError.kt new file mode 100644 index 0000000..323cf23 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/model/widget/WidgetError.kt @@ -0,0 +1,9 @@ +package com.appttude.h_mal.atlas_weather.model.widget + +import androidx.annotation.DrawableRes + +data class WidgetError( + @DrawableRes + val icon: Int, + val errorMessage: String +) diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/model/widget/WidgetState.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/model/widget/WidgetState.kt new file mode 100644 index 0000000..400139a --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/model/widget/WidgetState.kt @@ -0,0 +1,6 @@ +package com.appttude.h_mal.atlas_weather.model.widget + +sealed class WidgetState { + class HasData(val data: T) : WidgetState() + class HasError(val error: T) : WidgetState() +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/MainActivity.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/MainActivity.kt index 748233d..af883f7 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/MainActivity.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/ui/MainActivity.kt @@ -2,7 +2,6 @@ package com.appttude.h_mal.atlas_weather.ui import android.os.Bundle import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration @@ -10,7 +9,6 @@ import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupWithNavController import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.base.BaseActivity -import com.appttude.h_mal.atlas_weather.viewmodel.MainViewModel import com.google.android.material.bottomnavigation.BottomNavigationView import kotlinx.android.synthetic.main.activity_main_navigation.toolbar diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/utils/Constants.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/utils/Constants.kt index 84fd85c..7b1a1af 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/utils/Constants.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/utils/Constants.kt @@ -1,3 +1,3 @@ package com.appttude.h_mal.atlas_weather.utils -val FALLBACK_TIME: Long = 300000L \ No newline at end of file +const val FALLBACK_TIME: Long = 300000L \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/ApplicationViewModelFactory.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/ApplicationViewModelFactory.kt index 23b48c7..c476a9e 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/ApplicationViewModelFactory.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/ApplicationViewModelFactory.kt @@ -1,14 +1,18 @@ package com.appttude.h_mal.atlas_weather.viewmodel +import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.appttude.h_mal.atlas_weather.data.WeatherSource import com.appttude.h_mal.atlas_weather.data.location.LocationProvider -import com.appttude.h_mal.atlas_weather.data.repository.RepositoryImpl +import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository class ApplicationViewModelFactory( + private val application: Application, private val locationProvider: LocationProvider, - private val repository: RepositoryImpl + private val source: WeatherSource, + private val settingsRepository: SettingsRepository ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -17,12 +21,16 @@ class ApplicationViewModelFactory( return when { isAssignableFrom(WorldViewModel::class.java) -> WorldViewModel( locationProvider, - repository + source ) isAssignableFrom(MainViewModel::class.java) -> MainViewModel( locationProvider, - repository + source + ) + + isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel( + application, locationProvider, source, settingsRepository ) else -> throw IllegalArgumentException("Unknown ViewModel class") diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/MainViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/MainViewModel.kt index ac81c12..489f75a 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/MainViewModel.kt @@ -2,24 +2,23 @@ package com.appttude.h_mal.atlas_weather.viewmodel import android.Manifest import androidx.annotation.RequiresPermission +import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseViewModel +import com.appttude.h_mal.atlas_weather.data.WeatherSource import com.appttude.h_mal.atlas_weather.data.location.LocationProvider -import com.appttude.h_mal.atlas_weather.data.repository.Repository import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION -import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay -import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class MainViewModel( private val locationProvider: LocationProvider, - private val repository: Repository -) : WeatherViewModel() { + private val weatherSource: WeatherSource +) : BaseViewModel() { init { - repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { - it?.let { + weatherSource.repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { w -> + w?.let { val weather = WeatherDisplay(it) onSuccess(weather) } @@ -29,27 +28,14 @@ class MainViewModel( @RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION) fun fetchData() { onStart() - CoroutineScope(Dispatchers.IO).launch { + job = CoroutineScope(Dispatchers.IO).launch { try { - // Has the search been conducted in the last 5 minutes - val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) { - // Get location - val latLong = locationProvider.getCurrentLatLong() - // Get weather from api - val weather = repository - .getWeatherFromApi(latLong.first.toString(), latLong.second.toString()) - val currentLocation = - locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon) - val fullWeather = createFullWeather(weather, currentLocation) - EntityItem(CURRENT_LOCATION, fullWeather) - } else { - repository.getSingleWeather(CURRENT_LOCATION) - } - // Save data if not null - repository.saveLastSavedAt(CURRENT_LOCATION) - repository.saveCurrentWeatherToRoom(entityItem) + // Get location + val latLong = locationProvider.getCurrentLatLong() + weatherSource.getWeather(latLon = latLong) } catch (e: Exception) { - onError(e.message!!) + e.printStackTrace() + onError(e.message ?: "Retrieving weather failed") } } } diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..c0ed835 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/SettingsViewModel.kt @@ -0,0 +1,71 @@ +package com.appttude.h_mal.atlas_weather.viewmodel + +import android.Manifest +import android.app.Application +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import com.appttude.h_mal.atlas_weather.R +import com.appttude.h_mal.atlas_weather.application.BaseAppClass +import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseAndroidViewModel +import com.appttude.h_mal.atlas_weather.data.WeatherSource +import com.appttude.h_mal.atlas_weather.data.location.LocationProvider +import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository +import com.appttude.h_mal.atlas_weather.widget.NewAppWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Locale + +class SettingsViewModel( + application: Application, + private val locationProvider: LocationProvider, + private val weatherSource: WeatherSource, + private val settingsRepository: SettingsRepository +) : BaseAndroidViewModel(application) { + + private fun getContext() = getApplication().applicationContext + + fun updateWidget() { + val context = getContext() + val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) + val widgetManager = AppWidgetManager.getInstance(context) + val ids = + widgetManager.getAppWidgetIds( + ComponentName( + context, + NewAppWidget::class.java + ) + ) + AppWidgetManager.getInstance(context) + .notifyAppWidgetViewDataChanged(ids, R.id.whole_widget_view) + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + context.sendBroadcast(intent) + } + + fun refreshWeatherData() { + onStart() + job = CoroutineScope(Dispatchers.IO).launch { + try { + if (ActivityCompat.checkSelfPermission( + getContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + ) { + // Get location + val latLong = locationProvider.getCurrentLatLong() + weatherSource.forceFetchWeather(latLong) + } + updateWidget() + val units = settingsRepository.getUnitType().name.lowercase(Locale.ROOT) + onSuccess("Units have been changes to $units") + } catch (e: Exception) { + e.printStackTrace() + onError(e.message ?: "Retrieving weather failed") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModel.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModel.kt index 699f5de..4927f71 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModel.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModel.kt @@ -1,14 +1,16 @@ package com.appttude.h_mal.atlas_weather.viewmodel +import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseViewModel +import com.appttude.h_mal.atlas_weather.data.WeatherSource import com.appttude.h_mal.atlas_weather.data.location.LocationProvider -import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse -import com.appttude.h_mal.atlas_weather.data.repository.Repository -import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay import com.appttude.h_mal.atlas_weather.model.types.LocationType -import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel +import com.appttude.h_mal.atlas_weather.model.weather.FullWeather import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import java.io.IOException @@ -16,14 +18,12 @@ const val ALL_LOADED = "all_loaded" class WorldViewModel( private val locationProvider: LocationProvider, - private val repository: Repository -) : WeatherViewModel() { + private val weatherSource: WeatherSource +) : BaseViewModel() { private var currentLocation: String? = null - private val weatherListLiveData = repository.loadRoomWeatherLiveData() - init { - weatherListLiveData.observeForever { + weatherSource.repository.loadRoomWeatherLiveData().observeForever { val list = it.map { data -> WeatherDisplay(data) } @@ -37,7 +37,7 @@ class WorldViewModel( fun getSingleLocation(locationName: String) { CoroutineScope(Dispatchers.IO).launch { - val entity = repository.getSingleWeather(locationName) + val entity = weatherSource.repository.getSingleWeather(locationName) val item = WeatherDisplay(entity) onSuccess(item) } @@ -47,14 +47,8 @@ class WorldViewModel( onStart() CoroutineScope(Dispatchers.IO).launch { try { - val weatherEntity = if (repository.isSearchValid(locationName)) { - createWeatherEntity(locationName) - } else { - repository.getSingleWeather(locationName) - } + searchWeatherForLocation(locationName) onSuccess(Unit) - repository.saveCurrentWeatherToRoom(weatherEntity) - repository.saveLastSavedAt(weatherEntity.id) } catch (e: IOException) { onError(e.message!!) } @@ -64,29 +58,20 @@ class WorldViewModel( fun fetchDataForSingleLocationSearch(locationName: String) { CoroutineScope(Dispatchers.IO).launch { onStart() - // Check if location exists - if (repository.getSavedLocations().contains(locationName)) { + // Check if location already exists + if (weatherSource.repository.getSavedLocations().contains(locationName)) { onError("$locationName already exists") return@launch } try { - // Get weather from api - val entityItem = createWeatherEntity(locationName) - - // retrieved location name - val retrievedLocation = locationProvider.getLocationNameFromLatLong( - entityItem.weather.lat, - entityItem.weather.lon, - LocationType.City - ) - if (repository.getSavedLocations().contains(retrievedLocation)) { + val weather = searchWeatherForLocation(locationName) + val retrievedLocation = weather.locationString + // Check if location exists in stored + if (weatherSource.repository.getSavedLocations().contains(retrievedLocation)) { onError("$retrievedLocation already exists") return@launch } - // Save data if not null - repository.saveCurrentWeatherToRoom(entityItem) - repository.saveLastSavedAt(retrievedLocation) onSuccess("$retrievedLocation saved") } catch (e: IOException) { onError(e.message!!) @@ -96,30 +81,24 @@ class WorldViewModel( fun fetchAllLocations() { onStart() - if (!repository.isSearchValid(ALL_LOADED)) { + if (!weatherSource.repository.isSearchValid(ALL_LOADED)) { onSuccess(Unit) return } CoroutineScope(Dispatchers.IO).launch { try { - val list = mutableListOf() - repository.loadWeatherList().forEach { locationName -> + val list = mutableListOf>() + weatherSource.repository.loadWeatherList().forEach { locationName -> // If search not valid move onto next in loop - if (!repository.isSearchValid(locationName)) return@forEach + if (!weatherSource.repository.isSearchValid(locationName)) return@forEach - try { - val entity = createWeatherEntity(locationName) - list.add(entity) - repository.saveLastSavedAt(locationName) - } catch (e: IOException) { - } + val task = async{ searchWeatherForLocation(locationName) } + list.add(task) } - repository.saveWeatherListToRoom(list) - repository.saveLastSavedAt(ALL_LOADED) + list.awaitAll() + onSuccess(Unit) } catch (e: IOException) { onError(e.message!!) - } finally { - onSuccess(Unit) } } } @@ -128,34 +107,29 @@ class WorldViewModel( onStart() CoroutineScope(Dispatchers.IO).launch { try { - val success = repository.deleteSavedWeatherEntry(locationName) + val success = weatherSource.repository.deleteSavedWeatherEntry(locationName) if (!success) { onError("Failed to delete") + } else { + onSuccess(Unit) } } catch (e: IOException) { onError(e.message!!) - } finally { - onSuccess(Unit) } } } - private suspend fun getWeather(locationName: String): WeatherResponse { + private suspend fun searchWeatherForLocation(locationName: String): FullWeather { // Get location val latLong = locationProvider.getLatLongFromLocationName(locationName) - val lat = latLong.first - val lon = latLong.second - - // Get weather from api - return repository.getWeatherFromApi(lat.toString(), lon.toString()) - } - - private suspend fun createWeatherEntity(locationName: String): EntityItem { - val weather = getWeather(locationName) + // Search for location from provider (provider location name maybe different from #locationName) val location = - locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon, LocationType.City) - val fullWeather = createFullWeather(weather, location) - return createWeatherEntity(location, fullWeather) + locationProvider.getLocationNameFromLatLong( + latLong.first, + latLong.second, + LocationType.City + ) + return weatherSource.getWeather(latLong, location, LocationType.City) } } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/widget/BaseWidgetServiceIntentClass.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/widget/BaseWidgetServiceIntentClass.kt index 9e9c483..f0c0d95 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/widget/BaseWidgetServiceIntentClass.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/widget/BaseWidgetServiceIntentClass.kt @@ -22,7 +22,7 @@ abstract class BaseWidgetServiceIntentClass : JobIntentSe lateinit var appWidgetManager: AppWidgetManager lateinit var appWidgetIds: IntArray - fun initBaseWidget(componentName: ComponentName) { + fun initializeWidgetData(componentName: ComponentName) { appWidgetManager = AppWidgetManager.getInstance(baseContext) appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) } diff --git a/app/src/main/java/com/appttude/h_mal/atlas_weather/widget/WidgetJobServiceIntent.kt b/app/src/main/java/com/appttude/h_mal/atlas_weather/widget/WidgetJobServiceIntent.kt index e545be8..0553f9c 100644 --- a/app/src/main/java/com/appttude/h_mal/atlas_weather/widget/WidgetJobServiceIntent.kt +++ b/app/src/main/java/com/appttude/h_mal/atlas_weather/widget/WidgetJobServiceIntent.kt @@ -1,25 +1,21 @@ package com.appttude.h_mal.atlas_weather.widget -import android.Manifest.permission.ACCESS_COARSE_LOCATION import android.annotation.SuppressLint import android.app.PendingIntent import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.PackageManager.PERMISSION_GRANTED import android.icu.text.SimpleDateFormat -import android.os.PowerManager import android.widget.RemoteViews -import androidx.core.app.ActivityCompat.checkSelfPermission import com.appttude.h_mal.atlas_weather.R import com.appttude.h_mal.atlas_weather.helper.ServicesHelper import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetCellData +import com.appttude.h_mal.atlas_weather.model.widget.WidgetError +import com.appttude.h_mal.atlas_weather.model.widget.WidgetState.HasData +import com.appttude.h_mal.atlas_weather.model.widget.WidgetState.HasError import com.appttude.h_mal.atlas_weather.model.widget.WidgetWeatherCollection import com.appttude.h_mal.atlas_weather.ui.MainActivity -import com.appttude.h_mal.atlas_weather.utils.isInternetAvailable -import com.appttude.h_mal.atlas_weather.utils.tryOrNullSuspended import com.appttude.h_mal.atlas_weather.widget.WidgetState.* -import com.appttude.h_mal.atlas_weather.widget.WidgetState.Companion.getWidgetState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -46,61 +42,33 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass() { executeWidgetUpdate() } + @SuppressLint("MissingPermission") private fun executeWidgetUpdate() { val componentName = ComponentName(this, NewAppWidget::class.java) - initBaseWidget(componentName) + initializeWidgetData(componentName) - initiateWidgetUpdate(getCurrentWidgetState()) - } - - private fun initiateWidgetUpdate(state: WidgetState) { - when (state) { - NO_LOCATION, SCREEN_ON_CONNECTION_UNAVAILABLE -> updateErrorWidget(state) - SCREEN_ON_CONNECTION_AVAILABLE -> updateWidget(false) - SCREEN_OFF_CONNECTION_AVAILABLE -> updateWidget(true) - SCREEN_OFF_CONNECTION_UNAVAILABLE -> return - } - } - - private fun updateWidget(fromStorage: Boolean) { CoroutineScope(Dispatchers.IO).launch { - val result = getWidgetWeather(fromStorage) - appWidgetIds.forEach { id -> setupView(id, result) } + setLoadingView() + + val widgetState = helper.fetchWidgetData() + appWidgetIds.forEach { id -> + when (widgetState) { + is HasData<*> -> { + val data = widgetState.data as WidgetWeatherCollection + setupView(id, data) + } + + is HasError<*> -> { + if (widgetState.error is WidgetError) { + val error = widgetState.error + setupErrorView(id, error) + } + } + } + } } } - private fun updateErrorWidget(state: WidgetState) { - appWidgetIds.forEach { id -> setEmptyView(id, state) } - } - - private fun getCurrentWidgetState(): WidgetState { - val pm = getSystemService(POWER_SERVICE) as PowerManager - val isScreenOn = pm.isInteractive - val locationGranted = - checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED - val internetAvailable = isInternetAvailable(this.applicationContext) - - return getWidgetState(locationGranted, isScreenOn, internetAvailable) - } - - @SuppressLint("MissingPermission") - suspend fun getWidgetWeather(storageOnly: Boolean): WidgetWeatherCollection? { - return tryOrNullSuspended { - if (!storageOnly) helper.fetchData() - helper.getWidgetWeatherCollection() - } - } - - private fun setEmptyView(appWidgetId: Int, state: WidgetState) { - val error = when (state) { - NO_LOCATION -> "No Location Permission" - SCREEN_ON_CONNECTION_UNAVAILABLE -> "No network available" - else -> "No data" - } - - val views = createRemoteView(R.layout.weather_app_widget) - bindErrorView(appWidgetId, views, error) - } private fun setupView( appWidgetId: Int, @@ -117,6 +85,49 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass() { } } + private fun setupErrorView( + appWidgetId: Int, + error: WidgetError + ) { + val views = createRemoteView(R.layout.weather_app_widget) +// setLastUpdated(views, collection?.widgetData?.timeStamp) + views.setInt(R.id.whole_widget_view, "setBackgroundColor", helper.getWidgetBackground()) + + bindEmptyView(appWidgetId, views, error.errorMessage) + } + + @SuppressLint("DiscouragedApi") + private fun setLoadingView() { + appWidgetIds.forEach { id -> + val views = createRemoteView(R.layout.weather_app_widget) + views.setInt(R.id.whole_widget_view, "setBackgroundColor", helper.getWidgetBackground()) + + val clickUpdate = createUpdatePendingIntent(NewAppWidget::class.java, id) + + views.apply { + setTextViewText(R.id.widget_current_location, "Loading...") + setImageViewResource(R.id.location_icon, R.drawable.location_flag) + setOnClickPendingIntent(R.id.widget_current_location, clickUpdate) + + (0..4).forEach { i -> + val dayId: Int = + resources.getIdentifier("widget_item_day_$i", "id", packageName) + val imageId: Int = + resources.getIdentifier("widget_item_image_$i", "id", packageName) + val tempId: Int = + resources.getIdentifier("widget_item_temp_high_$i", "id", packageName) + + views.setTextViewText(dayId, "loading") + views.setTextViewText(tempId, "") + setImageViewResource(imageId, R.drawable.baseline_refresh_24) + } + + // Instruct the widget manager to update the widget + appWidgetManager.updateAppWidget(id, views) + } + } + } + override fun bindErrorView( widgetId: Int, views: RemoteViews, diff --git a/app/src/main/res/drawable/baseline_refresh_24.xml b/app/src/main/res/drawable/baseline_refresh_24.xml new file mode 100644 index 0000000..788bfdf --- /dev/null +++ b/app/src/main/res/drawable/baseline_refresh_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a00ebe6..fdfd652 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,4 +31,12 @@ Unable to retrieve weather Make sure you are connected to the internet and have location permissions granted No weather to display + Units + widget_black_background + Weather units + + + Metric + Imperial + diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/WorldItemFragment.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/WorldItemFragment.kt index 3454063..feee132 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/WorldItemFragment.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/WorldItemFragment.kt @@ -16,22 +16,22 @@ import kotlinx.android.synthetic.main.fragment_home.swipe_refresh class WorldItemFragment : BaseFragment(R.layout.fragment_home) { - private var param1: String? = null + private var retrievedLocationName: String? = null private lateinit var recyclerAdapter: WeatherRecyclerAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - param1 = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName - param1?.let { viewModel.setLocation(it) } + retrievedLocationName = WorldItemFragmentArgs.fromBundle(requireArguments()).locationName + retrievedLocationName?.let { viewModel.setLocation(it) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) recyclerAdapter = WeatherRecyclerAdapter { - val directions = - WorldItemFragmentDirections.actionWorldItemFragmentToFurtherDetailsFragment(it) + val directions = WorldItemFragmentDirections + .actionWorldItemFragmentToFurtherDetailsFragment(it) navigateTo(directions) } @@ -42,14 +42,14 @@ class WorldItemFragment : BaseFragment(R.layout.fragment_home) { swipe_refresh.apply { setOnRefreshListener { - param1?.let { + retrievedLocationName?.let { viewModel.fetchDataForSingleLocation(it) isRefreshing = true } } } - param1?.let { viewModel.getSingleLocation(it) } + retrievedLocationName?.let { viewModel.getSingleLocation(it) } } override fun onSuccess(data: Any?) { diff --git a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/settings/SettingsFragment.kt b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/settings/SettingsFragment.kt index eba82cd..2c99fc5 100644 --- a/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/settings/SettingsFragment.kt +++ b/app/src/monoWeather/java/com/appttude/h_mal/monoWeather/ui/settings/SettingsFragment.kt @@ -1,46 +1,24 @@ package com.appttude.h_mal.monoWeather.ui.settings -import android.appwidget.AppWidgetManager -import android.content.ComponentName -import android.content.Intent -import android.os.Bundle -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceManager import com.appttude.h_mal.atlas_weather.R -import com.appttude.h_mal.atlas_weather.widget.NewAppWidget +import com.appttude.h_mal.atlas_weather.base.BasePreferencesFragment +import com.appttude.h_mal.atlas_weather.utils.displayToast +import com.appttude.h_mal.atlas_weather.viewmodel.SettingsViewModel -class SettingsFragment : PreferenceFragmentCompat() { +class SettingsFragment : BasePreferencesFragment(R.xml.prefs_screen) { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.prefs_screen, rootKey) - - //listener on changed sort order preference: - val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) - prefs.registerOnSharedPreferenceChangeListener { _, key -> - if (key == "temp_units") { - val intent = Intent(requireContext(), NewAppWidget::class.java) - intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - val ids = AppWidgetManager.getInstance(requireContext()) - .getAppWidgetIds(ComponentName(requireContext(), NewAppWidget::class.java)) - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) - requireContext().sendBroadcast(intent) - } - - if (key == "widget_black_background") { - val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) - val widgetManager = AppWidgetManager.getInstance(requireContext()) - val ids = - widgetManager.getAppWidgetIds( - ComponentName( - requireContext(), - NewAppWidget::class.java - ) - ) - AppWidgetManager.getInstance(requireContext()) - .notifyAppWidgetViewDataChanged(ids, R.id.whole_widget_view) - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) - requireContext().sendBroadcast(intent) + override fun preferenceChanged(key: String) { + when (key) { + "UnitType" -> viewModel.refreshWeatherData() + "widget_black_background" -> { + viewModel.updateWidget() + displayToast("Widget background has been updates") } } } + + override fun onSuccess(data: Any?) { + super.onSuccess(data) + if (data is String) displayToast(data) + } } \ No newline at end of file diff --git a/app/src/monoWeather/res/layout/mono_item_one.xml b/app/src/monoWeather/res/layout/mono_item_one.xml index e6cbe7f..fb12f91 100644 --- a/app/src/monoWeather/res/layout/mono_item_one.xml +++ b/app/src/monoWeather/res/layout/mono_item_one.xml @@ -23,8 +23,8 @@ android:layout_height="32dp" android:layout_marginEnd="12dp" android:contentDescription="@string/image_string" - app:tint="@color/colorAccent" - app:srcCompat="@drawable/maps_and_flags" /> + app:srcCompat="@drawable/maps_and_flags" + app:tint="@color/colorAccent" /> + app:layout_constraintTop_toTopOf="@id/temp_main_4" + tools:text="@string/degrees_c" /> + app:srcCompat="@drawable/cloud_symbol" + app:tint="@color/colorAccent" /> + \ No newline at end of file diff --git a/app/src/test/java/com/appttude/h_mal/atlas_weather/data/WeatherSourceTest.kt b/app/src/test/java/com/appttude/h_mal/atlas_weather/data/WeatherSourceTest.kt new file mode 100644 index 0000000..db5b3e3 --- /dev/null +++ b/app/src/test/java/com/appttude/h_mal/atlas_weather/data/WeatherSourceTest.kt @@ -0,0 +1,196 @@ +package com.appttude.h_mal.atlas_weather.data + +import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl +import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse +import com.appttude.h_mal.atlas_weather.data.repository.RepositoryImpl +import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION +import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem +import com.appttude.h_mal.atlas_weather.model.types.LocationType +import com.appttude.h_mal.atlas_weather.model.types.UnitType +import com.appttude.h_mal.atlas_weather.model.weather.FullWeather +import com.appttude.h_mal.atlas_weather.utils.BaseTest +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals + +class WeatherSourceTest : BaseTest() { + + @InjectMockKs + lateinit var weatherSource: WeatherSource + + @MockK + lateinit var repository: RepositoryImpl + + @MockK + lateinit var locationProvider: LocationProviderImpl + + private lateinit var weatherResponse: WeatherResponse + + @Before + fun setUp() { + MockKAnnotations.init(this) + weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java) + } + + @Test + fun fetchDataForSingleLocation_validLocation_validReturn() { + // Arrange + val latlon = Pair( + weatherResponse.lat, + weatherResponse.lon + ) + val fullWeather = FullWeather(weatherResponse).apply { + temperatureUnit = "°C" + locationString = CURRENT_LOCATION + } + val entityItem = EntityItem(CURRENT_LOCATION, fullWeather) + + + // Act + every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true) + coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns latlon + coEvery { + repository.getWeatherFromApi( + weatherResponse.lat.toString(), + weatherResponse.lon.toString() + ) + }.returns(weatherResponse) + coEvery { + locationProvider.getLocationNameFromLatLong( + weatherResponse.lat, + weatherResponse.lon, + LocationType.City + ) + }.returns(CURRENT_LOCATION) + every { repository.getUnitType() } returns UnitType.METRIC + every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit + coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit + + // Assert + val result = + runBlocking { weatherSource.getWeather(latlon, locationType = LocationType.City) } + assertEquals(result, fullWeather) + } + + @Test(expected = IOException::class) + fun fetchDataForSingleLocation_failedWeatherApi_invalidReturn() { + // Arrange + val latlon = Pair( + weatherResponse.lat, + weatherResponse.lon + ) + + // Act + every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true) + coEvery { + repository.getWeatherFromApi( + weatherResponse.lat.toString(), + weatherResponse.lon.toString() + ) + } throws IOException("Unable fetch data") + + // Assert + runBlocking { weatherSource.getWeather(latlon) } + } + + @Test(expected = IOException::class) + fun fetchDataForSingleLocation_failedLocation_invalidReturn() { + // Arrange + val latlon = Pair( + weatherResponse.lat, + weatherResponse.lon + ) + + // Act + every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true) + coEvery { + repository.getWeatherFromApi( + weatherResponse.lat.toString(), + weatherResponse.lon.toString() + ) + } returns weatherResponse + coEvery { + locationProvider.getLocationNameFromLatLong( + weatherResponse.lat, + weatherResponse.lon + ) + }.throws(IOException()) + + // Assert + runBlocking { weatherSource.getWeather(latlon) } + } + + @Test + fun searchAboveFallbackTime_validLocation_validReturn() { + // Arrange + val latlon = Pair( + weatherResponse.lat, + weatherResponse.lon + ) + val fullWeather = FullWeather(weatherResponse).apply { + temperatureUnit = "°C" + locationString = CURRENT_LOCATION + } + val entityItem = EntityItem(CURRENT_LOCATION, fullWeather) + + // Act + coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem) + coEvery { repository.saveCurrentWeatherToRoom(entityItem) }.returns(Unit) + every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false) + + // Assert + val result = runBlocking { weatherSource.getWeather(latlon) } + assertEquals(result, fullWeather) + } + + @Test + fun forceFetchDataForSingleLocation_validLocation_validReturn() { + // Arrange + val latlon = Pair( + weatherResponse.lat, + weatherResponse.lon + ) + val fullWeather = FullWeather(weatherResponse).apply { + temperatureUnit = "°C" + locationString = CURRENT_LOCATION + } + val entityItem = EntityItem(CURRENT_LOCATION, fullWeather) + + // Act + coEvery { repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION) } returns entityItem + every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true) + coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns latlon + coEvery { + repository.getWeatherFromApi( + weatherResponse.lat.toString(), + weatherResponse.lon.toString() + ) + }.returns(weatherResponse) + coEvery { + locationProvider.getLocationNameFromLatLong( + weatherResponse.lat, + weatherResponse.lon, + LocationType.City + ) + }.returns(CURRENT_LOCATION) + every { repository.getUnitType() } returns UnitType.METRIC + every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit + coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit + + // Assert + val result = runBlocking { + weatherSource.forceFetchWeather( + latlon, + locationType = LocationType.City + ) + } + assertEquals(result, fullWeather) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImplTest.kt b/app/src/test/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImplTest.kt index dd10e6e..bb78ac1 100644 --- a/app/src/test/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImplTest.kt +++ b/app/src/test/java/com/appttude/h_mal/atlas_weather/data/repository/RepositoryImplTest.kt @@ -6,21 +6,17 @@ import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider import com.appttude.h_mal.atlas_weather.data.room.AppDatabase import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem +import com.appttude.h_mal.atlas_weather.model.types.UnitType import com.appttude.h_mal.atlas_weather.utils.BaseTest import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.doAnswer import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.coJustRun import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.justRun import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test -import org.mockito.ArgumentMatchers.anyString -import retrofit2.Response import java.io.IOException import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -94,6 +90,7 @@ class RepositoryImplTest : BaseTest() { //Act //create a successful retrofit response + every { prefs.getUnitsType() } returns (UnitType.METRIC) coEvery { api.getFromApi("", "") }.returns(mockResponse) // Assert @@ -110,6 +107,7 @@ class RepositoryImplTest : BaseTest() { //Act //create a successful retrofit response + every { prefs.getUnitsType() } returns (UnitType.METRIC) coEvery { api.getFromApi(any(), any()) } returns (mockResponse) // Assert diff --git a/app/src/test/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModelTest.kt b/app/src/test/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModelTest.kt index 5b311e2..a410070 100644 --- a/app/src/test/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModelTest.kt +++ b/app/src/test/java/com/appttude/h_mal/atlas_weather/viewmodel/WorldViewModelTest.kt @@ -1,26 +1,31 @@ package com.appttude.h_mal.atlas_weather.viewmodel import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.appttude.h_mal.atlas_weather.data.WeatherSource import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse -import com.appttude.h_mal.atlas_weather.data.repository.Repository import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION -import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.model.ViewState import com.appttude.h_mal.atlas_weather.model.types.LocationType import com.appttude.h_mal.atlas_weather.model.weather.FullWeather import com.appttude.h_mal.atlas_weather.utils.BaseTest import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue import com.appttude.h_mal.atlas_weather.utils.sleep +import com.nhaarman.mockitokotlin2.any import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.ArgumentMatchers +import org.mockito.ArgumentMatchers.anyList +import org.mockito.ArgumentMatchers.anyString import java.io.IOException +import kotlin.test.assertEquals import kotlin.test.assertIs @@ -32,8 +37,8 @@ class WorldViewModelTest : BaseTest() { @InjectMockKs lateinit var viewModel: WorldViewModel - @MockK(relaxed = true) - lateinit var repository: Repository + @RelaxedMockK + lateinit var weatherSource: WeatherSource @MockK lateinit var locationProvider: LocationProviderImpl @@ -43,30 +48,19 @@ class WorldViewModelTest : BaseTest() { @Before fun setUp() { MockKAnnotations.init(this) - weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java) } @Test fun fetchDataForSingleLocation_validLocation_validReturn() { // Arrange - val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply { - temperatureUnit = "°C" - locationString = CURRENT_LOCATION - }) + val latlon = any>() // Act - every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true) coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair( weatherResponse.lat, weatherResponse.lon ) - coEvery { - repository.getWeatherFromApi( - weatherResponse.lat.toString(), - weatherResponse.lon.toString() - ) - }.returns(weatherResponse) coEvery { locationProvider.getLocationNameFromLatLong( weatherResponse.lat, @@ -74,66 +68,209 @@ class WorldViewModelTest : BaseTest() { LocationType.City ) }.returns(CURRENT_LOCATION) - every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit - coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit - - viewModel.fetchDataForSingleLocation(CURRENT_LOCATION) + coEvery { + weatherSource.getWeather( + latlon, + CURRENT_LOCATION, + locationType = LocationType.City + ) + } returns FullWeather(weatherResponse) // Assert - viewModel.uiState.observeForever { - println(it.javaClass.name) + viewModel.fetchDataForSingleLocation(CURRENT_LOCATION) + + sleep(100) + assertIs>(viewModel.uiState.getOrAwaitValue()) + } + + @Test + fun fetchDataForSingleLocation_failedLocation_validReturn() { + // Arrange + val errorMessage = ArgumentMatchers.anyString() + + // Act + coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair( + weatherResponse.lat, + weatherResponse.lon + ) + coEvery { + locationProvider.getLocationNameFromLatLong( + weatherResponse.lat, + weatherResponse.lon, + LocationType.City + ) + } throws IOException(errorMessage) + + // Assert + viewModel.fetchDataForSingleLocation(CURRENT_LOCATION) + sleep(100) + val observerResults = viewModel.uiState.getOrAwaitValue() + assertIs>(observerResults) + assertEquals(observerResults.error as String, errorMessage) + } + + @Test + fun fetchDataForSingleLocation_failedApi_validReturn() { + // Arrange + val latlon = Pair(weatherResponse.lat, weatherResponse.lon) + val errorMessage = ArgumentMatchers.anyString() + + // Act + coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair( + weatherResponse.lat, + weatherResponse.lon + ) + coEvery { + locationProvider.getLocationNameFromLatLong( + weatherResponse.lat, + weatherResponse.lon, + LocationType.City + ) + }.returns(CURRENT_LOCATION) + coEvery { + weatherSource.getWeather( + latlon, + CURRENT_LOCATION, + locationType = LocationType.City + ) + } throws IOException(errorMessage) + + // Assert + viewModel.fetchDataForSingleLocation(CURRENT_LOCATION) + sleep(100) + val observerResults = viewModel.uiState.getOrAwaitValue() + assertIs>(observerResults) + assertEquals(observerResults.error as String, errorMessage) + } + + @Test + fun fetchDataForSingleLocationSearch_validLocation_validReturn() { + // Arrange + val latlon = Pair(weatherResponse.lat, weatherResponse.lon) + + // Act + every { weatherSource.repository.getSavedLocations() } returns anyList() + coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns latlon + coEvery { + locationProvider.getLocationNameFromLatLong( + weatherResponse.lat, + weatherResponse.lon, + LocationType.City + ) + }.returns(CURRENT_LOCATION) + coEvery { + weatherSource.getWeather( + latlon, + CURRENT_LOCATION, + locationType = LocationType.City + ) + } returns FullWeather(weatherResponse).apply { locationString = CURRENT_LOCATION } + + // Assert + viewModel.fetchDataForSingleLocationSearch(CURRENT_LOCATION) + + sleep(100) + val result = viewModel.uiState.getOrAwaitValue() + assertIs>(result) + assertEquals(result.data as String, "$CURRENT_LOCATION saved") + } + + @Test + fun fetchDataForSingleLocationSearch_locationAlreadyExists_errorReceived() { + // Act + every { weatherSource.repository.getSavedLocations() } returns listOf(CURRENT_LOCATION) + + // Assert + viewModel.fetchDataForSingleLocationSearch(CURRENT_LOCATION) + + sleep(100) + val result = viewModel.uiState.getOrAwaitValue() + assertIs>(result) + assertEquals(result.error as String, "$CURRENT_LOCATION already exists") + } + + @Test + fun fetchDataForSingleLocationSearch_retrievedLocationExists_validError() { + // Arrange + val latlon = Pair(weatherResponse.lat, weatherResponse.lon) + val retrievedLocation = anyString() + + // Act + every { weatherSource.repository.getSavedLocations() } returns listOf(retrievedLocation) + coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns latlon + coEvery { + locationProvider.getLocationNameFromLatLong( + weatherResponse.lat, + weatherResponse.lon, + LocationType.City + ) + }.returns(CURRENT_LOCATION) + coEvery { + weatherSource.getWeather( + latlon, + CURRENT_LOCATION, + locationType = LocationType.City + ) + } returns FullWeather(weatherResponse).apply { locationString = retrievedLocation } + + // Assert + viewModel.fetchDataForSingleLocationSearch(CURRENT_LOCATION) + + sleep(100) + val result = viewModel.uiState.getOrAwaitValue() + assertIs>(result) + assertEquals(result.error as String, "$retrievedLocation already exists") + } + + @Test + fun fetchAllLocations_validLocations_validReturn() { + // Arrange + val listOfPlaces = listOf("Sydney", "London", "Cairo") + + // Act + listOfPlaces.forEachIndexed { index, s -> + every { weatherSource.repository.isSearchValid(s) } returns true + coEvery { locationProvider.getLatLongFromLocationName(s) } returns Pair( + index.toDouble(), + index.toDouble() + ) + coEvery { + locationProvider.getLocationNameFromLatLong( + index.toDouble(), + index.toDouble(), + LocationType.City + ) + }.returns(s) + coEvery { + weatherSource.getWeather( + Pair(index.toDouble(), index.toDouble()), + s, + LocationType.City + ) + } } + coEvery { weatherSource.repository.loadWeatherList() } returns listOfPlaces - sleep(3000) + // Assert + viewModel.fetchAllLocations() + + sleep(100) assertIs>(viewModel.uiState.getOrAwaitValue()) } @Test - fun fetchDataForSingleLocation_invalidLocation_invalidReturn() { + fun deleteLocation_validLocations_validReturn() { // Arrange - val location = CURRENT_LOCATION + val location = anyString() // Act - every { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } throws IOException("Unable to get location") - every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true) - coEvery { - repository.getWeatherFromApi( - weatherResponse.lat.toString(), - weatherResponse.lon.toString() - ) - }.returns(weatherResponse) - - viewModel.fetchDataForSingleLocation(location) + coEvery { weatherSource.repository.deleteSavedWeatherEntry(location) } returns true // Assert - sleep(300) - assertIs>(viewModel.uiState.getOrAwaitValue()) - } + viewModel.deleteLocation(location) - @Test - fun searchAboveFallbackTime_validLocation_validReturn() { - // Arrange - val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply { - temperatureUnit = "°C" - locationString = CURRENT_LOCATION - }) - - // Act - coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem) - every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false) - coEvery { - repository.getWeatherFromApi( - weatherResponse.lat.toString(), - weatherResponse.lon.toString() - ) - }.returns(weatherResponse) - every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit - coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit - - viewModel.fetchDataForSingleLocation(CURRENT_LOCATION) - - // Assert - sleep(300) + sleep(100) assertIs>(viewModel.uiState.getOrAwaitValue()) } + } \ No newline at end of file