- change location retrieval accuracy

- change location retrieval caching from location provider
This commit is contained in:
2023-12-17 23:12:20 +00:00
parent 65d0c17b00
commit 226280452d
22 changed files with 281 additions and 137 deletions

1
.gitignore vendored
View File

@@ -89,6 +89,7 @@ gen-external-apklibs
.idea/assetWizardSettings.xml .idea/assetWizardSettings.xml
.idea/gradle.xml .idea/gradle.xml
.idea/jarRepositorie .idea/jarRepositorie
.idea/assetWizardSettings.xml
# Gem/fastlane # Gem/fastlane
Gemfile.lock Gemfile.lock

View File

@@ -3,26 +3,20 @@ package com.appttude.h_mal.atlas_weather.testSuite
import android.os.Build import android.os.Build
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room import androidx.room.Room
import androidx.room.util.UUIDUtil
import androidx.test.platform.app.InstrumentationRegistry 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.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.Converter 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.WeatherDao
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION 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.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather 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 com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.Assert.* import org.junit.Assert.*
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.mock
import java.util.UUID import java.util.UUID
@@ -98,16 +92,16 @@ class RoomDatabaseTests {
assertNull(dao.getCurrentFullWeatherSingle(id)) assertNull(dao.getCurrentFullWeatherSingle(id))
} }
private fun createEntity(id: String = CURRENT_LOCATION): EntityItem { private fun createEntity(id: String = CURRENT_LOCATION): WeatherEntity {
val weather = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val weather = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
FullWeather() FullWeather()
} else { } else {
mockk<FullWeather>() mockk<FullWeather>()
} }
return EntityItem(id, weather) return WeatherEntity(id, weather)
} }
private fun createEntityList(size: Int = 4): List<EntityItem> { private fun createEntityList(size: Int = 4): List<WeatherEntity> {
return (0.. size).map { return (0.. size).map {
val id = UUID.randomUUID().toString() val id = UUID.randomUUID().toString()
createEntity(id) createEntity(id)

View File

@@ -4,8 +4,15 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.appttude.h_mal.atlas_weather.model.ViewState 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
import okhttp3.internal.wait
import java.util.concurrent.CancellationException
open class BaseViewModel: ViewModel() { open class BaseViewModel : ViewModel() {
private val _uiState = MutableLiveData<ViewState>() private val _uiState = MutableLiveData<ViewState>()
val uiState: LiveData<ViewState> = _uiState val uiState: LiveData<ViewState> = _uiState
@@ -22,4 +29,15 @@ open class BaseViewModel: ViewModel() {
protected fun <E : Any> onError(error: E) { protected fun <E : Any> onError(error: E) {
_uiState.postValue(ViewState.HasError(error)) _uiState.postValue(ViewState.HasError(error))
} }
protected var job: Job? = null
fun cancelOperation() {
CoroutineScope(Dispatchers.IO).launch {
job?.run {
cancelAndJoin()
onSuccess(Unit)
}
}
}
} }

View File

@@ -1,29 +1,29 @@
package com.appttude.h_mal.atlas_weather.base.baseViewModels 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.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
abstract class WeatherViewModel : BaseViewModel() { abstract class WeatherViewModel : BaseViewModel() {
fun createFullWeather( fun createFullWeather(
weather: WeatherResponse, weather: WeatherResponse,
location: String locationName: String
): FullWeather { ): FullWeather {
return FullWeather(weather).apply { return FullWeather(weather).apply {
temperatureUnit = "°C" temperatureUnit = "°C"
locationString = location locationString = locationName
} }
} }
fun createWeatherEntity( fun createWeatherEntity(
locationId: String, locationName: String,
weather: FullWeather weather: FullWeather
): EntityItem { ): WeatherEntity {
weather.apply { weather.apply {
locationString = locationId locationString = locationName
} }
return EntityItem(locationId, weather) return WeatherEntity(locationName, weather)
} }
} }

View File

@@ -3,9 +3,11 @@ package com.appttude.h_mal.atlas_weather.data.location
import android.Manifest.permission.ACCESS_COARSE_LOCATION import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.location.Address
import android.location.Geocoder import android.location.Geocoder
import android.location.Location import android.location.Location
import android.location.LocationManager import android.location.LocationManager
import android.os.Build
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import com.appttude.h_mal.atlas_weather.model.types.LocationType import com.appttude.h_mal.atlas_weather.model.types.LocationType
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
@@ -20,8 +22,6 @@ import java.util.*
class LocationProviderImpl( class LocationProviderImpl(
private val applicationContext: Context private val applicationContext: Context
) : LocationProvider, LocationHelper(applicationContext) { ) : LocationProvider, LocationHelper(applicationContext) {
private var locationManager =
applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager?
private val client = LocationServices.getFusedLocationProviderClient(applicationContext) private val client = LocationServices.getFusedLocationProviderClient(applicationContext)
private val geoCoder: Geocoder by lazy { Geocoder(applicationContext, Locale.getDefault()) } private val geoCoder: Geocoder by lazy { Geocoder(applicationContext, Locale.getDefault()) }
@@ -70,11 +70,14 @@ class LocationProviderImpl(
private suspend fun getAFreshLocation(): Location? { private suspend fun getAFreshLocation(): Location? {
return client.getCurrentLocation( return client.getCurrentLocation(
Priority.PRIORITY_HIGH_ACCURACY, Priority.PRIORITY_HIGH_ACCURACY,
object : CancellationToken() { cancellationToken
override fun isCancellationRequested(): Boolean = false ).await()
override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken = }
this
}).await() private val cancellationToken = object : CancellationToken() {
override fun isCancellationRequested(): Boolean = false
override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken =
this
} }
} }

View File

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

View File

@@ -6,7 +6,7 @@ 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.LOCATION_CONST
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider 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.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.utils.FALLBACK_TIME import com.appttude.h_mal.atlas_weather.utils.FALLBACK_TIME
@@ -23,12 +23,12 @@ class RepositoryImpl(
return responseUnwrap { api.getFromApi(lat, long) } return responseUnwrap { api.getFromApi(lat, long) }
} }
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) { override suspend fun saveCurrentWeatherToRoom(weatherEntity: WeatherEntity) {
db.getWeatherDao().upsertFullWeather(entityItem) db.getWeatherDao().upsertFullWeather(weatherEntity)
} }
override suspend fun saveWeatherListToRoom( override suspend fun saveWeatherListToRoom(
list: List<EntityItem> list: List<WeatherEntity>
) { ) {
db.getWeatherDao().upsertListOfFullWeather(list) db.getWeatherDao().upsertListOfFullWeather(list)
} }
@@ -68,7 +68,7 @@ class RepositoryImpl(
return prefs.getAllKeys().toList() return prefs.getAllKeys().toList()
} }
override suspend fun getSingleWeather(locationName: String): EntityItem { override suspend fun getSingleWeather(locationName: String): WeatherEntity {
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName) return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
} }

View File

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

View File

@@ -6,30 +6,30 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query 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.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
@Dao @Dao
interface WeatherDao { interface WeatherDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertFullWeather(item: EntityItem) fun upsertFullWeather(item: WeatherEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun upsertListOfFullWeather(items: List<EntityItem>) fun upsertListOfFullWeather(items: List<WeatherEntity>)
@Query("SELECT * FROM EntityItem WHERE id = :userId LIMIT 1") @Query("SELECT * FROM WeatherEntity WHERE id = :userId LIMIT 1")
fun getCurrentFullWeather(userId: String): LiveData<EntityItem> fun getCurrentFullWeather(userId: String): LiveData<WeatherEntity>
@Query("SELECT * FROM EntityItem WHERE id = :userId LIMIT 1") @Query("SELECT * FROM WeatherEntity WHERE id = :userId LIMIT 1")
fun getCurrentFullWeatherSingle(userId: String): EntityItem fun getCurrentFullWeatherSingle(userId: String): WeatherEntity
@Query("SELECT * FROM EntityItem WHERE id != :id") @Query("SELECT * FROM WeatherEntity WHERE id != :id")
fun getAllFullWeatherWithoutCurrent(id: String = CURRENT_LOCATION): LiveData<List<EntityItem>> fun getAllFullWeatherWithoutCurrent(id: String = CURRENT_LOCATION): LiveData<List<WeatherEntity>>
@Query("SELECT * FROM EntityItem WHERE id != :id") @Query("SELECT * FROM WeatherEntity WHERE id != :id")
fun getWeatherListWithoutCurrent(id: String = CURRENT_LOCATION): List<EntityItem> fun getWeatherListWithoutCurrent(id: String = CURRENT_LOCATION): List<WeatherEntity>
@Query("DELETE FROM EntityItem WHERE id = :userId") @Query("DELETE FROM WeatherEntity WHERE id = :userId")
fun deleteEntry(userId: String): Int 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" const val CURRENT_LOCATION = "CurrentLocation"
@Entity @Entity
data class EntityItem( data class WeatherEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
val id: String, val id: String,
val weather: FullWeather val weather: FullWeather

View File

@@ -5,15 +5,18 @@ import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.annotation.RequiresPermission 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.location.LocationProvider
import com.appttude.h_mal.atlas_weather.data.repository.Repository 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.repository.SettingsRepository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION 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.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather 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.InnerWidgetCellData
import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetData 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.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.model.widget.WidgetWeatherCollection
import com.appttude.h_mal.atlas_weather.utils.toSmallDayName import com.appttude.h_mal.atlas_weather.utils.toSmallDayName
import com.squareup.picasso.Picasso import com.squareup.picasso.Picasso
@@ -47,10 +50,10 @@ class ServicesHelper(
temperatureUnit = "°C" temperatureUnit = "°C"
locationString = currentLocation locationString = currentLocation
} }
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather) val weatherEntity = WeatherEntity(CURRENT_LOCATION, fullWeather)
// Save data if not null // Save data if not null
repository.saveLastSavedAt(CURRENT_LOCATION) repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(entityItem) repository.saveCurrentWeatherToRoom(weatherEntity)
true true
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
@@ -58,6 +61,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 = "°C"
locationString = currentLocation
}
val weatherEntity = WeatherEntity(CURRENT_LOCATION, fullWeather)
// Save data to database
repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(weatherEntity)
val data = createWidgetWeatherCollection(weatherEntity, currentLocation)
return WidgetState.HasData(data)
}
suspend fun getWidgetWeather(): WidgetData? { suspend fun getWidgetWeather(): WidgetData? {
return try { return try {
val result = repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION) val result = repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION)
@@ -127,6 +201,30 @@ class ServicesHelper(
} }
} }
private fun createWidgetWeatherCollection(result: WeatherEntity, 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<InnerWidgetCellData>()
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? { private suspend fun getBitmapFromUrl(imageAddress: String?): Bitmap? {
return suspendCoroutine { cont -> return suspendCoroutine { cont ->
Picasso.get().load(imageAddress).into(object : Target { Picasso.get().load(imageAddress).into(object : Target {

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package com.appttude.h_mal.atlas_weather.model.widget
sealed class WidgetState {
class HasData<T : Any>(val data: T) : WidgetState()
class HasError<T : Any>(val error: T) : WidgetState()
}

View File

@@ -5,7 +5,7 @@ import androidx.annotation.RequiresPermission
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider 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.Repository
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION 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.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -18,8 +18,8 @@ class MainViewModel(
) : WeatherViewModel() { ) : WeatherViewModel() {
init { init {
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {w ->
it?.let { w?.let {
val weather = WeatherDisplay(it) val weather = WeatherDisplay(it)
onSuccess(weather) onSuccess(weather)
} }
@@ -29,10 +29,10 @@ class MainViewModel(
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION) @RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
fun fetchData() { fun fetchData() {
onStart() onStart()
CoroutineScope(Dispatchers.IO).launch { job = CoroutineScope(Dispatchers.IO).launch {
try { try {
// Has the search been conducted in the last 5 minutes // Has the search been conducted in the last 5 minutes
val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) { val weatherEntity = if (repository.isSearchValid(CURRENT_LOCATION)) {
// Get location // Get location
val latLong = locationProvider.getCurrentLatLong() val latLong = locationProvider.getCurrentLatLong()
// Get weather from api // Get weather from api
@@ -41,13 +41,13 @@ class MainViewModel(
val currentLocation = val currentLocation =
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon) locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
val fullWeather = createFullWeather(weather, currentLocation) val fullWeather = createFullWeather(weather, currentLocation)
EntityItem(CURRENT_LOCATION, fullWeather) WeatherEntity(CURRENT_LOCATION, fullWeather)
} else { } else {
repository.getSingleWeather(CURRENT_LOCATION) repository.getSingleWeather(CURRENT_LOCATION)
} }
// Save data if not null // Save data if not null
repository.saveLastSavedAt(CURRENT_LOCATION) repository.saveLastSavedAt(CURRENT_LOCATION)
repository.saveCurrentWeatherToRoom(entityItem) repository.saveCurrentWeatherToRoom(weatherEntity)
} catch (e: Exception) { } catch (e: Exception) {
onError(e.message!!) onError(e.message!!)
} }

View File

@@ -3,7 +3,7 @@ package com.appttude.h_mal.atlas_weather.viewmodel
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider 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.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.repository.Repository 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.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay 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.model.types.LocationType
import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel
@@ -102,7 +102,7 @@ class WorldViewModel(
} }
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val list = mutableListOf<EntityItem>() val list = mutableListOf<WeatherEntity>()
repository.loadWeatherList().forEach { locationName -> repository.loadWeatherList().forEach { locationName ->
// If search not valid move onto next in loop // If search not valid move onto next in loop
if (!repository.isSearchValid(locationName)) return@forEach if (!repository.isSearchValid(locationName)) return@forEach
@@ -151,7 +151,7 @@ class WorldViewModel(
return repository.getWeatherFromApi(lat.toString(), lon.toString()) return repository.getWeatherFromApi(lat.toString(), lon.toString())
} }
private suspend fun createWeatherEntity(locationName: String): EntityItem { private suspend fun createWeatherEntity(locationName: String): WeatherEntity {
val weather = getWeather(locationName) val weather = getWeather(locationName)
val location = val location =
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon, LocationType.City) locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon, LocationType.City)

View File

@@ -22,7 +22,7 @@ abstract class BaseWidgetServiceIntentClass<T : AppWidgetProvider> : JobIntentSe
lateinit var appWidgetManager: AppWidgetManager lateinit var appWidgetManager: AppWidgetManager
lateinit var appWidgetIds: IntArray lateinit var appWidgetIds: IntArray
fun initiallizeWidgetData(componentName: ComponentName) { fun initializeWidgetData(componentName: ComponentName) {
appWidgetManager = AppWidgetManager.getInstance(baseContext) appWidgetManager = AppWidgetManager.getInstance(baseContext)
appWidgetIds = appWidgetManager.getAppWidgetIds(componentName) appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
} }

View File

@@ -1,25 +1,21 @@
package com.appttude.h_mal.atlas_weather.widget package com.appttude.h_mal.atlas_weather.widget
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.icu.text.SimpleDateFormat import android.icu.text.SimpleDateFormat
import android.os.PowerManager
import android.widget.RemoteViews 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.R
import com.appttude.h_mal.atlas_weather.helper.ServicesHelper 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.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.model.widget.WidgetWeatherCollection
import com.appttude.h_mal.atlas_weather.ui.MainActivity 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.*
import com.appttude.h_mal.atlas_weather.widget.WidgetState.Companion.getWidgetState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,59 +42,35 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass<NewAppWidget>() {
executeWidgetUpdate() executeWidgetUpdate()
} }
@SuppressLint("MissingPermission")
private fun executeWidgetUpdate() { private fun executeWidgetUpdate() {
val componentName = ComponentName(this, NewAppWidget::class.java) val componentName = ComponentName(this, NewAppWidget::class.java)
initiallizeWidgetData(componentName) initializeWidgetData(componentName)
initiateWidgetUpdate(getCurrentWidgetState())
}
private fun initiateWidgetUpdate(state: WidgetState) {
when (state) {
NO_LOCATION, SCREEN_ON_CONNECTION_UNAVAILABLE -> updateErrorWidget(state)
else -> updateWidget(false)
}
}
private fun updateWidget(fromStorage: Boolean) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val result = getWidgetWeather(fromStorage) setLoadingView()
appWidgetIds.forEach { id -> setupView(id, result) }
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)
}
}
else -> return@launch
}
}
} }
} }
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( private fun setupView(
appWidgetId: Int, appWidgetId: Int,
@@ -115,6 +87,49 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass<NewAppWidget>() {
} }
} }
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( override fun bindErrorView(
widgetId: Int, widgetId: Int,
views: RemoteViews, views: RemoteViews,

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@@ -5,22 +5,17 @@ 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.LOCATION_CONST
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider 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.AppDatabase
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.utils.BaseTest import com.appttude.h_mal.atlas_weather.utils.BaseTest
import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doAnswer
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import retrofit2.Response
import java.io.IOException import java.io.IOException
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
@@ -124,7 +119,7 @@ class RepositoryImplTest : BaseTest() {
@Test @Test
fun loadWeatherList_validResponse() { fun loadWeatherList_validResponse() {
// Arrange // Arrange
val elements = listOf<EntityItem>( val elements = listOf<WeatherEntity>(
mockk { every { id } returns any() }, mockk { every { id } returns any() },
mockk { every { id } returns any() }, 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.Repository
import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository 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.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem import com.appttude.h_mal.atlas_weather.data.room.entity.WeatherEntity
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather 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.BaseTest
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
@@ -44,7 +44,7 @@ class ServicesHelperTest : BaseTest() {
@Test @Test
fun testWidgetDataAsync_successfulResponse() = runBlocking { fun testWidgetDataAsync_successfulResponse() = runBlocking {
// Arrange // Arrange
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply { val weatherEntity = WeatherEntity(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C" temperatureUnit = "°C"
locationString = CURRENT_LOCATION locationString = CURRENT_LOCATION
}) })
@@ -68,7 +68,7 @@ class ServicesHelperTest : BaseTest() {
) )
}.returns(CURRENT_LOCATION) }.returns(CURRENT_LOCATION)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit coEvery { repository.saveCurrentWeatherToRoom(weatherEntity) } returns Unit
// Assert // Assert
val result = helper.fetchData() val result = helper.fetchData()

View File

@@ -5,7 +5,7 @@ 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.network.response.forecast.WeatherResponse
import com.appttude.h_mal.atlas_weather.data.repository.Repository 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.CURRENT_LOCATION
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem 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.ViewState
import com.appttude.h_mal.atlas_weather.model.types.LocationType 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.model.weather.FullWeather
@@ -50,7 +50,7 @@ class WorldViewModelTest : BaseTest() {
@Test @Test
fun fetchDataForSingleLocation_validLocation_validReturn() { fun fetchDataForSingleLocation_validLocation_validReturn() {
// Arrange // Arrange
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply { val weatherEntity = WeatherEntity(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C" temperatureUnit = "°C"
locationString = CURRENT_LOCATION locationString = CURRENT_LOCATION
}) })
@@ -75,7 +75,7 @@ class WorldViewModelTest : BaseTest() {
) )
}.returns(CURRENT_LOCATION) }.returns(CURRENT_LOCATION)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit coEvery { repository.saveCurrentWeatherToRoom(weatherEntity) } returns Unit
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION) viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
@@ -113,13 +113,13 @@ class WorldViewModelTest : BaseTest() {
@Test @Test
fun searchAboveFallbackTime_validLocation_validReturn() { fun searchAboveFallbackTime_validLocation_validReturn() {
// Arrange // Arrange
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply { val weatherEntity = WeatherEntity(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
temperatureUnit = "°C" temperatureUnit = "°C"
locationString = CURRENT_LOCATION locationString = CURRENT_LOCATION
}) })
// Act // Act
coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem) coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(weatherEntity)
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false) every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false)
coEvery { coEvery {
repository.getWeatherFromApi( repository.getWeatherFromApi(
@@ -128,7 +128,7 @@ class WorldViewModelTest : BaseTest() {
) )
}.returns(weatherResponse) }.returns(weatherResponse)
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit coEvery { repository.saveCurrentWeatherToRoom(weatherEntity) } returns Unit
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION) viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)