Image Card & Controls (work in progress)

Dear Openhabians,

I have been experimenting with some widgets and cards and what I was looking for is a widget that combines both a simple look and featuring more detailed controls when needed.

Please note that I am an absolute layman in terms of CSS or webdesign, all I know is what I’ve learned so far since the release of OH3.

Today I want to share my recently created widget that consists of a cell-like card with a background image, labels in the center content and chips in the footer, additionally, control buttons can be unfolded for quick actions.
ImageCardFold1_smaller1
To configure all of these in a versatile way, data is entered as JSON. With a separate switch item, more detailed controls in the form of f7-icon buttons can be unfolded by a click of the title or through changing the detail switch item to ON.

The current limitations are:

  • Label content: 3 labels with icons
  • Chip content: 10 chips with icons
  • Control lines 1&2: 6 buttons with icons each

Of course, you can change the ranges on the different oh-repeater components, but you might be hitting size limitations.
The items are aligned using a flex box.

Known issues & work in progress:

  • Labels and their icons get separated on line wrap
  • Chip text and icon colors might differ when using plain text colors (like “orange”)
  • Only call JSON.parse once per repeater as suggested by @JimT

Done:

  • Margin on both sides might not match the other Openhab cells - maybe somebody has advice on that? FIxed in v0.4, cards now have full width as expected
  • Dark mode looks ugly Fixed in v0.4

Updates

  • v0.4: Card width fixed, dark mode fixed, new modify option “static”: Display a static chip text instead of the item state
    image
    image

How To Use

How to create the JSON inputs:

If you don’t use any of the options (e.g. you don’t need Line 2), just fill it with

{}

That way, it won’t come up as undefined in the widget but will be hidden.

Chip Data

The chip JSON is shown with the following examples:

Example 1

{
	"0": {
		"item": "TTS_Temperatur",
		"icon": "thermometer",
		"textcolor": "orange"
	},
	"1": {
		"item": "H_Irrigation",
		"icon": "drop_triangle_fill",
		"textcolor": "blue",
		"visibleState": "ON"
	},
	"2": {
		"item": "MQ_PLUG1",
		"icon": "drop",
		"textcolor": "blue",
		"visibleState": "ON"
	},
	"3": {
		"item": "H_Auto_Irrigation_Active",
		"icon": "arrow_counterclockwise_circle_fill",
		"textcolor": "red",
		"visibleState": "ON"
	}
}

Example 2

{
	"0": {
		"item": "LocalSun_Rise_Start",
		"modify": "fromNow",
		"icon": "sunrise",
		"textcolor": "purple"
	},
	"1": {
		"item": "LocalSun_Set_Start",
		"modify": "fromNow",
		"icon": "sunset",
		"textcolor": "darkorange"
	},
        "2": {
		"item": "MC_Integrity",
		"icon": "exclamationmark_triangle",
		"textcolor": "red",
		"visibleState": "OFF",
		"modify": "static",
		"staticText": "ManCave"
	}
}

The function is described as follows:

  • item: Item state to display
  • icon: f7(!)-icon to display
  • textcolor: chip text & icon color
  • visibleState (optional): Only display the chip when it’s state matches this string (no above/below function yet)
  • modify: only two functions available up to now: dayjs.fromNow transformation for timestamps or static text

Label Data
This one is pretty self-explanatory:

{
	"0": {
		"item": "AqaraMQ_THPS3_TEMP",
		"icon": "thermometer"
	},
	"1": {
		"item": "AqaraMQ_THPS3_HUMIDITY",
		"icon": "drop"
	},
	"2": {
		"item": "AqaraMQ_THPS3_PRESSURE",
		"icon": "gauge"
	}
}

Control Button Line 1 & 2
There are two main options of what a button can do:

  • Send a command to an item
  • Open a page as popup (as shown in entry “2”)
{
	"0": {
		"item": "GF_LivingRoom_RollerShutter_BlindsControl ",
		"icon": "chevron_down",
		"oncolor": "orange",
		"action": "command",
		"actionCommand": "DOWN"
	},
	"1": {
		"item": "GF_Dining_RollerShutter_BlindsControl",
		"icon": "chevron_down",
		"oncolor": "orange",
		"action": "command",
		"actionCommand": "DOWN"
	},
	"2": {
		"item": "",
		"icon": "dial",
		"oncolor": "orange",
		"action": "popup",
		"actionModal": "page:Heat"
	},
	"3": {
		"item": "DachlukeBad_Switch",
		"icon": "square_arrow_up_fill",
		"oncolor": "purple",
		"action": "command",
		"actionCommand": "TOGGLE"
	}
}

Please keep in mind, that this is an experimental widget of someone who is trying to find his way through unknown territory (at least for me), so feel free to improve it and adapt it where you want.
I’ll probably start a github if this widget is something people would want.

Resources

Finally, here’s the code:

uid: ImageCardFold_v0.4
tags: []
props:
  parameters:
    - description: image for display
      label: Background Image
      name: image
      required: true
      type: TEXT
    - description: visible
      label: Visible
      name: visible
      required: false
      type: TEXT
    - context: item
      description: Detail item switch to expand controls
      label: Detail Switch Item
      name: detailItem
      required: false
      type: TEXT
    - description: Title Text
      label: Title
      name: ti
      required: false
      type: TEXT
    - description: Text Color
      label: Text Color
      name: tc
      required: false
      type: TEXT
    - description: Label as JSON
      label: Label Data
      name: labeljson
      required: true
      type: TEXT
    - description: Chip Data as JSON
      label: Chip Data
      name: chipjson
      required: true
      type: TEXT
    - description: Chip Background Color
      label: Chip Background
      name: cbg
      required: false
      type: TEXT
    - description: Line 1 Icons as JSON
      label: Line 1 Data
      name: l1json
      required: true
      type: TEXT
    - description: Line 2 Icons as JSON
      label: Line 2 Data
      name: l2json
      required: true
      type: TEXT
  parameterGroups: []
timestamp: Mar 12, 2021, 10:36:38 AM
component: f7-block
config:
  visible: =props.visible
  style:
    height: auto
    margin: 0
    padding: 0
slots:
  default:
    - component: f7-card
      config:
        style:
          background-image: ='url(' + props.image + ')'
          background-size: cover
          background-position: center
      slots:
        header:
          - component: f7-block
            config:
              style:
                background: transparent
                width: 100%
                height: auto
                margin: 0 auto
                padding: 0 auto
            slots:
              default:
                - component: oh-link
                  config:
                    style:
                      background: transparent
                      color: =props.tc
                      font-size: 200%
                    text: =props.ti
                    action: command
                    actionItem: =props.detailItem
                    actionCommand: TOGGLE
        default:
          - component: f7-card-content
            config:
              style:
                background: transparent
                min-height: 40px
                height: auto
                display: flex
                flex-direction: row
                flex-wrap: wrap
                flex-grow: 2
                flex-shrink: 1
                justify-content: space-around
                align-items: center
            slots:
              default:
                - component: oh-repeater
                  config:
                    visible: "=(props.labeljson === undefined) ? false : true"
                    sourceType: range
                    for: i
                    rangeStart: 0
                    rangeStop: 2
                    fragment: true
                  slots:
                    default:
                      - component: f7-icon
                        config:
                          visible: "=(JSON.parse(props.labeljson)[loop.i]['icon']) ? true : false"
                          f7: =JSON.parse(props.labeljson)[loop.i]["icon"]
                          style:
                            background: transparent
                            color: =props.tc
                            font-size: 200%
                      - component: Label
                        config:
                          visible: "=(JSON.parse(props.labeljson)[loop.i]) ? true : false"
                          style:
                            background: transparent
                            color: =props.tc
                            font-size: 200%
                            font-weight: bold
                          text: '=(items[(JSON.parse(props.labeljson)[loop.i]["item"])].displayState) ? items[(JSON.parse(props.labeljson)[loop.i]["item"])].displayState : items[(JSON.parse(props.labeljson)[loop.i]["item"])].state'
          - component: f7-card-content
            config:
              visible: "=(props.l1json === undefined) ? false : true"
              style:
                background: var(--f7-card-bg-color)
                box-shadow: inset 0px 3px 3px rgba(0,0,0,0.5)
                -webkit-transition: all 0.75s ease-in-out
                height: '=(items[props.detailItem].state == "ON")? "50px" : "0px"'
                margin: 0px
                padding: 0px
                overflow: hidden
                visibility: visible
                transform-origin: top
                transform: '=(items[props.detailItem].state == "ON")? "perspective(500px) rotateX(-0deg)" : "perspective(500px) rotateX(-90deg)"'
                display: flex
                flex-direction: row
                flex-wrap: wrap
                flex-grow: 2
                flex-shrink: 1
                justify-content: space-around
                align-items: center
            slots:
              default:
                - component: oh-repeater
                  config:
                    sourceType: range
                    for: i
                    rangeStart: 0
                    rangeStop: 5
                    fragment: true
                  slots:
                    default:
                      - component: f7-block
                        config:
                          visible: "=(JSON.parse(props.l1json)[loop.i]) ? true : false"
                          style:
                            background: lightgray
                            -webkit-transition: all 0.75s ease-in-out
                            margin: 0 auto
                            padding: 0 auto
                            display: flex
                            flex-direction: row
                            align-items: center
                            justify-content: center
                            text-align: center
                            border-radius: 50%
                            width: 35px
                            height: 35px
                            border: '=(items[(JSON.parse(props.l1json)[loop.i]["item"])].state == "ON") ? "solid 2px " + JSON.parse(props.l1json)[loop.i]["oncolor"] : "solid 2px lightgray"'
                            box-shadow: '=(items[(JSON.parse(props.l1json)[loop.i]["item"])].state == "ON") ? "1px 1px 5px rgba(0,0,0,0.5), inset 0px 0px 12px -5px " + JSON.parse(props.l1json)[loop.i]["oncolor"] : "1px 1px 10px rgba(0,0,0,0.5)"'
                        slots:
                          default:
                            - component: oh-link
                              config:
                                visible: "=(JSON.parse(props.l1json)[loop.i]) ? true : false"
                                iconOnly: false
                                iconF7: =(JSON.parse(props.l1json)[loop.i]["icon"])
                                iconColor: '=(items[(JSON.parse(props.l1json)[loop.i]["item"])].state == "ON") ? JSON.parse(props.l1json)[loop.i]["oncolor"] : "gray"'
                                action: =JSON.parse(props.l1json)[loop.i]["action"]
                                actionModal: =JSON.parse(props.l1json)[loop.i]["actionModal"]
                                actionItem: =JSON.parse(props.l1json)[loop.i]["item"]
                                actionCommand: =JSON.parse(props.l1json)[loop.i]["actionCommand"]
                                iconSize: 20
                                style:
                                  position: absolute
          - component: f7-card-content
            config:
              visible: "=(props.l2json === undefined) ? false : true"
              style:
                background: var(--f7-card-bg-color)
                box-shadow: =(items.WidgetDetail.state == "OFF")? "inset 0px 0px 0px rgba(0,0,0,0.5)":"inset 0px 1px 5px rgba(0,0,0,0.5)"
                -webkit-transition: all 0.75s ease-in-out
                height: '=(items[props.detailItem].state == "ON")? "50px" : "0px"'
                margin: 0px
                padding: 0px
                transform-origin: bottom
                transform: '=(items[props.detailItem].state == "ON")? "perspective(500px) rotateX(0deg)" : "perspective(500px) rotateX(90deg)"'
                display: flex
                flex-direction: row
                flex-wrap: wrap
                flex-grow: 2
                flex-shrink: 1
                justify-content: space-around
                align-items: center
            slots:
              default:
                - component: oh-repeater
                  config:
                    sourceType: range
                    for: i
                    rangeStart: 0
                    rangeStop: 5
                    fragment: true
                  slots:
                    default:
                      - component: f7-block
                        config:
                          visible: "=(JSON.parse(props.l2json)[loop.i]) ? true : false"
                          style:
                            background: lightgray
                            -webkit-transition: all 0.75s ease-in-out
                            margin: 0 auto
                            padding: 0 auto
                            display: flex
                            flex-direction: row
                            align-items: center
                            justify-content: center
                            text-align: center
                            border-radius: 50%
                            width: 35px
                            height: 35px
                            border: '=(items[(JSON.parse(props.l2json)[loop.i]["item"])].state == "ON") ? "solid 2px " + JSON.parse(props.l2json)[loop.i]["oncolor"] : "solid 2px lightgray"'
                            box-shadow: '=(items[(JSON.parse(props.l2json)[loop.i]["item"])].state == "ON") ? "1px 1px 5px rgba(0,0,0,0.5), inset 0px 0px 5px " + JSON.parse(props.l2json)[loop.i]["oncolor"] : "1px 1px 10px rgba(0,0,0,0.5)"'
                        slots:
                          default:
                            - component: oh-link
                              config:
                                visible: "=(JSON.parse(props.l2json)[loop.i]) ? true : false"
                                iconOnly: false
                                iconF7: =(JSON.parse(props.l2json)[loop.i]["icon"])
                                iconColor: '=(items[(JSON.parse(props.l2json)[loop.i]["item"])].state == "ON") ? JSON.parse(props.l2json)[loop.i]["oncolor"] : "gray"'
                                action: =JSON.parse(props.l2json)[loop.i]["action"]
                                actionModal: =JSON.parse(props.l2json)[loop.i]["actionModal"]
                                actionItem: =JSON.parse(props.l2json)[loop.i]["item"]
                                actionCommand: =JSON.parse(props.l2json)[loop.i]["actionCommand"]
                                iconSize: 20
                                style:
                                  position: absolute
          - component: f7-card-footer
            config:
              style:
                background: transparent
                display: flex
                flex-direction: row
                flex-wrap: wrap
                flex-grow: 2
                flex-shrink: 1
                justify-content: space-between
                -webkit-transition: all 0.75s ease-in
            slots:
              default:
                - component: oh-repeater
                  config:
                    sourceType: range
                    for: i
                    rangeStart: 0
                    rangeStop: 9
                    fragment: true
                  slots:
                    default:
                      - component: f7-chip
                        config:
                          visible: '=(JSON.parse(props.chipjson)[loop.i]) ? ((JSON.parse(props.chipjson)[loop.i]["visibleState"]) ? ((items[(JSON.parse(props.chipjson)[loop.i]["item"])].state == JSON.parse(props.chipjson)[loop.i]["visibleState"]) ? true : false) : true) : false'
                          text: '=(JSON.parse(props.chipjson)[loop.i]["modify"] == "fromNow") ? dayjs(items[(JSON.parse(props.chipjson)[loop.i]["item"])].state).fromNow() : ((JSON.parse(props.chipjson)[loop.i]["modify"] == "static") ? (JSON.parse(props.chipjson)[loop.i]["staticText"]) : items[(JSON.parse(props.chipjson)[loop.i]["item"])].state)'
                          iconF7: =(JSON.parse(props.chipjson)[loop.i]["icon"])
                          iconColor: '=(JSON.parse(props.chipjson)[loop.i]["textcolor"]) ? (JSON.parse(props.chipjson)[loop.i]["textcolor"]) : "white"'
                          style:
                            font-weight: bold
                            color: '=(JSON.parse(props.chipjson)[loop.i]["textcolor"]) ? (JSON.parse(props.chipjson)[loop.i]["textcolor"]) : "white"'
                            background: '=(JSON.parse(props.chipjson)[loop.i]["background"]) ? (JSON.parse(props.chipjson)[loop.i]["background"]) : props.cbg'

I hope it’ll work for you, too - i’d be happy to see some of those widgets combined with your great ideas!

6 Likes

Is there a way to just call JSON.parse() once, store the resulting object in a variable, and then use that variable in multiple places?

1 Like

Hmm very good idea! I’ll try that on the weekend! Last time I tried to create a variable I crashed my OH UI, must have done something wrong!

@BobMiles I found a neat solution to just calling JSON.parse once within oh-repeater:

                - component: oh-repeater
                  config:
                    visible: "=(props.labeljson === undefined) ? false : true"
                    sourceType: array
                    in: =JSON.parse(props.labeljson)
                    for: pick_yourarray_element_name
                    rangeStart: 0
                    rangeStop: 2
                    fragment: true
                  slots:
                    default:
                      - component: f7-icon
                        config:
                          visible: "=loop.pick_yourarray_element_name['icon']) ? true : false"
                          f7: =loop.pick_yourarray_element_name["icon"]
                          style:
                            background: transparent
                            color: =props.tc
                            font-size: 200%
                      - component: Label
                        config:
                          visible: "=loop.pick_yourarray_element_name ? true : false" # not quite sure here
                          style:
                            background: transparent
                            color: =props.tc
                            font-size: 200%
                            font-weight: bold
                          text: '=(items[(loop.pick_yourarray_element_name["item"])].displayState) ? items[(loop.pick_yourarray_element_name["item"])].displayState : items[(loop.pick_yourarray_element_name["item"])].state'

I was so happy to have discovered this!

2 Likes

Very cool! Thanks! I’ll integrate that into the original code!

I forgot to remove rangeStart and rangeStop - they aren’t needed of course.

This is indeed a smart solution!

Thanks for the feedback @JimT and @RGroll. I have just updated the code (v0.4) to implement:

  • Dark mode, kudos to Rainer for giving me the variable-hint for the background
  • Static text for chips that replaces the item state
  • Fixed the card width

I’m still working on the JSON-parse repeater calls in order to implement the elegant suggestion from @JimT