mirror of
https://github.com/hmalik144/Weather-apps.git
synced 2025-12-10 02:05:20 +00:00
Imperial units (#24)
- change location retrieval accuracy - change location retrieval caching from location provider - expand unit test suite - code refactoring - Imperial units added - UI test for unit change
This commit is contained in:
407
.idea/assetWizardSettings.xml
generated
407
.idea/assetWizardSettings.xml
generated
@@ -1,407 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="WizardSettings">
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="imageWizard">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="confirmationStep">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="resourceDirectory" value="monoWeather" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="imageAssetPanel">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="actionbar">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="clipArt">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\ic_home_black_24dp.xml" />
|
||||
<entry key="paddingPercent" value="-10" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="clipartAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material_design_icons/action/ic_home_black_24dp.xml" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="text">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="textAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="outputName" value="ic_home" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="launcher">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="foregroundClipArt">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="ffffff" />
|
||||
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\baseline_public_24.xml" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="foregroundClipartAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material/icons/materialicons/public/baseline_public_24.xml" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="foregroundImage">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="foregroundText">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="foregroundTextAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="backgroundAssetType" value="COLOR" />
|
||||
<entry key="backgroundColor" value="000000" />
|
||||
<entry key="foregroundAssetType" value="CLIP_ART" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="launcherLegacy">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="clipArt">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="ffffff" />
|
||||
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\ic_grade_black_24dp.xml" />
|
||||
<entry key="paddingPercent" value="-10" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="clipartAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material_design_icons/action/ic_grade_black_24dp.xml" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="image">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
<entry key="imagePath" value="C:\Users\h_mal\Desktop\Atlas weather\notif.png" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="text">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="textAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="assetType" value="IMAGE" />
|
||||
<entry key="iconShape" value="NONE" />
|
||||
<entry key="imageAsset" value="C:\Users\h_mal\Desktop\Atlas weather\notif.png" />
|
||||
<entry key="outputName" value="ic_notif" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="notification">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="clipArt">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\ic_android_black_24dp.xml" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="text">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="textAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="tvBanner">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="foregroundText">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="tvChannel">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="foregroundClipArt">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="imagePath" value="C:\Users\h_mal\AppData\Local\Temp\ic_android_black_24dp.xml" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="foregroundImage">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="foregroundText">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="foregroundTextAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="vectorWizard">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="vectorAssetStep">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="children">
|
||||
<map>
|
||||
<entry key="clipartAsset">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material/icons/materialicons/cloud_queue/baseline_cloud_queue_24.xml" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="ffffff" />
|
||||
<entry key="outputName" value="ic_baseline_cloud_queue_24" />
|
||||
<entry key="sourceFile" value="D:\Android Studio Projects\Private work\Altas_-_Weather" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -8,10 +8,9 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.Root
|
||||
import androidx.test.espresso.UiController
|
||||
import androidx.test.espresso.ViewAction
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
@@ -20,15 +19,12 @@ import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.appttude.h_mal.atlas_weather.application.TestAppClass
|
||||
import com.appttude.h_mal.atlas_weather.helpers.BaseCustomMatcher
|
||||
import com.appttude.h_mal.atlas_weather.helpers.BaseViewAction
|
||||
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
|
||||
import com.appttude.h_mal.atlas_weather.helpers.SnapshotRule
|
||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.Matcher
|
||||
import org.hamcrest.Matchers
|
||||
import org.hamcrest.TypeSafeMatcher
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -46,6 +42,8 @@ open class BaseTest<A : Activity>(
|
||||
private lateinit var testActivity: Activity
|
||||
private lateinit var decorView: View
|
||||
|
||||
private val prefs by lazy { PreferenceProvider(ApplicationProvider.getApplicationContext()) }
|
||||
|
||||
@get:Rule
|
||||
var permissionRule = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
|
||||
@@ -87,6 +85,8 @@ open class BaseTest<A : Activity>(
|
||||
testApp.stubLocation(location, lat, long)
|
||||
}
|
||||
|
||||
fun clearPrefs() = prefs.clearPrefs()
|
||||
|
||||
fun getActivity() = testActivity
|
||||
|
||||
@After
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.view.View
|
||||
import android.widget.DatePicker
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.Espresso.onData
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.UiController
|
||||
@@ -28,6 +29,8 @@ import org.hamcrest.Matcher
|
||||
@SuppressWarnings("unused")
|
||||
open class BaseTestRobot {
|
||||
|
||||
fun goBack() = Espresso.pressBack()
|
||||
|
||||
fun fillEditText(resId: Int, text: String?): ViewInteraction =
|
||||
onView(withId(resId)).perform(
|
||||
ViewActions.replaceText(text),
|
||||
@@ -151,4 +154,8 @@ open class BaseTestRobot {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun openMenuItem() {
|
||||
matchView(R.id.settings_fragment).perform(click())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.appttude.h_mal.atlas_weather.testSuite
|
||||
import android.os.Build
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.room.Room
|
||||
import androidx.room.util.UUIDUtil
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
||||
import com.appttude.h_mal.atlas_weather.data.room.Converter
|
||||
@@ -11,18 +10,13 @@ import com.appttude.h_mal.atlas_weather.data.room.WeatherDao
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
import com.appttude.h_mal.atlas_weather.test.BuildConfig
|
||||
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.Mockito.mock
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.appttude.h_mal.monoWeather.robot
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.assertion.ViewAssertions
|
||||
import androidx.test.espresso.contrib.RecyclerViewActions
|
||||
import androidx.test.espresso.matcher.RootMatchers.isDialog
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import com.appttude.h_mal.atlas_weather.BaseTestRobot
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.model.types.UnitType
|
||||
|
||||
|
||||
fun settingsScreen(func: SettingsScreen.() -> Unit) = SettingsScreen().apply { func() }
|
||||
class SettingsScreen : BaseTestRobot() {
|
||||
|
||||
fun selectWeatherUnits(unitType: UnitType) {
|
||||
onView(withId(androidx.preference.R.id.recycler_view))
|
||||
.perform(
|
||||
RecyclerViewActions.actionOnItem<ViewHolder>(
|
||||
ViewMatchers.hasDescendant(withText(R.string.weather_units)),
|
||||
click()))
|
||||
val label = when (unitType) {
|
||||
UnitType.METRIC -> "Metric"
|
||||
UnitType.IMPERIAL -> "Imperial"
|
||||
}
|
||||
|
||||
onView(withText(label))
|
||||
.inRoot(isDialog())
|
||||
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
|
||||
.perform(click())
|
||||
}
|
||||
|
||||
|
||||
fun verifyCurrentTemperature(temperature: Int) =
|
||||
matchText(R.id.temp_main_4, temperature.toString())
|
||||
|
||||
fun verifyCurrentLocation(location: String) = matchText(R.id.location_main_4, location)
|
||||
fun refresh() = pullToRefresh(R.id.swipe_refresh)
|
||||
|
||||
fun verifyUnableToRetrieve() {
|
||||
matchText(R.id.header_text, R.string.retrieve_warning)
|
||||
matchText(R.id.body_text, R.string.empty_retrieve_warning)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package com.appttude.h_mal.monoWeather.tests
|
||||
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.BaseTest
|
||||
import com.appttude.h_mal.atlas_weather.model.types.UnitType
|
||||
import com.appttude.h_mal.atlas_weather.ui.MainActivity
|
||||
import com.appttude.h_mal.atlas_weather.utils.Stubs
|
||||
import com.appttude.h_mal.monoWeather.robot.settingsScreen
|
||||
import com.appttude.h_mal.monoWeather.robot.weatherScreen
|
||||
import org.junit.Test
|
||||
|
||||
@@ -11,6 +13,7 @@ class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
|
||||
|
||||
override fun beforeLaunch() {
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Metric)
|
||||
clearPrefs()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -21,4 +24,25 @@ class HomePageUITest : BaseTest<MainActivity>(MainActivity::class.java) {
|
||||
verifyCurrentLocation("Mock Location")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadApp_changeToImperial_returnsValidPage() {
|
||||
weatherScreen {
|
||||
isDisplayed()
|
||||
verifyCurrentTemperature(2)
|
||||
verifyCurrentLocation("Mock Location")
|
||||
stubEndpoint("https://api.openweathermap.org/data/2.5/onecall", Stubs.Imperial)
|
||||
openMenuItem()
|
||||
}
|
||||
settingsScreen {
|
||||
selectWeatherUnits(UnitType.IMPERIAL)
|
||||
goBack()
|
||||
}
|
||||
weatherScreen {
|
||||
isDisplayed()
|
||||
refresh()
|
||||
verifyCurrentTemperature(58)
|
||||
verifyCurrentLocation("Mock Location")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,13 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
open class BaseViewModel: ViewModel() {
|
||||
open class BaseViewModel : ViewModel() {
|
||||
|
||||
private val _uiState = MutableLiveData<ViewState>()
|
||||
val uiState: LiveData<ViewState> = _uiState
|
||||
@@ -22,4 +27,15 @@ open class BaseViewModel: ViewModel() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.appttude.h_mal.atlas_weather.base.baseViewModels
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
|
||||
abstract class WeatherViewModel : BaseViewModel() {
|
||||
|
||||
fun createFullWeather(
|
||||
weather: WeatherResponse,
|
||||
location: String
|
||||
): FullWeather {
|
||||
return FullWeather(weather).apply {
|
||||
temperatureUnit = "°C"
|
||||
locationString = location
|
||||
}
|
||||
}
|
||||
|
||||
fun createWeatherEntity(
|
||||
locationId: String,
|
||||
weather: FullWeather
|
||||
): EntityItem {
|
||||
weather.apply {
|
||||
locationString = locationId
|
||||
}
|
||||
|
||||
return EntityItem(locationId, weather)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -5,13 +5,8 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.HandlerThread
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||
import com.google.android.gms.location.LocationCallback
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationResult
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.CancellationToken
|
||||
@@ -19,21 +14,23 @@ import com.google.android.gms.tasks.OnTokenCanceledListener
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
class LocationProviderImpl(
|
||||
private val applicationContext: Context
|
||||
) : LocationProvider, LocationHelper(applicationContext) {
|
||||
private var locationManager =
|
||||
applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager?
|
||||
private val client = LocationServices.getFusedLocationProviderClient(applicationContext)
|
||||
private val geoCoder: Geocoder by lazy { Geocoder(applicationContext, Locale.getDefault()) }
|
||||
|
||||
@RequiresPermission(value = ACCESS_COARSE_LOCATION)
|
||||
override suspend fun getCurrentLatLong(): Pair<Double, Double> {
|
||||
val location = client.lastLocation.await() ?: getAFreshLocation()
|
||||
val lastLocation = client.lastLocation.await()
|
||||
lastLocation?.let {
|
||||
val delta = it.time - System.currentTimeMillis()
|
||||
if (delta < 300000) return it.getLatLonPair()
|
||||
}
|
||||
|
||||
val location = getAFreshLocation()
|
||||
return location?.getLatLonPair() ?: throw IOException("Unable to get location")
|
||||
}
|
||||
|
||||
@@ -68,57 +65,16 @@ class LocationProviderImpl(
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun getAFreshLocation(): Location? {
|
||||
return client.getCurrentLocation(Priority.PRIORITY_LOW_POWER, object : CancellationToken() {
|
||||
override fun isCancellationRequested(): Boolean = false
|
||||
override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken = this
|
||||
}).await()
|
||||
return client.getCurrentLocation(
|
||||
Priority.PRIORITY_HIGH_ACCURACY,
|
||||
cancellationToken
|
||||
).await()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun requestFreshLocation(): Location? {
|
||||
val handlerThread = HandlerThread("MyHandlerThread")
|
||||
handlerThread.start()
|
||||
// Now get the Looper from the HandlerThread
|
||||
// NOTE: This call will block until the HandlerThread gets control and initializes its Looper
|
||||
val looper = handlerThread.looper
|
||||
|
||||
return suspendCoroutine { cont ->
|
||||
val callback = object : LocationCallback() {
|
||||
override fun onLocationResult(p0: LocationResult) {
|
||||
client.removeLocationUpdates(this)
|
||||
cont.resume(p0.lastLocation)
|
||||
}
|
||||
}
|
||||
|
||||
with(locationManager!!) {
|
||||
when {
|
||||
isProviderEnabled(LocationManager.GPS_PROVIDER) -> {
|
||||
client.requestLocationUpdates(
|
||||
createLocationRequest(Priority.PRIORITY_HIGH_ACCURACY),
|
||||
callback,
|
||||
looper
|
||||
)
|
||||
}
|
||||
|
||||
isProviderEnabled(LocationManager.NETWORK_PROVIDER) -> {
|
||||
client.requestLocationUpdates(
|
||||
createLocationRequest(Priority.PRIORITY_LOW_POWER),
|
||||
callback,
|
||||
looper
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
cont.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
private val cancellationToken = object : CancellationToken() {
|
||||
override fun isCancellationRequested(): Boolean = false
|
||||
override fun onCanceledRequested(p0: OnTokenCanceledListener): CancellationToken =
|
||||
this
|
||||
}
|
||||
|
||||
private fun createLocationRequest(priority: Int) = LocationRequest.create()
|
||||
.setPriority(priority)
|
||||
.setNumUpdates(1)
|
||||
.setExpirationDuration(1000)
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package com.appttude.h_mal.atlas_weather.data.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.model.types.UnitType
|
||||
|
||||
/**
|
||||
* Shared preferences to save & load last timestamp
|
||||
*/
|
||||
const val LOCATION_CONST = "location_"
|
||||
const val UNIT_CONST = "UnitType"
|
||||
|
||||
class PreferenceProvider(
|
||||
context: Context
|
||||
@@ -30,7 +33,7 @@ class PreferenceProvider(
|
||||
return preference.getLong(locationName, 0L)
|
||||
}
|
||||
|
||||
fun getAllKeys() = preference.all.keys.apply {
|
||||
fun getAllKeysExcludingCurrent() = preference.all.keys.apply {
|
||||
remove(CURRENT_LOCATION)
|
||||
}
|
||||
|
||||
@@ -50,4 +53,18 @@ class PreferenceProvider(
|
||||
return preference.getBoolean("widget_black_background", false)
|
||||
}
|
||||
|
||||
fun setUnitsType(type: UnitType) {
|
||||
preference.edit().putString(UNIT_CONST, type.name).apply()
|
||||
}
|
||||
|
||||
fun getUnitsType(): UnitType {
|
||||
val unit = preference.getString(UNIT_CONST, UnitType.METRIC.name)
|
||||
return UnitType.getByName(unit) ?: UnitType.METRIC
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun clearPrefs() {
|
||||
preference.edit().clear().apply()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.appttude.h_mal.atlas_weather.data.repository
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.types.UnitType
|
||||
|
||||
interface Repository {
|
||||
|
||||
@@ -18,4 +19,5 @@ interface Repository {
|
||||
suspend fun deleteSavedWeatherEntry(locationName: String): Boolean
|
||||
fun getSavedLocations(): List<String>
|
||||
suspend fun getSingleWeather(locationName: String): EntityItem
|
||||
fun getUnitType() : UnitType
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
|
||||
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.types.UnitType
|
||||
import com.appttude.h_mal.atlas_weather.utils.FALLBACK_TIME
|
||||
|
||||
|
||||
@@ -20,7 +21,7 @@ class RepositoryImpl(
|
||||
lat: String,
|
||||
long: String
|
||||
): WeatherResponse {
|
||||
return responseUnwrap { api.getFromApi(lat, long) }
|
||||
return responseUnwrap { api.getFromApi(lat, long, units = prefs.getUnitsType().name.lowercase()) }
|
||||
}
|
||||
|
||||
override suspend fun saveCurrentWeatherToRoom(entityItem: EntityItem) {
|
||||
@@ -65,11 +66,15 @@ class RepositoryImpl(
|
||||
}
|
||||
|
||||
override fun getSavedLocations(): List<String> {
|
||||
return prefs.getAllKeys().toList()
|
||||
return prefs.getAllKeysExcludingCurrent().toList()
|
||||
}
|
||||
|
||||
override suspend fun getSingleWeather(locationName: String): EntityItem {
|
||||
return db.getWeatherDao().getCurrentFullWeatherSingle(locationName)
|
||||
}
|
||||
|
||||
override fun getUnitType(): UnitType {
|
||||
return prefs.getUnitsType()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,19 @@ import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.types.UnitType
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetCellData
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetData
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.WidgetData
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.WidgetError
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.WidgetState
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.WidgetWeatherCollection
|
||||
import com.appttude.h_mal.atlas_weather.utils.toSmallDayName
|
||||
import com.squareup.picasso.Picasso
|
||||
@@ -58,6 +62,77 @@ class ServicesHelper(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
suspend fun fetchWidgetData(): WidgetState {
|
||||
// Data was loaded within last 5 minutes - no need to retrieve again
|
||||
if (!repository.isSearchValid(CURRENT_LOCATION)) {
|
||||
val data = getWidgetWeatherCollection()
|
||||
data?.let {
|
||||
return WidgetState.HasData(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Try and retrieve location
|
||||
val latLong = try {
|
||||
locationProvider.getCurrentLatLong()
|
||||
} catch (e: IOException) {
|
||||
val data = getWidgetWeatherCollection()
|
||||
data?.let {
|
||||
return WidgetState.HasData(it)
|
||||
}
|
||||
|
||||
val error = WidgetError(
|
||||
icon = R.drawable.ic_baseline_cloud_off_24,
|
||||
errorMessage = "Failed to retrieve location, check location"
|
||||
)
|
||||
return WidgetState.HasError(error)
|
||||
}
|
||||
|
||||
// Get weather from api
|
||||
val weather = try {
|
||||
repository
|
||||
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
|
||||
} catch (e: IOException) {
|
||||
val data = getWidgetWeatherCollection()
|
||||
data?.let {
|
||||
return WidgetState.HasData(it)
|
||||
}
|
||||
|
||||
val error = WidgetError(
|
||||
icon = R.drawable.ic_baseline_cloud_off_24,
|
||||
errorMessage = "Failed to retrieve weather data, check connection"
|
||||
)
|
||||
return WidgetState.HasError(error)
|
||||
}
|
||||
|
||||
val currentLocation = try {
|
||||
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
|
||||
} catch (e: IOException) {
|
||||
val data = getWidgetWeatherCollection()
|
||||
data?.let {
|
||||
return WidgetState.HasData(it)
|
||||
}
|
||||
|
||||
val error = WidgetError(
|
||||
icon = R.drawable.ic_baseline_cloud_off_24,
|
||||
errorMessage = "Failed to retrieve location name"
|
||||
)
|
||||
return WidgetState.HasError(error)
|
||||
}
|
||||
|
||||
val fullWeather = FullWeather(weather).apply {
|
||||
temperatureUnit = if (repository.getUnitType() == UnitType.METRIC) "°C" else "°F"
|
||||
locationString = currentLocation
|
||||
}
|
||||
val entityItem = EntityItem(CURRENT_LOCATION, fullWeather)
|
||||
// Save data to database
|
||||
repository.saveLastSavedAt(CURRENT_LOCATION)
|
||||
repository.saveCurrentWeatherToRoom(entityItem)
|
||||
|
||||
val data = createWidgetWeatherCollection(entityItem, currentLocation)
|
||||
return WidgetState.HasData(data)
|
||||
}
|
||||
|
||||
suspend fun getWidgetWeather(): WidgetData? {
|
||||
return try {
|
||||
val result = repository.loadSingleCurrentWeatherFromRoom(CURRENT_LOCATION)
|
||||
@@ -127,6 +202,33 @@ class ServicesHelper(
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWidgetWeatherCollection(
|
||||
result: EntityItem,
|
||||
locationName: String
|
||||
): WidgetWeatherCollection {
|
||||
val widgetData = result.weather.let {
|
||||
val bitmap = it.current?.icon
|
||||
val temp = it.current?.temp?.toInt().toString()
|
||||
val epoc = System.currentTimeMillis()
|
||||
|
||||
WidgetData(locationName, bitmap, temp, epoc)
|
||||
}
|
||||
|
||||
val list = mutableListOf<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? {
|
||||
return suspendCoroutine { cont ->
|
||||
Picasso.get().load(imageAddress).into(object : Target {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package com.appttude.h_mal.atlas_weather.utils
|
||||
|
||||
val FALLBACK_TIME: Long = 300000L
|
||||
const val FALLBACK_TIME: Long = 300000L
|
||||
@@ -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")
|
||||
|
||||
@@ -2,24 +2,23 @@ package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseViewModel
|
||||
import com.appttude.h_mal.atlas_weather.data.WeatherSource
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainViewModel(
|
||||
private val locationProvider: LocationProvider,
|
||||
private val repository: Repository
|
||||
) : WeatherViewModel() {
|
||||
private val weatherSource: WeatherSource
|
||||
) : BaseViewModel() {
|
||||
|
||||
init {
|
||||
repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever {
|
||||
it?.let {
|
||||
weatherSource.repository.loadCurrentWeatherFromRoom(CURRENT_LOCATION).observeForever { w ->
|
||||
w?.let {
|
||||
val weather = WeatherDisplay(it)
|
||||
onSuccess(weather)
|
||||
}
|
||||
@@ -29,27 +28,14 @@ class MainViewModel(
|
||||
@RequiresPermission(value = Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
fun fetchData() {
|
||||
onStart()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
job = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Has the search been conducted in the last 5 minutes
|
||||
val entityItem = if (repository.isSearchValid(CURRENT_LOCATION)) {
|
||||
// Get location
|
||||
val latLong = locationProvider.getCurrentLatLong()
|
||||
// Get weather from api
|
||||
val weather = repository
|
||||
.getWeatherFromApi(latLong.first.toString(), latLong.second.toString())
|
||||
val currentLocation =
|
||||
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon)
|
||||
val fullWeather = createFullWeather(weather, currentLocation)
|
||||
EntityItem(CURRENT_LOCATION, fullWeather)
|
||||
} else {
|
||||
repository.getSingleWeather(CURRENT_LOCATION)
|
||||
}
|
||||
// Save data if not null
|
||||
repository.saveLastSavedAt(CURRENT_LOCATION)
|
||||
repository.saveCurrentWeatherToRoom(entityItem)
|
||||
// Get location
|
||||
val latLong = locationProvider.getCurrentLatLong()
|
||||
weatherSource.getWeather(latLon = latLong)
|
||||
} catch (e: Exception) {
|
||||
onError(e.message!!)
|
||||
e.printStackTrace()
|
||||
onError(e.message ?: "Retrieving weather failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.application.BaseAppClass
|
||||
import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseAndroidViewModel
|
||||
import com.appttude.h_mal.atlas_weather.data.WeatherSource
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.SettingsRepository
|
||||
import com.appttude.h_mal.atlas_weather.widget.NewAppWidget
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
class SettingsViewModel(
|
||||
application: Application,
|
||||
private val locationProvider: LocationProvider,
|
||||
private val weatherSource: WeatherSource,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : BaseAndroidViewModel(application) {
|
||||
|
||||
private fun getContext() = getApplication<BaseAppClass>().applicationContext
|
||||
|
||||
fun updateWidget() {
|
||||
val context = getContext()
|
||||
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
|
||||
val widgetManager = AppWidgetManager.getInstance(context)
|
||||
val ids =
|
||||
widgetManager.getAppWidgetIds(
|
||||
ComponentName(
|
||||
context,
|
||||
NewAppWidget::class.java
|
||||
)
|
||||
)
|
||||
AppWidgetManager.getInstance(context)
|
||||
.notifyAppWidgetViewDataChanged(ids, R.id.whole_widget_view)
|
||||
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
fun refreshWeatherData() {
|
||||
onStart()
|
||||
job = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
getContext(),
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
// Get location
|
||||
val latLong = locationProvider.getCurrentLatLong()
|
||||
weatherSource.forceFetchWeather(latLong)
|
||||
}
|
||||
updateWidget()
|
||||
val units = settingsRepository.getUnitType().name.lowercase(Locale.ROOT)
|
||||
onSuccess("Units have been changes to $units")
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e.message ?: "Retrieving weather failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
import com.appttude.h_mal.atlas_weather.base.baseViewModels.BaseViewModel
|
||||
import com.appttude.h_mal.atlas_weather.data.WeatherSource
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.forecast.WeatherDisplay
|
||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||
import com.appttude.h_mal.atlas_weather.base.baseViewModels.WeatherViewModel
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
|
||||
@@ -16,14 +18,12 @@ const val ALL_LOADED = "all_loaded"
|
||||
|
||||
class WorldViewModel(
|
||||
private val locationProvider: LocationProvider,
|
||||
private val repository: Repository
|
||||
) : WeatherViewModel() {
|
||||
private val weatherSource: WeatherSource
|
||||
) : BaseViewModel() {
|
||||
private var currentLocation: String? = null
|
||||
|
||||
private val weatherListLiveData = repository.loadRoomWeatherLiveData()
|
||||
|
||||
init {
|
||||
weatherListLiveData.observeForever {
|
||||
weatherSource.repository.loadRoomWeatherLiveData().observeForever {
|
||||
val list = it.map { data ->
|
||||
WeatherDisplay(data)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class WorldViewModel(
|
||||
|
||||
fun getSingleLocation(locationName: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val entity = repository.getSingleWeather(locationName)
|
||||
val entity = weatherSource.repository.getSingleWeather(locationName)
|
||||
val item = WeatherDisplay(entity)
|
||||
onSuccess(item)
|
||||
}
|
||||
@@ -47,14 +47,8 @@ class WorldViewModel(
|
||||
onStart()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val weatherEntity = if (repository.isSearchValid(locationName)) {
|
||||
createWeatherEntity(locationName)
|
||||
} else {
|
||||
repository.getSingleWeather(locationName)
|
||||
}
|
||||
searchWeatherForLocation(locationName)
|
||||
onSuccess(Unit)
|
||||
repository.saveCurrentWeatherToRoom(weatherEntity)
|
||||
repository.saveLastSavedAt(weatherEntity.id)
|
||||
} catch (e: IOException) {
|
||||
onError(e.message!!)
|
||||
}
|
||||
@@ -64,29 +58,20 @@ class WorldViewModel(
|
||||
fun fetchDataForSingleLocationSearch(locationName: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
onStart()
|
||||
// Check if location exists
|
||||
if (repository.getSavedLocations().contains(locationName)) {
|
||||
// Check if location already exists
|
||||
if (weatherSource.repository.getSavedLocations().contains(locationName)) {
|
||||
onError("$locationName already exists")
|
||||
return@launch
|
||||
}
|
||||
|
||||
try {
|
||||
// Get weather from api
|
||||
val entityItem = createWeatherEntity(locationName)
|
||||
|
||||
// retrieved location name
|
||||
val retrievedLocation = locationProvider.getLocationNameFromLatLong(
|
||||
entityItem.weather.lat,
|
||||
entityItem.weather.lon,
|
||||
LocationType.City
|
||||
)
|
||||
if (repository.getSavedLocations().contains(retrievedLocation)) {
|
||||
val weather = searchWeatherForLocation(locationName)
|
||||
val retrievedLocation = weather.locationString
|
||||
// Check if location exists in stored
|
||||
if (weatherSource.repository.getSavedLocations().contains(retrievedLocation)) {
|
||||
onError("$retrievedLocation already exists")
|
||||
return@launch
|
||||
}
|
||||
// Save data if not null
|
||||
repository.saveCurrentWeatherToRoom(entityItem)
|
||||
repository.saveLastSavedAt(retrievedLocation)
|
||||
onSuccess("$retrievedLocation saved")
|
||||
} catch (e: IOException) {
|
||||
onError(e.message!!)
|
||||
@@ -96,30 +81,24 @@ class WorldViewModel(
|
||||
|
||||
fun fetchAllLocations() {
|
||||
onStart()
|
||||
if (!repository.isSearchValid(ALL_LOADED)) {
|
||||
if (!weatherSource.repository.isSearchValid(ALL_LOADED)) {
|
||||
onSuccess(Unit)
|
||||
return
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val list = mutableListOf<EntityItem>()
|
||||
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): EntityItem {
|
||||
val weather = getWeather(locationName)
|
||||
// Search for location from provider (provider location name maybe different from #locationName)
|
||||
val location =
|
||||
locationProvider.getLocationNameFromLatLong(weather.lat, weather.lon, LocationType.City)
|
||||
val fullWeather = createFullWeather(weather, location)
|
||||
return createWeatherEntity(location, fullWeather)
|
||||
locationProvider.getLocationNameFromLatLong(
|
||||
latLong.first,
|
||||
latLong.second,
|
||||
LocationType.City
|
||||
)
|
||||
return weatherSource.getWeather(latLong, location, LocationType.City)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ abstract class BaseWidgetServiceIntentClass<T : AppWidgetProvider> : JobIntentSe
|
||||
lateinit var appWidgetManager: AppWidgetManager
|
||||
lateinit var appWidgetIds: IntArray
|
||||
|
||||
fun initBaseWidget(componentName: ComponentName) {
|
||||
fun initializeWidgetData(componentName: ComponentName) {
|
||||
appWidgetManager = AppWidgetManager.getInstance(baseContext)
|
||||
appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
package com.appttude.h_mal.atlas_weather.widget
|
||||
|
||||
import android.Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.icu.text.SimpleDateFormat
|
||||
import android.os.PowerManager
|
||||
import android.widget.RemoteViews
|
||||
import androidx.core.app.ActivityCompat.checkSelfPermission
|
||||
import com.appttude.h_mal.atlas_weather.R
|
||||
import com.appttude.h_mal.atlas_weather.helper.ServicesHelper
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.InnerWidgetCellData
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.WidgetError
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.WidgetState.HasData
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.WidgetState.HasError
|
||||
import com.appttude.h_mal.atlas_weather.model.widget.WidgetWeatherCollection
|
||||
import com.appttude.h_mal.atlas_weather.ui.MainActivity
|
||||
import com.appttude.h_mal.atlas_weather.utils.isInternetAvailable
|
||||
import com.appttude.h_mal.atlas_weather.utils.tryOrNullSuspended
|
||||
import com.appttude.h_mal.atlas_weather.widget.WidgetState.*
|
||||
import com.appttude.h_mal.atlas_weather.widget.WidgetState.Companion.getWidgetState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -46,61 +42,33 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass<NewAppWidget>() {
|
||||
executeWidgetUpdate()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun executeWidgetUpdate() {
|
||||
val componentName = ComponentName(this, NewAppWidget::class.java)
|
||||
initBaseWidget(componentName)
|
||||
initializeWidgetData(componentName)
|
||||
|
||||
initiateWidgetUpdate(getCurrentWidgetState())
|
||||
}
|
||||
|
||||
private fun initiateWidgetUpdate(state: WidgetState) {
|
||||
when (state) {
|
||||
NO_LOCATION, SCREEN_ON_CONNECTION_UNAVAILABLE -> updateErrorWidget(state)
|
||||
SCREEN_ON_CONNECTION_AVAILABLE -> updateWidget(false)
|
||||
SCREEN_OFF_CONNECTION_AVAILABLE -> updateWidget(true)
|
||||
SCREEN_OFF_CONNECTION_UNAVAILABLE -> return
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateWidget(fromStorage: Boolean) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val result = getWidgetWeather(fromStorage)
|
||||
appWidgetIds.forEach { id -> setupView(id, result) }
|
||||
setLoadingView()
|
||||
|
||||
val widgetState = helper.fetchWidgetData()
|
||||
appWidgetIds.forEach { id ->
|
||||
when (widgetState) {
|
||||
is HasData<*> -> {
|
||||
val data = widgetState.data as WidgetWeatherCollection
|
||||
setupView(id, data)
|
||||
}
|
||||
|
||||
is HasError<*> -> {
|
||||
if (widgetState.error is WidgetError) {
|
||||
val error = widgetState.error
|
||||
setupErrorView(id, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateErrorWidget(state: WidgetState) {
|
||||
appWidgetIds.forEach { id -> setEmptyView(id, state) }
|
||||
}
|
||||
|
||||
private fun getCurrentWidgetState(): WidgetState {
|
||||
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||
val isScreenOn = pm.isInteractive
|
||||
val locationGranted =
|
||||
checkSelfPermission(this, ACCESS_COARSE_LOCATION) == PERMISSION_GRANTED
|
||||
val internetAvailable = isInternetAvailable(this.applicationContext)
|
||||
|
||||
return getWidgetState(locationGranted, isScreenOn, internetAvailable)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun getWidgetWeather(storageOnly: Boolean): WidgetWeatherCollection? {
|
||||
return tryOrNullSuspended {
|
||||
if (!storageOnly) helper.fetchData()
|
||||
helper.getWidgetWeatherCollection()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setEmptyView(appWidgetId: Int, state: WidgetState) {
|
||||
val error = when (state) {
|
||||
NO_LOCATION -> "No Location Permission"
|
||||
SCREEN_ON_CONNECTION_UNAVAILABLE -> "No network available"
|
||||
else -> "No data"
|
||||
}
|
||||
|
||||
val views = createRemoteView(R.layout.weather_app_widget)
|
||||
bindErrorView(appWidgetId, views, error)
|
||||
}
|
||||
|
||||
private fun setupView(
|
||||
appWidgetId: Int,
|
||||
@@ -117,6 +85,49 @@ class WidgetJobServiceIntent : BaseWidgetServiceIntentClass<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(
|
||||
widgetId: Int,
|
||||
views: RemoteViews,
|
||||
|
||||
5
app/src/main/res/drawable/baseline_refresh_24.xml
Normal file
5
app/src/main/res/drawable/baseline_refresh_24.xml
Normal 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>
|
||||
@@ -31,4 +31,12 @@
|
||||
<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 name="weather_units">Weather units</string>
|
||||
|
||||
<string-array name="units">
|
||||
<item>Metric</item>
|
||||
<item>Imperial</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<ListPreference
|
||||
android:title="@string/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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,17 @@ import com.appttude.h_mal.atlas_weather.data.prefs.LOCATION_CONST
|
||||
import com.appttude.h_mal.atlas_weather.data.prefs.PreferenceProvider
|
||||
import com.appttude.h_mal.atlas_weather.data.room.AppDatabase
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.types.UnitType
|
||||
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||
import com.nhaarman.mockitokotlin2.any
|
||||
import com.nhaarman.mockitokotlin2.doAnswer
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.justRun
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
@@ -94,6 +90,7 @@ class RepositoryImplTest : BaseTest() {
|
||||
|
||||
//Act
|
||||
//create a successful retrofit response
|
||||
every { prefs.getUnitsType() } returns (UnitType.METRIC)
|
||||
coEvery { api.getFromApi("", "") }.returns(mockResponse)
|
||||
|
||||
// Assert
|
||||
@@ -110,6 +107,7 @@ class RepositoryImplTest : BaseTest() {
|
||||
|
||||
//Act
|
||||
//create a successful retrofit response
|
||||
every { prefs.getUnitsType() } returns (UnitType.METRIC)
|
||||
coEvery { api.getFromApi(any(), any()) } returns (mockResponse)
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
package com.appttude.h_mal.atlas_weather.viewmodel
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.appttude.h_mal.atlas_weather.data.WeatherSource
|
||||
import com.appttude.h_mal.atlas_weather.data.location.LocationProviderImpl
|
||||
import com.appttude.h_mal.atlas_weather.data.network.response.forecast.WeatherResponse
|
||||
import com.appttude.h_mal.atlas_weather.data.repository.Repository
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.CURRENT_LOCATION
|
||||
import com.appttude.h_mal.atlas_weather.data.room.entity.EntityItem
|
||||
import com.appttude.h_mal.atlas_weather.model.ViewState
|
||||
import com.appttude.h_mal.atlas_weather.model.types.LocationType
|
||||
import com.appttude.h_mal.atlas_weather.model.weather.FullWeather
|
||||
import com.appttude.h_mal.atlas_weather.utils.BaseTest
|
||||
import com.appttude.h_mal.atlas_weather.utils.getOrAwaitValue
|
||||
import com.appttude.h_mal.atlas_weather.utils.sleep
|
||||
import com.nhaarman.mockitokotlin2.any
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.InjectMockKs
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.impl.annotations.RelaxedMockK
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers
|
||||
import org.mockito.ArgumentMatchers.anyList
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
|
||||
@@ -32,8 +37,8 @@ class WorldViewModelTest : BaseTest() {
|
||||
@InjectMockKs
|
||||
lateinit var viewModel: WorldViewModel
|
||||
|
||||
@MockK(relaxed = true)
|
||||
lateinit var repository: Repository
|
||||
@RelaxedMockK
|
||||
lateinit var weatherSource: WeatherSource
|
||||
|
||||
@MockK
|
||||
lateinit var locationProvider: LocationProviderImpl
|
||||
@@ -43,30 +48,19 @@ class WorldViewModelTest : BaseTest() {
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this)
|
||||
|
||||
weatherResponse = getTestData("weather_sample.json", WeatherResponse::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchDataForSingleLocation_validLocation_validReturn() {
|
||||
// Arrange
|
||||
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
|
||||
temperatureUnit = "°C"
|
||||
locationString = CURRENT_LOCATION
|
||||
})
|
||||
val latlon = any<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(entityItem) } returns Unit
|
||||
|
||||
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||
coEvery {
|
||||
weatherSource.getWeather(
|
||||
latlon,
|
||||
CURRENT_LOCATION,
|
||||
locationType = LocationType.City
|
||||
)
|
||||
} returns FullWeather(weatherResponse)
|
||||
|
||||
// Assert
|
||||
viewModel.uiState.observeForever {
|
||||
println(it.javaClass.name)
|
||||
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||
|
||||
sleep(100)
|
||||
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchDataForSingleLocation_failedLocation_validReturn() {
|
||||
// Arrange
|
||||
val errorMessage = ArgumentMatchers.anyString()
|
||||
|
||||
// Act
|
||||
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
|
||||
weatherResponse.lat,
|
||||
weatherResponse.lon
|
||||
)
|
||||
coEvery {
|
||||
locationProvider.getLocationNameFromLatLong(
|
||||
weatherResponse.lat,
|
||||
weatherResponse.lon,
|
||||
LocationType.City
|
||||
)
|
||||
} throws IOException(errorMessage)
|
||||
|
||||
// Assert
|
||||
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||
sleep(100)
|
||||
val observerResults = viewModel.uiState.getOrAwaitValue()
|
||||
assertIs<ViewState.HasError<*>>(observerResults)
|
||||
assertEquals(observerResults.error as String, errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchDataForSingleLocation_failedApi_validReturn() {
|
||||
// Arrange
|
||||
val latlon = Pair(weatherResponse.lat, weatherResponse.lon)
|
||||
val errorMessage = ArgumentMatchers.anyString()
|
||||
|
||||
// Act
|
||||
coEvery { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } returns Pair(
|
||||
weatherResponse.lat,
|
||||
weatherResponse.lon
|
||||
)
|
||||
coEvery {
|
||||
locationProvider.getLocationNameFromLatLong(
|
||||
weatherResponse.lat,
|
||||
weatherResponse.lon,
|
||||
LocationType.City
|
||||
)
|
||||
}.returns(CURRENT_LOCATION)
|
||||
coEvery {
|
||||
weatherSource.getWeather(
|
||||
latlon,
|
||||
CURRENT_LOCATION,
|
||||
locationType = LocationType.City
|
||||
)
|
||||
} throws IOException(errorMessage)
|
||||
|
||||
// Assert
|
||||
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||
sleep(100)
|
||||
val observerResults = viewModel.uiState.getOrAwaitValue()
|
||||
assertIs<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
|
||||
|
||||
sleep(3000)
|
||||
// Assert
|
||||
viewModel.fetchAllLocations()
|
||||
|
||||
sleep(100)
|
||||
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchDataForSingleLocation_invalidLocation_invalidReturn() {
|
||||
fun deleteLocation_validLocations_validReturn() {
|
||||
// Arrange
|
||||
val location = CURRENT_LOCATION
|
||||
val location = anyString()
|
||||
|
||||
// Act
|
||||
every { locationProvider.getLatLongFromLocationName(CURRENT_LOCATION) } throws IOException("Unable to get location")
|
||||
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(true)
|
||||
coEvery {
|
||||
repository.getWeatherFromApi(
|
||||
weatherResponse.lat.toString(),
|
||||
weatherResponse.lon.toString()
|
||||
)
|
||||
}.returns(weatherResponse)
|
||||
|
||||
viewModel.fetchDataForSingleLocation(location)
|
||||
coEvery { weatherSource.repository.deleteSavedWeatherEntry(location) } returns true
|
||||
|
||||
// Assert
|
||||
sleep(300)
|
||||
assertIs<ViewState.HasError<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
viewModel.deleteLocation(location)
|
||||
|
||||
@Test
|
||||
fun searchAboveFallbackTime_validLocation_validReturn() {
|
||||
// Arrange
|
||||
val entityItem = EntityItem(CURRENT_LOCATION, FullWeather(weatherResponse).apply {
|
||||
temperatureUnit = "°C"
|
||||
locationString = CURRENT_LOCATION
|
||||
})
|
||||
|
||||
// Act
|
||||
coEvery { repository.getSingleWeather(CURRENT_LOCATION) }.returns(entityItem)
|
||||
every { repository.isSearchValid(CURRENT_LOCATION) }.returns(false)
|
||||
coEvery {
|
||||
repository.getWeatherFromApi(
|
||||
weatherResponse.lat.toString(),
|
||||
weatherResponse.lon.toString()
|
||||
)
|
||||
}.returns(weatherResponse)
|
||||
every { repository.saveLastSavedAt(CURRENT_LOCATION) } returns Unit
|
||||
coEvery { repository.saveCurrentWeatherToRoom(entityItem) } returns Unit
|
||||
|
||||
viewModel.fetchDataForSingleLocation(CURRENT_LOCATION)
|
||||
|
||||
// Assert
|
||||
sleep(300)
|
||||
sleep(100)
|
||||
assertIs<ViewState.HasData<*>>(viewModel.uiState.getOrAwaitValue())
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user