blog:2023:0719_homectrl_intf_redesign

HomeCtrl: Re-designing app interface

My homeCtrl app is finally running fine in Chrome on my android phone, but the interface is currently not very convinient, so I think I need to redesign that to make it easier to use from a phone or tablet or small screen. Let's get started right away!

Youtube video for this article available at:

Here is what the initial design of the app looked like by the way:

First let's start with a simple first row on the app showing a config button, then one button per server:

Next I thought it would be very good if I could just load an application level css file at start, so added support for that:

        // Load the application level style.css:
        auto& nvapp = NervApp::instance();
        String styleFile = get_path(nvapp.get_root_path(), "style.css");

        String content = read_text_file(styleFile.c_str());
        app.setStyleSheet(content.c_str());
/

With the code above I could introduce a dedicated style.css file to store the CSS data for all the widgets:

/* Main button style */
#MainButton {
    border: 2px solid rgb(152, 155, 170);
    background-color: #D3D3D3;
    border-radius: 5px;
    padding-left: 5px;
    padding-right: 5px;
    padding-top: 5px;
    padding-bottom: 5px;  
    color: black;   
    font-size: 18px;
    font-weight: 400;
    font-style: normal; 
}

#MainButton:hover {
    background-color: lightblue;
}

#ActionButton {
    border: 2px solid blue;
    background-color: #BCC6CC;
    border-radius: 10px;
    padding-left: 10px;
    padding-right: 10px;
    padding-top: 10px;
    padding-bottom: 10px;  
    color: black;   
    font-size: 16px;
    font-weight: 600;
    font-style: normal; 
}

Then I spent some time working my way to build the updated design of the application, and this is now rather looking like this:

One noticeable issue I had with the WASM implementation of the HomeView app thougn was the fact that everytime I would touch the screenn this would trigger the display of then virtual android keyboard, which was really a significant pain in fact.

Investigating on this issue, I found many report of this bug online, and eventually reached this page: https://bugreports.qt.io/browse/QTBUG-88803

I tried a few different incremental changes, but in the end, what I think really worked for me was to add the inputmode attribute on my canvas:

<canvas id="qtcanvas" oncontextmenu="event.preventDefault()" contenteditable="true" inputmode="none"></canvas>

Note that I have also updated the createCanvas() function in the qtloader.js file, so I'm not absolutely sure where the fixing came from:

    function createCanvas () {
        var canvas = document.createElement('canvas')
        canvas.className = 'QtCanvas'
        canvas.style.height = '100%'
        canvas.style.width = '100%'

        // Set contentEditable in order to enable clipboard events; hide the resulting focus frame.
        canvas.contentEditable = true
        canvas.inputMode = 'none'
        canvas.style.outline = '0px solid transparent'
        canvas.style.caretColor = 'transparent'
        canvas.style.cursor = 'default'

        return canvas
    }

But anyway, now the app behaves as expected: no more virtual keyboard popping up on every screen touch, but still the keyboard will correctly pop up when I touch text edit areas to write a login and password for instance, so we are all good on this point it seems 👍.

In my previous “android app” version for this tool I had been using toast messages to report success or failure of a particular command, and I wanted to keep using something similar here.

Problem is, I don't think there is anything like this available just out of the box from Qt. But there was some hope at least: my initial idea was to add a fully customized widget on top of the “MainPanel” of my application, but without any kind of layout to position it amongt the other sibling widget. So I have this code in the MainWindow constructor:

    // NOLINTNEXTLINE
    _toast = new QWidget(this);
    _toast->setObjectName("ToastPanel");

    // NOLINTNEXTLINE
    _toastTimer = new QTimer();

    // Connect the QTimer timeout signal to the callback function
    QObject::connect(_toastTimer, &QTimer::timeout, [this, panel]() {
        // logDEBUG("Hiding toast message.");
        _toast->hide(); // Hide the toast message when the timer times out
        // logDEBUG("Re-enabling panels");
        panel->enable_panels();
        // logDEBUG("Done");
    });

    // Create the floating widget
    auto* tlyt = new QVBoxLayout();
    _toast->setLayout(tlyt);

    _toastBtn = new QPushButton("Action done");
    _toastBtn->setIcon(_okIcon);
    _toastBtn->setObjectName("ToastButton");
    tlyt->addWidget(_toastBtn, 0, Qt::AlignCenter);

    _toast->hide();

And then I added a dedicated show_toast() function to display that widget:

void MainWindow::show_toast(Bool success, const String& message, U32 dur) {

    auto psize = size();
    I32 width = 300;
    I32 height = 70;
    I32 xoff = (psize.width() - width) / 2;
    I32 yoff = 80;

    _toast->setStyleSheet("");
    _toastBtn->setStyleSheet("");
    if (!success) {
        // _toast->setStyleSheet("border-color: #FF8C00;");
        _toast->setStyleSheet("border-color: #FDBD01;");
        _toastBtn->setStyleSheet("font-size: 16px;");
    }

    _toast->setGeometry(xoff, yoff, width, height);
    _toastBtn->setText(message.c_str());
    _toastBtn->setIcon(success ? _okIcon : _failIcon);
    _toastBtn->setIconSize({32, 32});
    // _toast->move(xoff, yoff);

    _toastTimer->setSingleShot(true); // Set the timer to be single-shot
    _toastTimer->setInterval((I32)dur);

    // Start the timer
    _toastTimer->start();

    // Showing toast message:
    // logDEBUG("Showing toast message.");
    _toast->show();
}

And it turned out that by then tweeting the CSS for this “toast widget” I could finally produce a result that looked great from my perspective:

Hmmm, but now I'm realizing when building the GIF image above that I don't get the warning icon shown when the credentials are wrong. Let's fix that. Okay, I just forgot to update the list of assets to be included in the WASM app actually:

  set(assetFiles
      "config.yml"
      "style.css"
      "assets/icons/gear.png"
      "assets/icons/server.png"
      "assets/icons/checked.png"
      "assets/icons/warning.png"
      "assets/icons/on-button.png"
      "assets/icons/off-button.png"
      "assets/icons/grid.png")

  setup_emscripten_qt_target(${TARGET_NAME} assetFiles ${TARGET_DIR})

Which also make me think now I should also add a similar “toast message” with a spinner once a command is sent to let the user know that this process was started (before we get the feedback from the server, which may take some time or even never happen) 🤔, Let's do it 😁!

Allright, in the process I also searched how one would display a gif animation in QT ⇒ for that we have to use the QMovie class, and I now have:

    _okIcon = create_pixmap("checked").scaled({32, 32}, Qt::KeepAspectRatio);
    _failIcon = create_pixmap("warning").scaled({32, 32}, Qt::KeepAspectRatio);

    _spinner = create_gif_movie("spinner2");

And now I have also introduced support for different type of toast messages, with different icons/style:

    switch (tmode) {
    case TOAST_OK:
        _toastIcon->setPixmap(_okIcon);
        _toast->setStyleSheet("border-color: #52D017;");
        break;
    case TOAST_WARNING:
    case TOAST_ERROR:
        _toastIcon->setPixmap(_failIcon);
        _toast->setStyleSheet("border-color: #FDBD01;");
        break;
    case TOAST_PROCESSING:
        _toastIcon->setMovie(_spinner.get());
        _toast->setStyleSheet("border-color: #625D5D;");
        _spinner->start();
        break;
    }

Important note: To get the .gif file to load properly, I had to also instance the plugins/imageformats/qgif.dll file in the desktop version of the app.

Similarly to get the gif image to show up in the WASM version we have to enable the integration of the QGIF plugin:

#ifdef __EMSCRIPTEN__
#include <QtCore/QtPlugin>

Q_IMPORT_PLUGIN(QWasmIntegrationPlugin)
Q_IMPORT_PLUGIN(QGifPlugin)
// Q_IMPORT_PLUGIN(QICNSPlugin)
// Q_IMPORT_PLUGIN(QICOPlugin)
// Q_IMPORT_PLUGIN(QJpegPlugin)
// Q_IMPORT_PLUGIN(QTgaPlugin)
// Q_IMPORT_PLUGIN(QTiffPlugin)
// Q_IMPORT_PLUGIN(QWbmpPlugin)
// Q_IMPORT_PLUGIN(QWebpPlugin)
// Q_IMPORT_PLUGIN(QEglFSEmulatorIntegrationPlugin)
#endif

And this means we also need to link to the library plugins/imageformats/libqgif.a to get the symbol qt_static_plugin_QGifPlugin()

I thought I would also need to link to “${QT6_DIR}/plugins/platforms/objects-Release/QGifPlugin_init/QGifPlugin_init.cpp.o”, but this doesn't seem to be necessary ?

Side note: As a quick bonus, I also figured out that I could delayed callback in QT simply with the following code (instead of having to build a QTimer object myself) [this will certainly prove very useful on the long run 😉]:

    QTimer::singleShot(8000, [this, rman, url, handler, req]() {
        rman->post_async(url.c_str(), req, handler, nullptr, nullptr,
                         _timeoutSecs);
    });

Now that I have a more robust mechanism to display the toast message, I think I could also prevent any post request in case the toast is still visible: this could be useful to avoid the possibility of the user clicking twice on a button before the first action is completed for instance:

    auto* win = MainWindow::instance();
    win->load_state_data();

    if (win->is_toast_visible()) {
        logWARN("Toast message still visible, cannot perform another post "
                "request yet.");
        return;
    }

I also discovered this website https://icons8.com/preloaders/ in the process, which is excellent to get some free/customizable gif animations 👍!

Here is the final result we got, with “processing” toast displaying an animated icon while the request is being sent/processed (note that in the git animation below I was intentionally slowing down the request processing, otherwise, we would just not see that at all when testing on a local network ;-)!):

Next I was trying to hide some of the more “technical” elements from the nex interface: for instance on the HomeCtrl page, only the action to activate our entrance gate is really needed on a day to day basis, the other elements, used to manually enable/disable the valves and pump in our solar heating system should only be needed for rare maintenance, and otherwise, should be activated/desactivated automatically.

But to achieve that, I first had to update the server side to report additional details in the JSON state data. Here is the main part of the update introduced at this step, mainly in gpio_manager.py:

    def get_current_state(self):
        "Retrieve the current state of all pins"
        state = {}
        pins = {}
        sensors = {}

        idx = 0
        for pname, pin in self.pins.items():
            desc = pin.get_desc()
            pins[pname] = {
                "state": pin.is_enabled(),
                "type": "out" if pin.is_output() else "in",
                "actions": pin.get_available_actions(),
                "advanced": desc.get("advanced", False),
                "index": idx,
            }
            idx += 1
            if "pin" in desc:
                pins[pname]["pin"] = desc["pin"]

        idx = 0
        for sname, sens in self.sensors.items():
            sensors[sname] = {
                "value": sens.get_value(),
                "unit": "°C",
                "index": idx,
                "prev_value": sens.get_prev_value(),
            }
            idx += 1

        state["pins"] = pins
        state["sensors"] = sensors

        return state

⇒ As reported above, I'm now introducing support for the advanced, index or prev_value entries. The “advanced” value is read directly from the config file, while the prev_value is dynamically updated and the “index” just give us the preferred order of display.

Note that, technically we should not really need the “index” value if we coudl simply read/write JSON data respecting the order of elements in arrays, but for some obscure reason it seems this order is not always respected when I'm loadin data from a JSON string on the NervLand C++ side (once we get the JSON response from our network requests) [this is to be investigated eventually]

from there it was fairly easy to add all the “advanced” widgets in a vector of widgets, to be able to toggle there visibility status when pressing the toggle display button:

void ServerPanel::toggle_advanced_widgets() {
    _showAdvanced = !_showAdvanced;
    for (auto* wid : _advancedWidgets) {
        wid->setVisible(_showAdvanced);
    }
}

With this change, I can hide the “advanced” widgets by default, which will give us the simplified initial view:

As a final touch on the app, I have also updated the favicon: at first I thought I would have to update the nginx site config to achieve this, but it turns out that it seems to work just out-of-the-box if you place a favicon.ico file in the homeview folder, and clear the browser cache on the client side, so really nothing to do here :-)!

  • blog/2023/0719_homectrl_intf_redesign.txt
  • Last modified: 2023/07/21 19:59
  • by 127.0.0.1