- change location retrieval accuracy

- change location retrieval caching from location provider
 - expand unit test suite
 - code refactoring
 - Imperial units added
This commit is contained in:
2023-12-25 00:43:11 +00:00
parent f3f2ef1cc1
commit 32ce5112c1
37 changed files with 963 additions and 293 deletions

View File

@@ -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)
}
}

View File

@@ -8,7 +8,7 @@ import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.Converter
import com.appttude.h_mal.atlas_weather.data.room.WeatherDao
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
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.utils.getOrAwaitValue
import io.mockk.mockk
@@ -92,16 +92,16 @@ class RoomDatabaseTests {
assertNull(dao.getCurrentFullWeatherSingle(id))
}
private fun createEntity(id: String = CURRENT_LOCATION): WeatherEntity {
private fun createEntity(id: String = CURRENT_LOCATION): EntityItem {
val weather = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
FullWeather()
} else {
mockk<FullWeather>()
}
return WeatherEntity(id, weather)
return EntityItem(id, weather)
}
private fun createEntityList(size: Int = 4): List<WeatherEntity> {
private fun createEntityList(size: Int = 4): List<EntityItem> {
return (0.. size).map {
val id = UUID.randomUUID().toString()
createEntity(id)

View File

@@ -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

View File

@@ -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<V : BaseAndroidViewModel>(@XmlRes private val preferencesResId: Int) :
PreferenceFragmentCompat(),
KodeinAware {
override val kodein by kodein()
private val factory by instance<ApplicationViewModelFactory>()
val viewModel: V by getFragmentViewModel()
var mActivity: BaseActivity? = null
private fun getFragmentViewModel(): Lazy<V> =
createViewModelLazy(getGenericClassAt(0), { viewModelStore }, factoryProducer = { factory })
private var shortAnimationDuration by Delegates.notNull<Int>()
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)
}
}

View File

@@ -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<ViewState>()
val uiState: LiveData<ViewState> = _uiState
fun onStart() {
_uiState.postValue(ViewState.HasStarted)
}
fun <T : Any> onSuccess(result: T) {
_uiState.postValue(ViewState.HasData(result))
}
protected fun <E : Any> onError(error: E) {
_uiState.postValue(ViewState.HasError(error))
}
protected var job: Job? = null
fun cancelOperation() {
CoroutineScope(Dispatchers.IO).launch {
job?.run {
cancelAndJoin()
onSuccess(Unit)
}
}
}
}

View File

@@ -9,8 +9,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import okhttp3.internal.wait
import java.util.concurrent.CancellationException
open class BaseViewModel : ViewModel() {

View File

@@ -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.WeatherEntity
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
abstract class WeatherViewModel : BaseViewModel() {
fun createFullWeather(
weather: WeatherResponse,
locationName: String
): FullWeather {
return FullWeather(weather).apply {
temperatureUnit = "°C"
locationString = locationName
}
}
fun createWeatherEntity(
locationName: String,
weather: FullWeather
): WeatherEntity {
weather.apply {
locationString = locationName
}
return WeatherEntity(locationName, weather)
}
}

View File

@@ -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<Double, Double>,
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<Double, Double>,
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<Double, Double>,
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
}
}

View File

@@ -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<Double, Double>
fun getLatLongFromLocationName(location: String): Pair<Double, Double>
suspend fun getLocationNameFromLatLong(

View File

@@ -3,11 +3,8 @@ package com.appttude.h_mal.atlas_weather.data.location
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.annotation.SuppressLint
import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.os.Build
import androidx.annotation.RequiresPermission
import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.google.android.gms.location.LocationServices

View File

@@ -4,11 +4,13 @@ import android.content.Context
import android.content.SharedPreferences
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 +32,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 +52,13 @@ 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
}
}

View File

@@ -2,20 +2,22 @@ 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.WeatherEntity
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.types.UnitType
interface Repository {
suspend fun getWeatherFromApi(lat: String, long: String): WeatherResponse
suspend fun saveCurrentWeatherToRoom(weatherEntity: WeatherEntity)
suspend fun saveWeatherListToRoom(list: List<WeatherEntity>)
fun loadRoomWeatherLiveData(): LiveData<List<WeatherEntity>>
suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem)
suspend fun saveWeatherListToRoom(list: List<EntityItem>)
fun loadRoomWeatherLiveData(): LiveData<List<EntityItem>>
suspend fun loadWeatherList(): List<String>
fun loadCurrentWeatherFromRoom(id: String): LiveData<WeatherEntity>
suspend fun loadSingleCurrentWeatherFromRoom(id: String): WeatherEntity
fun loadCurrentWeatherFromRoom(id: String): LiveData<EntityItem>
suspend fun loadSingleCurrentWeatherFromRoom(id: String): EntityItem
fun isSearchValid(locationName: String): Boolean
fun saveLastSavedAt(locationName: String)
suspend fun deleteSavedWeatherEntry(locationName: String): Boolean
fun getSavedLocations(): List<String>
suspend fun getSingleWeather(locationName: String): WeatherEntity
suspend fun getSingleWeather(locationName: String): EntityItem
fun getUnitType() : UnitType
}

View File

@@ -6,7 +6,8 @@ import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherRe
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.WeatherEntity
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,15 +21,15 @@ 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(weatherEntity: WeatherEntity) {
db.getWeatherDao().upsertFullWeather(weatherEntity)
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
db.getWeatherDao().upsertFullWeather(entityItem)
}
override suspend fun saveWeatherListToRoom(
list: List<WeatherEntity>
list: List<EntityItem>
) {
db.getWeatherDao().upsertListOfFullWeather(list)
}
@@ -65,11 +66,15 @@ class RepositoryImpl(
}
override fun getSavedLocations(): List<String> {
return prefs.getAllKeys().toList()
return prefs.getAllKeysExcludingCurrent().toList()
}
override suspend fun getSingleWeather(locationName: String): WeatherEntity {
override suspend fun getSingleWeather(locationName: String): EntityItem {
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
}
override fun getUnitType(): UnitType {
return prefs.getUnitsType()
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -5,11 +5,11 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
@Database(
entities = [WeatherEntity::class],
version = 1,
entities = [EntityItem::class],
version = 2,
exportSchema = false
)
@TypeConverters(Converter::class)

View File

@@ -6,30 +6,30 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
@Dao
interface WeatherDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertFullWeather(item: WeatherEntity)
fun upsertFullWeather(item: EntityItem)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertListOfFullWeather(items: List<WeatherEntity>)
fun upsertListOfFullWeather(items: List<EntityItem>)
@Query("SELECT * FROM WeatherEntity WHERE id = :userId LIMIT 1")
fun getCurrentFullWeather(userId: String): LiveData<WeatherEntity>
@Query("SELECT * FROM EntityItem WHERE id = :userId LIMIT 1")
fun getCurrentFullWeather(userId: String): LiveData<EntityItem>
@Query("SELECT * FROM WeatherEntity WHERE id = :userId LIMIT 1")
fun getCurrentFullWeatherSingle(userId: String): WeatherEntity
@Query("SELECT * FROM EntityItem WHERE id = :userId LIMIT 1")
fun getCurrentFullWeatherSingle(userId: String): EntityItem
@Query("SELECT * FROM WeatherEntity WHERE id != :id")
fun getAllFullWeatherWithoutCurrent(id: String = CURRENT_LOCATION): LiveData<List<WeatherEntity>>
@Query("SELECT * FROM EntityItem WHERE id != :id")
fun getAllFullWeatherWithoutCurrent(id: String = CURRENT_LOCATION): LiveData<List<EntityItem>>
@Query("SELECT * FROM WeatherEntity WHERE id != :id")
fun getWeatherListWithoutCurrent(id: String = CURRENT_LOCATION): List<WeatherEntity>
@Query("SELECT * FROM EntityItem WHERE id != :id")
fun getWeatherListWithoutCurrent(id: String = CURRENT_LOCATION): List<EntityItem>
@Query("DELETE FROM WeatherEntity WHERE id = :userId")
@Query("DELETE FROM EntityItem WHERE id = :userId")
fun deleteEntry(userId: String): Int
}

View File

@@ -8,7 +8,7 @@ import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
const val CURRENT_LOCATION = "CurrentLocation"
@Entity
data class WeatherEntity(
data class EntityItem(
@PrimaryKey(autoGenerate = false)
val id: String,
val weather: FullWeather

View File

@@ -10,7 +10,8 @@ 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.WeatherEntity
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
@@ -50,10 +51,10 @@ class ServicesHelper(
temperatureUnit = "°C"
locationString = currentLocation
}
val weatherEntity = WeatherEntity(CURRENT_LOCATION, fullWeather)
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
// Save data if not null
repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(weatherEntity)
repository.saveCurrentWeatherToRoom(entityItem)
true
} catch (e: IOException) {
e.printStackTrace()
@@ -120,15 +121,15 @@ class ServicesHelper(
}
val fullWeather = FullWeather(weather).apply {
temperatureUnit = "°C"
temperatureUnit = if (repository.getUnitType() == UnitType.METRIC) "°C" else "°F"
locationString = currentLocation
}
val weatherEntity = WeatherEntity(CURRENT_LOCATION, fullWeather)
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
// Save data to database
repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(weatherEntity)
repository.saveCurrentWeatherToRoom(entityItem)
val data = createWidgetWeatherCollection(weatherEntity, currentLocation)
val data = createWidgetWeatherCollection(entityItem, currentLocation)
return WidgetState.HasData(data)
}
@@ -201,7 +202,10 @@ class ServicesHelper(
}
}
private fun createWidgetWeatherCollection(result: WeatherEntity, locationName: String): WidgetWeatherCollection {
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()

View File

@@ -2,7 +2,7 @@ package com.appttude.h_mal.atlas_weather.model.forecast
import android.os.Parcel
import android.os.Parcelable
import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
import com.appttude.h_mal.atlas_weather.model.weather.Hour
@@ -43,7 +43,7 @@ data class WeatherDisplay(
) {
}
constructor(entity: WeatherEntity) : this(
constructor(entity: EntityItem) : this(
entity.weather.current?.temp,
entity.weather.temperatureUnit,
entity.id,

View File

@@ -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
)
}
}
}
}

View File

@@ -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

View File

@@ -1,3 +1,3 @@
package com.appttude.h_mal.atlas_weather.utils
val FALLBACK_TIME: Long = 300000L
const val FALLBACK_TIME: Long = 300000L

View File

@@ -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")

View File

@@ -2,23 +2,23 @@ package com.appttude.h_mal.atlas_weather.viewmodel
import android.Manifest
import androidx.annotation.RequiresPermission
import androidx.lifecycle.viewModelScope
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.WeatherEntity
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 {w ->
weatherSource.repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { w ->
w?.let {
val weather = WeatherDisplay(it)
onSuccess(weather)
@@ -31,25 +31,11 @@ class MainViewModel(
onStart()
job = CoroutineScope(Dispatchers.IO).launch {
try {
// Has the search been conducted in the last 5 minutes
val weatherEntity = 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)
WeatherEntity(CURRENT_LOCATION, fullWeather)
} else {
repository.getSingleWeather(CURRENT_LOCATION)
}
// Save data if not null
repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(weatherEntity)
weatherSource.getWeather(latLon = latLong)
} catch (e: Exception) {
onError(e.message!!)
onError(e.message ?: "Retrieving weather failed")
}
}
}

View File

@@ -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.annotation.RequiresPermission
import androidx.core.app.ActivityCompat
import com.appttude.h_mal.atlas_weather.R
import com.appttude.h_mal.atlas_weather.application.AppClass
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<AppClass>().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) {
onError(e.message ?: "Retrieving weather failed")
}
}
}
}

View File

@@ -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.WeatherEntity
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<WeatherEntity>()
repository.loadWeatherList().forEach { locationName ->
val list = mutableListOf<Deferred<FullWeather>>()
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): WeatherEntity {
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)
}
}

View File

@@ -64,8 +64,6 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass<NewAppWidget>() {
setupErrorView(id, error)
}
}
else -> return@launch
}
}
}

View File

@@ -31,4 +31,11 @@
<string name="retrieve_warning">Unable to retrieve weather</string>
<string name="empty_retrieve_warning">Make sure you are connected to the internet and have location permissions granted</string>
<string name="no_weather_to_display">No weather to display</string>
<string name="unit_key">Units</string>
<string name="widget_black_background">widget_black_background</string>
<string-array name="units">
<item>Metric</item>
<item>Imperial</item>
</string-array>
</resources>

View File

@@ -16,22 +16,22 @@ import kotlinx.android.synthetic.main.fragment_home.swipe_refresh
class WorldItemFragment : BaseFragment<WorldViewModel>(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<WorldViewModel>(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?) {

View File

@@ -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<SettingsViewModel>(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)
}
}

View File

@@ -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" />
<TextView
android:id="@+id/location_main_4"
@@ -51,11 +51,11 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:text="@string/degrees_c"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintLeft_toRightOf="@id/temp_main_4"
app:layout_constraintTop_toTopOf="@id/temp_main_4" />
app:layout_constraintTop_toTopOf="@id/temp_main_4"
tools:text="@string/degrees_c" />
<ImageView
android:id="@+id/icon_main_4"
@@ -64,11 +64,11 @@
android:layout_marginTop="6dp"
android:layout_marginBottom="12dp"
android:contentDescription="@string/image_string"
app:tint="@color/colorAccent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/temp_main_4"
app:srcCompat="@drawable/cloud_symbol" />
app:srcCompat="@drawable/cloud_symbol"
app:tint="@color/colorAccent" />
<TextView
android:id="@+id/condition_main_4"

View File

@@ -1,10 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<ListPreference
android:title="Weather units"
android:entries="@array/units"
android:entryValues="@array/units"
android:defaultValue="Metric"
android:key="UnitType"
/>
<PreferenceCategory android:title="Widget Settings">
<SwitchPreference
android:defaultValue="false"
android:key="widget_black_background"
android:key="@string/widget_black_background"
android:title="Set widget background black" />
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -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)
}
}

View File

@@ -5,7 +5,8 @@ import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherRe
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.WeatherEntity
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 io.mockk.MockKAnnotations
@@ -89,6 +90,7 @@ class RepositoryImplTest : BaseTest() {
//Act
//create a successful retrofit response
every { prefs.getUnitsType() } returns (UnitType.METRIC)
coEvery { api.getFromApi("", "") }.returns(mockResponse)
// Assert
@@ -105,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
@@ -119,7 +122,7 @@ class RepositoryImplTest : BaseTest() {
@Test
fun loadWeatherList_validResponse() {
// Arrange
val elements = listOf<WeatherEntity>(
val elements = listOf<EntityItem>(
mockk { every { id } returns any() },
mockk { every { id } returns any() },
mockk { every { id } returns any() },

View File

@@ -5,7 +5,7 @@ import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherRe
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.WeatherEntity
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.utils.BaseTest
import io.mockk.MockKAnnotations
@@ -44,7 +44,7 @@ class ServicesHelperTest : BaseTest() {
@Test
fun testWidgetDataAsync_successfulResponse() = runBlocking {
// Arrange
val weatherEntity = WeatherEntity(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
@@ -68,7 +68,7 @@ class ServicesHelperTest : BaseTest() {
)
}.returns(CURRENT_LOCATION)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(weatherEntity) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
// Assert
val result = helper.fetchData()

View File

@@ -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.WeatherEntity
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 weatherEntity = WeatherEntity(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
val latlon = any<Pair<Double, Double>>()
// 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(weatherEntity) } 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(3000)
sleep(100)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
}
@Test
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
fun fetchDataForSingleLocation_failedLocation_validReturn() {
// Arrange
val location = CURRENT_LOCATION
val errorMessage = ArgumentMatchers.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()
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
weatherResponse.lat,
weatherResponse.lon
)
}.returns(weatherResponse)
viewModel.fetchDataForSingleLocation(location)
coEvery {
locationProvider.getLocationNameFromLatLong(
weatherResponse.lat,
weatherResponse.lon,
LocationType.City
)
} throws IOException(errorMessage)
// Assert
sleep(300)
assertIs<ViewState.HasError<*>>(viewModel.uiState.getOrAwaitValue())
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
sleep(100)
val observerResults = viewModel.uiState.getOrAwaitValue()
assertIs<ViewState.HasError<*>>(observerResults)
assertEquals(observerResults.error as String, errorMessage)
}
@Test
fun searchAboveFallbackTime_validLocation_validReturn() {
fun fetchDataForSingleLocation_failedApi_validReturn() {
// Arrange
val weatherEntity = WeatherEntity(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C"
locationString = CURRENT_LOCATION
})
val latlon = Pair(weatherResponse.lat, weatherResponse.lon)
val errorMessage = ArgumentMatchers.anyString()
// Act
coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(weatherEntity)
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false)
coEvery {
repository.getWeatherFromApi(
weatherResponse.lat.toString(),
weatherResponse.lon.toString()
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
weatherResponse.lat,
weatherResponse.lon
)
}.returns(weatherResponse)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(weatherEntity) } returns Unit
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
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
sleep(300)
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
sleep(100)
val observerResults = viewModel.uiState.getOrAwaitValue()
assertIs<ViewState.HasError<*>>(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<ViewState.HasData<*>>(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<ViewState.HasError<*>>(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<ViewState.HasError<*>>(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
// Assert
viewModel.fetchAllLocations()
sleep(100)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
}
@Test
fun deleteLocation_validLocations_validReturn() {
// Arrange
val location = anyString()
// Act
coEvery { weatherSource.repository.deleteSavedWeatherEntry(location) } returns true
// Assert
viewModel.deleteLocation(location)
sleep(100)
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
}
}