diff --git a/mobile/src/main/java/org/openhab/habdroid/model/ParsedState.kt b/mobile/src/main/java/org/openhab/habdroid/model/ParsedState.kt index 288836338e..bb4117dae0 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/ParsedState.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/ParsedState.kt @@ -24,6 +24,7 @@ import java.util.Locale import java.util.regex.Pattern import kotlin.math.roundToInt import kotlinx.parcelize.Parcelize +import org.openhab.habdroid.util.asColorTemperatureToKelvin @Parcelize data class HsvState internal constructor(val hue: Float, val saturation: Float, val value: Float) : Parcelable { @@ -206,6 +207,9 @@ fun ParsedState.NumberState?.withValue(value: Float): ParsedState.NumberState { return ParsedState.NumberState(value, this?.unit, this?.format) } +fun ParsedState.NumberState.toColorTemperatureInKelvin() = + ParsedState.NumberState(value.asColorTemperatureToKelvin(), "K", "%.0f %unit%") + /** * Parses a state string into the parsed representation. * diff --git a/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt b/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt index 290f72b41c..d540172815 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt @@ -129,6 +129,7 @@ data class Widget( Input, Buttongrid, Button, + Colortemperaturepicker, Unknown } diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/WidgetAdapter.kt b/mobile/src/main/java/org/openhab/habdroid/ui/WidgetAdapter.kt index 93451cefd5..41b289ca8e 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/WidgetAdapter.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/WidgetAdapter.kt @@ -101,6 +101,7 @@ import org.openhab.habdroid.model.Item import org.openhab.habdroid.model.LabeledValue import org.openhab.habdroid.model.ParsedState import org.openhab.habdroid.model.Widget +import org.openhab.habdroid.model.toColorTemperatureInKelvin import org.openhab.habdroid.model.withValue import org.openhab.habdroid.ui.widget.AutoHeightPlayerView import org.openhab.habdroid.ui.widget.ContextMenuAwareRecyclerView @@ -112,6 +113,7 @@ import org.openhab.habdroid.util.IconBackground import org.openhab.habdroid.util.ImageConversionPolicy import org.openhab.habdroid.util.MjpegStreamer import org.openhab.habdroid.util.PrefKeys +import org.openhab.habdroid.util.asColorTemperatureInKelvinToColor import org.openhab.habdroid.util.beautify import org.openhab.habdroid.util.determineDataUsagePolicy import org.openhab.habdroid.util.getChartTheme @@ -120,6 +122,7 @@ import org.openhab.habdroid.util.getImageWidgetScalingType import org.openhab.habdroid.util.getPrefs import org.openhab.habdroid.util.orDefaultIfEmpty import org.openhab.habdroid.util.resolveThemedColor +import org.openhab.habdroid.util.toColoredRoundedRect /** * This class provides openHAB widgets adapter for list view. @@ -239,6 +242,7 @@ class WidgetAdapter( TYPE_VIDEO -> VideoViewHolder(initData) TYPE_WEB -> WebViewHolder(initData) TYPE_COLOR -> ColorViewHolder(initData) + TYPE_COLORTEMPERATURE -> ColorTemperatureViewHolder(initData) TYPE_VIDEO_MJPEG -> MjpegVideoViewHolder(initData) TYPE_LOCATION -> MapViewHelper.createViewHolder(initData) TYPE_INPUT -> InputViewHolder(initData) @@ -389,6 +393,7 @@ class WidgetAdapter( } Widget.Type.Webview -> TYPE_WEB Widget.Type.Colorpicker -> TYPE_COLOR + Widget.Type.Colortemperaturepicker -> TYPE_COLORTEMPERATURE Widget.Type.Mapview -> TYPE_LOCATION Widget.Type.Input -> if (widget.shouldUseDateTimePickerForInput()) TYPE_DATETIMEINPUT else TYPE_INPUT Widget.Type.Buttongrid -> TYPE_BUTTONGRID @@ -1702,6 +1707,30 @@ class WidgetAdapter( } } + class ColorTemperatureViewHolder internal constructor(initData: ViewHolderInitData) : LabeledItemBaseViewHolder( + initData, + R.layout.widgetlist_colortemperatureitem, + R.layout.widgetlist_colortemperatureitem_compact + ) { + private val previewImage = itemView.findViewById(R.id.current_temperature) + + override fun bind(widget: Widget) { + super.bind(widget) + val drawable = (widget.state ?: widget.item?.state) + ?.asNumber + ?.toColorTemperatureInKelvin() + ?.value + ?.asColorTemperatureInKelvinToColor() + ?.toColoredRoundedRect(previewImage.context) + previewImage.setImageDrawable(drawable) + } + + override fun handleRowClick() { + val widget = boundWidget ?: return + fragmentPresenter.showBottomSheet(ColorTemperatureSliderBottomSheet(), widget) + } + } + class MjpegVideoViewHolder internal constructor(initData: ViewHolderInitData) : HeavyDataViewHolder(initData, R.layout.widgetlist_videomjpegitem) { private val imageView = widgetContentView as WidgetImageView @@ -1808,12 +1837,13 @@ class WidgetAdapter( private const val TYPE_VIDEO = 15 private const val TYPE_WEB = 16 private const val TYPE_COLOR = 17 - private const val TYPE_VIDEO_MJPEG = 18 - private const val TYPE_LOCATION = 19 - private const val TYPE_INPUT = 20 - private const val TYPE_DATETIMEINPUT = 21 - private const val TYPE_BUTTONGRID = 22 - private const val TYPE_INVISIBLE = 23 + private const val TYPE_COLORTEMPERATURE = 18 + private const val TYPE_VIDEO_MJPEG = 19 + private const val TYPE_LOCATION = 20 + private const val TYPE_INPUT = 21 + private const val TYPE_DATETIMEINPUT = 22 + private const val TYPE_BUTTONGRID = 23 + private const val TYPE_INVISIBLE = 24 private fun toInternalViewType(viewType: Int, compactMode: Boolean): Int { return viewType or (if (compactMode) 0x100 else 0) diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/WidgetDetailBottomSheets.kt b/mobile/src/main/java/org/openhab/habdroid/ui/WidgetDetailBottomSheets.kt index 26d4f9520d..8983d34a3f 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/WidgetDetailBottomSheets.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/WidgetDetailBottomSheets.kt @@ -13,6 +13,9 @@ package org.openhab.habdroid.ui +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -29,6 +32,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.Slider +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,9 +40,12 @@ import kotlinx.coroutines.cancel import org.openhab.habdroid.R import org.openhab.habdroid.core.connection.Connection import org.openhab.habdroid.model.Widget +import org.openhab.habdroid.model.toColorTemperatureInKelvin import org.openhab.habdroid.model.withValue import org.openhab.habdroid.ui.widget.WidgetSlider import org.openhab.habdroid.util.ColorPickerHelper +import org.openhab.habdroid.util.asColorTemperatureInKelvinToColor +import org.openhab.habdroid.util.asColorTemperatureToKelvin import org.openhab.habdroid.util.parcelable open class AbstractWidgetBottomSheet : BottomSheetDialogFragment() { @@ -67,10 +74,8 @@ open class AbstractWidgetBottomSheet : BottomSheetDialogFragment() { } } -class SliderBottomSheet : AbstractWidgetBottomSheet(), WidgetSlider.UpdateListener { - private var updateJob: Job? = null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { +open class SliderBottomSheet : AbstractWidgetBottomSheet(), WidgetSlider.UpdateListener { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val view = inflater.inflate(R.layout.bottom_sheet_setpoint, container, false) view.findViewById(R.id.slider).apply { @@ -98,6 +103,51 @@ class SliderBottomSheet : AbstractWidgetBottomSheet(), WidgetSlider.UpdateListen } } +class ColorTemperatureSliderBottomSheet : SliderBottomSheet(), View.OnLayoutChangeListener { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val v = super.onCreateView(inflater, container, savedInstanceState) + v.findViewById(R.id.slider).apply { + addOnLayoutChangeListener(this@ColorTemperatureSliderBottomSheet) + setLabelFormatter { value -> "${value.roundToInt()} K" } + } + return v + } + + override suspend fun onValueUpdate(value: Float) { + val item = widget.item ?: return + val state = widget.state?.asNumber?.toColorTemperatureInKelvin()?.withValue(value) + Log.d(TAG, "Send state $state for ${item.name}") + connection?.httpClient?.sendItemUpdate(item, state) + } + + override fun onLayoutChange(view: View, l: Int, t: Int, r: Int, b: Int, ol: Int, ot: Int, or: Int, ob: Int) { + applyColorTemperatureGradientToTrack(view as WidgetSlider, r - l, b - t) + } + + private fun applyColorTemperatureGradientToTrack(slider: WidgetSlider, width: Int, height: Int) { + val min = widget.minValue.asColorTemperatureToKelvin() + val max = widget.maxValue.asColorTemperatureToKelvin() + val steps = 20 + val positions = (0 until steps).map { 1F * it / steps }.toFloatArray() + val colors = positions + .map { it * (max - min) + min } + .map { it.asColorTemperatureInKelvinToColor() } + .toIntArray() + val shader = LinearGradient(0F, 0F, width.toFloat(), height.toFloat(), colors, positions, Shader.TileMode.CLAMP) + + listOf("activeTrackPaint", "inactiveTrackPaint") + .map { Slider::class.java.superclass.getDeclaredField(it) } + .forEach { field -> + field.isAccessible = true + (field.get(slider) as Paint).shader = shader + } + } + + companion object { + private val TAG = SliderBottomSheet::class.java.simpleName + } +} + class SelectionBottomSheet : AbstractWidgetBottomSheet() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val view = inflater.inflate(R.layout.bottom_sheet_selection, container, false) diff --git a/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt b/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt index 978fe6e66d..781ca27840 100644 --- a/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt +++ b/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt @@ -23,11 +23,13 @@ import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.PackageManager +import android.content.res.ColorStateList import android.content.res.Configuration import android.content.res.Resources import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas +import android.graphics.Color import android.net.ConnectivityManager import android.net.Network import android.net.Uri @@ -53,6 +55,9 @@ import com.caverock.androidsvg.RenderOptions import com.caverock.androidsvg.SVG import com.google.android.material.color.DynamicColors import com.google.android.material.color.MaterialColors +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.RelativeCornerSize +import com.google.android.material.shape.ShapeAppearanceModel import java.io.EOFException import java.io.IOException import java.io.InputStream @@ -68,8 +73,10 @@ import javax.jmdns.ServiceInfo import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException +import kotlin.math.ln import kotlin.math.max import kotlin.math.round +import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -151,6 +158,44 @@ fun HttpUrl.toRelativeUrl(): String { return this.toString().substring(base.toString().length - 1) } +fun Float.asColorTemperatureToKelvin(): Float = if (this < 1000) { + // likely Mirek + 1000000F / this +} else { + this +} + +fun Float.asColorTemperatureInKelvinToColor(): Int { + // For algorithm, see https://web.archive.org/web/20151024031939/http://www.zombieprototypes.com/?p=210 + val temp = (this.coerceIn(1000F, 10000F) / 100F).toDouble() + // calculates a + bx + c * ln(x) + val approximate = { x: Double, a: Double, b: Double, c: Double -> a + b * x + c * ln(x) } + val red = when { + temp <= 66 -> 255.0 + else -> approximate(temp - 55, 351.97690566805693, 0.114206453784165, -40.25366309332127) + }.coerceIn(0.0, 255.0) + + val green = when { + temp <= 66 -> approximate(temp - 2, -155.25485562709179, -0.44596950469579133, 104.49216199393888) + else -> approximate(temp - 50, 325.4494125711974, 0.07943456536662342, -28.0852963507957) + }.coerceIn(0.0, 255.0) + + val blue = when { + temp < 20 -> 0.0 + temp > 66 -> 255.0 + else -> approximate(temp - 10, -254.76935184120902, 0.8274096064007395, 115.67994401066147) + }.coerceIn(0.0, 255.0) + + return Color.argb(255, red.roundToInt(), green.roundToInt(), blue.roundToInt()) +} + +fun Int.toColoredRoundedRect(context: Context) = MaterialShapeDrawable.createWithElevationOverlay(context).apply { + fillColor = ColorStateList.valueOf(this@toColoredRoundedRect) + shapeAppearanceModel = ShapeAppearanceModel.Builder() + .setAllCornerSizes(RelativeCornerSize(0.15f)) + .build() +} + /** * This method converts dp unit to equivalent pixels, depending on device density. * diff --git a/mobile/src/main/res/layout/widgetlist_colortemperatureitem.xml b/mobile/src/main/res/layout/widgetlist_colortemperatureitem.xml new file mode 100644 index 0000000000..f764e03c20 --- /dev/null +++ b/mobile/src/main/res/layout/widgetlist_colortemperatureitem.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/mobile/src/main/res/layout/widgetlist_colortemperatureitem_compact.xml b/mobile/src/main/res/layout/widgetlist_colortemperatureitem_compact.xml new file mode 100644 index 0000000000..51048d7630 --- /dev/null +++ b/mobile/src/main/res/layout/widgetlist_colortemperatureitem_compact.xml @@ -0,0 +1,19 @@ + + + + + + + +