diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..24683a7b1c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.idea/ linguist-generated=false diff --git a/.idea/dictionaries/openhab.xml b/.idea/dictionaries/openhab.xml index 0dfc1db2e2..be0bf34f64 100644 --- a/.idea/dictionaries/openhab.xml +++ b/.idea/dictionaries/openhab.xml @@ -5,6 +5,7 @@ addr basicui basicuidark + buttongrid colorpicker datasource demomode diff --git a/mobile/src/main/java/org/openhab/habdroid/model/LabeledValue.kt b/mobile/src/main/java/org/openhab/habdroid/model/LabeledValue.kt index b648841ddd..1ae91c9379 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/LabeledValue.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/LabeledValue.kt @@ -21,12 +21,18 @@ import org.json.JSONObject import org.openhab.habdroid.util.optStringOrNull @Parcelize -data class LabeledValue internal constructor(val value: String, val label: String, val icon: IconResource?) : Parcelable +data class LabeledValue internal constructor( + val value: String, + val label: String, + val icon: IconResource?, + val row: Int, + val column: Int +) : Parcelable @Throws(JSONException::class) fun JSONObject.toLabeledValue(valueKey: String, labelKey: String): LabeledValue { val value = getString(valueKey) val label = optString(labelKey, value) val icon = optStringOrNull("icon")?.toOH2IconResource() - return LabeledValue(value, label, icon) + return LabeledValue(value, label, icon, optInt("row"), optInt("column")) } 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 845ddaca3e..6f524a1dfd 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt @@ -116,6 +116,7 @@ data class Widget( Video, Webview, Input, + Buttongrid, Unknown } @@ -303,7 +304,7 @@ fun Node.collectWidgets(parent: Widget?): List { "label" -> mappingLabel = childNode.textContent } } - mappings.add(LabeledValue(mappingCommand, mappingLabel, null)) + mappings.add(LabeledValue(mappingCommand, mappingLabel, null, 0, 0)) } else -> {} } diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/ViewExtensions.kt b/mobile/src/main/java/org/openhab/habdroid/ui/ViewExtensions.kt index 04eb5e276b..fd09116e10 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/ViewExtensions.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/ViewExtensions.kt @@ -124,6 +124,7 @@ fun RemoteViews.duplicate(): RemoteViews { } fun MaterialButton.setTextAndIcon(connection: Connection, mapping: LabeledValue) { + contentDescription = mapping.label val iconUrl = mapping.icon?.toUrl(context, true) if (iconUrl == null) { icon = null 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 1ece3b4ca8..d33aa5d132 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/WidgetAdapter.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/WidgetAdapter.kt @@ -32,6 +32,7 @@ import android.view.inputmethod.EditorInfo import android.webkit.WebChromeClient import android.webkit.WebView import android.widget.Button +import android.widget.GridLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -43,11 +44,11 @@ import androidx.core.content.ContextCompat import androidx.core.view.children import androidx.core.view.get import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.ContentLoadingProgressBar import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.DialogFragment -import androidx.recyclerview.widget.RecyclerView import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.datasource.DataSource @@ -58,6 +59,7 @@ import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.datepicker.MaterialDatePicker @@ -74,6 +76,7 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.temporal.ChronoUnit import java.util.Locale +import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope @@ -221,6 +224,7 @@ class WidgetAdapter( TYPE_LOCATION -> MapViewHelper.createViewHolder(initData) TYPE_INPUT -> InputViewHolder(initData) TYPE_DATETIMEINPUT -> DateTimeInputViewHolder(initData) + TYPE_BUTTONGRID -> ButtongridViewHolder(initData) TYPE_INVISIBLE -> InvisibleWidgetViewHolder(initData) else -> throw IllegalArgumentException("View type $viewType is not known") } @@ -345,6 +349,7 @@ class WidgetAdapter( Widget.Type.Colorpicker -> TYPE_COLOR Widget.Type.Mapview -> TYPE_LOCATION Widget.Type.Input -> if (widget.shouldUseDateTimePickerForInput()) TYPE_DATETIMEINPUT else TYPE_INPUT + Widget.Type.Buttongrid -> TYPE_BUTTONGRID else -> TYPE_GENERICITEM } return toInternalViewType(actualViewType, compactMode) @@ -780,6 +785,58 @@ class WidgetAdapter( } } + class ButtongridViewHolder internal constructor(private val initData: ViewHolderInitData) : + LabeledItemBaseViewHolder(initData, R.layout.widgetlist_buttongriditem), View.OnClickListener { + private val table: GridLayout = itemView.findViewById(R.id.widget_content) + private val spareViews = mutableListOf() + private val maxColumns = itemView.resources.getInteger(R.integer.section_switch_max_buttons) + + override fun bind(widget: Widget) { + super.bind(widget) + + val showLabelAndIcon = widget.label.isNotEmpty() + && widget.labelSource == Widget.LabelSource.SitemapDefinition + labelView.isVisible = showLabelAndIcon + iconView.isVisible = showLabelAndIcon + + val mappings = widget.mappings.filter { it.column != 0 && it.row != 0 } + spareViews.addAll(table.children.map { it as? MaterialButton }.filterNotNull()) + table.removeAllViews() + + table.rowCount = mappings.maxOfOrNull { it.row } ?: 0 + table.columnCount = min(mappings.maxOfOrNull { it.column } ?: 0, maxColumns) + (0 until table.rowCount).forEach { row -> + (0 until table.columnCount).forEach { column -> + val buttonView = spareViews.removeFirstOrNull() ?: + initData.inflater.inflate(R.layout.widgetlist_sectionswitchitem_button, table, false) + as MaterialButton + // Rows and columns start with 1 in Sitemap definition, thus decrement them here + val mapping = mappings.firstOrNull { it.row - 1 == row && it.column - 1 == column } + // Create invisible buttons if there's no mapping so each cell has an equal size + buttonView.isInvisible = mapping == null + if (mapping != null) { + buttonView.setOnClickListener(this) + buttonView.setTextAndIcon(connection, mapping) + buttonView.tag = mapping.value + buttonView.visibility = View.VISIBLE + } + + table.addView( + buttonView, + GridLayout.LayoutParams( + GridLayout.spec(row, GridLayout.FILL, 1f), + GridLayout.spec(column, GridLayout.FILL, 1f) + ) + ) + } + } + } + + override fun onClick(view: View) { + connection.httpClient.sendItemCommand(boundWidget?.item, view.tag as String) + } + } + class SliderViewHolder internal constructor(initData: ViewHolderInitData) : LabeledItemBaseViewHolder(initData, R.layout.widgetlist_slideritem, R.layout.widgetlist_slideritem_compact), WidgetSlider.UpdateListener { @@ -1555,7 +1612,8 @@ class WidgetAdapter( private const val TYPE_LOCATION = 18 private const val TYPE_INPUT = 19 private const val TYPE_DATETIMEINPUT = 20 - private const val TYPE_INVISIBLE = 21 + private const val TYPE_BUTTONGRID = 21 + private const val TYPE_INVISIBLE = 22 private fun toInternalViewType(viewType: Int, compactMode: Boolean): Int { return viewType or (if (compactMode) 0x100 else 0) diff --git a/mobile/src/main/res/layout/widgetlist_buttongriditem.xml b/mobile/src/main/res/layout/widgetlist_buttongriditem.xml new file mode 100644 index 0000000000..cab703e2a0 --- /dev/null +++ b/mobile/src/main/res/layout/widgetlist_buttongriditem.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt b/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt index 01adf9c0e0..de1b8904f4 100644 --- a/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt +++ b/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt @@ -107,8 +107,8 @@ class ItemTest { @Test fun getCommandOptions() { val sut = itemWithCommandOptions.toItem() - assertEquals(LabeledValue("1", "One", "switch".toOH2IconResource()), sut.options!!.component1()) - assertEquals(LabeledValue("2", "Two", null), sut.options!!.component2()) + assertEquals(LabeledValue("1", "One", "switch".toOH2IconResource(), 1, 2), sut.options!!.component1()) + assertEquals(LabeledValue("2", "Two", null, 0, 0), sut.options!!.component2()) } @Test @@ -209,7 +209,9 @@ class ItemTest { { 'command': '1', 'label': 'One', - 'icon': 'switch' + 'icon': 'switch', + 'row': 1, + 'column': 2 }, { 'command': '2',