View <-> LiveData connection tweaks, some re-org

This commit is contained in:
Aidan Follestad 2018-12-07 00:11:13 -08:00
parent 9a849ab8ac
commit fc6bdf1c39
85 changed files with 737 additions and 373 deletions

View file

@ -21,12 +21,12 @@ import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.viewcomponents.ext.attachLiveData
import com.afollestad.nocknock.viewcomponents.ext.conceal
import com.afollestad.nocknock.viewcomponents.ext.onLayout
import com.afollestad.nocknock.viewcomponents.ext.toViewError
import com.afollestad.nocknock.viewcomponents.ext.toViewText
import com.afollestad.nocknock.viewcomponents.ext.toViewVisibility
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
import kotlinx.android.synthetic.main.activity_addsite.inputName
@ -88,9 +88,11 @@ class AddSiteActivity : AppCompatActivity() {
.toViewError(this, responseTimeoutInput)
// Validation mode
responseValidationMode.attachLiveData(this, viewModel.validationMode,
{ ValidationMode.fromIndex(it) },
{ it.toIndex() }
responseValidationMode.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationMode,
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
@ -98,7 +100,11 @@ class AddSiteActivity : AppCompatActivity() {
.toViewText(this, validationModeDescription)
// Validation search term
responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
responseValidationSearchTerm.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationSearchTerm,
pullInChanges = false
)
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)

View file

@ -32,8 +32,8 @@ import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.putSite
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.livedata.map
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import com.afollestad.nocknock.viewcomponents.ext.map
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -50,7 +50,9 @@ class AddSiteViewModel(
// Public properties
val name = MutableLiveData<String>()
val url = MutableLiveData<String>()
val timeout = MutableLiveData<Int>()
val timeout = MutableLiveData<Int>().apply {
this.value = 10000
}
val validationMode = MutableLiveData<ValidationMode>()
val validationSearchTerm = MutableLiveData<String>()
val validationScript = MutableLiveData<String>()
@ -214,8 +216,8 @@ class AddSiteViewModel(
)
return Site(
id = 0,
name = name.value!!,
url = url.value!!,
name = name.value!!.trim(),
url = url.value!!.trim(),
settings = newSettings,
lastResult = null
)

View file

@ -26,12 +26,12 @@ import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.viewcomponents.ext.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.ext.toViewError
import com.afollestad.nocknock.viewcomponents.ext.toViewText
import com.afollestad.nocknock.viewcomponents.ext.toViewVisibility
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
@ -102,9 +102,12 @@ class ViewSiteActivity : AppCompatActivity() {
.toViewError(this, responseTimeoutInput)
// Validation mode
responseValidationMode.attachLiveData(this, viewModel.validationMode,
{ ValidationMode.fromIndex(it) },
{ it.toIndex() })
responseValidationMode.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationMode,
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription()

View file

@ -38,10 +38,10 @@ import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.utilities.livedata.map
import com.afollestad.nocknock.utilities.livedata.zip
import com.afollestad.nocknock.utilities.providers.StringProvider
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import com.afollestad.nocknock.viewcomponents.ext.map
import com.afollestad.nocknock.viewcomponents.ext.zip
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -232,8 +232,8 @@ class ViewSiteViewModel(
@VisibleForTesting(otherwise = PRIVATE)
fun getValidationArgs(): String? {
return when (validationMode.value) {
TERM_SEARCH -> validationSearchTerm.value
JAVASCRIPT -> validationScript.value
TERM_SEARCH -> validationSearchTerm.value?.trim()
JAVASCRIPT -> validationScript.value?.trim()
else -> null
}
}
@ -310,8 +310,8 @@ class ViewSiteViewModel(
disabled = false
)
return site.copy(
name = name.value!!,
url = url.value!!,
name = name.value!!.trim(),
url = url.value!!.trim(),
settings = newSettings
)
.withStatus(status = WAITING)

View file

@ -93,7 +93,7 @@
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.interval.CheckIntervalLayout
<com.afollestad.nocknock.viewcomponents.interval.ValidationIntervalLayout
android:id="@+id/checkIntervalLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -115,7 +115,7 @@
android:layout_marginTop="@dimen/content_inset_less"
/>
<com.afollestad.nocknock.viewcomponents.interval.CheckIntervalLayout
<com.afollestad.nocknock.viewcomponents.interval.ValidationIntervalLayout
android:id="@+id/checkIntervalLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -24,7 +24,7 @@ import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.test
import com.afollestad.nocknock.utilities.livedata.test
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argumentCaptor

View file

@ -23,7 +23,7 @@ import com.afollestad.nocknock.MOCK_MODEL_3
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.test
import com.afollestad.nocknock.utilities.livedata.test
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import kotlinx.coroutines.Dispatchers

View file

@ -31,7 +31,7 @@ import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.test
import com.afollestad.nocknock.utilities.livedata.test
import com.afollestad.nocknock.utilities.providers.StringProvider
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat

View file

@ -20,7 +20,7 @@ android {
dependencies {
implementation 'androidx.annotation:annotation:' + versions.androidx
implementation 'androidx.appcompat:appcompat:' + versions.androidx
api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:' + versions.coroutines
@ -30,9 +30,8 @@ dependencies {
implementation 'org.mozilla:rhino:' + versions.rhino
testImplementation 'junit:junit:' + versions.junit
testImplementation 'org.mockito:mockito-core:' + versions.mockito
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin
testImplementation 'com.google.truth:truth:' + versions.truth
testImplementation 'androidx.arch.core:core-testing:' + versions.archTesting
}
apply from: '../spotless.gradle'

View file

@ -18,9 +18,29 @@ package com.afollestad.nocknock.utilities.ext
import android.text.Editable
import android.text.TextWatcher
import android.widget.EditText
import androidx.annotation.IntRange
import kotlin.math.min
fun EditText.onTextChanged(cb: (String) -> Unit) {
fun EditText.setTextAndMaintainSelection(text: CharSequence) {
val formerStart = min(selectionStart, text.length)
val formerEnd = min(selectionEnd, text.length)
setText(text)
if (formerEnd <= formerStart) {
setSelection(formerStart)
} else {
setSelection(formerStart, formerEnd)
}
}
fun EditText.onTextChanged(
@IntRange(from = 0, to = 10000) debounce: Int = 0,
cb: (String) -> Unit
) {
addTextChangedListener(object : TextWatcher {
val callbackRunner = Runnable {
cb(text.trim().toString())
}
override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(
@ -35,6 +55,13 @@ fun EditText.onTextChanged(cb: (String) -> Unit) {
start: Int,
before: Int,
count: Int
) = cb(s.toString().trim())
) {
removeCallbacks(callbackRunner)
if (debounce == 0) {
callbackRunner.run()
} else {
postDelayed(callbackRunner, debounce.toLong())
}
}
})
}

View file

@ -0,0 +1,53 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.utilities.livedata
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.Observer
/** @author Aidan Follestad (@afollestad) */
class DistinctLiveData<T>(source1: LiveData<T>) : MediatorLiveData<T>() {
private var isInitialized = false
private var lastValue: T? = null
init {
super.addSource(source1) {
if (!isInitialized) {
value = it
isInitialized = true
lastValue = it
} else if (lastValue != it) {
value = it
lastValue = it
}
}
}
override fun <S : Any?> addSource(
source: LiveData<S>,
onChanged: Observer<in S>
) {
throw UnsupportedOperationException()
}
override fun <T : Any?> removeSource(toRemote: LiveData<T>) {
throw UnsupportedOperationException()
}
}
fun <T> LiveData<T>.distinct(): MediatorLiveData<T> = DistinctLiveData(this)

View file

@ -0,0 +1,25 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.utilities.livedata
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
fun <X, Y> LiveData<X>.map(mapper: (X) -> Y) =
Transformations.map(this, mapper)!!
fun <X, Y> LiveData<X>.switchMap(mapper: (X) -> LiveData<Y>) =
Transformations.switchMap(this, mapper)!!

View file

@ -13,13 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock
package com.afollestad.nocknock.utilities.livedata
import androidx.annotation.CheckResult
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
/** @author Aidan Follestad (@afollestad) */
class TestLiveData<T>(data: LiveData<T>) {
@ -34,17 +32,32 @@ class TestLiveData<T>(data: LiveData<T>) {
}
fun assertNoValues() {
assertWithMessage("Expected no values, but got: $receivedValues").that(receivedValues)
.isEmpty()
if (receivedValues.isNotEmpty()) {
throw AssertionError("Expected no values, but got: $receivedValues")
}
}
fun assertValues(vararg assertValues: T) {
val assertList = assertValues.toList()
assertThat(receivedValues).isEqualTo(assertList)
if (!assertList.contentEquals(receivedValues)) {
throw AssertionError("Expected $assertList\n\t\tBut got: $receivedValues")
}
receivedValues.clear()
}
@CheckResult fun values(): List<T> = receivedValues
private fun List<T>.contentEquals(other: List<T>): Boolean {
if (this.size != other.size) {
return false
}
for ((index, value) in this.withIndex()) {
if (other[index] != value) {
return false
}
}
return true
}
}
@CheckResult fun <T> LiveData<T>.test() = TestLiveData(this)

View file

@ -0,0 +1,102 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.utilities.livedata
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.Observer
typealias Zipper<T, K, R> = (T, K) -> R
/** @author Aidan Follestad (@afollestad) */
class ZipLiveData<T, K, R>(
source1: LiveData<T>,
source2: LiveData<K>,
private val distinctUntilChanged: Boolean,
private val resetAfterEmission: Boolean,
private val zipper: Zipper<T, K, R>
) : MediatorLiveData<R>() {
private var data1: T? = null
private var data2: K? = null
private var lastNotified: R? = null
init {
super.addSource(source1) {
if (data1 == it) return@addSource
data1 = it
maybeNotify()
}
super.addSource(source2) {
if (data2 == it) return@addSource
data2 = it
maybeNotify()
}
}
private fun maybeNotify() {
if (data1 != null && data2 != null) {
val zippedUp = zipper(data1!!, data2!!)
if (!distinctUntilChanged || zippedUp != lastNotified) {
value = zippedUp
lastNotified = zippedUp
if (resetAfterEmission) {
data1 = null
data2 = null
}
}
}
}
override fun <S : Any?> addSource(
source: LiveData<S>,
onChanged: Observer<in S>
) {
throw UnsupportedOperationException()
}
override fun <T : Any?> removeSource(toRemote: LiveData<T>) {
throw UnsupportedOperationException()
}
}
fun <T, K, R> zip(
source1: LiveData<T>,
source2: LiveData<K>,
distinctUntilChanged: Boolean = true,
resetAfterEmission: Boolean = false,
zipper: Zipper<T, K, R>
) = ZipLiveData(
source1 = source1,
source2 = source2,
distinctUntilChanged = distinctUntilChanged,
resetAfterEmission = resetAfterEmission,
zipper = zipper
)
fun <T, K> zip(
source1: LiveData<T>,
source2: LiveData<K>,
distinctUntilChanged: Boolean = true,
resetAfterEmission: Boolean = false
) = zip(
source1 = source1,
source2 = source2,
distinctUntilChanged = distinctUntilChanged,
resetAfterEmission = resetAfterEmission,
zipper = { left, right -> Pair(left, right) })

View file

@ -0,0 +1,47 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.utilities.livedata
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.MutableLiveData
import org.junit.Rule
import org.junit.Test
/** @author Aidan Follestad (@afollestad) */
class DistinctTest {
@Rule @JvmField val rule = InstantTaskExecutorRule()
@Test fun filterLastValues() {
val data = MutableLiveData<String>()
val distinct = data.distinct()
.test()
data.postValue("Hello")
data.postValue("Hello")
data.postValue("Hi")
data.postValue("Hi")
data.postValue("Hello")
distinct.assertValues(
"Hello",
"Hi",
"Hello"
)
}
}

View file

@ -0,0 +1,123 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.utilities.livedata
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.MutableLiveData
import org.junit.Rule
import org.junit.Test
/** @author Aidan Follestad (@afollestad) */
class ZipTest {
@Rule @JvmField val rule = InstantTaskExecutorRule()
@Test fun test_withDistinct() {
val data1 = MutableLiveData<String>()
val data2 = MutableLiveData<Int>()
val zipped = zip(data1, data2, true)
.test()
data1.postValue("Hello")
data2.postValue(24)
zipped.assertValues(Pair("Hello", 24))
data1.postValue("Hello")
data2.postValue(24)
zipped.assertNoValues()
}
@Test fun test_noDistinct() {
val data1 = MutableLiveData<String>()
val data2 = MutableLiveData<Int>()
val zipped = zip(data1, data2, false)
.test()
data1.postValue("Hello")
data2.postValue(24)
zipped.assertValues(Pair("Hello", 24))
data1.postValue("Hi")
data2.postValue(24)
zipped.assertValues(Pair("Hi", 24))
}
@Test fun test_noDistinct_resetAfterEmission() {
val data1 = MutableLiveData<String>()
val data2 = MutableLiveData<Int>()
val zipped = zip(data1, data2, false, true)
.test()
data1.postValue("Hello")
data2.postValue(24)
zipped.assertValues(Pair("Hello", 24))
data1.postValue("Hi")
data2.postValue(50)
zipped.assertValues(Pair("Hi", 50))
}
@Test fun test_withDistinct_customZipper() {
val data1 = MutableLiveData<String>()
val data2 = MutableLiveData<Int>()
val zipped = zip(data1, data2, true,
zipper = { left, right ->
"$left $right"
}).test()
data1.postValue("Hello")
data2.postValue(24)
zipped.assertValues("Hello 24")
data1.postValue("Hello")
data2.postValue(24)
zipped.assertNoValues()
}
@Test fun test_noDistinct_customZipper() {
val data1 = MutableLiveData<String>()
val data2 = MutableLiveData<Int>()
val zipped = zip(data1, data2, false,
zipper = { left, right ->
"$left $right"
}).test()
data1.postValue("Hello")
data2.postValue(24)
zipped.assertValues("Hello 24")
data1.postValue("Hi")
data2.postValue(24)
zipped.assertValues("Hi 24")
}
@Test fun test_noDistinct_customZipper_resetAfterEmission() {
val data1 = MutableLiveData<String>()
val data2 = MutableLiveData<Int>()
val zipped = zip(data1, data2, false, true,
zipper = { left, right ->
"$left $right"
}).test()
data1.postValue("Hello")
data2.postValue(24)
zipped.assertValues("Hello 24")
data1.postValue("Hi")
data2.postValue(50)
zipped.assertValues("Hi 50")
}
}

View file

@ -1,148 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.ext
import android.view.View
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.Transformations
import com.afollestad.nocknock.utilities.ext.onTextChanged
fun <X, Y> LiveData<X>.map(mapper: (X) -> Y) =
Transformations.map(this, mapper)!!
fun <X, Y> LiveData<X>.switchMap(mapper: (X) -> LiveData<Y>) =
Transformations.switchMap(this, mapper)!!
inline fun <reified T> EditText.attachLiveData(
lifecycleOwner: LifecycleOwner,
data: MutableLiveData<T>
) {
// Out
when {
T::class == Int::class -> {
onTextChanged { data.postValue(it.trim().toInt() as T) }
}
T::class == Long::class -> {
onTextChanged { data.postValue(it.trim().toLong() as T) }
}
T::class == String::class -> {
onTextChanged { data.postValue(it.trim() as T) }
}
else -> {
throw IllegalArgumentException("Can't send EditText text changes into ${T::class}")
}
}
// In
data.observe(lifecycleOwner, Observer {
when {
T::class == Int::class -> setText(it as Int)
T::class == String::class -> setText(it as String)
}
})
}
fun <T> Spinner.attachLiveData(
lifecycleOwner: LifecycleOwner,
data: MutableLiveData<T>,
outTransformer: (Int) -> T,
inTransformer: (T) -> Int
) {
// Out
onItemSelected { data.postValue(outTransformer(it)) }
// In
data.observe(lifecycleOwner, Observer {
setSelection(inTransformer(it))
})
}
fun LiveData<Int?>.toViewError(
owner: LifecycleOwner,
view: EditText
) = observe(owner, Observer { error ->
view.error = if (error != null) {
view.resources.getString(error)
} else {
null
}
})
inline fun <reified T> LiveData<T>.toViewText(
owner: LifecycleOwner,
view: TextView
) = observe(owner, Observer {
when {
T::class == Int::class -> view.setText(it as Int)
T::class == String::class -> view.text = it as String
else -> throw IllegalStateException("Cannot set ${T::class} as view text.")
}
})
fun LiveData<Boolean>.toViewVisibility(
owner: LifecycleOwner,
view: View
) = observe(owner, Observer { view.showOrHide(it) })
/** @author Aidan Follestad (@afollestad) */
class ZipLiveData<T, K>(
source1: LiveData<T>,
source2: LiveData<K>
) : MediatorLiveData<Pair<T, K>>() {
private var data1: T? = null
private var data2: K? = null
init {
super.addSource(source1) {
data1 = it
maybeNotify()
}
super.addSource(source2) {
data2 = it
maybeNotify()
}
}
private fun maybeNotify() {
if (data1 != null && data2 != null) {
value = Pair(data1!!, data2!!)
}
}
override fun <S : Any?> addSource(
source: LiveData<S>,
onChanged: Observer<in S>
) {
throw UnsupportedOperationException()
}
override fun <T : Any?> removeSource(toRemote: LiveData<T>) {
throw UnsupportedOperationException()
}
}
fun <T, K> zip(
source1: LiveData<T>,
source2: LiveData<K>
): MediatorLiveData<Pair<T, K>> {
return ZipLiveData(source1, source2)
}

View file

@ -1,133 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.interval
import android.content.Context
import android.util.AttributeSet
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import androidx.lifecycle.Lifecycle.State.DESTROYED
import androidx.lifecycle.Lifecycle.State.RESUMED
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.WEEK
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.viewcomponents.R.array
import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import kotlinx.android.synthetic.main.check_interval_layout.view.input
import kotlinx.android.synthetic.main.check_interval_layout.view.spinner
/** @author Aidan Follestad (@afollestad) */
class CheckIntervalLayout(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs), LifecycleOwner {
companion object {
const val INDEX_MINUTE = 0
const val INDEX_HOUR = 1
const val INDEX_DAY = 2
const val INDEX_WEEK = 3
}
init {
orientation = VERTICAL
inflate(context, layout.check_interval_layout, this)
}
private lateinit var valueData: MutableLiveData<Int>
private lateinit var multiplierData: MutableLiveData<Long>
private val lifecycle = LifecycleRegistry(this)
override fun onFinishInflate() {
super.onFinishInflate()
val spinnerAdapter = ArrayAdapter(
context,
layout.list_item_spinner,
resources.getStringArray(array.interval_options)
)
spinnerAdapter.setDropDownViewResource(
layout.list_item_spinner_dropdown
)
spinner.adapter = spinnerAdapter
lifecycle.markState(STARTED)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
lifecycle.markState(RESUMED)
}
override fun onDetachedFromWindow() {
lifecycle.markState(DESTROYED)
super.onDetachedFromWindow()
}
fun setError(error: String?) {
input.error = error
}
fun attach(
valueData: MutableLiveData<Int>,
multiplierData: MutableLiveData<Long>,
errorData: LiveData<Int?>
) {
this.valueData = valueData
this.multiplierData = multiplierData
this.valueData.observe(this, Observer {
input.setText("$it")
})
this.multiplierData.observe(this, Observer { multiplier ->
val targetPos = when (multiplier) {
MINUTE -> 0
HOUR -> 1
DAY -> 2
WEEK -> 3
else -> throw IllegalStateException("Unknown multiplier: $multiplier")
}
if (spinner.selectedItemPosition != targetPos) {
spinner.setSelection(targetPos)
}
})
errorData.observe(this, Observer {
setError(if (it != null) resources.getString(it) else null)
})
input.onTextChanged { this.valueData.value = it.toInt() }
spinner.onItemSelected {
this.multiplierData.value = when (it) {
INDEX_MINUTE -> MINUTE
INDEX_HOUR -> HOUR
INDEX_DAY -> DAY
INDEX_WEEK -> WEEK
else -> throw IllegalStateException("Unknown multiplier index: $it")
}
}
}
override fun getLifecycle() = lifecycle
}

View file

@ -0,0 +1,101 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.interval
import android.content.Context
import android.util.AttributeSet
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.WEEK
import com.afollestad.nocknock.viewcomponents.R.array
import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import kotlinx.android.synthetic.main.validation_interval_layout.view.input
import kotlinx.android.synthetic.main.validation_interval_layout.view.spinner
/** @author Aidan Follestad (@afollestad) */
class ValidationIntervalLayout(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
companion object {
const val INDEX_MINUTE = 0
const val INDEX_HOUR = 1
const val INDEX_DAY = 2
const val INDEX_WEEK = 3
}
init {
orientation = VERTICAL
inflate(context, layout.validation_interval_layout, this)
}
override fun onFinishInflate() {
super.onFinishInflate()
val spinnerAdapter = ArrayAdapter(
context,
layout.list_item_spinner,
resources.getStringArray(array.interval_options)
)
spinnerAdapter.setDropDownViewResource(
layout.list_item_spinner_dropdown
)
spinner.adapter = spinnerAdapter
}
fun attach(
valueData: MutableLiveData<Int>,
multiplierData: MutableLiveData<Long>,
errorData: LiveData<Int?>
) {
input.attachLiveData(lifecycleOwner(), valueData)
spinner.attachLiveData(
lifecycleOwner = lifecycleOwner(),
data = multiplierData,
inTransformer = {
when (it) {
MINUTE -> 0
HOUR -> 1
DAY -> 2
WEEK -> 3
else -> throw IllegalStateException("Unknown multiplier: $it")
}
},
outTransformer = {
when (it) {
INDEX_MINUTE -> MINUTE
INDEX_HOUR -> HOUR
INDEX_DAY -> DAY
INDEX_WEEK -> WEEK
else -> throw IllegalStateException("Unknown multiplier index: $it")
}
}
)
errorData.toViewError(lifecycleOwner(), this, ::setError)
}
private fun setError(error: String?) {
input.error = error
}
}

View file

@ -18,22 +18,17 @@ package com.afollestad.nocknock.viewcomponents.js
import android.content.Context
import android.util.AttributeSet
import android.widget.HorizontalScrollView
import androidx.lifecycle.Lifecycle.State.CREATED
import androidx.lifecycle.Lifecycle.State.DESTROYED
import androidx.lifecycle.Lifecycle.State.RESUMED
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.viewcomponents.R.dimen
import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.R.string
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.dimenInt
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.toViewVisibility
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.javascript_input_layout.view.error_text
import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput
@ -41,10 +36,7 @@ import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput
class JavaScriptInputLayout(
context: Context,
attrs: AttributeSet? = null
) : HorizontalScrollView(context, attrs), LifecycleOwner {
private val lifecycle = LifecycleRegistry(this)
private lateinit var codeData: MutableLiveData<String>
) : HorizontalScrollView(context, attrs) {
init {
val contentInset = dimenInt(dimen.content_inset)
@ -61,51 +53,20 @@ class JavaScriptInputLayout(
inflate(context, layout.javascript_input_layout, this)
}
override fun onFinishInflate() {
super.onFinishInflate()
lifecycle.markState(CREATED)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
lifecycle.markState(RESUMED)
}
override fun onDetachedFromWindow() {
lifecycle.markState(DESTROYED)
super.onDetachedFromWindow()
}
fun setError(error: String?) {
error_text.showOrHide(error != null)
error_text.text = error
}
fun attach(
codeData: MutableLiveData<String>,
errorData: LiveData<Int?>,
visibility: LiveData<Boolean>
) {
this.codeData = codeData
this.codeData.observe(this, Observer {
if (it.isNullOrEmpty()) {
setDefaultCode()
} else {
userInput.setText(it)
}
})
errorData.observe(this, Observer {
setError(if (it != null) resources.getString(it) else null)
})
visibility.toViewVisibility(this, this)
userInput.onTextChanged { this.codeData.value = it }
userInput.attachLiveData(lifecycleOwner(), codeData)
errorData.toViewError(lifecycleOwner(), this, ::setError)
visibility.toViewVisibility(lifecycleOwner(), this)
}
fun clear() = userInput.setText("")
private fun setDefaultCode() = userInput.setText(
string.default_js
)
override fun getLifecycle() = lifecycle
private fun setError(error: String?) {
error_text.showOrHide(error != null)
error_text.text = error
}
}

View file

@ -0,0 +1,120 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.livedata
import android.view.View
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection
import com.afollestad.nocknock.utilities.livedata.distinct
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
inline fun <reified T> EditText.attachLiveData(
lifecycleOwner: LifecycleOwner,
data: MutableLiveData<T>,
debounce: Int = 100,
pushOutChanges: Boolean = true,
pullInChanges: Boolean = false
) {
// Initial value
if (T::class == String::class) {
data.value = this.text.trim().toString() as T
}
// Out
if (pushOutChanges) {
when {
T::class == Int::class -> {
onTextChanged(debounce) { data.postValue(it.trim().toInt() as T) }
}
T::class == Long::class -> {
onTextChanged(debounce) { data.postValue(it.trim().toLong() as T) }
}
T::class == String::class -> {
onTextChanged(debounce) { data.postValue(it as T) }
}
else -> {
throw IllegalArgumentException("Can't send EditText text changes into ${T::class}")
}
}
}
// In
if (pullInChanges) {
data.distinct()
.observe(lifecycleOwner, Observer {
when {
T::class == Int::class -> setText(it as Int)
T::class == String::class -> setTextAndMaintainSelection(it as String)
}
})
}
}
fun <T> Spinner.attachLiveData(
lifecycleOwner: LifecycleOwner,
data: MutableLiveData<T>,
outTransformer: (Int) -> T,
inTransformer: (T) -> Int
) {
// Out
onItemSelected { data.postValue(outTransformer(it)) }
// In
data.distinct()
.observe(lifecycleOwner, Observer {
setSelection(inTransformer(it))
})
}
fun LiveData<Int?>.toViewError(
owner: LifecycleOwner,
view: View,
setter: (String?) -> Unit
) = observe(owner, Observer { error ->
setter(
if (error != null) {
view.resources.getString(error)
} else {
null
}
)
})
fun LiveData<Int?>.toViewError(
owner: LifecycleOwner,
view: EditText
) = toViewError(owner, view, view::setError)
fun LiveData<*>.toViewText(
owner: LifecycleOwner,
view: TextView
) = distinct().observe(owner, Observer {
when (it) {
is Int -> view.setText(it)
is String -> view.text = it
else -> throw IllegalStateException("Can't set $it to a text view.")
}
})
fun LiveData<Boolean>.toViewVisibility(
owner: LifecycleOwner,
view: View
) = distinct().observe(owner, Observer { view.showOrHide(it) })

View file

@ -0,0 +1,59 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.livedata
import android.view.View
import android.view.View.OnAttachStateChangeListener
import androidx.lifecycle.Lifecycle.State.CREATED
import androidx.lifecycle.Lifecycle.State.DESTROYED
import androidx.lifecycle.Lifecycle.State.RESUMED
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import com.afollestad.nocknock.viewcomponents.R
/** @author Aidan Follestad (@afollestad) */
class ViewLifecycleOwner(view: View) : LifecycleOwner, OnAttachStateChangeListener {
private val lifecycleRegistry = LifecycleRegistry(this)
init {
view.addOnAttachStateChangeListener(this)
}
override fun getLifecycle() = lifecycleRegistry
override fun onViewAttachedToWindow(v: View?) {
lifecycleRegistry.markState(CREATED)
lifecycleRegistry.markState(STARTED)
lifecycleRegistry.markState(RESUMED)
}
override fun onViewDetachedFromWindow(v: View) {
lifecycleRegistry.markState(DESTROYED)
}
}
fun View.lifecycleOwner(): LifecycleOwner {
val tagOwner = getTag(R.id.view_lifecycle_registry) as? ViewLifecycleOwner
return if (tagOwner != null) {
tagOwner
} else {
val newOwner = ViewLifecycleOwner(this)
setTag(R.id.view_lifecycle_registry, newOwner)
newOwner
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item format="integer" name="view_lifecycle_registry" type="id"/>
</resources>