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',