diff --git a/libnymea-app/devicemanager.cpp b/libnymea-app/devicemanager.cpp index 8d9ba643..b14e1852 100644 --- a/libnymea-app/devicemanager.cpp +++ b/libnymea-app/devicemanager.cpp @@ -76,7 +76,7 @@ void DeviceManager::init() qWarning() << "received an event from a device we don't know..." << deviceId << event; return; } - qDebug() << "Event received" << deviceId.toString() << eventTypeId.toString() << qUtf8Printable(QJsonDocument::fromVariant(event).toJson()); +// qDebug() << "Event received" << deviceId.toString() << eventTypeId.toString() << qUtf8Printable(QJsonDocument::fromVariant(event).toJson()); dev->eventTriggered(eventTypeId.toString(), event.value("params").toMap()); emit eventTriggered(deviceId.toString(), eventTypeId.toString(), event.value("params").toMap()); }); @@ -231,7 +231,7 @@ void DeviceManager::notificationReceived(const QVariantMap &data) qWarning() << "received an event from a device we don't know..." << deviceId << qUtf8Printable(QJsonDocument::fromVariant(data).toJson()); return; } - qDebug() << "Event received" << deviceId.toString() << eventTypeId.toString() << qUtf8Printable(QJsonDocument::fromVariant(event).toJson()); +// qDebug() << "Event received" << deviceId.toString() << eventTypeId.toString() << qUtf8Printable(QJsonDocument::fromVariant(event).toJson()); dev->eventTriggered(eventTypeId.toString(), event.value("params").toMap()); } else if (notification == "Integrations.IOConnectionAdded") { QVariantMap connectionMap = data.value("params").toMap().value("ioConnection").toMap(); @@ -750,7 +750,7 @@ void DeviceManager::executeBrowserItemActionResponse(const QVariantMap ¶ms) void DeviceManager::getIOConnectionsResponse(const QVariantMap ¶ms) { - qDebug() << "Get IO connections response" << qUtf8Printable(QJsonDocument::fromVariant(params).toJson()); +// qDebug() << "Get IO connections response" << qUtf8Printable(QJsonDocument::fromVariant(params).toJson()); foreach (const QVariant &connectionVariant, params.value("params").toMap().value("ioConnections").toList()) { QVariantMap connectionMap = connectionVariant.toMap(); diff --git a/libnymea-app/libnymea-app-core.h b/libnymea-app/libnymea-app-core.h index 1b6b0117..d0448342 100644 --- a/libnymea-app/libnymea-app-core.h +++ b/libnymea-app/libnymea-app-core.h @@ -92,6 +92,7 @@ #include "ruletemplates/ruleactionparamtemplate.h" #include "connection/awsclient.h" #include "models/devicemodel.h" +#include "models/sortfilterproxymodel.h" #include "system/systemcontroller.h" #include "types/package.h" #include "types/packages.h" @@ -177,8 +178,11 @@ void registerQmlTypes() { qmlRegisterType(uri, 1, 0, "VendorsProxy"); qmlRegisterUncreatableType(uri, 1, 0, "Device", "Can't create this in QML. Get it from the Devices."); + qmlRegisterUncreatableType(uri, 1, 0, "Thing", "Can't create this in QML. Get it from the Things."); qmlRegisterUncreatableType(uri, 1, 0, "Devices", "Can't create this in QML. Get it from the DeviceManager."); + qmlRegisterUncreatableType(uri, 1, 0, "Things", "Can't create this in QML. Get it from the ThingManager."); qmlRegisterType(uri, 1, 0, "DevicesProxy"); + qmlRegisterType(uri, 1, 0, "ThingsProxy"); qmlRegisterType(uri, 1, 0, "InterfacesModel"); qmlRegisterType(uri, 1, 0, "InterfacesSortModel"); @@ -311,6 +315,8 @@ void registerQmlTypes() { qmlRegisterUncreatableType(uri, 1, 0, "IOConnection", "Get it from IOConnections"); qmlRegisterType(uri, 1, 0, "IOInputConnectionWatcher"); qmlRegisterType(uri, 1, 0, "IOOutputConnectionWatcher"); + + qmlRegisterType(uri, 1, 0, "SortFilterProxyModel"); } #endif // LIBNYMEAAPPCORE_H diff --git a/libnymea-app/libnymea-app.pro b/libnymea-app/libnymea-app.pro index 8e3e1925..4a7233f4 100644 --- a/libnymea-app/libnymea-app.pro +++ b/libnymea-app/libnymea-app.pro @@ -27,6 +27,7 @@ INCLUDEPATH += $$top_srcdir/QtZeroConf SOURCES += \ configuration/networkmanager.cpp \ engine.cpp \ + models/sortfilterproxymodel.cpp \ ruletemplates/calendaritemtemplate.cpp \ ruletemplates/timedescriptortemplate.cpp \ ruletemplates/timeeventitemtemplate.cpp \ @@ -162,6 +163,7 @@ SOURCES += \ HEADERS += \ configuration/networkmanager.h \ engine.h \ + models/sortfilterproxymodel.h \ ruletemplates/calendaritemtemplate.h \ ruletemplates/timedescriptortemplate.h \ ruletemplates/timeeventitemtemplate.h \ diff --git a/libnymea-app/models/sortfilterproxymodel.cpp b/libnymea-app/models/sortfilterproxymodel.cpp new file mode 100644 index 00000000..d2e3f9f0 --- /dev/null +++ b/libnymea-app/models/sortfilterproxymodel.cpp @@ -0,0 +1,62 @@ +#include "sortfilterproxymodel.h" + +#include + +SortFilterProxyModel::SortFilterProxyModel(QObject *parent) : QSortFilterProxyModel(parent) +{ + connect(this, &QSortFilterProxyModel::sourceModelChanged, this, [=](){ + connect(sourceModel(), &QAbstractItemModel::rowsInserted, this, &SortFilterProxyModel::countChanged); + connect(sourceModel(), &QAbstractItemModel::rowsRemoved, this, &SortFilterProxyModel::countChanged); + connect(sourceModel(), &QAbstractItemModel::modelReset, this, &SortFilterProxyModel::countChanged); + emit countChanged(); + }); +} + +QString SortFilterProxyModel::filterRoleName() const +{ + return m_filterRoleName; +} + +void SortFilterProxyModel::setFilterRoleName(const QString &filterRoleName) +{ + if (m_filterRoleName != filterRoleName) { + m_filterRoleName = filterRoleName; + emit filterRoleNameChanged(); + invalidateFilter(); + emit countChanged(); + } +} + +QStringList SortFilterProxyModel::filterList() const +{ + return m_filterList; +} + +void SortFilterProxyModel::setFilterList(const QStringList &filterList) +{ + if (m_filterList != filterList) { + m_filterList = filterList; + emit filterListChanged(); + invalidateFilter(); + emit countChanged(); + } +} + +QVariant SortFilterProxyModel::data(int row, const QString &role) const +{ + int roleId = roleNames().key(role.toUtf8()); + return QSortFilterProxyModel::data(index(row, 0), roleId); +} + +bool SortFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + if (!m_filterList.isEmpty() && !m_filterRoleName.isEmpty()) { + QModelIndex idx = sourceModel()->index(source_row, 0, source_parent); + int filterRole = sourceModel()->roleNames().key(m_filterRoleName.toUtf8()); + QVariant data = sourceModel()->data(idx, filterRole); + if (!m_filterList.contains(data.toString())) { + return false; + } + } + return QSortFilterProxyModel::filterAcceptsRow(source_row, source_parent); +} diff --git a/libnymea-app/models/sortfilterproxymodel.h b/libnymea-app/models/sortfilterproxymodel.h new file mode 100644 index 00000000..02dccaba --- /dev/null +++ b/libnymea-app/models/sortfilterproxymodel.h @@ -0,0 +1,37 @@ +#ifndef SORTFILTERPROXYMODEL_H +#define SORTFILTERPROXYMODEL_H + +#include + +class SortFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + Q_PROPERTY(QString filterRoleName READ filterRoleName WRITE setFilterRoleName NOTIFY filterRoleNameChanged) + Q_PROPERTY(QStringList filterList READ filterList WRITE setFilterList NOTIFY filterListChanged) + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) + +public: + explicit SortFilterProxyModel(QObject *parent = nullptr); + + QString filterRoleName() const; + void setFilterRoleName(const QString &filterRoleName); + + QStringList filterList() const; + void setFilterList(const QStringList &filterList); + + Q_INVOKABLE QVariant data(int row, const QString &role) const; + +signals: + void filterRoleNameChanged(); + void filterListChanged(); + void countChanged(); + +protected: + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + +private: + QString m_filterRoleName; + QStringList m_filterList; +}; + +#endif // SORTFILTERPROXYMODEL_H diff --git a/nymea-app/images.qrc b/nymea-app/images.qrc index 15fd48a9..e49974da 100644 --- a/nymea-app/images.qrc +++ b/nymea-app/images.qrc @@ -129,7 +129,7 @@ ui/images/send.svg ui/images/sensors.svg ui/images/settings.svg - ui/images/share.svg + ui/images/things.svg ui/images/slideshow.svg ui/images/closable-move.svg ui/images/starred.svg @@ -234,5 +234,6 @@ ui/images/garage/garage-100.svg ui/images/navigationpad.svg ui/images/qrcode.svg + ui/images/energy.svg diff --git a/nymea-app/mainmenumodel.cpp b/nymea-app/mainmenumodel.cpp new file mode 100644 index 00000000..d18cc578 --- /dev/null +++ b/nymea-app/mainmenumodel.cpp @@ -0,0 +1,12 @@ +#include "mainmenumodel.h" + +MainMenuModel::MainMenuModel(QObject *parent) : QAbstractListModel(parent) +{ + +} + +int MainMenuModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return m_list.count(); +} diff --git a/nymea-app/mainmenumodel.h b/nymea-app/mainmenumodel.h new file mode 100644 index 00000000..f90cd3de --- /dev/null +++ b/nymea-app/mainmenumodel.h @@ -0,0 +1,24 @@ +#ifndef MAINMENUMODEL_H +#define MAINMENUMODEL_H + +#include + +class MainMenuItem: public QObject +{ + Q_OBJECT + +}; + +class MainMenuModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit MainMenuModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + +private: + QList m_list; +}; + +#endif // MAINMENUMODEL_H diff --git a/nymea-app/nymea-app.pro b/nymea-app/nymea-app.pro index a699195f..4be2cba9 100644 --- a/nymea-app/nymea-app.pro +++ b/nymea-app/nymea-app.pro @@ -13,6 +13,7 @@ linux:!android:!nozeroconf:LIBS += -lavahi-client -lavahi-common PRE_TARGETDEPS += ../libnymea-app HEADERS += \ + mainmenumodel.h \ platformintegration/generic/raspberrypihelper.h \ stylecontroller.h \ pushnotifications.h \ @@ -22,6 +23,7 @@ HEADERS += \ ruletemplates/messages.h SOURCES += main.cpp \ + mainmenumodel.cpp \ platformintegration/generic/raspberrypihelper.cpp \ stylecontroller.cpp \ pushnotifications.cpp \ diff --git a/nymea-app/resources.qrc b/nymea-app/resources.qrc index 0a5a92aa..3690d04a 100644 --- a/nymea-app/resources.qrc +++ b/nymea-app/resources.qrc @@ -10,8 +10,7 @@ ui/RootItem.qml ui/mainviews/ScenesView.qml ui/mainviews/FavoritesView.qml - ui/mainviews/DevicesPageDelegate.qml - ui/mainviews/DevicesPage.qml + ui/mainviews/ThingsView.qml ui/connection/ConnectPage.qml ui/connection/ManualConnectPage.qml ui/connection/ConnectingPage.qml @@ -108,6 +107,7 @@ ui/delegates/ParamDelegate.qml ui/delegates/ActionDelegate.qml ui/delegates/ThingDelegate.qml + ui/delegates/InterfaceTile.qml ui/system/LogViewerPage.qml ui/system/PluginsPage.qml ui/system/PluginParamsPage.qml @@ -218,5 +218,9 @@ ui/thingconfiguration/ThingClassDetailsPage.qml ui/components/ClosablesControlLarge.qml ui/devicepages/BarcodeScannerThingPage.qml + ui/mainviews/GaragesView.qml + ui/mainviews/EnergyView.qml + ui/components/MainViewBase.qml + ui/components/SmartMeterChart.qml diff --git a/nymea-app/ui/MainPage.qml b/nymea-app/ui/MainPage.qml index 0bb0096c..21e44622 100644 --- a/nymea-app/ui/MainPage.qml +++ b/nymea-app/ui/MainPage.qml @@ -33,6 +33,8 @@ import QtQuick.Controls 2.2 import QtQuick.Controls.Material 2.1 import QtQuick.Layouts 1.2 import QtQuick.Window 2.3 +import Qt.labs.settings 1.0 +import Qt.labs.folderlistmodel 2.2 import Nymea 1.0 import "components" import "delegates" @@ -42,7 +44,8 @@ Page { id: root header: FancyHeader { - title: swipeView.currentItem.title + id: mainHeader + title: filteredContentModel.data(swipeView.currentIndex, "displayName") leftButtonVisible: true leftButtonImageSource: { switch (engine.jsonRpcClient.currentConnection.bearerType) { @@ -65,10 +68,14 @@ Page { var dialog = connectionDialogComponent.createObject(root) dialog.open(); } - + onMenuOpenChanged: { + if (menuOpen && d.configOverlay) { + d.configOverlay.destroy() + } + } model: ListModel { - ListElement { iconSource: "../images/share.svg"; text: qsTr("Configure things"); page: "thingconfiguration/EditThingsPage.qml" } + ListElement { iconSource: "../images/things.svg"; text: qsTr("Configure things"); page: "thingconfiguration/EditThingsPage.qml" } ListElement { iconSource: "../images/magic.svg"; text: qsTr("Magic"); page: "MagicPage.qml" } ListElement { iconSource: "../images/stock_application.svg"; text: qsTr("App settings"); page: "appsettings/AppSettingsPage.qml" } ListElement { iconSource: "../images/settings.svg"; text: qsTr("System settings"); page: "SettingsPage.qml" } @@ -79,84 +86,6 @@ Page { } } - property int currentViewIndex: 0 - - property bool swipeViewReady: false - property bool tabsReady: false - - // FIXME: All this can go away when we require Controls 2.3 (Qt 5.10) or greater as TabBar got a major rework there. - // Ideally we'd just list the 3 items and set visible to false if the server version isn't good enough but TabBar - // has troubles dealing with that. For now, let's manually fill it and use a timer to initialize the currentIndex. - Component.onCompleted: { - // Fill SwipeView (The 2 static views things and scenes will already be there). - if (engine.jsonRpcClient.ensureServerVersion(1.6)) { - swipeView.insertItem(0, favoritesViewComponent.createObject(swipeView)) - } - var experienceView = null; - if (styleController.currentExperience != "Default") { - experienceView = experienceViewComponent.createObject(swipeView, {source: "experiences/" + styleController.currentExperience + "/Main.qml" }); - swipeView.insertItem(0, experienceView) - } - root.swipeViewReady = true; - - - var pi = 0; - if (experienceView) { - tabEntryComponent.createObject(tabBar, {text: experienceView.title, iconSource: experienceView.icon, pageIndex: pi++}) - } - if (engine.jsonRpcClient.ensureServerVersion(1.6)) { - tabEntryComponent.createObject(tabBar, {text: qsTr("Favorites"), iconSource: "../images/starred.svg", pageIndex: pi++}) - } - tabEntryComponent.createObject(tabBar, {text: qsTr("Things"), iconSource: "../images/share.svg", pageIndex: pi++}) - tabEntryComponent.createObject(tabBar, {text: qsTr("Scenes"), iconSource: "../images/slideshow.svg", pageIndex: pi++}) - if (engine.jsonRpcClient.ensureServerVersion(1.6)) { - tabEntryComponent.createObject(tabBar, {text: qsTr("Groups"), iconSource: "../images/view-grid-symbolic.svg", pageIndex: pi++}) - } - - root.tabsReady = true - } - - readonly property bool viewReady: swipeViewReady && tabsReady - onViewReadyChanged: { - if (tabSettings.currentMainViewIndex > swipeView.count) { - tabSettings.currentMainViewIndex = swipeView.count - 1; - } - - // Load current index from settings - currentViewIndex = tabSettings.currentMainViewIndex; - - // If setting is not initialized yet, init to "Things" page (might be 0 or 1, depending whether we have tags support) - if (currentViewIndex === -1) { - currentViewIndex = engine.jsonRpcClient.ensureServerVersion(1.6) ? 1 : 0 - } - - // and set up a binding to sync changes back to the settings - tabSettings.currentMainViewIndex = Qt.binding(function() { return root.currentViewIndex; }); - - // Tabbar gets a little confused if it's bound to it before the init happened, do it now - tabBar.currentIndex = Qt.binding(function() { return root.currentViewIndex; }); - } - - // FIXME: Currently we don't have any feedback for executeAction - // we don't want all the results, e.g. on looped calls like "all off" - // Connections { - // target: engine.deviceManager - // onExecuteActionReply: { - // var text = params["deviceError"] - // switch(text) { - // case "DeviceErrorNoError": - // return; - // case "DeviceErrorHardwareNotAvailable": - // text = qsTr("Could not execute action. The thing is not available"); - // break; - // } - - // var errorDialog = Qt.createComponent(Qt.resolvedUrl("components/ErrorDialog.qml")) - // var popup = errorDialog.createObject(root, {text: text}) - // popup.open() - // } - // } - Connections { target: engine.ruleManager onAddRuleReply: { @@ -170,6 +99,69 @@ Page { QtObject { id: d property var editRulePage: null + property var configOverlay: null + } + + Settings { + id: mainViewSettings + category: engine.jsonRpcClient.currentHost.uuid + property string mainMenuContent: "" + property var sortOrder: [] + property var filterList: ["things"] + property int currentIndex: 0 + } + + ListModel { + id: mainMenuBaseModel + // TODO: Should read this from disk somehow maybe? + ListElement { name: "things"; source: "ThingsView"; displayName: qsTr("Things"); icon: "things" } + ListElement { name: "favorites"; source: "FavoritesView"; displayName: qsTr("Favorites"); icon: "starred" } + ListElement { name: "groups"; source: "GroupsView"; displayName: qsTr("Groups"); icon: "view-grid-symbolic" } + ListElement { name: "scenes"; source: "ScenesView"; displayName: qsTr("Scenes"); icon: "slideshow" } + ListElement { name: "garages"; source: "GaragesView"; displayName: qsTr("Garages"); icon: "garage/garage-100" } + ListElement { name: "energy"; source: "EnergyView"; displayName: qsTr("Energy"); icon: "smartmeter" } + } + + ListModel { + id: mainMenuModel + ListElement { name: "dummy"; source: "Dummy"; displayName: ""; icon: "" } + + Component.onCompleted: { + var configList = {} + var newList = {} + var newItems = 0 + for (var i = 0; i < mainMenuBaseModel.count; i++) { + var item = mainMenuBaseModel.get(i); + var idx = mainViewSettings.sortOrder.indexOf(item.name); + if (idx === -1) { + newList[newItems++] = item; + } else { + configList[idx] = item; + } + } + + clear(); + + for (idx in configList) { + item = configList[idx]; + mainMenuModel.append(item) + } + for (idx in newList) { + item = newList[idx]; + mainMenuModel.append(item) + } + + tabBar.currentIndex = Qt.binding(function() { return mainViewSettings.currentIndex; }) + swipeView.currentIndex = Qt.binding(function() { return tabBar.currentIndex; }) + mainViewSettings.currentIndex = Qt.binding(function() { return swipeView.currentIndex; }) + } + } + + SortFilterProxyModel { + id: filteredContentModel + sourceModel: mainMenuModel + filterList: mainViewSettings.filterList + filterRoleName: "name" } ColumnLayout { @@ -226,8 +218,8 @@ Page { } } - Item { + id: contentContainer Layout.fillWidth: true Layout.fillHeight: true clip: true @@ -235,131 +227,17 @@ Page { SwipeView { id: swipeView anchors.fill: parent - currentIndex: root.currentViewIndex + opacity: d.configOverlay === null ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } } - onCurrentIndexChanged: { - root.currentViewIndex = currentIndex - } + Repeater { + model: filteredContentModel - Component { - id: experienceViewComponent - Loader { + delegate: Loader { width: swipeView.width height: swipeView.height clip: true - readonly property string title: item ? item.title : "" - readonly property string icon: item ? item.icon : "" - } - } - - Component { - id: favoritesViewComponent - FavoritesView { - id: favoritesView - objectName: "favorites" - width: swipeView.width - height: swipeView.height - property string title: qsTr("My favorites") - - EmptyViewPlaceholder { - anchors { left: parent.left; right: parent.right; margins: app.margins } - anchors.verticalCenter: parent.verticalCenter - visible: favoritesView.count === 0 && !engine.deviceManager.fetchingData - title: qsTr("There are no favorite things yet.") - text: engine.deviceManager.devices.count === 0 ? - qsTr("It appears there are no things set up either yet. In order to use favorites you need to add some things first.") : - qsTr("Favorites allow you to keep track of your most important things when you have lots of them. Watch out for the star when interacting with things and use it to mark them as your favorites.") - imageSource: "images/starred.svg" - buttonVisible: engine.deviceManager.devices.count === 0 - buttonText: qsTr("Add a thing") - onButtonClicked: pageStack.push(Qt.resolvedUrl("thingconfiguration/NewThingPage.qml")) - } - - } - } - - DevicesPage { - property string title: qsTr("My things") - width: swipeView.width - height: swipeView.height - model: InterfacesSortModel { - interfacesModel: InterfacesModel { - engine: _engine - devices: DevicesProxy { - engine: _engine - } - shownInterfaces: app.supportedInterfaces - showUncategorized: true - } - } - - EmptyViewPlaceholder { - anchors { left: parent.left; right: parent.right; margins: app.margins } - anchors.verticalCenter: parent.verticalCenter - visible: engine.deviceManager.devices.count === 0 && !engine.deviceManager.fetchingData - title: qsTr("Welcome to %1!").arg(app.systemName) - // Have that split in 2 because we need those strings separated in EditDevicesPage too and don't want translators to do them twice - text: qsTr("There are no things set up yet.") + "\n" + qsTr("In order for your %1 system to be useful, go ahead and add some things.").arg(app.systemName) - imageSource: "qrc:/styles/%1/logo.svg".arg(styleController.currentStyle) - buttonText: qsTr("Add a thing") - onButtonClicked: pageStack.push(Qt.resolvedUrl("thingconfiguration/NewThingPage.qml")) - } - } - - ScenesView { - id: scenesView - property string title: qsTr("My scenes"); - width: swipeView.width - height: swipeView.height - - EmptyViewPlaceholder { - anchors { left: parent.left; right: parent.right; margins: app.margins } - anchors.verticalCenter: parent.verticalCenter - visible: scenesView.count === 0 && !engine.deviceManager.fetchingData - title: qsTr("There are no scenes set up yet.") - text: engine.deviceManager.devices.count === 0 ? - qsTr("It appears there are no things set up either yet. In order to use scenes you need to add some things first.") : - qsTr("Scenes provide a useful way to control your things with just one click.") - imageSource: "images/slideshow.svg" - buttonText: engine.deviceManager.devices.count === 0 ? qsTr("Add a thing") : qsTr("Add a scene") - onButtonClicked: { - if (engine.deviceManager.devices.count === 0) { - pageStack.push(Qt.resolvedUrl("thingconfiguration/NewThingPage.qml")) - } else { - var newRule = engine.ruleManager.createNewRule(); - d.editRulePage = pageStack.push(Qt.resolvedUrl("magic/EditRulePage.qml"), {rule: newRule }); - d.editRulePage.startAddAction(); - d.editRulePage.StackView.onRemoved.connect(function() { - newRule.destroy(); - }) - d.editRulePage.onAccept.connect(function() { - d.editRulePage.busy = true; - engine.ruleManager.addRule(d.editRulePage.rule); - }) - d.editRulePage.onCancel.connect(function() { - pageStack.pop(); - }) - } - } - } - } - - GroupsView { - id: groupsView - property string title: qsTr("My groups"); - width: swipeView.width - height: swipeView.height - - EmptyViewPlaceholder { - anchors { left: parent.left; right: parent.right; margins: app.margins } - anchors.verticalCenter: parent.verticalCenter - visible: groupsView.count == 0 && !engine.deviceManager.fetchingData && !engine.tagsManager.busy - title: qsTr("There are no groups set up yet.") - text: qsTr("Grouping things can be useful to control multiple devices at once, for example an entire room. Watch out for the group symbol when interacting with things and use it to add them to groups.") - imageSource: "images/view-grid-symbolic.svg" - buttonVisible: false -// buttonText: qsTr("Create a group") -// onButtonClicked: pageStack.push(Qt.resolvedUrl("thingconfiguration/NewThingPage.qml")) + source: "mainviews/" + model.source + ".qml" } } } @@ -383,19 +261,300 @@ Page { } } - footer: TabBar { - id: tabBar - Material.elevation: 3 - position: TabBar.Footer - implicitHeight: 70 + (app.landscape ? -20 : 0) + footer: Item { + readonly property bool shown: tabsRepeater.count > 1 || mainHeader.menuOpen || d.configOverlay + implicitHeight: shown ? 70 + (app.landscape ? -20 : 0) : 0 + Behavior on implicitHeight { NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }} + clip: true - Component { - id: tabEntryComponent - MainPageTabButton { - property int pageIndex: 0 -// height: tabBar.height - onClicked: root.currentViewIndex = pageIndex - alignment: app.landscape ? Qt.Horizontal : Qt.Vertical + TabBar { + id: tabBar + anchors.fill: parent + Material.elevation: 3 + position: TabBar.Footer + + visible: !mainHeader.menuOpen && !d.configOverlay + opacity: d.configOverlay === null ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } } + + Repeater { + id: tabsRepeater + model: filteredContentModel + + delegate: MainPageTabButton { + alignment: app.landscape ? Qt.Horizontal : Qt.Vertical + height: tabBar.height + anchors.verticalCenter: parent.verticalCenter + text: model.displayName + iconSource: "../images/" + model.icon + ".svg" + + onPressAndHold: { + PlatformHelper.vibrate(PlatformHelper.HapticsFeedbackSelection) + d.configOverlay = configComponent.createObject(contentContainer) + mainHeader.menuOpen = false; + } + } + } + } + + MainPageTabButton { + anchors.fill: parent + alignment: app.landscape ? Qt.Horizontal : Qt.Vertical + text: d.configOverlay ? qsTr("Done") : qsTr("Configure") + iconSource: "../images/configure.svg" + opacity: visible ? 1 : 0 + visible: mainHeader.menuOpen || d.configOverlay + Behavior on opacity { NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } } + + checked: false + checkable: false + + onClicked: { + if (d.configOverlay) { + d.configOverlay.destroy() + } else { + PlatformHelper.vibrate(PlatformHelper.HapticsFeedbackSelection) + d.configOverlay = configComponent.createObject(contentContainer) + mainHeader.menuOpen = false; + } + } + } + } + + Component { + id: configComponent + Item { + id: configOverlay + width: contentContainer.width + height: contentContainer.height + + NumberAnimation { + target: configOverlay + property: "scale" + duration: 200 + easing.type: Easing.InOutQuad + from: 2 + to: 1 + running: true + } + NumberAnimation { + target: configOverlay + property: "opacity" + duration: 200 + easing.type: Easing.InOutQuad + from: 0 + to: 1 + running: true + } + + ListView { + id: configListView + model: mainMenuModel + width: parent.width + height: parent.height / 2.5 + anchors.centerIn: parent + orientation: ListView.Horizontal + moveDisplaced: Transition { + NumberAnimation { properties: "x,y"; duration: 200 } + } + + property int delegateWidth: width / 2.5 + + property bool dragging: draggingIndex >= 0 + property int draggingIndex : -1 + + MouseArea { + id: dndArea + anchors.fill: parent + preventStealing: configListView.dragging + property int dragOffset: 0 + + onPressAndHold: { + mouse.accepted = true + var mouseXInListView = configListView.contentItem.mapFromItem(dndArea, mouseX, mouseY).x; + configListView.draggingIndex = configListView.indexAt(mouseXInListView, mouseY) + var item = mainMenuModel.get(configListView.draggingIndex) + dndItem.displayName = item.displayName + dndItem.icon = item.icon + var visualItem = configListView.itemAt(mouseXInListView, mouseY) + dndItem.isEnabled = visualItem.isEnabled + dndArea.dragOffset = configListView.mapToItem(visualItem, mouseX, mouseY).x + PlatformHelper.vibrate(PlatformHelper.HapticsFeedbackImpact) + } + onMouseYChanged: { + if (configListView.dragging) { + var mouseXInListView = configListView.contentItem.mapFromItem(dndArea, mouseX, mouseY).x; + var indexUnderMouse = configListView.indexAt(mouseXInListView - dndArea.dragOffset / 2, mouseY) + indexUnderMouse = Math.min(Math.max(0, indexUnderMouse), configListView.count - 1) + if (configListView.draggingIndex !== indexUnderMouse) { + PlatformHelper.vibrate(PlatformHelper.HapticsFeedbackSelection) + mainMenuModel.move(configListView.draggingIndex, indexUnderMouse, 1) + configListView.draggingIndex = indexUnderMouse; + } + } + } + onReleased: { + print("released!") + var mouseXInListView = configListView.contentItem.mapFromItem(dndArea, mouseX, mouseY).x; + var clickedIndex = configListView.indexAt(mouseXInListView, mouseY) + var item = mainMenuModel.get(clickedIndex) + var isEnabled = mainViewSettings.filterList.indexOf(item.name) >= 0; + if (!configListView.dragging) { + var newList = [] + for (var i = 0; i < mainMenuModel.count; i++) { + var entry = mainMenuModel.get(i).name; + if (entry === item.name) { + if (!isEnabled) { + newList.push(item.name) + } + } else { + if (mainViewSettings.filterList.indexOf(entry) >= 0) { + newList.push(entry) + } + } + } + if (newList.length === 0) { + newList.push("things") + } + + mainViewSettings.filterList = newList + } + configListView.draggingIndex = -1; + + var newSortOrder = [] + for (var i = 0; i < mainMenuModel.count; i++) { + newSortOrder.push(mainMenuModel.get(i).name) + } + mainViewSettings.sortOrder = newSortOrder; + } + Timer { + id: scroller + interval: 2 + repeat: true + running: direction != 0 + property int direction: { + if (!configListView.dragging) { + return 0; + } + return dndArea.mouseX < 50 ? -1 : dndArea.mouseX > dndArea.width - 50 ? 1 : 0 + } + onTriggered: { + configListView.contentX = Math.min(Math.max(0, configListView.contentX + direction), configListView.contentWidth - configListView.width) + } + } + } + + delegate: Item { + id: configDelegate + width: configListView.delegateWidth + height: configListView.height + property bool isEnabled: mainViewSettings.filterList.indexOf(model.name) >= 0 + visible: configListView.draggingIndex !== index + + Pane { + anchors.fill: parent + anchors.margins: app.margins / 2 + Material.elevation: 2 + + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + + contentItem: ItemDelegate { + anchors.fill: parent + + padding: app.margins * 2 + contentItem: GridLayout { + columns: 1 + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + ColorIcon { + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) * .8 + height: width + name: Qt.resolvedUrl("images/" + model.icon + ".svg") + color: configDelegate.isEnabled ? app.accentColor : keyColor + } + } + + + Label { + text: model.displayName + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + font.pixelSize: app.largeFont + } + } + } + } + } + Item { + id: dndItem + width: configListView.delegateWidth + height: configListView.height + property bool isEnabled: false + property string displayName: "" + property string icon: "things" + visible: configListView.dragging + x: dndArea.mouseX - dndArea.dragOffset + onVisibleChanged: { + if (visible) { + dragStartAnimation.start(); + } + } + + NumberAnimation { + id: dragStartAnimation + target: dndItem + property: "scale" + from: 1 + to: 0.9 + duration: 200 + } + + Pane { + anchors.fill: parent + anchors.margins: app.margins / 2 + Material.elevation: 2 + + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + + contentItem: ItemDelegate { + anchors.fill: parent + + padding: app.margins * 2 + contentItem: GridLayout { + columns: 1 + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + ColorIcon { + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) * .8 + height: width + name: Qt.resolvedUrl("images/" + dndItem.icon + ".svg") + color: dndItem.isEnabled ? app.accentColor : keyColor + } + } + + + Label { + text: dndItem.displayName + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + font.pixelSize: app.largeFont + } + } + } + } + } } } } @@ -453,7 +612,7 @@ Page { font.pixelSize: app.smallFont elide: Text.ElideRight color: Material.color(Material.Grey) -// horizontalAlignment: Text.AlignHCenter + // horizontalAlignment: Text.AlignHCenter } Label { Layout.fillWidth: true @@ -461,7 +620,7 @@ Page { font.pixelSize: app.smallFont elide: Text.ElideRight color: Material.color(Material.Grey) -// horizontalAlignment: Text.AlignHCenter + // horizontalAlignment: Text.AlignHCenter } } ColorIcon { diff --git a/nymea-app/ui/Nymea.qml b/nymea-app/ui/Nymea.qml index 2b2918bb..90843525 100644 --- a/nymea-app/ui/Nymea.qml +++ b/nymea-app/ui/Nymea.qml @@ -33,6 +33,7 @@ import QtQuick.Controls 2.2 import QtQuick.Controls.Material 2.1 import QtQuick.Layouts 1.2 import Qt.labs.settings 1.0 +import Qt.labs.folderlistmodel 2.2 import QtQuick.Window 2.3 import Nymea 1.0 @@ -533,6 +534,11 @@ ApplicationWindow { onStateChanged: closeTimer.stop() } + FolderListModel { + id: availableMainViews + folder: "mainviews" + showFiles: false + } // NOTE: If using a Dialog, make sure closePolicy does not contain Dialog.CloseOnPressOutside // or the virtual keyboard will close when pressing it... diff --git a/nymea-app/ui/components/ColorIcon.qml b/nymea-app/ui/components/ColorIcon.qml index be85552d..715069cd 100644 --- a/nymea-app/ui/components/ColorIcon.qml +++ b/nymea-app/ui/components/ColorIcon.qml @@ -48,7 +48,10 @@ Item { id: image anchors.fill: parent anchors.margins: parent ? parent.margins : 0 - source: width > 0 && height > 0 && icon.name ? icon.name : "" + source: width > 0 && height > 0 && icon.name ? + icon.name.endsWith(".svg") ? icon.name + : "qrc:/ui/images/" + icon.name + ".svg" + : "" sourceSize { width: width height: height diff --git a/nymea-app/ui/components/FancyHeader.qml b/nymea-app/ui/components/FancyHeader.qml index 3ed45383..59099639 100644 --- a/nymea-app/ui/components/FancyHeader.qml +++ b/nymea-app/ui/components/FancyHeader.qml @@ -35,7 +35,7 @@ import QtQuick.Controls.Material 2.1 ToolBar { id: root - height: 50 + (d.menuOpen ? app.iconSize * 3 + app.margins / 2 : 0) + height: 50 + (menuOpen ? app.iconSize * 3 + app.margins / 2 : 0) Behavior on height { NumberAnimation { easing.type: Easing.InOutQuad; duration: 200 } } property string title @@ -47,16 +47,13 @@ ToolBar { signal clicked(int index); signal leftButtonClicked(); - QtObject { - id: d - property bool menuOpen: false - } + property bool menuOpen: false RowLayout { id: mainRow height: 50 width: parent.width - opacity: d.menuOpen ? 0 : 1 + opacity: menuOpen ? 0 : 1 Behavior on opacity { NumberAnimation { easing.type: Easing.InOutQuad; duration: 200 } } HeaderButton { @@ -81,7 +78,7 @@ ToolBar { HeaderButton { id: menuButton imageSource: "../images/navigation-menu.svg" - onClicked: d.menuOpen = true + onClicked: menuOpen = true } } @@ -89,7 +86,7 @@ ToolBar { height: 50 anchors.bottom: menuPanel.top width: parent.width - opacity: d.menuOpen ? 1 : 0 + opacity: menuOpen ? 1 : 0 visible: opacity > 0 Behavior on opacity { NumberAnimation { easing.type: Easing.InOutQuad; duration: 200 } } @@ -106,7 +103,7 @@ ToolBar { HeaderButton { imageSource:"../images/close.svg" - onClicked: d.menuOpen = false + onClicked: menuOpen = false } } @@ -118,7 +115,7 @@ ToolBar { width: Math.min(menuRow.childrenRect.width, parent.width) height: app.iconSize * 3 contentWidth: menuRow.childrenRect.width - opacity: d.menuOpen ? 1 : 0 + opacity: menuOpen ? 1 : 0 visible: opacity > 0 Behavior on opacity { NumberAnimation { easing.type: Easing.InOutQuad; duration: 200 } } @@ -132,7 +129,7 @@ ToolBar { width: app.iconSize * 3 onClicked: { - d.menuOpen = false + menuOpen = false root.clicked(index) } diff --git a/nymea-app/ui/components/MainPageTabButton.qml b/nymea-app/ui/components/MainPageTabButton.qml index aee5adc3..27c01103 100644 --- a/nymea-app/ui/components/MainPageTabButton.qml +++ b/nymea-app/ui/components/MainPageTabButton.qml @@ -44,22 +44,27 @@ TabButton { opacity: 0.05 } - contentItem: GridLayout { - columns: root.alignment === Qt.Vertical ? 1 : 2 - rowSpacing: 4 - ColorIcon { - Layout.preferredWidth: app.iconSize - Layout.preferredHeight: app.iconSize - Layout.alignment: Qt.AlignHCenter - name: root.iconSource - color: root.checked ? app.accentColor : keyColor - } - Label { - Layout.fillWidth: root.alignment === Qt.Vertical - text: root.text - horizontalAlignment: Text.AlignHCenter - font.pixelSize: app.smallFont - color: root.checked ? app.accentColor : Material.foreground + contentItem: Item { + height: root.height + Grid { + anchors.centerIn: parent + columns: root.alignment == Qt.Vertical ? 1 : 2 + spacing: root.alignment == Qt.Horizontal ? app.margins : app.margins / 2 + horizontalItemAlignment: Grid.AlignHCenter + verticalItemAlignment: Grid.AlignVCenter + + ColorIcon { + width: app.iconSize + height: app.iconSize + name: root.iconSource + color: root.checked ? app.accentColor : keyColor + } + Label { + id: textLabel + text: root.text + font.pixelSize: app.smallFont + color: root.checked ? app.accentColor : Material.foreground + } } } } diff --git a/nymea-app/ui/mainviews/DevicesPage.qml b/nymea-app/ui/components/MainViewBase.qml similarity index 74% rename from nymea-app/ui/mainviews/DevicesPage.qml rename to nymea-app/ui/components/MainViewBase.qml index 1cc17923..a33bbbf8 100644 --- a/nymea-app/ui/mainviews/DevicesPage.qml +++ b/nymea-app/ui/components/MainViewBase.qml @@ -34,30 +34,13 @@ import QtQuick.Controls.Material 2.1 import QtQuick.Layouts 1.2 import Nymea 1.0 import "../components" +import "../delegates" MouseArea { id: root - property alias count: interfacesGridView.count - property alias model: interfacesGridView.model // Prevent scroll events to swipe left/right in case they fall through the grid preventStealing: true onWheel: wheel.accepted = true - GridView { - id: interfacesGridView - anchors.fill: parent - anchors.margins: app.margins / 2 - - readonly property int minTileWidth: 172 - readonly property int tilesPerRow: root.width / minTileWidth - - cellWidth: width / tilesPerRow - cellHeight: cellWidth - delegate: DevicesPageDelegate { - width: interfacesGridView.cellWidth - height: interfacesGridView.cellHeight - iface: Interfaces.findByName(model.name) - } - } } diff --git a/nymea-app/ui/components/SmartMeterChart.qml b/nymea-app/ui/components/SmartMeterChart.qml new file mode 100644 index 00000000..350cc071 --- /dev/null +++ b/nymea-app/ui/components/SmartMeterChart.qml @@ -0,0 +1,127 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU General Public License as published by the Free Software +* Foundation, GNU version 3. This project is distributed in the hope that it +* will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty +* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +* Public License for more details. +* +* You should have received a copy of the GNU General Public License along with +* this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.1 +import QtQuick.Layouts 1.1 +import QtCharts 2.2 +import Nymea 1.0 + +ChartView { + id: chart + backgroundColor: app.backgroundColor + theme: ChartView.ChartThemeLight + legend.labelColor: app.foregroundColor + legend.font.pixelSize: app.smallFont + legend.alignment: Qt.AlignRight + titleColor: app.foregroundColor + + property ThingsProxy meters: null + property int multiplier: 1 + + Connections { + target: meters + onCountChanged: chart.refresh() + } + + Component.onCompleted: { + chart.refresh() + } + + QtObject { + id: d + property var sliceMap: {} + } + + function refresh() { + pieSeries.clear(); + d.sliceMap = {} + print("calculating", chart.multiplier) + for (var i = 0; i < meters.count; i++) { + var thing = meters.get(i); + print("thing:", thing.name) + var value = 0; + var totalConsumedStateType = thing.thingClass.stateTypes.findByName("totalEnergyConsumed") + if (totalConsumedStateType) { + var totalConsumedState = thing.states.getState(totalConsumedStateType.id) + value = value + (totalConsumedState.value * chart.multiplier) + print("Adding", totalConsumedState.value * chart.multiplier, value) + } + var totalProducedStateType = thing.thingClass.stateTypes.findByName("totalEnergyProduced") + if (totalProducedStateType) { + var totalProducedState = thing.states.getState(totalProducedStateType.id) + value = value - (totalProducedState.value * chart.multiplier) + print("removing", totalProducedState.value * chart.multiplier, value) + } + print("consumed", totalConsumedState.value, "produced", totalProducedState.value) + print("value", value) + var slice = pieSeries.append(thing.name, Math.max(0, value)) + var color = app.accentColor + for (var j = 0; j < i; j+=2) { + if (i % 2 == 0) { + color = Qt.lighter(color, 1.2); + } else { + color = Qt.darker(color, 1.2) + } + } + slice.color = color + d.sliceMap[slice] = i + } + } + + PieSeries { + id: pieSeries + holeSize: 0.6 + size: 0.8 + + onClicked: { + print("clicked slice", slice, d.sliceMap[slice], meters.get(d.sliceMap[slice])) + pageStack.push("../devicepages/SmartMeterDevicePage.qml", {device: meters.get(d.sliceMap[slice])}) + } + } + + ColumnLayout { + x: chart.plotArea.x + (chart.plotArea.width * 0.5) - (width / 2) + y: chart.plotArea.y + (chart.plotArea.height * 0.5) - (height / 2) + + Label { + font.pixelSize: app.largeFont + Layout.alignment: Qt.AlignHCenter + text: Math.round(pieSeries.sum * 1000) / 1000 + } + + Label { + text: "KWh" + Layout.alignment: Qt.AlignHCenter + } + } +} + diff --git a/nymea-app/ui/mainviews/DevicesPageDelegate.qml b/nymea-app/ui/delegates/InterfaceTile.qml similarity index 96% rename from nymea-app/ui/mainviews/DevicesPageDelegate.qml rename to nymea-app/ui/delegates/InterfaceTile.qml index 1b6cdd36..cc67c627 100644 --- a/nymea-app/ui/mainviews/DevicesPageDelegate.qml +++ b/nymea-app/ui/delegates/InterfaceTile.qml @@ -161,7 +161,7 @@ MainPageTile { case "media": return mediaControlComponent default: - console.warn("DevicesPageDelegate, inlineControl: Unhandled interface", iface.name) + console.warn("InterfaceTile, inlineControl: Unhandled interface", iface.name) } } @@ -267,7 +267,7 @@ MainPageTile { if (thing.thingClass.interfaces.indexOf("statefulgaragedoor") >= 0 || thing.thingClass.interfaces.indexOf("extendedstatefulgaragedoor") >= 0 || thing.thingClass.interfaces.indexOf("garagegate") >= 0) { statefulCount++; var stateType = thing.thingClass.stateTypes.findByName("state"); - if (stateType && device.states.getState(stateType.id).value !== "closed") { + if (stateType && thing.states.getState(stateType.id).value !== "closed") { count++; } } @@ -285,7 +285,7 @@ MainPageTile { return "" // return qsTr("%1 installed").arg(devicesProxy.count) } - console.warn("DevicesPageDelegate, inlineButtonControl: Unhandled interface", model.name) + console.warn("InterfaceTile, inlineButtonControl: Unhandled interface", model.name) } font.pixelSize: app.smallFont elide: Text.ElideRight @@ -329,7 +329,7 @@ MainPageTile { case "extendedshutter": return "../images/up.svg" default: - console.warn("DevicesPageDelegate, inlineButtonControl image: Unhandled interface", iface.name) + console.warn("InterfaceTile", "inlineButtonControl image: Unhandled interface", iface.name) } return "" } @@ -370,7 +370,7 @@ MainPageTile { } break; default: - console.warn("DevicesPageDelegate, inlineButtonControl clicked: Unhandled interface", iface.name) + console.warn("InterfaceTile:", "inlineButtonControl clicked: Unhandled interface", iface.name) } } } @@ -409,7 +409,7 @@ MainPageTile { case "extendedshutter": return "../images/media-playback-stop.svg" default: - console.warn("DevicesPageDelegate, inlineButtonControl image: Unhandled interface", iface.name) + console.warn("InterfaceTile, inlineButtonControl image: Unhandled interface", iface.name) } return ""; } @@ -450,7 +450,7 @@ MainPageTile { } break; default: - console.warn("DevicesPageDelegate, inlineButtonControl clicked: Unhandled interface", iface.name) + console.warn("InterfaceTile, inlineButtonControl clicked: Unhandled interface", iface.name) } } } @@ -500,7 +500,7 @@ MainPageTile { case "extendedshutter": return "../images/down.svg" default: - console.warn("DevicesPageDelegate, inlineButtonControl image: Unhandled interface", iface.name) + console.warn("InterfaceTile, inlineButtonControl image: Unhandled interface", iface.name) } } } @@ -585,7 +585,7 @@ MainPageTile { } default: - console.warn("DevicesPageDelegate, inlineButtonControl clicked: Unhandled interface", iface.name) + console.warn("InterfaceTile, inlineButtonControl clicked: Unhandled interface", iface.name) } } } diff --git a/nymea-app/ui/grouping/GroupInterfacesPage.qml b/nymea-app/ui/grouping/GroupInterfacesPage.qml index 87f158e9..d89f419b 100644 --- a/nymea-app/ui/grouping/GroupInterfacesPage.qml +++ b/nymea-app/ui/grouping/GroupInterfacesPage.qml @@ -71,12 +71,12 @@ Page { cellWidth: width / tilesPerRow cellHeight: cellWidth -// delegate: DevicesPageDelegate { +// delegate: InterfaceTile { // width: interfacesGridView.cellWidth // height: interfacesGridView.cellHeight // } - delegate: DevicesPageDelegate { + delegate: InterfaceTile { width: interfacesGridView.cellWidth height: interfacesGridView.cellHeight iface: Interfaces.findByName(model.name) diff --git a/nymea-app/ui/images/energy.svg b/nymea-app/ui/images/energy.svg new file mode 100644 index 00000000..888be089 --- /dev/null +++ b/nymea-app/ui/images/energy.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/nymea-app/ui/images/share.svg b/nymea-app/ui/images/things.svg similarity index 100% rename from nymea-app/ui/images/share.svg rename to nymea-app/ui/images/things.svg diff --git a/nymea-app/ui/mainviews/EnergyView.qml b/nymea-app/ui/mainviews/EnergyView.qml new file mode 100644 index 00000000..e9cf0415 --- /dev/null +++ b/nymea-app/ui/mainviews/EnergyView.qml @@ -0,0 +1,91 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU General Public License as published by the Free Software +* Foundation, GNU version 3. This project is distributed in the hope that it +* will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty +* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +* Public License for more details. +* +* You should have received a copy of the GNU General Public License along with +* this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.1 +import QtQuick.Layouts 1.2 +import QtCharts 2.2 +import Nymea 1.0 +import "../components" +import "../delegates" + +MainViewBase { + id: root + + ThingsProxy { + id: consumers + engine: _engine + shownInterfaces: ["smartmeterconsumer"] + } + ThingsProxy { + id: producers + engine: _engine + shownInterfaces: ["smartmeterproducer"] + } + + EmptyViewPlaceholder { + anchors.centerIn: parent + width: parent.width - app.margins * 2 + visible: !engine.thingManager.fetchingData && consumers.count == 0 + title: qsTr("There are no energy meters installed.") + text: qsTr("To get an overview of your current energy usage, install some energy meters.") + imageSource: "../images/smartmeter.svg" + buttonText: qsTr("Add things") + } + + Flickable { + anchors.fill: parent + contentHeight: energyGrid.childrenRect.height + + GridLayout { + id: energyGrid + width: parent.width + visible: consumers.count > 0 + columns: Math.floor(root.width / 300) + + SmartMeterChart { + Layout.fillWidth: true + Layout.preferredHeight: width * .7 + meters: consumers + title: qsTr("Total consumed energy") + visible: consumers.count > 0 + } + SmartMeterChart { + Layout.fillWidth: true + Layout.preferredHeight: width * .7 + meters: producers + title: qsTr("Total produced energy") + visible: producers.count > 0 + multiplier: -1 + } + } + } +} diff --git a/nymea-app/ui/mainviews/FavoritesView.qml b/nymea-app/ui/mainviews/FavoritesView.qml index 9e87aff7..5695f373 100644 --- a/nymea-app/ui/mainviews/FavoritesView.qml +++ b/nymea-app/ui/mainviews/FavoritesView.qml @@ -36,15 +36,10 @@ import Nymea 1.0 import "../components" import "../delegates" -MouseArea { +MainViewBase { id: root property bool editMode: false - readonly property int count: tagsProxy.count - - // Prevent scroll events to swipe left/right in case they fall through the grid - preventStealing: true - onWheel: wheel.accepted = true TagsProxyModel { id: tagsProxy @@ -167,4 +162,19 @@ MouseArea { } } } + + EmptyViewPlaceholder { + anchors { left: parent.left; right: parent.right; margins: app.margins } + anchors.verticalCenter: parent.verticalCenter + visible: gridView.count === 0 && !engine.deviceManager.fetchingData + title: qsTr("There are no favorite things yet.") + text: engine.deviceManager.devices.count === 0 ? + qsTr("It appears there are no things set up either yet. In order to use favorites you need to add some things first.") : + qsTr("Favorites allow you to keep track of your most important things when you have lots of them. Watch out for the star when interacting with things and use it to mark them as your favorites.") + imageSource: "../images/starred.svg" + buttonVisible: engine.deviceManager.devices.count === 0 + buttonText: qsTr("Add a thing") + onButtonClicked: pageStack.push(Qt.resolvedUrl("../thingconfiguration/NewThingPage.qml")) + } + } diff --git a/nymea-app/ui/mainviews/GaragesView.qml b/nymea-app/ui/mainviews/GaragesView.qml new file mode 100644 index 00000000..add60278 --- /dev/null +++ b/nymea-app/ui/mainviews/GaragesView.qml @@ -0,0 +1,240 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU General Public License as published by the Free Software +* Foundation, GNU version 3. This project is distributed in the hope that it +* will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty +* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +* Public License for more details. +* +* You should have received a copy of the GNU General Public License along with +* this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +import QtQuick 2.3 +import QtQuick.Layouts 1.2 +import QtQuick.Controls 2.2 +import "../components" +import Nymea 1.0 + +MainViewBase { + id: root + + readonly property bool landscape: width > height + + DevicesProxy { + id: garagesFilterModel + engine: _engine + shownInterfaces: ["garagedoor", "garagegate"] + } + + EmptyViewPlaceholder { + anchors.centerIn: parent + width: parent.width - app.margins * 2 + text: qsTr("There are no garage doors set up yet.") + imageSource: "qrc:/ui/images/garage/garage-100.svg" + buttonText: qsTr("Set up now") + visible: garagesFilterModel.count === 0 && !engine.thingManager.fetchingData + onButtonClicked: pageStack.push(Qt.resolvedUrl("../thingconfiguration/NewThingPage.qml")) + } + + SwipeView { + id: swipeView + anchors.fill: parent + + Repeater { + model: garagesFilterModel + + Item { + id: garageGateView + width: swipeView.width + height: swipeView.height + + readonly property Device thing: garagesFilterModel.get(index) + + + readonly property bool isImpulseBased: thing.thingClass.interfaces.indexOf("impulsegaragedoor") >= 0 + readonly property bool isStateful: thing.thingClass.interfaces.indexOf("statefulgaragedoor") >= 0 + readonly property bool isExtended: thing.thingClass.interfaces.indexOf("extendedstatefulgaragedoor") >= 0 + + // Stateful garagedoor + readonly property StateType stateStateType: thing.thingClass.stateTypes.findByName("state") + readonly property State stateState: stateStateType ? thing.states.getState(stateStateType.id) : null + + // Extended stateful garagedoor + readonly property StateType percentageStateType: thing.thingClass.stateTypes.findByName("percentage") + readonly property State percentageState: percentageStateType ? thing.states.getState(percentageStateType.id) : null + + + // Backward compatiblity with old garagegate interface + readonly property StateType intermediatePositionStateType: thing.thingClass.stateTypes.findByName("intermediatePosition") + readonly property var intermediatePositionState: intermediatePositionStateType ? device.states.getState(intermediatePositionStateType.id) : null + + // Some garages may also implement the light interface + readonly property var lightStateType: thing.thingClass.stateTypes.findByName("power") + readonly property var lightState: lightStateType ? thing.states.getState(lightStateType.id) : null + + ColumnLayout { + anchors.fill: parent + anchors.topMargin: app.margins + anchors.bottomMargin: app.margins + + Label { + Layout.fillWidth: true + font.pixelSize: app.largeFont + text: garageGateView.thing.name + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + + GridLayout { + columns: root.landscape ? 2 : 1 + + ColorIcon { + id: shutterImage + Layout.preferredWidth: root.landscape ? + Math.min(parent.width - shutterControlsContainer.minimumWidth, parent.height) - app.margins + : Math.min(Math.min(parent.width, 500), parent.height - shutterControlsContainer.minimumHeight) + Layout.preferredHeight: width + Layout.alignment: Qt.AlignHCenter + property string currentImage: { + if (garageGateView.isExtended) { + return app.pad(Math.round(garageGateView.percentageState.value / 10), 2) + "0" + } + if (garageGateView.intermediatePositionStateType) { + return garageGateView.stateState.value === "closed" ? "100" + : garageGateView.intermediatePositionState.value === false ? "000" : "050" + } + return "100" + } + name: "../images/garage/garage-" + currentImage + ".svg" + + Item { + id: arrows + anchors.centerIn: parent + width: app.iconSize * 2 + height: parent.height * .6 + clip: true + visible: garageGateView.stateStateType && (garageGateView.stateState.value === "opening" || garageGateView.stateState.value === "closing") + property bool up: garageGateView.stateState && garageGateView.stateState.value === "opening" + + // NumberAnimation doesn't reload to/from while it's running. If we switch from closing to opening or vice versa + // we need to somehow stop and start the animation + property bool animationHack: true + onAnimationHackChanged: { + if (!animationHack) hackTimer.start(); + } + Timer { id: hackTimer; interval: 1; onTriggered: arrows.animationHack = true } + Connections { target: garageGateView.stateState; onValueChanged: arrows.animationHack = false } + + NumberAnimation { + target: arrowColumn + property: "y" + duration: 500 + easing.type: Easing.Linear + from: arrows.up ? app.iconSize : -app.iconSize + to: arrows.up ? -app.iconSize : app.iconSize + loops: Animation.Infinite + running: arrows.animationHack && garageGateView.stateState && (garageGateView.stateState.value === "opening" || garageGateView.stateState.value === "closing") + } + + Column { + id: arrowColumn + width: parent.width + + Repeater { + model: arrows.height / app.iconSize + 1 + ColorIcon { + name: arrows.up ? "../images/up.svg" : "../images/down.svg" + width: parent.width + height: width + color: app.accentColor + } + } + } + } + } + + Item { + id: shutterControlsContainer + Layout.fillWidth: true + Layout.margins: app.margins * 2 + Layout.fillHeight: true + property int minimumWidth: app.iconSize * 2.5 * (garageGateView.lightState ? 4 : 3) + property int minimumHeight: app.iconSize * 2.5 + + ItemDelegate { + height: app.iconSize * 2 + width: height + anchors.centerIn: parent + visible: garageGateView.isImpulseBased + ColorIcon { + anchors.fill: parent + name: "../images/closable-move.svg" + anchors.margins: app.margins + } + onClicked: { + var actionTypeId = garageGateView.thing.thingClass.actionTypes.findByName("triggerImpulse").id + print("Triggering impulse", actionTypeId) + engine.thingManager.executeAction(garageGateView.thing.id, actionTypeId) + } + } + + ShutterControls { + id: shutterControls + device: garageGateView.thing + anchors.centerIn: parent + spacing: (parent.width - app.iconSize*2*children.length) / (children.length - 1) + visible: !garageGateView.isImpulseBased + + ItemDelegate { + width: app.iconSize * 2 + height: width + visible: garageGateView.lightStateType !== null + + ColorIcon { + anchors.fill: parent + anchors.margins: app.margins + name: "../images/light-" + (garageGateView.lightState && garageGateView.lightState.value === true ? "on" : "off") + ".svg" + color: garageGateView.lightState && garageGateView.lightState.value === true ? Material.accent : keyColor + } + onClicked: { + var params = []; + var param = {}; + param["paramTypeId"] = garageGateView.lightStateType.id; + param["value"] = !garageGateView.lightState.value; + params.push(param) + engine.deviceManager.executeAction(garageGateView.device.id, garageGateView.lightStateType.id, params) + } + } + } + } + } + } + } + } + } + + PageIndicator { + anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter } + count: garagesFilterModel.count + currentIndex: swipeView.currentIndex + } +} diff --git a/nymea-app/ui/mainviews/GroupsView.qml b/nymea-app/ui/mainviews/GroupsView.qml index 01f1a7d3..408d6e49 100644 --- a/nymea-app/ui/mainviews/GroupsView.qml +++ b/nymea-app/ui/mainviews/GroupsView.qml @@ -35,12 +35,8 @@ import Nymea 1.0 import QtQuick.Controls.Material 2.2 import "../components" -MouseArea { +MainViewBase { id: root - preventStealing: true - onWheel: wheel.accepted = true - - readonly property int count: groupsGridView.count GridView { id: groupsGridView @@ -491,4 +487,15 @@ MouseArea { device: mediaControllers.count > 0 ? mediaControllers.get(0) : null } } + + EmptyViewPlaceholder { + anchors { left: parent.left; right: parent.right; margins: app.margins } + anchors.verticalCenter: parent.verticalCenter + visible: groupsGridView.count == 0 && !engine.deviceManager.fetchingData && !engine.tagsManager.busy + title: qsTr("There are no groups set up yet.") + text: qsTr("Grouping things can be useful to control multiple devices at once, for example an entire room. Watch out for the group symbol when interacting with things and use it to add them to groups.") + imageSource: "../images/view-grid-symbolic.svg" + buttonVisible: false + } + } diff --git a/nymea-app/ui/mainviews/ScenesView.qml b/nymea-app/ui/mainviews/ScenesView.qml index a4b09e2a..d8235a32 100644 --- a/nymea-app/ui/mainviews/ScenesView.qml +++ b/nymea-app/ui/mainviews/ScenesView.qml @@ -35,15 +35,9 @@ import Nymea 1.0 import QtQuick.Controls.Material 2.2 import "../components" -MouseArea { +MainViewBase { id: root - readonly property int count: interfacesGridView.count - - // Prevent scroll events to swipe left/right in case they fall through the grid - preventStealing: true - onWheel: wheel.accepted = true - GridView { id: interfacesGridView anchors.fill: parent @@ -81,4 +75,37 @@ MouseArea { } } } + + + EmptyViewPlaceholder { + anchors { left: parent.left; right: parent.right; margins: app.margins } + anchors.verticalCenter: parent.verticalCenter + visible: interfacesGridView.count === 0 && !engine.deviceManager.fetchingData + title: qsTr("There are no scenes set up yet.") + text: engine.deviceManager.devices.count === 0 ? + qsTr("It appears there are no things set up either yet. In order to use scenes you need to add some things first.") : + qsTr("Scenes provide a useful way to control your things with just one click.") + imageSource: "../images/slideshow.svg" + buttonText: engine.deviceManager.devices.count === 0 ? qsTr("Add a thing") : qsTr("Add a scene") + onButtonClicked: { + if (engine.deviceManager.devices.count === 0) { + pageStack.push(Qt.resolvedUrl("../thingconfiguration/NewThingPage.qml")) + } else { + var newRule = engine.ruleManager.createNewRule(); + d.editRulePage = pageStack.push(Qt.resolvedUrl("../magic/EditRulePage.qml"), {rule: newRule }); + d.editRulePage.startAddAction(); + d.editRulePage.StackView.onRemoved.connect(function() { + newRule.destroy(); + }) + d.editRulePage.onAccept.connect(function() { + d.editRulePage.busy = true; + engine.ruleManager.addRule(d.editRulePage.rule); + }) + d.editRulePage.onCancel.connect(function() { + pageStack.pop(); + }) + } + } + } + } diff --git a/nymea-app/ui/mainviews/ThingsView.qml b/nymea-app/ui/mainviews/ThingsView.qml new file mode 100644 index 00000000..72da5b29 --- /dev/null +++ b/nymea-app/ui/mainviews/ThingsView.qml @@ -0,0 +1,83 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2020, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU General Public License as published by the Free Software +* Foundation, GNU version 3. This project is distributed in the hope that it +* will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty +* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +* Public License for more details. +* +* You should have received a copy of the GNU General Public License along with +* this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +import QtQuick 2.8 +import QtQuick.Controls 2.1 +import QtQuick.Controls.Material 2.1 +import QtQuick.Layouts 1.2 +import Nymea 1.0 +import "../components" +import "../delegates" + +MainViewBase { + id: root + + InterfacesSortModel { + id: mainModel + interfacesModel: InterfacesModel { + engine: _engine + devices: DevicesProxy { + engine: _engine + } + shownInterfaces: app.supportedInterfaces + showUncategorized: true + } + } + + GridView { + id: interfacesGridView + anchors.fill: parent + anchors.margins: app.margins / 2 + model: mainModel + + readonly property int minTileWidth: 172 + readonly property int tilesPerRow: root.width / minTileWidth + + cellWidth: width / tilesPerRow + cellHeight: cellWidth + delegate: InterfaceTile { + width: interfacesGridView.cellWidth + height: interfacesGridView.cellHeight + iface: Interfaces.findByName(model.name) + } + } + + EmptyViewPlaceholder { + anchors { left: parent.left; right: parent.right; margins: app.margins } + anchors.verticalCenter: parent.verticalCenter + visible: engine.deviceManager.devices.count === 0 && !engine.deviceManager.fetchingData + title: qsTr("Welcome to %1!").arg(app.systemName) + // Have that split in 2 because we need those strings separated in EditDevicesPage too and don't want translators to do them twice + text: qsTr("There are no things set up yet.") + "\n" + qsTr("In order for your %1 system to be useful, go ahead and add some things.").arg(app.systemName) + imageSource: "qrc:/styles/%1/logo.svg".arg(styleController.currentStyle) + buttonText: qsTr("Add a thing") + onButtonClicked: pageStack.push(Qt.resolvedUrl("../thingconfiguration/NewThingPage.qml")) + } +} diff --git a/nymea-app/ui/thingconfiguration/SetupWizard.qml b/nymea-app/ui/thingconfiguration/SetupWizard.qml index 61dbc57c..83c2170e 100644 --- a/nymea-app/ui/thingconfiguration/SetupWizard.qml +++ b/nymea-app/ui/thingconfiguration/SetupWizard.qml @@ -309,7 +309,7 @@ Page { } BusyIndicator { running: visible - anchors.horizontalCenter: parent.horizontalCenter + Layout.alignment: Qt.AlignHCenter } }