diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 122ac0e48..c1976fe7e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -274,6 +274,14 @@ jobs: echo "$FASTLANE_OUTPUT" SHARE_URL=$(echo "$FASTLANE_OUTPUT" | awk '/APP_SHARE_URL:/ {print $NF}') echo "Google play store APK link: $SHARE_URL" >> $GITHUB_STEP_SUMMARY + + # Write the Play Store link into this job's check run output + curl -s -X PATCH \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "https://api.github.com/repos/${{ github.repository }}/check-runs/${{ job.check_run_id }}" \ + -d "{\"output\":{\"title\":\"Android ${{ matrix.ANDROID_ABI }}\",\"summary\":\"$SHARE_URL\"}}" - name: Build AAB if: ${{ github.ref_name == 'master' || github.ref_type == 'tag' }} @@ -309,3 +317,11 @@ jobs: echo "$FASTLANE_OUTPUT" SHARE_URL=$(echo "$FASTLANE_OUTPUT" | awk '/APP_SHARE_URL:/ {print $NF}') echo "Google play store AAB link: $SHARE_URL" >> $GITHUB_STEP_SUMMARY + + # Write the Play Store link into this job's check run output + curl -s -X PATCH \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "https://api.github.com/repos/${{ github.repository }}/check-runs/${{ job.check_run_id }}" \ + -d "{\"output\":{\"title\":\"Android ${{ matrix.ANDROID_ABI }}\",\"summary\":\"$SHARE_URL\"}}" \ No newline at end of file diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index 827ef7a10..a03edd9d1 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -68,9 +68,12 @@ jobs: uses: actions/checkout@v6 - name: Install Requirements + #workaround for the current false positive bug in cppcheck 2.20 (latest version) run: | - brew update - brew install cppcheck + curl -L https://github.com/danmar/cppcheck/archive/refs/tags/2.19.1.tar.gz | tar xz + cmake -S cppcheck-2.19.1 -B cppcheck-build -DCMAKE_BUILD_TYPE=Release + cmake --build cppcheck-build -j$(sysctl -n hw.logicalcpu) + cmake --install cppcheck-build - name: Run cppcheck test run: ./scripts/cppcheck.bash diff --git a/.zenodo.json b/.zenodo.json index 21c739cf3..176982a58 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -2,7 +2,7 @@ "description": "

Mergin Maps mobile app is a QGIS powered app for Android and iOS devices.

", "license": "GPLv3", "title": "Mergin Maps mobile app", - "version": "2026.1.2", + "version": "2026.2.0", "upload_type": "software", "publication_date": "2022-02-24", "creators": [ @@ -39,7 +39,7 @@ "related_identifiers": [ { "scheme": "url", - "identifier": "https://github.com/MerginMaps/mobile/tree/2026.1.2", + "identifier": "https://github.com/MerginMaps/mobile/tree/2026.2.0", "relation": "isSupplementTo" }, { diff --git a/CITATION.cff b/CITATION.cff index 742fc777b..632185ef2 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,4 +1,4 @@ -cff-version: 2026.1.2 +cff-version: 2026.2.0 message: "If you use this software, please cite it as below." authors: - family-names: "Martin" diff --git a/CMakeLists.txt b/CMakeLists.txt index f413ea798..b43b4e1e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,8 +6,8 @@ cmake_minimum_required(VERSION 3.22) # Note: To update version use script/update_all_versions.bash set(MM_VERSION_MAJOR "2026") -set(MM_VERSION_MINOR "1") -set(MM_VERSION_PATCH "2") +set(MM_VERSION_MINOR "2") +set(MM_VERSION_PATCH "0") if (VCPKG_TARGET_TRIPLET MATCHES ".*ios.*") set(IOS TRUE) diff --git a/INSTALL.md b/INSTALL.md index 461a92e22..386ae2ca3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,23 +1,27 @@ -# Table of Contents +# Table of Contents - [Table of Contents](#table-of-contents) -- [1. Introduction](#1-introduction) -- [2. Overview](#2-overview) - - [2.1 Secrets](#21-secrets) - - [2.2 Code formatting](#22-code-formatting) -- [3. Building GNU/Linux](#3-building-gnulinux) -- [4. Building Android (on Linux/macOS/Windows)](#4-building-android-on-linuxmacoswindows) - - [4.1. Android on Ubuntu](#41-android-on-ubuntu) - - [4.2. Android on macOS](#42-android-on-macos) - - [4.3. Android on Windows](#43-android-on-windows) -- [5. Building iOS](#5-building-ios) -- [6. Building macOS](#6-building-macos) -- [7. Building Windows](#7-building-windows) -- [8. FAQ](#8-faq) -- [9. Auto Testing](#9-auto-testing) - -# 1. Introduction +- [1. Introduction](#introduction) +- [2. Overview](#overview) + - [2.1 Secrets](#secrets) + - [2.2 Code formatting](#code-formatting) + - [2.3 Qt packages](#qt-packages) + - [2.4 Vcpkg](#vcpkg) + - [2.5 ccache](#ccache) +- [3. Building GNU/Linux](#building-linux) + - [3.1 Ubuntu 22.04](#ubuntu) +- [4. Building Android (on Linux/macOS/Windows)](#building-android) + - [4.1. Android on Ubuntu](#android-on-linux) + - [4.2. Android on macOS](#android-on-macos) + - [4.3. Android on Windows](#android-on-windows) +- [5. Building iOS](#building-ios) +- [6. Building macOS](#building-macos) +- [7. Building Windows](#building-windows) +- [8. FAQ](#faq) +- [9. Auto Testing](#auto-testing) + +# 1. Introduction This document is the original installation guide of the described software Mergin Maps mobile app. The software and hardware descriptions named in this @@ -39,7 +43,7 @@ For code architecture of codebase, please see [docs](./docs/README.md). **Note to document writers:** Please use this document as the central place for describing build procedures. Please do not remove this notice. -# 2. Overview +# 2. Overview Mobile app, like a number of major projects (e.g., KDE), uses [CMake](https://www.cmake.org) for building from source. @@ -53,7 +57,7 @@ Generally, for building setup, we recommend to use the same versions of librarie [GitHub Actions](https://github.com/MerginMaps/mobile/tree/master/.github/workflows). Open workflow file for your platform/target and see the version of libraries used and replicate it in your setup. -## 2.1 Secrets +## 2.1 Secrets To communicate with MerginAPI, some endpoints need to attach `api_key`. To not leak API_KEY, the source code that returns the API_KEYS is encrypted. @@ -81,7 +85,7 @@ cd core/ openssl aes-256-cbc -d -in merginsecrets.cpp.enc -out merginsecrets.cpp -md md5 ``` -## 2.2 Code formatting +## 2.2 Code formatting We use `astyle` to format CPP and Objective-C files. Format is similar to what QGIS has. We use `cmake-format` to format CMake files. @@ -92,12 +96,12 @@ their usage For more details about code conventions, please read our [code conventions doc](./docs/code_convention.md). -## 2.3 Qt packages +## 2.3 Qt packages Mergin Maps Mobile app is built with Qt. Qt is build with vcpkg as part of the configure step, but it is recommended to install QtCreator and Qt on your host to be able to release translations. -## 2.4 Vcpkg +## 2.4 Vcpkg Dependencies are build with vcpkg. To fix the version of libraries, you need to download vcpkg and checkout to git commit specified in the file `VCPKG_BASELINE` in the repository. @@ -115,13 +119,13 @@ in the file `VCPKG_BASELINE` in the repository. ./bootstrap-vcpkg.sh ``` -## 2.4 ccache +## 2.5 ccache Install and configure ccache for development. It speeds up the development significantly. -# 3. Building GNU/Linux +# 3. Building GNU/Linux -## 3.1 Ubuntu 22.04 +## 3.1 Ubuntu 22.04 Steps to build and run mobile app: @@ -157,7 +161,7 @@ Steps to build and run mobile app: Alternatively you can open QtCreator and add cmake defines to the QtCreator Project setup table and configure from QtCreator (recommended for development and debugging) - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. ``` mkdir -p build @@ -172,6 +176,10 @@ Steps to build and run mobile app: -GNinja \ -S ../mobile ``` + + Note: `libpq` will fail to build if the `zic` tool is not in the system path. In that case, set the `ZIC` environment variable to the full path leading to + the executable, for example: `export ZIC=/usr/sbin/zic`. + 4. Build application ``` @@ -184,13 +192,13 @@ Steps to build and run mobile app: ./app/Input ``` - For testing read [Auto Testing](#AutoTesting) section. + For testing read [Auto Testing](#auto-testing) section. -# 4. Building Android (on Linux/macOS/Windows) +# 4. Building Android (on Linux/macOS/Windows) For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back -## 4.1. Android on Linux +## 4.1. Android on Linux 1. Install some dependencies, see requirements in `.github/workflows/android.yml` @@ -275,7 +283,7 @@ For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back ``` - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. 4. Build and Run @@ -290,7 +298,7 @@ For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back MerginMaps ``` -## 4.2. Android on macOS +## 4.2. Android on macOS 1. Install Java - `brew install openjdk@17`, then make this java version default ``export JAVA_HOME=`usr/libexec/java_home -v 17` ``. Check if it's default by executing `java --version` @@ -391,7 +399,7 @@ For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back ``` - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. 4. Build and Run @@ -406,12 +414,12 @@ For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back MerginMaps ``` -## 4.3. Android on Windows +## 4.3. Android on Windows Even technically it should be possible, we haven't tried this setup yet. If you managed to compile mobile app for Android on Windows, please help us to update this section. -# 5. Building iOS +# 5. Building iOS - you have to run Release or RelWithDebInfo builds. Debug builds will usually crash on some Qt's assert - if there is any problem running mobile app from Qt Creator, open cmake-generated project in XCode directly @@ -441,7 +449,7 @@ mobile app for Android on Windows, please help us to update this section. Alternatively you can open QtCreator and add cmake defines to the QtCreator Project setup table and configure from QtCreator (recommended for development and debugging) - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. Note: make sure you adjust VCPKG_HOST_TRIPLET and CMAKE_SYSTEM_PROCESSOR if you use x64-osx host machine. @@ -492,7 +500,7 @@ Alternatively, navigate to the build folder and open the Xcode Project: Once the project is opened, build it from Xcode. -# 6. Building macOS +# 6. Building macOS 1. Install some dependencies, critically XCode, bison and flex. See "Install Build Dependencies" step in `.github/workflows/macos.yml` ``` @@ -521,7 +529,7 @@ Once the project is opened, build it from Xcode. Alternatively you can open QtCreator and add cmake defines to the QtCreator Project setup table and configure from QtCreator (recommended for development and debugging) - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. Note: for x64-osx (intel laptops) build use VCPKG_TARGET_TRIPLET instead of arm64-osx (Mx laptops) @@ -555,7 +563,7 @@ Once the project is opened, build it from Xcode. ./app/MerginMaps.app/Contents/MacOS/MerginMaps ``` -# 7. Building Windows +# 7. Building Windows 1. Install some dependencies. See `.github/workflows/win.yml` Critically Visual Studio, cmake, bison and flex. Setup build VS environment (adjust to your version) @@ -583,7 +591,7 @@ Once the project is opened, build it from Xcode. Alternatively you can open QtCreator and add cmake defines to the QtCreator Project setup table and configure from QtCreator (recommended for development and debugging) - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. ``` mkdir build @@ -613,7 +621,7 @@ Once the project is opened, build it from Xcode. ./app/MerginMaps.exe ``` -# 8. FAQ +# 8. FAQ - If you have "error: undefined reference to 'stdout'" or so, make sure that in BUILD ENV you have ANDROID_NDK_PLATFORM=android-24 or later! ![image](https://user-images.githubusercontent.com/22449698/166630970-a776576f-c505-4265-b4c8-ffbe212c6745.png) @@ -625,7 +633,7 @@ Once the project is opened, build it from Xcode. - Make sure it's targeting **build** directory - If using Visual Studio Code to configure and build the project, check the template in the `docs` folder. -# 9. Auto Testing +# 9. Auto Testing You need to add cmake define `-DENABLE_TESTING=TRUE` on your cmake configure line. Also, you need to open Passbolt and check for password for user `test_mobileapp` on `app.dev.merginmaps.com`, @@ -641,4 +649,4 @@ TEST_API_PASSWORD= ``` Build binary, and you can run tests either with `ctest` or you can run individual tests by adding `--test` -e.g. ` ./MerginMaps --testMerginApi` \ No newline at end of file +e.g. ` ./MerginMaps --testMerginApi` diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index e474aa9b2..a3131e959 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -56,6 +56,10 @@ set(MM_SRCS featurelayerpair.cpp featuresmodel.cpp fieldsmodel.cpp + filter/uniquevaluesfiltermodel.cpp + filter/valuemapfiltermodel.cpp + filter/searchproxymodel.cpp + filtercontroller.cpp guidelinecontroller.cpp hapticsmodel.cpp identifykit.cpp @@ -147,6 +151,10 @@ set(MM_HDRS featurelayerpair.h featuresmodel.h fieldsmodel.h + filter/uniquevaluesfiltermodel.h + filter/valuemapfiltermodel.h + filter/searchproxymodel.h + filtercontroller.h guidelinecontroller.h hapticsmodel.h identifykit.h @@ -318,6 +326,7 @@ target_include_directories( MerginMaps PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/ ${CMAKE_CURRENT_SOURCE_DIR}/attributes + ${CMAKE_CURRENT_SOURCE_DIR}/filter ${CMAKE_CURRENT_SOURCE_DIR}/map ${CMAKE_CURRENT_SOURCE_DIR}/layer ${CMAKE_CURRENT_SOURCE_DIR}/maptools diff --git a/app/activeproject.cpp b/app/activeproject.cpp index 6f1859b0c..78d786a9e 100644 --- a/app/activeproject.cpp +++ b/app/activeproject.cpp @@ -75,6 +75,9 @@ ActiveProject::ActiveProject( AppSettings &appSettings setAutosyncEnabled( mAppSettings.autosyncAllowed() ); QObject::connect( &mAppSettings, &AppSettings::autosyncAllowedChanged, this, &ActiveProject::setAutosyncEnabled ); + + mFilterController = std::make_unique(); + connect( this, &ActiveProject::projectReloaded, mFilterController.get(), &FilterController::loadFilterConfig ); } ActiveProject::~ActiveProject() = default; @@ -668,3 +671,8 @@ bool ActiveProject::photoSketchingEnabled() const return mQgsProject->readBoolEntry( QStringLiteral( "Mergin" ), QStringLiteral( "PhotoSketching/Enabled" ), false ); } + +FilterController *ActiveProject::filterController() const +{ + return mFilterController.get(); +} diff --git a/app/activeproject.h b/app/activeproject.h index 92fee83b8..f15913df0 100644 --- a/app/activeproject.h +++ b/app/activeproject.h @@ -24,6 +24,7 @@ #include "inputmapsettings.h" #include "merginprojectmetadata.h" #include "synchronizationoptions.h" +#include "filtercontroller.h" /** * \brief The ActiveProject class can load a QGIS project and holds its data. @@ -34,6 +35,7 @@ class ActiveProject: public QObject Q_PROPERTY( LocalProject localProject READ localProject NOTIFY localProjectChanged ) // LocalProject instance of active project, changes when project is loaded Q_PROPERTY( QgsProject *qgsProject READ qgsProject NOTIFY qgsProjectChanged ) // QgsProject instance of active project, never changes Q_PROPERTY( AutosyncController *autosyncController READ autosyncController NOTIFY autosyncControllerChanged ) + Q_PROPERTY( FilterController *filterController READ filterController NOTIFY filterControllerChanged ) Q_PROPERTY( InputMapSettings *mapSettings READ mapSettings WRITE setMapSettings NOTIFY mapSettingsChanged ) Q_PROPERTY( QString projectRole READ projectRole WRITE setProjectRole NOTIFY projectRoleChanged ) @@ -149,6 +151,11 @@ class ActiveProject: public QObject */ bool photoSketchingEnabled() const; + /** + * Returns filterController, which loads any filters setup in QGIS plugin + */ + FilterController *filterController() const; + signals: void qgsProjectChanged(); void localProjectChanged( LocalProject project ); @@ -184,6 +191,8 @@ class ActiveProject: public QObject void appStateChanged( Qt::ApplicationState state ); + void filterControllerChanged( FilterController *controller ); + public slots: // Reloads project if current project path matches given path (it's the same project) bool reloadProject( QString projectDir ); @@ -225,6 +234,7 @@ class ActiveProject: public QObject LocalProjectsManager &mLocalProjectsManager; InputMapSettings *mMapSettings = nullptr; std::unique_ptr mAutosyncController; + std::unique_ptr mFilterController; QString mProjectLoadingLog; QString mProjectRole; diff --git a/app/appsettings.cpp b/app/appsettings.cpp index 58e340ec1..5289164e6 100644 --- a/app/appsettings.cpp +++ b/app/appsettings.cpp @@ -34,6 +34,7 @@ AppSettings::AppSettings( QObject *parent ): QObject( parent ) const bool autolockPosition = settings.value( QStringLiteral( "autolockPosition" ), true ).toBool(); int hapticsTypeInt = settings.value( "hapticsType", 0 ).toInt(); const HapticsType hapticsType = static_cast( hapticsTypeInt ); + const bool alwaysShowFilterButton = settings.value( QStringLiteral( "alwaysShowFilterButton" ), false ).toBool(); settings.endGroup(); @@ -51,6 +52,7 @@ AppSettings::AppSettings( QObject *parent ): QObject( parent ) setIgnoreMigrateVersion( ignoreMigrateVersion ); setAutolockPosition( autolockPosition ); setHapticsType( hapticsType ); + setAlwaysShowFilterButton( alwaysShowFilterButton ); } QString AppSettings::defaultLayer() const @@ -372,3 +374,18 @@ void AppSettings::setWindowPosition( const QList &newWindowPosition ) emit windowPositionChanged(); } + +bool AppSettings::alwaysShowFilterButton() const +{ + return mAlwaysShowFilterButton; +} + +void AppSettings::setAlwaysShowFilterButton( bool alwaysShowFilterButton ) +{ + if ( mAlwaysShowFilterButton == alwaysShowFilterButton ) + return; + + mAlwaysShowFilterButton = alwaysShowFilterButton; + setValue( QStringLiteral( "alwaysShowFilterButton" ), alwaysShowFilterButton ); + emit alwaysShowFilterButtonChanged( mAlwaysShowFilterButton ); +} diff --git a/app/appsettings.h b/app/appsettings.h index a684e416c..7aa29d3c5 100644 --- a/app/appsettings.h +++ b/app/appsettings.h @@ -38,6 +38,7 @@ class AppSettings: public QObject Q_PROPERTY( bool autolockPosition READ autolockPosition WRITE setAutolockPosition NOTIFY autolockPositionChanged ) Q_PROPERTY( QList windowPosition READ windowPosition WRITE setWindowPosition NOTIFY windowPositionChanged ) Q_PROPERTY( HapticsType hapticsType READ hapticsType WRITE setHapticsType NOTIFY hapticsTypeChanged ) + Q_PROPERTY( bool alwaysShowFilterButton READ alwaysShowFilterButton WRITE setAlwaysShowFilterButton NOTIFY alwaysShowFilterButtonChanged ) public: // enum of haptic modes we support @@ -110,6 +111,9 @@ class AppSettings: public QObject HapticsType hapticsType() const; void setHapticsType( HapticsType hapticsType ); + bool alwaysShowFilterButton() const; + void setAlwaysShowFilterButton( bool alwaysShowFilterButton ); + public slots: void setReuseLastEnteredValues( bool reuseLastEnteredValues ); @@ -129,6 +133,7 @@ class AppSettings: public QObject void autosyncAllowedChanged( bool autosyncAllowed ); void autolockPositionChanged( bool autolockPosition ); void hapticsTypeChanged( HapticsType hapticsType ); + void alwaysShowFilterButtonChanged( bool alwaysShowFilterButton ); void ignoreMigrateVersionChanged(); @@ -169,6 +174,7 @@ class AppSettings: public QObject QString mIgnoreMigrateVersion; HapticsType mHapticsType; + bool mAlwaysShowFilterButton = false; }; #endif // APPSETTINGS_H diff --git a/app/attributes/attributeformmodel.cpp b/app/attributes/attributeformmodel.cpp index 0311edf72..614483cbd 100644 --- a/app/attributes/attributeformmodel.cpp +++ b/app/attributes/attributeformmodel.cpp @@ -178,7 +178,7 @@ bool AttributeFormModel::setData( const QModelIndex &index, const QVariant &valu case AttributeValue: { const FormItem *item = mController->formItem( uuid ); - //if ( mController->formValue( item->fieldIndex() ) == value ) + if ( item->rawValue() == value ) { return false; diff --git a/app/filter/searchproxymodel.cpp b/app/filter/searchproxymodel.cpp new file mode 100644 index 000000000..7f16671a2 --- /dev/null +++ b/app/filter/searchproxymodel.cpp @@ -0,0 +1,30 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "searchproxymodel.h" + +SearchProxyModel::SearchProxyModel( QObject *parent ) + : QSortFilterProxyModel{parent} +{ + setFilterCaseSensitivity( Qt::CaseInsensitive ); +} + +QString SearchProxyModel::searchString() const +{ + return mSearchString; +} + +void SearchProxyModel::setSearchString( const QString &search ) +{ + if ( mSearchString == search ) + return; + mSearchString = search; + setFilterFixedString( search ); + emit searchStringChanged(); +} diff --git a/app/filter/searchproxymodel.h b/app/filter/searchproxymodel.h new file mode 100644 index 000000000..d276829cd --- /dev/null +++ b/app/filter/searchproxymodel.h @@ -0,0 +1,36 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef SEARCHPROXYMODEL_H +#define SEARCHPROXYMODEL_H + +#include +#include + +class SearchProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( QString searchString READ searchString WRITE setSearchString NOTIFY searchStringChanged ) + + public: + explicit SearchProxyModel( QObject *parent = nullptr ); + + QString searchString() const; + void setSearchString( const QString &newSearchString ); + + signals: + void searchStringChanged(); + + private: + QString mSearchString; +}; + +#endif // SEARCHPROXYMODEL_H diff --git a/app/filter/uniquevaluesfiltermodel.cpp b/app/filter/uniquevaluesfiltermodel.cpp new file mode 100644 index 000000000..501080c8f --- /dev/null +++ b/app/filter/uniquevaluesfiltermodel.cpp @@ -0,0 +1,146 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "uniquevaluesfiltermodel.h" +#include "coreutils.h" + +#include +#include +#include + + +UniqueValuesFilterModel::UniqueValuesFilterModel( QObject *parent ) : QAbstractListModel( parent ) +{ + connect( &mResultWatcher, &QFutureWatcher::finished, this, &UniqueValuesFilterModel::onLoadingFinished ); +} + +int UniqueValuesFilterModel::rowCount( const QModelIndex &parent ) const +{ + Q_UNUSED( parent ) + return mItems.size(); +} + +QVariant UniqueValuesFilterModel::data( const QModelIndex &index, int role ) const +{ + if ( !index.isValid() || index.row() >= mItems.size() ) + return {}; + + switch ( role ) + { + case Qt::DisplayRole: + return mItems.at( index.row() ); + default: + return {}; + } +} + +QString UniqueValuesFilterModel::layerId() const +{ + return mLayerId; +} + +void UniqueValuesFilterModel::setLayerId( const QString &layerId ) +{ + if ( mLayerId == layerId ) + return; + + mLayerId = layerId; + emit layerIdChanged(); +} + +QString UniqueValuesFilterModel::fieldName() const +{ + return mFieldName; +} + +void UniqueValuesFilterModel::setFieldName( const QString &fieldName ) +{ + if ( mFieldName == fieldName ) + return; + + mFieldName = fieldName; + emit fieldNameChanged(); +} + +void UniqueValuesFilterModel::populate() +{ + if ( mLayerId.isEmpty() || mFieldName.isEmpty() ) return; + + QgsMapLayer *mapLayer = QgsProject::instance()->mapLayer( mLayerId ); + if ( !mapLayer ) + { + CoreUtils::log( QStringLiteral( "Filtering" ), QStringLiteral( "Could not get layer %1" ).arg( mLayerId ) ); + } + QgsVectorLayer *layer = qobject_cast( mapLayer ); + + if ( !layer ) return; + + int fieldIndex = layer->fields().lookupField( mFieldName ); + if ( fieldIndex < 0 ) + { + CoreUtils::log( QStringLiteral( "Filtering" ), QStringLiteral( "Error, field %1 could not be found, dropdown filter won't work." ).arg( mFieldName ) ); + return; + } + + // model already populated? + if ( mItems.size() > 0 ) return; + + if ( mResultWatcher.isRunning() ) return; + + QgsVectorLayer *layerClone = layer->clone(); + + mIsLoading = true; + emit isLoadingChanged(); + + mResultWatcher.setFuture( QtConcurrent::run( &UniqueValuesFilterModel::loadUniqueValues, this, layerClone, fieldIndex ) ); +} + +QVariantList UniqueValuesFilterModel::loadUniqueValues( QgsVectorLayer *layer, int fieldIndex ) +{ + std::unique_ptr l( layer ); + + const QSet uniqueValues = l->uniqueValues( fieldIndex, 1000000 ); + + QVariantList results; + + results.reserve( uniqueValues.size() ); + + for ( const QVariant &v : uniqueValues ) + { + results.append( v ); + } + + std::sort( results.begin(), results.end(), []( const QVariant & a, const QVariant & b ) + { + return a.toString() < b.toString(); + } ); + + return results; +} + +void UniqueValuesFilterModel::onLoadingFinished() +{ + beginResetModel(); + + mItems.clear(); + mItems = mResultWatcher.result(); + + // TODO: measure how long it takes to move results from future result to mItems ~ there might be a way to avoid the copy + + endResetModel(); + emit countChanged(); + + mIsLoading = false; + emit isLoadingChanged(); +} + +bool UniqueValuesFilterModel::isLoading() const +{ + return mIsLoading; +} diff --git a/app/filter/uniquevaluesfiltermodel.h b/app/filter/uniquevaluesfiltermodel.h new file mode 100644 index 000000000..046b2c0fe --- /dev/null +++ b/app/filter/uniquevaluesfiltermodel.h @@ -0,0 +1,67 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef UNIQUEVALUESFILTERMODEL_H +#define UNIQUEVALUESFILTERMODEL_H + +#include +#include +#include + +class QgsVectorLayer; + +// This model loads unique values from the selected layer+field and exposes them via Qt::DisplayRole +class UniqueValuesFilterModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( QString layerId READ layerId WRITE setLayerId NOTIFY layerIdChanged ) + Q_PROPERTY( QString fieldName READ fieldName WRITE setFieldName NOTIFY fieldNameChanged ) + Q_PROPERTY( bool isLoading READ isLoading NOTIFY isLoadingChanged ) + Q_PROPERTY( int count READ rowCount NOTIFY countChanged ) + + public: + explicit UniqueValuesFilterModel( QObject *parent = nullptr ); + ~UniqueValuesFilterModel() override = default; + + int rowCount( const QModelIndex &parent = QModelIndex() ) const override; + QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const override; + + QString layerId() const; + void setLayerId( const QString &layerId ); + + QString fieldName() const; + void setFieldName( const QString &fieldName ); + + bool isLoading() const; + + Q_INVOKABLE void populate(); + + signals: + void layerIdChanged(); + void fieldNameChanged(); + void isLoadingChanged(); + void countChanged(); + + public slots: + void onLoadingFinished(); + + private: + QVariantList loadUniqueValues( QgsVectorLayer *layer, int fieldIndex ); + + QString mLayerId; + QString mFieldName; + + QVariantList mItems; + QFutureWatcher mResultWatcher; + bool mIsLoading = false; +}; + +#endif // UNIQUEVALUESFILTERMODEL_H diff --git a/app/filter/valuemapfiltermodel.cpp b/app/filter/valuemapfiltermodel.cpp new file mode 100644 index 000000000..d51cecfd8 --- /dev/null +++ b/app/filter/valuemapfiltermodel.cpp @@ -0,0 +1,131 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "valuemapfiltermodel.h" + +#include + +ValueMapFilterModel::ValueMapFilterModel( QObject *parent ) + : QAbstractListModel( parent ) +{ + connect( &mResultWatcher, &QFutureWatcher>::finished, this, &ValueMapFilterModel::onLoadingFinished ); +} + +int ValueMapFilterModel::rowCount( const QModelIndex &parent ) const +{ + Q_UNUSED( parent ) + return mItems.size(); +} + +QVariant ValueMapFilterModel::data( const QModelIndex &index, int role ) const +{ + if ( !index.isValid() || index.row() >= mItems.size() ) + return {}; + + const Item &item = mItems.at( index.row() ); + + switch ( role ) + { + case Qt::DisplayRole: + return item.description; + case KeyRole: + return item.key; + default: + return {}; + } +} + +QHash ValueMapFilterModel::roleNames() const +{ + QHash roles = QAbstractListModel::roleNames(); + roles[KeyRole] = QStringLiteral( "Key" ).toLatin1(); + + return roles; +} + +QVariantMap ValueMapFilterModel::config() const +{ + return mConfig; +} + +bool ValueMapFilterModel::isLoading() const +{ + return mIsLoading; +} + +void ValueMapFilterModel::setConfig( const QVariantMap &config ) +{ + if ( mConfig == config ) + return; + + mConfig = config; + emit configChanged(); + + if ( mResultWatcher.isRunning() ) + { + mHasPendingLoad = true; + return; + } + + startLoad(); +} + +void ValueMapFilterModel::startLoad() +{ + mIsLoading = true; + emit isLoadingChanged(); + + mResultWatcher.setFuture( QtConcurrent::run( &ValueMapFilterModel::loadItems, mConfig ) ); +} + +QList ValueMapFilterModel::loadItems( const QVariantMap &config ) +{ + const QVariantList mapList = config.value( QStringLiteral( "map" ) ).toList(); + + QList items; + items.reserve( mapList.size() ); + + for ( const QVariant &entry : mapList ) + { + const QVariantMap entryMap = entry.toMap(); + + if ( entryMap.isEmpty() ) + continue; + + // Each entry is a single-key map: {"Display Text": "stored_value"} + Item item; + item.description = entryMap.constBegin().key(); + item.key = entryMap.constBegin().value().toString(); + + items.append( item ); + } + + return items; +} + +void ValueMapFilterModel::onLoadingFinished() +{ + beginResetModel(); + + mItems = mResultWatcher.result(); + + endResetModel(); + emit countChanged(); + + if ( mHasPendingLoad ) + { + mHasPendingLoad = false; + startLoad(); + } + else + { + mIsLoading = false; + emit isLoadingChanged(); + } +} diff --git a/app/filter/valuemapfiltermodel.h b/app/filter/valuemapfiltermodel.h new file mode 100644 index 000000000..945bfa152 --- /dev/null +++ b/app/filter/valuemapfiltermodel.h @@ -0,0 +1,75 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef VALUEMAPFILTERMODEL_H +#define VALUEMAPFILTERMODEL_H + +#include +#include +#include + +/** + * Populates a list model from a QGIS ValueMap editor widget config. + * Exposes TextRole (display label) and ValueRole (stored key) for each entry. + */ +class ValueMapFilterModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( QVariantMap config READ config WRITE setConfig NOTIFY configChanged ) + Q_PROPERTY( bool isLoading READ isLoading NOTIFY isLoadingChanged ) + Q_PROPERTY( int count READ rowCount NOTIFY countChanged ) + + public: + enum Roles + { + KeyRole = Qt::UserRole + 1, // DisplayRole is used for description + }; + Q_ENUM( Roles ) + + explicit ValueMapFilterModel( QObject *parent = nullptr ); + ~ValueMapFilterModel() override = default; + + bool isLoading() const; + + int rowCount( const QModelIndex &parent = QModelIndex() ) const override; + QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const override; + QHash roleNames() const override; + + QVariantMap config() const; + void setConfig( const QVariantMap &config ); + + signals: + void configChanged(); + void isLoadingChanged(); + void countChanged(); + + private slots: + void onLoadingFinished(); + + private: + struct Item + { + QString description; + QString key; + }; + + static QList loadItems( const QVariantMap &config ); + void startLoad(); + + QVariantMap mConfig; + + QList mItems; + QFutureWatcher> mResultWatcher; + bool mIsLoading = false; + bool mHasPendingLoad = false; +}; + +#endif // VALUEMAPFILTERMODEL_H diff --git a/app/filtercontroller.cpp b/app/filtercontroller.cpp new file mode 100644 index 000000000..bfcb24178 --- /dev/null +++ b/app/filtercontroller.cpp @@ -0,0 +1,486 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "filtercontroller.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "coreutils.h" + + +FilterController::FilterController( QObject *parent ) + : QObject( parent ) +{ +} + +void FilterController::clearLayerFilters( const QString &layerId ) +{ + QgsMapLayer *layer = QgsProject::instance()->mapLayers().value( layerId ); + QgsVectorLayer *vectorLayer = qobject_cast( layer ); + vectorLayer->setSubsetString( QStringLiteral( "" ) ); + + for ( FieldFilter filter : mFieldFilters ) + { + if ( filter.layerId == layerId ) + { + filter.value.clear(); + } + } +} + +void FilterController::clearAllFilters() +{ + for ( FieldFilter &filter : mFieldFilters ) + { + filter.value.clear(); + } + mFilteringEnabled = false; + emit hasFiltersEnabledChanged(); + + const QMap layers = QgsProject::instance()->mapLayers(); + for ( auto it = layers.constBegin(); it != layers.constEnd(); ++it ) + { + QgsVectorLayer *vectorLayer = qobject_cast( it.value() ); + if ( vectorLayer ) + { + vectorLayer->setSubsetString( QStringLiteral( "" ) ); + } + } +} + +void FilterController::loadFilterConfig( const QgsProject *project ) +{ + mFieldFilters.clear(); + setFiltersEnabled( false ); + + bool valueRead = false; + const bool filteringAvailable = project->readBoolEntry( QStringLiteral( "Mergin" ), QStringLiteral( "Filtering/Enabled" ), false, &valueRead ); + + //return early if filtering is not setup + if ( !valueRead ) + { + return; + } + mFilteringAvailable = filteringAvailable; + emit hasFiltersAvailableChanged(); + + const QString filtersDef = project->readEntry( QStringLiteral( "Mergin" ), QStringLiteral( "Filtering/Filters" ) ); + QJsonParseError jsonError; + const QJsonDocument filtersRaw = QJsonDocument::fromJson( filtersDef.toUtf8(), &jsonError ); + if ( jsonError.error != QJsonParseError::NoError ) + { + CoreUtils::log( QStringLiteral( "Feature Filtering" ), QStringLiteral( "Could not parse filters from json document." ) ); + return; + } + + if ( !filtersRaw.isEmpty() && filtersRaw.isArray() ) + { + const QJsonArray filtersArray = filtersRaw.array(); + for ( auto filter = filtersArray.constBegin(); filter != filtersArray.constEnd(); ++filter ) + { + FieldFilter newFieldFilter; + QJsonObject filterObject = filter->toObject(); + + newFieldFilter.filterId = QUuid::createUuid().toString( QUuid::WithoutBraces ); + + newFieldFilter.filterName = filterObject.value( QStringLiteral( "filter_name" ) ).toString(); + + QString filterTypeRaw = filterObject.value( QStringLiteral( "filter_type" ) ).toString(); + if ( filterTypeRaw == QStringLiteral( "Text" ) ) + { + newFieldFilter.filterType = FieldFilter::TextFilter; + } + else if ( filterTypeRaw == QStringLiteral( "Number" ) ) + { + newFieldFilter.filterType = FieldFilter::NumberFilter; + } + else if ( filterTypeRaw == QStringLiteral( "Date" ) ) + { + newFieldFilter.filterType = FieldFilter::DateFilter; + } + else if ( filterTypeRaw == QStringLiteral( "Checkbox" ) ) + { + newFieldFilter.filterType = FieldFilter::CheckboxFilter; + } + else if ( filterTypeRaw == QStringLiteral( "Single select" ) ) + { + newFieldFilter.filterType = FieldFilter::SingleSelectFilter; + } + else if ( filterTypeRaw == QStringLiteral( "Multi select" ) ) + { + newFieldFilter.filterType = FieldFilter::MultiSelectFilter; + } + + newFieldFilter.fieldName = filterObject.value( QStringLiteral( "field_name" ) ).toString(); + newFieldFilter.provider = filterObject.value( QStringLiteral( "provider" ) ).toString(); + newFieldFilter.sqlExpression = filterObject.value( QStringLiteral( "sql_expression" ) ).toString(); + newFieldFilter.layerId = filterObject.value( QStringLiteral( "layer_id" ) ).toString(); + + mFieldFilters.append( newFieldFilter ); + } + } +} + +QString FilterController::buildFieldExpression( const FieldFilter &filter ) const +{ + QString expressionCopy = filter.sqlExpression; + switch ( filter.filterType ) + { + case FieldFilter::TextFilter: + { + QString textValue = QgsExpression::quotedString( filter.value.toList().at( 0 ).toString() ); + // remove single quotes from the beginning and end of returned string + textValue = textValue.slice( 1, textValue.size() - 2 ); + expressionCopy.replace( QStringLiteral( "@@value@@" ), textValue ); + break; + } + case FieldFilter::CheckboxFilter: + case FieldFilter::SingleSelectFilter: + { + expressionCopy.replace( QStringLiteral( "@@value@@" ), QgsExpression::quotedValue( filter.value.toList().at( 0 ) ) ); + break; + } + case FieldFilter::NumberFilter: + { + const QVariant &variantFrom = filter.value.toList().at( 0 ); + const QString valueFrom = variantFrom.isValid() ? variantFrom.toString() : QString::number( std::numeric_limits::min() ); + const QVariant &variantTo = filter.value.toList().at( 1 ); + const QString valueTo = variantTo.isValid() ? variantTo.toString() : QString::number( std::numeric_limits::max() ); + + expressionCopy.replace( QStringLiteral( "@@value_from@@" ), valueFrom ); + expressionCopy.replace( QStringLiteral( "@@value_to@@" ), valueTo ); + break; + } + case FieldFilter::DateFilter: + { + // GeoPackage stores datetimes as timezone-naive strings (effectively UTC), + // so we must convert local datetimes to UTC before comparing. + // Use a custom format to avoid the 'Z' suffix that Qt::ISODate adds for UTC. + const QString isoFormat = QStringLiteral( "yyyy-MM-ddTHH:mm:ss.zzz" ); + const QString minimumDateTime = QStringLiteral( "0001-01-01T00:00:00.000" ); + const QString maximumDateTime = QStringLiteral( "9999-12-31T23:59:59.999" ); + + QString dateFrom; + const QVariant &variantFrom = filter.value.toList().at( 0 ); + if ( variantFrom.isValid() ) + { + QDateTime dateTimeFrom = variantFrom.toDateTime( ); + QTime timeFrom = dateTimeFrom.time(); + timeFrom.setHMS( timeFrom.hour(), timeFrom.minute(), 0 ); + dateTimeFrom.setTime( timeFrom ); + dateFrom = dateTimeFrom.toString( isoFormat ); + } + else + { + dateFrom = minimumDateTime; + } + + const QVariant &variantTo = filter.value.toList().at( 1 ); + QString dateTo; + if ( variantTo.isValid() ) + { + if ( variantTo.toDateTime().time().hour() > 0 || variantTo.toDateTime().time().minute() > 0 ) + { + QDateTime dateTimeTo = variantFrom.toDateTime( ); + QTime timeFrom = dateTimeTo.time(); + timeFrom.setHMS( timeFrom.hour(), timeFrom.minute(), 59, 999 ); + dateTimeTo.setTime( timeFrom ); + dateTo = dateTimeTo.toString( isoFormat ); + } + else + { + dateTo = variantFrom.toDateTime().toString( isoFormat ); + } + } + else + { + dateTo = maximumDateTime; + } + + expressionCopy.replace( QStringLiteral( "@@value_from@@" ), dateFrom ); + expressionCopy.replace( QStringLiteral( "@@value_to@@" ), dateTo ); + break; + } + case FieldFilter::MultiSelectFilter: + { + const QVariantList values = filter.value.toList(); + if ( values.isEmpty() ) + { + expressionCopy = {}; + break; + } + + QStringList quotedValues; + for ( const QVariant &v : values ) + { + quotedValues << QgsExpression::quotedValue( v ); + } + expressionCopy.replace( QStringLiteral( "@@values@@" ), quotedValues.join( QStringLiteral( ", " ) ) ); + break; + } + } + + return expressionCopy; +} + +QString FilterController::generateFilterExpression( const QString &layerId ) const +{ + QStringList expressions; + + for ( const FieldFilter &filter : mFieldFilters ) + { + if ( filter.layerId != layerId || !filter.value.isValid() ) continue; + + QString expr = buildFieldExpression( filter ); + if ( !expr.isEmpty() ) + { + expressions << QStringLiteral( "(%1)" ).arg( expr ); + } + } + + if ( expressions.isEmpty() ) + return {}; + + return expressions.join( QStringLiteral( " AND " ) ); +} + +void FilterController::applyFiltersToLayer( QgsVectorLayer *layer ) +{ + if ( !layer ) + return; + + const QString filterExpr = generateFilterExpression( layer->id() ); + const bool success = layer->setSubsetString( filterExpr ); + + if ( !filterExpr.isEmpty() && success && !mFilteringEnabled ) + { + mFilteringEnabled = true; + emit hasFiltersEnabledChanged(); + } + + qDebug() << "Applied filter to layer" << layer->name() << ":" << filterExpr << "success:" << success; + + // Trigger a layer refresh to ensure the filter takes effect + if ( success ) + { + layer->triggerRepaint(); + } +} + +void FilterController::applyFiltersToAllLayers() +{ + // Change filters enabled to false before enabling filters to find out if any are active + mFilteringEnabled = false; + emit hasFiltersEnabledChanged(); + + const QgsProject *project = QgsProject::instance(); + if ( !project ) + return; + + const QMap layers = project->mapLayers(); + for ( auto it = layers.constBegin(); it != layers.constEnd(); ++it ) + { + QgsVectorLayer *vectorLayer = qobject_cast( it.value() ); + if ( vectorLayer ) + { + applyFiltersToLayer( vectorLayer ); + } + } +} + +bool FilterController::hasFiltersAvailable() const +{ + return mFilteringAvailable; +} + +bool FilterController::hasFiltersEnabled() const +{ + return mFilteringEnabled; +} + +void FilterController::setFiltersEnabled( const bool filtersEnabled ) +{ + if ( mFilteringEnabled != filtersEnabled ) + { + mFilteringEnabled = filtersEnabled; + emit hasFiltersEnabledChanged(); + } +} + +QVariantList FilterController::getFilters() const +{ + QVariantList uiFilters; + for ( const FieldFilter &filter : mFieldFilters ) + { + QVariantMap filterLite; + filterLite.insert( QStringLiteral( "filterName" ), filter.filterName ); + filterLite.insert( QStringLiteral( "filterType" ), filter.filterType ); + filterLite.insert( QStringLiteral( "filterId" ), filter.filterId ); + filterLite.insert( QStringLiteral( "value" ), filter.value ); + + uiFilters.append( filterLite ); + } + return uiFilters; +} + +void FilterController::processFilters( const QVariantMap &newFilters ) +{ + // save all newFilter values to mFieldFilters values + for ( FieldFilter &filter : mFieldFilters ) + { + if ( newFilters.contains( filter.filterId ) ) + { + filter.value = newFilters.value( filter.filterId ); + } + } + + applyFiltersToAllLayers(); +} + +bool FilterController::hasActiveFilterOnLayer( const QString &layerId ) +{ + const QgsProject *project = QgsProject::instance(); + if ( !project ) + return false; + + const QgsVectorLayer *layer = qobject_cast( project->mapLayers().value( layerId ) ); + return !layer->subsetString().isEmpty(); +} + +bool FilterController::isDateFilterDateTime( const QString &filterId ) +{ + for ( FieldFilter &filter : mFieldFilters ) + { + if ( filter.filterId == filterId ) + { + const QgsVectorLayer *layer = qobject_cast( QgsProject::instance()->mapLayer( filter.layerId ) ); + const QMetaType::Type fieldType = layer->fields().field( filter.fieldName ).type(); + return fieldType == QMetaType::QDateTime; + } + } + return false; +} + +QVariantMap FilterController::getCheckboxConfiguration( const QString &filterId ) +{ + if ( filterId.isEmpty() ) return {}; + + FieldFilter fieldFilter; + for ( const FieldFilter &filter : std::as_const( mFieldFilters ) ) + { + if ( filterId == filter.filterId ) + { + fieldFilter = filter; + break; + } + } + + if ( !fieldFilter.hasFilterInfo() ) return {}; + + if ( fieldFilter.filterType != FieldFilter::CheckboxFilter ) return {}; + + const QgsVectorLayer *layer = qobject_cast( QgsProject::instance()->mapLayer( fieldFilter.layerId ) ); + if ( !layer ) return {}; + + const int fieldIndex = layer->fields().lookupField( fieldFilter.fieldName ); + if ( fieldIndex < 0 ) return {}; + + const QgsEditorWidgetSetup widgetSetup = layer->editorWidgetSetup( fieldIndex ); + if ( QString::compare( widgetSetup.type(), QStringLiteral( "CheckBox" ), Qt::CaseInsensitive ) != 0 ) return {}; + + const QVariantMap config = widgetSetup.config(); + const QString checkedState = config.value( QStringLiteral( "CheckedState" ) ).toString(); + const QString uncheckedState = config.value( QStringLiteral( "UncheckedState" ) ).toString(); + + if ( checkedState.isEmpty() && uncheckedState.isEmpty() ) return {}; + + const QMetaType::Type fieldType = static_cast( layer->fields().field( fieldIndex ).type() ); + const bool isIntField = ( fieldType == QMetaType::Int || fieldType == QMetaType::UInt || + fieldType == QMetaType::LongLong || fieldType == QMetaType::ULongLong ); + + QVariantMap result; + + if ( !checkedState.isEmpty() ) + { + result[QStringLiteral( "customLabelForTrue" )] = checkedState; + bool ok = false; + const int intVal = checkedState.toInt( &ok ); + result[QStringLiteral( "customValueForTrue" )] = ( isIntField && ok ) ? QVariant( intVal ) : QVariant( checkedState ); + } + + if ( !uncheckedState.isEmpty() ) + { + result[QStringLiteral( "customLabelForFalse" )] = uncheckedState; + bool ok = false; + const int intVal = uncheckedState.toInt( &ok ); + result[QStringLiteral( "customValueForFalse" )] = ( isIntField && ok ) ? QVariant( intVal ) : QVariant( uncheckedState ); + } + + return result; +} + +QVariantMap FilterController::getDropdownConfiguration( const QString &filterId ) +{ + if ( filterId.isEmpty() ) return {}; + + FieldFilter fieldFilter; + for ( const FieldFilter &filter : std::as_const( mFieldFilters ) ) + { + if ( filterId == filter.filterId ) + { + fieldFilter = filter; + break; + } + } + + if ( !fieldFilter.hasFilterInfo() ) return {}; + + if ( fieldFilter.filterType != FieldFilter::SingleSelectFilter && fieldFilter.filterType != FieldFilter::MultiSelectFilter ) return {}; + + QVariantMap map; + + const QgsProject *project = QgsProject::instance(); + + if ( !project ) return {}; + + const QgsVectorLayer *layer = qobject_cast( project->mapLayers().value( fieldFilter.layerId ) ); + + if ( !layer ) return {}; + + const int fieldIndex = layer->fields().lookupField( fieldFilter.fieldName ); + const QgsEditorWidgetSetup fieldConfig = layer->editorWidgetSetup( fieldIndex ); + + if ( QString::compare( fieldConfig.type(), QStringLiteral( "ValueMap" ), Qt::CaseInsensitive ) == 0 ) + { + map["type"] = QStringLiteral( "value_map" ); + map["config"] = fieldConfig.config(); + } + else if ( QString::compare( fieldConfig.type(), QStringLiteral( "ValueRelation" ), Qt::CaseInsensitive ) == 0 ) + { + map["type"] = QStringLiteral( "value_relation" ); + map["config"] = fieldConfig.config(); + } + else + { + map["type"] = QStringLiteral( "unique_values" ); + map["layer_id"] = fieldFilter.layerId; + map["field_name"] = fieldFilter.fieldName; + } + + return map; +} diff --git a/app/filtercontroller.h b/app/filtercontroller.h new file mode 100644 index 000000000..d7c062431 --- /dev/null +++ b/app/filtercontroller.h @@ -0,0 +1,179 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef FILTERCONTROLLER_H +#define FILTERCONTROLLER_H + +#include +#include +#include + +class QgsVectorLayer; +class QgsMapLayer; + +/** + * @brief Single field filter definition + */ +struct FieldFilter +{ + Q_GADGET + QML_ELEMENT + QML_UNCREATABLE( "Full FieldFilter is not exposed to QML, use only the exposed enum." ) + + public: + enum FilterType + { + TextFilter, + NumberFilter, + DateFilter, + CheckboxFilter, + SingleSelectFilter, + MultiSelectFilter + }; + Q_ENUM( FilterType ) + + QString filterId; // generated ID (not persistent) for faster lookup + QString filterName; // name of filter as set by user + FilterType filterType; + QString layerId; // ID of layer this filter applies on + QString fieldName; // name of field, which gets filtered + QString sqlExpression; // SQL expression generated by plugin + QVariant value; // single value (text, checkbox) or list of values (date, number, single/multi select) + QString provider; // data provider type + + bool isValid() const + { + const bool hasValue = value.isValid() && !value.isNull(); + const bool isNamed = !filterName.isEmpty(); + return isNamed && hasValue && hasFilterInfo(); + } + + bool hasFilterInfo() const + { + return !layerId.isEmpty() && !fieldName.isEmpty() && !sqlExpression.isEmpty(); + } +}; + + +/** + * @brief FilterController manages feature filtering across all layers in the project. + * + * It stores filter definitions per layer and generates QGIS filter expressions. + * The filtering affects both map rendering and feature lists. + */ +class FilterController : public QObject +{ + Q_OBJECT + + /** + * Whether filtering has been enabled in QGIS plugin on active project + */ + Q_PROPERTY( bool filteringAvailable READ hasFiltersAvailable NOTIFY hasFiltersAvailableChanged ) + + /** + * Whether filtering is currently active (can be temporarily disabled) + */ + Q_PROPERTY( bool filteringEnabled READ hasFiltersEnabled WRITE setFiltersEnabled NOTIFY hasFiltersEnabledChanged ) + + public: + explicit FilterController( QObject *parent = nullptr ); + ~FilterController() override = default; + + /** + * Gets list of all available filters + * \return QVariantList of QVariantMaps, which has subset of properties from FieldFilter + */ + Q_INVOKABLE QVariantList getFilters() const; + + /** + * Receives the set values for filtering, saves them and applies them to layers + */ + Q_INVOKABLE void processFilters( const QVariantMap &newFilters ); + + /** + * @brief Clears all filters for a specific layer + */ + Q_INVOKABLE void clearLayerFilters( const QString &layerId ); + + /** + * @brief Clears all filters for all layers + */ + Q_INVOKABLE void clearAllFilters(); + + // Indicates if the filter with filterId should use unique values, value relation or value map + returns configs for each + Q_INVOKABLE QVariantMap getDropdownConfiguration( const QString &filterId ); + + /** + * @brief Returns custom label/value configuration for a CheckboxFilter field. + */ + Q_INVOKABLE QVariantMap getCheckboxConfiguration( const QString &filterId ); + + /** + * Queries whether there is any filtering active on the layer specified by ID. Essentially just checks the + * subsetString of layer. + */ + Q_INVOKABLE bool hasActiveFilterOnLayer( const QString &layerId ); + + /** + * Returns whether the date filter is datetime or just date field. Used to show date or date & time UI for users. + */ + Q_INVOKABLE bool isDateFilterDateTime( const QString &filterId ); + + bool hasFiltersAvailable() const; + + bool hasFiltersEnabled() const; + + void setFiltersEnabled( bool filtersEnabled ); + + + signals: + void hasActiveFiltersChanged(); + void layerFilterChanged( const QString &layerId ); + void hasFiltersAvailableChanged(); + void hasFiltersEnabledChanged(); + + public slots: + + /** + * Gets triggered when active project changes. Loads filtering config from project file and initializes everything. + */ + void loadFilterConfig( const QgsProject *project ); + + private: + /** + * @brief Applies filters to all vector layers in the current project + */ + void applyFiltersToAllLayers(); + + /** + * @brief Applies the current filters to a vector layer's subset string + * @param layer The layer to apply filters to + */ + void applyFiltersToLayer( QgsVectorLayer *layer ); + + /** + * @brief Generates a QGIS filter expression string for a layer + * @param layerId The layer ID + * @return Filter expression string, empty if no filters active + */ + QString generateFilterExpression( const QString &layerId ) const; + + /** + * Sets filter values for expression and generates usable field filter expression + * \return String of usable SQL expression + */ + QString buildFieldExpression( const FieldFilter &filter ) const; + + QList mFieldFilters; + + bool mFilteringAvailable = false; + bool mFilteringEnabled = false; +}; + +#endif // FILTERCONTROLLER_H diff --git a/app/i18n/input_ca.qm b/app/i18n/input_ca.qm index 8b3681222..5279f80f7 100644 Binary files a/app/i18n/input_ca.qm and b/app/i18n/input_ca.qm differ diff --git a/app/i18n/input_en.qm b/app/i18n/input_en.qm index 61969a250..666e47cd0 100644 Binary files a/app/i18n/input_en.qm and b/app/i18n/input_en.qm differ diff --git a/app/i18n/input_et.qm b/app/i18n/input_et.qm index 45c1cc3c7..c537a2535 100644 Binary files a/app/i18n/input_et.qm and b/app/i18n/input_et.qm differ diff --git a/app/i18n/input_et_EE.qm b/app/i18n/input_et_EE.qm index bf5eb8547..dd682896a 100644 Binary files a/app/i18n/input_et_EE.qm and b/app/i18n/input_et_EE.qm differ diff --git a/app/i18n/input_fi.qm b/app/i18n/input_fi.qm index 6e7e0e13a..30fff2e1c 100644 Binary files a/app/i18n/input_fi.qm and b/app/i18n/input_fi.qm differ diff --git a/app/i18n/input_fi_FI.qm b/app/i18n/input_fi_FI.qm index ec6651172..1a106a023 100644 Binary files a/app/i18n/input_fi_FI.qm and b/app/i18n/input_fi_FI.qm differ diff --git a/app/i18n/input_hu.qm b/app/i18n/input_hu.qm index 5163085f2..51da9c8d1 100644 Binary files a/app/i18n/input_hu.qm and b/app/i18n/input_hu.qm differ diff --git a/app/i18n/input_sk.qm b/app/i18n/input_sk.qm index 97a8a0115..c1108b1ce 100644 Binary files a/app/i18n/input_sk.qm and b/app/i18n/input_sk.qm differ diff --git a/app/i18n/input_sk_SK.qm b/app/i18n/input_sk_SK.qm index 95368c812..ef723a1e0 100644 Binary files a/app/i18n/input_sk_SK.qm and b/app/i18n/input_sk_SK.qm differ diff --git a/app/icons/Filter.svg b/app/icons/Filter.svg new file mode 100644 index 000000000..f9b351a07 --- /dev/null +++ b/app/icons/Filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/icons.qrc b/app/icons/icons.qrc index 8d9a78db9..656d4b479 100644 --- a/app/icons/icons.qrc +++ b/app/icons/icons.qrc @@ -27,6 +27,7 @@ Facebook.svg Features.svg FeaturesFilled.svg + Filter.svg GPSAntennaHeight.svg GPSIcon.svg GPSSatellite.svg diff --git a/app/layerfeaturesmodel.h b/app/layerfeaturesmodel.h index 47995c4ed..27d2fb2b9 100644 --- a/app/layerfeaturesmodel.h +++ b/app/layerfeaturesmodel.h @@ -73,6 +73,7 @@ class LayerFeaturesModel : public FeaturesModel int featuresLimit() const; QgsVectorLayer *layer() const; QString searchExpression() const; + bool fetchingResults() const { return mFetchingResults; } void setSearchExpression( const QString &searchExpression ); void setLayer( QgsVectorLayer *newLayer ); diff --git a/app/mmstyle.h b/app/mmstyle.h index 1dc74b4f6..aa2207657 100644 --- a/app/mmstyle.h +++ b/app/mmstyle.h @@ -127,6 +127,7 @@ class MMStyle: public QObject Q_PROPERTY( QUrl eraserIcon READ eraserIcon CONSTANT ) Q_PROPERTY( QUrl facebookIcon READ facebookIcon CONSTANT ) Q_PROPERTY( QUrl featuresIcon READ featuresIcon CONSTANT ) + Q_PROPERTY( QUrl filterIcon READ filterIcon CONSTANT ) Q_PROPERTY( QUrl globeIcon READ globeIcon CONSTANT ) Q_PROPERTY( QUrl globalIcon READ globalIcon CONSTANT ) Q_PROPERTY( QUrl gpsIcon READ gpsIcon CONSTANT ) @@ -430,6 +431,7 @@ class MMStyle: public QObject QUrl closeIcon() const {return QUrl( "qrc:/Close.svg" );} QUrl deleteIcon() const {return QUrl( "qrc:/Delete.svg" );} QUrl featuresIcon() const {return QUrl( "qrc:/Features.svg" );} + QUrl filterIcon() const {return QUrl( "qrc:/Filter.svg" );} QUrl downloadIcon() const {return QUrl( "qrc:/Download.svg" );} QUrl uploadIcon() const {return QUrl( "qrc:/Upload.svg" );} QUrl editIcon() const {return QUrl( "qrc:/Edit.svg" );} diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index d9662b019..da028ac0c 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -31,12 +31,14 @@ set(MM_QML components/MMListDelegate.qml components/MMListDrawer.qml components/MMListFooterSpacer.qml + components/MMListEmptyLoaderDelegate.qml components/MMListMultiselectDrawer.qml components/MMListView.qml components/MMListSpacer.qml components/MMBusyIndicator.qml components/MMMessage.qml components/MMNotification.qml + components/MMNotificationBox.qml components/MMNotificationView.qml components/MMPage.qml components/MMPageHeader.qml @@ -54,10 +56,22 @@ set(MM_QML components/MMToolbar.qml components/MMToolbarButton.qml components/MMSingleClickMouseArea.qml + components/MMSegmentControl.qml components/private/MMBaseInput.qml components/private/MMBaseSingleLineInput.qml components/private/MMToolbarLongButton.qml components/private/MMToolbarShortButton.qml + filters/MMFilterChip.qml + filters/MMFiltersPanel.qml + filters/components/MMFilterBanner.qml + filters/components/MMFilterBoolInput.qml + filters/components/MMFilterDateRange.qml + filters/components/MMFilterDropdownValueMapInput.qml + filters/components/MMFilterDropdownValueRelationInput.qml + filters/components/MMFilterDropdownUniqueValuesInput.qml + filters/components/MMFilterRangeInput.qml + filters/components/MMFilterTextEditor.qml + filters/components/MMFilterTextInput.qml dialogs/MMCloseAccountDialog.qml dialogs/MMDownloadProjectDialog.qml dialogs/MMMigrateToMerginDialog.qml diff --git a/app/qml/components/MMButton.qml b/app/qml/components/MMButton.qml index 0c91ef0d9..019c651d0 100644 --- a/app/qml/components/MMButton.qml +++ b/app/qml/components/MMButton.qml @@ -15,7 +15,7 @@ Button { id: root enum Types { Primary, Secondary, Tertiary } - enum Sizes { Small, Regular } + enum Sizes { Small, Regular, ExtraSmall } property int type: MMButton.Types.Primary property int size: MMButton.Sizes.Regular @@ -162,11 +162,20 @@ Button { state: "default" - implicitHeight: root.type === MMButton.Types.Tertiary ? buttonContent.height : buttonContent.height + topPadding + bottomPadding - implicitWidth: row.paintedChildrenWidth + 2 * ( root.size === MMButton.Sizes.Small ? __style.margin16 : __style.margin20 ) + implicitHeight: buttonContent.height + topPadding + bottomPadding + + implicitWidth: { + let margin = __style.margin20 + if ( root.size === MMButton.Sizes.ExtraSmall ) margin = __style.margin8 + else if ( root.size === MMButton.Sizes.Small ) margin = __style.margin16 + return row.paintedChildrenWidth + 2 * margin + } topPadding: { - if ( root.type === MMButton.Types.Tertiary ) { + if ( root.size === MMButton.Sizes.ExtraSmall ) { + return __style.margin2; + } + else if ( root.type === MMButton.Types.Tertiary ) { return 0; } else if ( root.size === MMButton.Sizes.Small ) { @@ -178,7 +187,10 @@ Button { } bottomPadding: { - if ( root.type === MMButton.Types.Tertiary ) { + if ( root.size === MMButton.Sizes.ExtraSmall ) { + return __style.margin2; + } + else if ( root.type === MMButton.Types.Tertiary ) { return 0; } else if ( root.size === MMButton.Sizes.Small ) { @@ -200,7 +212,12 @@ Button { id: row property real paintedChildrenWidth: buttonIconLeft.paintedWidth + buttonContent.implicitWidth + buttonIconRight.paintedWidth + spacing - property real maxWidth: parent.width - 2 * ( root.size === MMButton.Sizes.Small ? __style.margin16 : __style.margin20 ) + property real maxWidth: { + let margin = __style.margin20 + if ( root.size === MMButton.Sizes.ExtraSmall ) margin = __style.margin8 + else if ( root.size === MMButton.Sizes.Small ) margin = __style.margin16 + return parent.width - 2 * margin + } x: ( parent.width - width ) / 2 @@ -232,7 +249,7 @@ Button { width: parent.width - buttonIconLeft.paintedWidth - buttonIconRight.paintedWidth - font: __style.t3 + font: root.size === MMButton.Sizes.ExtraSmall ? __style.t5 : __style.t3 text: root.text } @@ -266,7 +283,7 @@ Button { background: Rectangle { id: buttonBackground - radius: __style.radius30 + radius: root.size === MMButton.Sizes.ExtraSmall ? __style.radius40 : __style.radius30 border.width: 2 * __dp } diff --git a/app/qml/components/MMDrawer.qml b/app/qml/components/MMDrawer.qml index 4badaa59f..3b571d2fd 100644 --- a/app/qml/components/MMDrawer.qml +++ b/app/qml/components/MMDrawer.qml @@ -43,10 +43,11 @@ Drawer { layer.effect: MMShadow {} Rectangle { - color: __style.polarColor width: parent.width height: parent.height / 2 y: parent.height / 2 + + color: __style.polarColor } } @@ -54,8 +55,9 @@ Drawer { id: mainColumn anchors.fill: parent + spacing: 0 - focus : true + focus: true Keys.onReleased: function( event ) { if ( event.key === Qt.Key_Back || event.key === Qt.Key_Escape ) { @@ -74,9 +76,7 @@ Drawer { width: parent.width - onCloseClicked: { - root.close() - } + onCloseClicked: root.close() } Item { @@ -98,7 +98,6 @@ Drawer { let leftSideOverflow = ( parent.width - minSidesPadding - __style.maxPageWidth ) / 2 return leftSideOverflow + minLeftPadding } - return minLeftPadding } @@ -107,7 +106,6 @@ Drawer { let rightSideOverflow = ( parent.width - minSidesPadding - __style.maxPageWidth ) / 2 return rightSideOverflow + minRightPadding } - return minRightPadding } @@ -123,7 +121,11 @@ Drawer { id: bottomSpacer width: parent.width - height: root.drawerBottomMargin > 0 ? __style.safeAreaBottom + root.drawerBottomMargin :0 + height: root.drawerBottomMargin > 0 ? __style.safeAreaBottom + root.drawerBottomMargin : 0 } } + + Behavior on implicitHeight { + PropertyAnimation { duration: 200; easing.type: Easing.InOutQuad } + } } diff --git a/app/qml/components/MMListEmptyLoaderDelegate.qml b/app/qml/components/MMListEmptyLoaderDelegate.qml new file mode 100644 index 000000000..738df0f99 --- /dev/null +++ b/app/qml/components/MMListEmptyLoaderDelegate.qml @@ -0,0 +1,40 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +Item { + id: root + + property bool isLoading: false + + height: root.isLoading + ? busyIndicator.height + 2 * __style.margin12 + : noItemsText.implicitHeight + 2 * __style.margin20 + + MMText { + id: noItemsText + + anchors.centerIn: parent + + visible: !root.isLoading + + text: qsTr( "No items" ) + font: __style.p5 + color: __style.mediumGreyColor + } + + MMBusyIndicator { + id: busyIndicator + + anchors.centerIn: parent + + running: root.isLoading + } +} diff --git a/app/qml/components/MMListMultiselectDrawer.qml b/app/qml/components/MMListMultiselectDrawer.qml index ee59f5e6c..ce746e2c7 100644 --- a/app/qml/components/MMListMultiselectDrawer.qml +++ b/app/qml/components/MMListMultiselectDrawer.qml @@ -34,8 +34,8 @@ MMDrawer { interactive: !listViewComponent.interactive drawerBottomMargin: listViewComponent.count === 0 - ? (__style.margin20 + __style.safeAreaBottom) - : 0 + ? ( __style.margin20 + __style.safeAreaBottom ) + : 0 drawerContent: Item { width: parent.width @@ -52,10 +52,10 @@ MMDrawer { I.MMSearchInput { id: searchBar - delayedSearch: true - width: parent.width + delayedSearch: true + placeholderText: qsTr( "Search" ) textFieldBackground.color: __style.lightGreenColor @@ -63,6 +63,8 @@ MMDrawer { visible: root.withSearch onSearchTextChanged: root.searchTextChanged( searchBar.searchText ) + + textField.onPressed: root.showFullScreen = true } MMListSpacer { id: searchBarSpacer; height: __style.spacing20; visible: root.withSearch } @@ -71,18 +73,13 @@ MMDrawer { width: parent.width height: listViewComponent.count === 0 ? emptyStateDelegateLoader.height : listViewComponent.height - MMScrollView { - width: parent.width - height: Math.min( contentHeight, root.drawerContentAvailableHeight - internal.searchBarVerticalSpace ) - enabled: contentHeight > height - - Loader { - id: emptyStateDelegateLoader + Loader { + id: emptyStateDelegateLoader - visible: listViewComponent.count === 0 + width: parent.width - width: parent.width - } + visible: listViewComponent.count === 0 + sourceComponent: defaultEmptyStateComponent } MMListView { @@ -90,6 +87,7 @@ MMDrawer { width: parent.width height: Math.min( contentHeight, root.drawerContentAvailableHeight - internal.searchBarVerticalSpace ) + visible: count > 0 interactive: contentHeight > height @@ -104,11 +102,14 @@ MMDrawer { text: model[root.textRole] secondaryText: model[root.secondaryTextRole] ?? "" + rightContent: MMIcon { + source: __style.doneCircleIcon + visible: _delegate.checked + } + onClicked: { if ( root.multiSelect ) { _delegate.checked = !_delegate.checked - - // add or remove the item from the selected features list addOrRemoveSelected( model[root.valueRole] ) } else { @@ -116,11 +117,6 @@ MMDrawer { root.close() } } - - rightContent: MMIcon { - source: __style.doneCircleIcon - visible: _delegate.checked - } } footer: MMListSpacer { height: __style.safeAreaBottom + __style.margin8 + ( root.multiSelect ? confirmButton.height : 0 ) } @@ -141,12 +137,22 @@ MMDrawer { text: qsTr( "Confirm selection" ) - onClicked: { - root.selectionFinished( root.selected ) - } + onClicked: root.selectionFinished( root.selected ) } } + QtObject { + id: internal + + property real searchBarVerticalSpace: root.withSearch ? searchBar.height + searchBarSpacer.height : 0 + } + + Component { + id: defaultEmptyStateComponent + + MMListEmptyLoaderDelegate {} + } + function addOrRemoveSelected( val ) { if ( root.selected.indexOf( val ) === -1 ) { root.selected.push( val ) @@ -155,10 +161,4 @@ MMDrawer { root.selected = root.selected.filter( ( x ) => x !== val ) } } - - QtObject { - id: internal - - property real searchBarVerticalSpace: root.withSearch ? searchBar.height + searchBarSpacer.height : 0 - } } diff --git a/app/qml/components/MMPageHeader.qml b/app/qml/components/MMPageHeader.qml index 4931f1282..0b8888580 100644 --- a/app/qml/components/MMPageHeader.qml +++ b/app/qml/components/MMPageHeader.qml @@ -24,6 +24,7 @@ Rectangle { property bool backVisible: true property alias backButton: backBtn + property alias leftItemContent: leftButtonGroup.children property alias rightItemContent: rightButtonGroup.children property real topSpacing: __style.safeAreaTop // offset size from top of the page, normally safeAreaTop, but can be overriden (e.g. login page) @@ -40,11 +41,11 @@ Rectangle { // If there is a right or a left icon, we need to shift the margin // of the opposite side to keep the text centred to the center of the screen property real leftMarginShift: { - return Math.max( internal.backBtnRealWidth, rightButtonGroup.width ) + internal.headerSpacing + __style.pageMargins + return Math.max( internal.backBtnRealWidth, internal.leftGroupRealWidth, rightButtonGroup.width ) + internal.headerSpacing + __style.pageMargins } property real rightMarginShift: { - return Math.max( internal.backBtnRealWidth, rightButtonGroup.width ) + internal.headerSpacing + __style.pageMargins + return Math.max( internal.backBtnRealWidth, internal.leftGroupRealWidth, rightButtonGroup.width ) + internal.headerSpacing + __style.pageMargins } anchors { @@ -75,6 +76,18 @@ Rectangle { onClicked: root.backClicked() } + Item { + id: leftButtonGroup + + x: __style.pageMargins + __style.safeAreaLeft + y: ( root.baseHeaderHeight / 2 - height / 2 ) + root.topSpacing + + width: childrenRect.width + height: childrenRect.height + + visible: !root.backVisible // Only show when back button is hidden + } + Item { id: rightButtonGroup @@ -90,5 +103,6 @@ Rectangle { property real headerSpacing: 10 * __dp property real backBtnRealWidth: backBtn.visible ? backBtn.width : 0 + property real leftGroupRealWidth: leftButtonGroup.visible ? leftButtonGroup.width : 0 } } diff --git a/app/qml/components/MMSegmentControl.qml b/app/qml/components/MMSegmentControl.qml new file mode 100644 index 000000000..c4d0899f2 --- /dev/null +++ b/app/qml/components/MMSegmentControl.qml @@ -0,0 +1,100 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +pragma ComponentBehavior: Bound + +import QtQuick + +Item { + id: root + + enum Options { All, True, False } + + property int selectedIndex: MMSegmentControl.Options.All + property color backgroundColor: __style.polarColor + + property string allText: qsTr( "All" ) + property string trueText: qsTr( "True" ) + property string falseText: qsTr( "False" ) + + implicitHeight: __style.row50 + implicitWidth: 3 * ( __style.row50 + 2 * __style.margin20 ) + 2 * __style.margin12 + + Rectangle { + anchors.fill: parent + radius: __style.radius12 + color: root.backgroundColor + } + + Row { + anchors.fill: parent + leftPadding: __style.margin12 + rightPadding: __style.margin12 + topPadding: __style.margin8 + bottomPadding: __style.margin8 + + Repeater { + model: 3 + + delegate: Rectangle { + id: segment + + required property int index + + readonly property bool isSelected: root.enabled && root.selectedIndex === index + readonly property bool isAllOption: index === MMSegmentControl.Options.All + + width: ( parent.width - parent.leftPadding - parent.rightPadding ) / 3 + height: parent.height - parent.topPadding - parent.bottomPadding + + radius: __style.radius8 + + color: isSelected ? ( isAllOption ? __style.mediumGreenColor : __style.positiveColor ) : __style.transparentColor + + border.color: ( isSelected && !isAllOption ) ? __style.forestColor : __style.transparentColor + border.width: ( isSelected && !isAllOption ) ? 1.0 * __dp : 0 + + MMText { + anchors.centerIn: parent + // extra padding + width: parent.width - 2 * __style.margin8 + horizontalAlignment: Text.AlignHCenter + + text: { + switch ( segment.index ) { + case MMSegmentControl.Options.All: return root.allText + case MMSegmentControl.Options.True: return root.trueText + case MMSegmentControl.Options.False: return root.falseText + } + return "" + } + font: { + // bold only if selected + if ( segment.isSelected ) return __style.t3 + return __style.p5 + } + color: { + if ( !root.enabled ) return __style.mediumGreyColor + if ( segment.isSelected ) return __style.forestColor + return __style.nightColor + } + } + + MouseArea { + anchors.fill: parent + enabled: root.enabled + onClicked: { + if ( root.selectedIndex !== segment.index ) { + root.selectedIndex = segment.index + } + } + } + } + } + } +} diff --git a/app/qml/components/private/MMBaseInput.qml b/app/qml/components/private/MMBaseInput.qml index 0044acf53..4423d4ba7 100644 --- a/app/qml/components/private/MMBaseInput.qml +++ b/app/qml/components/private/MMBaseInput.qml @@ -32,6 +32,7 @@ Item { property string errorMsg: "" property string warningMsg: "" + property string infoMsg: "" property alias inputContent: contentGroup.children @@ -65,7 +66,7 @@ Item { states: [ State { name: "valid" - when: !shouldShowValidation || ( !warningMsg && !errorMsg ) + when: !shouldShowValidation || ( !warningMsg && !errorMsg && !infoMsg ) }, State { name: "error" @@ -74,6 +75,10 @@ Item { State { name: "warning" when: warningMsg && !errorMsg + }, + State { + name: "information" + when: infoMsg } ] @@ -179,11 +184,15 @@ Item { width: parent.width MMComponents.MMIcon { - source: __style.errorCircleIcon + source: { + if ( root.validationState === "information" ) return __style.infoFilledIcon + return __style.errorCircleIcon + } size: __style.icon16 color: { if ( root.validationState === "error" ) return __style.negativeColor if ( root.validationState === "warning" ) return __style.warningColor + if ( root.validationState === "information" ) return __style.informativeColor return __style.forestColor } } @@ -194,11 +203,13 @@ Item { text: { if ( root.validationState === "error" ) return root.errorMsg if ( root.validationState === "warning" ) return root.warningMsg + if ( root.validationState === "information" ) return root.infoMsg return "" } color: { if ( root.validationState === "error" ) return __style.grapeColor if ( root.validationState === "warning" ) return __style.earthColor + if ( root.validationState === "information" ) return __style.deepOceanColor return __style.forestColor } diff --git a/app/qml/filters/MMFilterChip.qml b/app/qml/filters/MMFilterChip.qml new file mode 100644 index 000000000..acd76475f --- /dev/null +++ b/app/qml/filters/MMFilterChip.qml @@ -0,0 +1,47 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick +import QtQuick.Controls + +import "../components" + +Rectangle { + id: root + + property string text: "" + property bool selected: false + + signal clicked() + + implicitWidth: chipText.implicitWidth + __style.margin24 + implicitHeight: __style.row36 + + radius: __style.radius20 + color: selected ? __style.grassColor : __style.lightGreenColor + border.color: selected ? __style.grassColor : __style.mediumGreenColor + border.width: 1 + + MMText { + id: chipText + + anchors.centerIn: parent + + text: root.text + font: __style.p5 + color: selected ? __style.forestColor : __style.nightColor + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + + onClicked: root.clicked() + } +} diff --git a/app/qml/filters/MMFiltersPanel.qml b/app/qml/filters/MMFiltersPanel.qml new file mode 100644 index 000000000..7b94a0083 --- /dev/null +++ b/app/qml/filters/MMFiltersPanel.qml @@ -0,0 +1,224 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls + +import "../components" as MMComponents + +// +// TODO: rename this file to "MMFiltersDrawer"! +// + +MMComponents.MMDrawer { + id: root + + modal: false + interactive: false + closePolicy: Popup.CloseOnEscape + + drawerBottomMargin: 0 + + dropShadow: true + + background: Rectangle { + color: __style.polarColor + radius: __style.radius20 + + layer.enabled: root.dropShadow + layer.effect: MMComponents.MMShadow {} + + Rectangle { + color: __style.polarColor + width: parent.width + height: parent.height / 2 + y: parent.height / 2 + } + } + + Component.onCompleted: { + root.open() + } + + QtObject { + id: internal + + property var filterValues: ({}) + } + + drawerHeader.title: qsTr( "Filters" ) + drawerHeader.titleFont: __style.t2 + + drawerHeader.topLeftItemContent: MMComponents.MMButton { + type: MMButton.Types.Primary + size: MMButton.Sizes.Small + text: qsTr( "Reset" ) + fontColor: __style.grapeColor + bgndColor: __style.negativeLightColor + bgndColorHover: __style.grapeColor + fontColorHover: __style.negativeLightColor + + anchors { + left: parent.left + leftMargin: __style.pageMargins + __style.safeAreaLeft + verticalCenter: parent.verticalCenter + } + + onClicked: { + internal.filterValues = {} + + __activeProject.filterController.clearAllFilters() + + inputRepeater.model = null + inputRepeater.model = __activeProject.filterController.getFilters() + } + } + + drawerContent: Item { + width: parent.width + height: root.drawerContentAvailableHeight + + MMComponents.MMScrollView { + id: scrollView + + width: parent.width + height: parent.height + + Column { + id: contentColumn + + width: scrollView.availableWidth + spacing: __style.margin20 + + MMComponents.MMText { + width: parent.width + visible: inputRepeater.count === 0 + text: qsTr( "No filtering" ) + font: __style.p4 + color: __style.mediumGreyColor + horizontalAlignment: Text.AlignHCenter + } + + Repeater { + id: inputRepeater + + model: __activeProject.filterController.getFilters() + + delegate: Loader { + id: fieldLoader + + required property var modelData + + width: contentColumn.width + + Component.onCompleted: { + let props = { filterName: modelData.filterName } + + const currentValue = modelData.value ? modelData.value : internal.filterValues[modelData.filterId] + props['currentValue'] = currentValue + + const filterType = modelData.filterType + + if ( filterType === FieldFilter.TextFilter ) + { + setSource( "components/MMFilterTextEditor.qml", props ) + } + else if ( filterType === FieldFilter.NumberFilter ) + { + setSource( "components/MMFilterRangeInput.qml", props ) + } + else if ( filterType === FieldFilter.DateFilter ) + { + props['hasTime'] = __activeProject.filterController.isDateFilterDateTime(modelData.filterId) + setSource( "components/MMFilterDateRange.qml", props ) + } + else if ( filterType === FieldFilter.CheckboxFilter ) + { + const checkboxConfig = __activeProject.filterController.getCheckboxConfiguration( modelData.filterId ) + props["customLabelForTrue"] = checkboxConfig["customLabelForTrue"] + props["customLabelForFalse"] = checkboxConfig["customLabelForFalse"] + props["customValueForTrue"] = checkboxConfig["customValueForTrue"] + props["customValueForFalse"] = checkboxConfig["customValueForFalse"] + setSource( "components/MMFilterBoolInput.qml", props ) + } + else if ( filterType === FieldFilter.SingleSelectFilter || filterType === FieldFilter.MultiSelectFilter ) + { + // TODO: might be worth moving this logic to C++ + + const isMulti = filterType === FieldFilter.MultiSelectFilter + props['isMultiSelect'] = isMulti + + const dropdownConfig = __activeProject.filterController.getDropdownConfiguration( modelData.filterId ) + + if ( !Object.keys( dropdownConfig ).length ) + { + __inputUtils.log( "Filters", "Received invalid config for dropdown filter " + modelData.filterName ) + return; + } + + if ( dropdownConfig["type"] === "unique_values" ) + { + props["vectorLayerId"] = dropdownConfig["layer_id"] + props["fieldName"] = dropdownConfig["field_name"] + setSource( "components/MMFilterDropdownUniqueValuesInput.qml", props ) + } + else if ( dropdownConfig["type"] === "value_relation" ) + { + props["widgetConfig"] = dropdownConfig["config"] + setSource( "components/MMFilterDropdownValueRelationInput.qml", props ) + } + else if ( dropdownConfig["type"] === "value_map" ) + { + props["widgetConfig"] = dropdownConfig["config"] + setSource( "components/MMFilterDropdownValueMapInput.qml", props ) + } + } + } + + Connections { + target: fieldLoader.item + ignoreUnknownSignals: true + + function onCurrentValueChanged() { + internal.filterValues[fieldLoader.modelData.filterId] = fieldLoader.item.currentValue + } + } + } + } + + Item { + width: parent.width + height: showResultsButton.height + __style.margin12 + __style.safeAreaBottom + } + } + } + + MMComponents.MMButton { + id: showResultsButton + + z: 1 + width: parent.width + + anchors { + bottom: parent.bottom + bottomMargin: __style.margin8 + __style.safeAreaBottom + left: parent.left + right: parent.right + } + + text: qsTr( "Apply filters" ) + + onClicked: { + __activeProject.filterController.processFilters(internal.filterValues) + root.close() + } + } + } +} diff --git a/app/qml/filters/components/MMFilterBanner.qml b/app/qml/filters/components/MMFilterBanner.qml new file mode 100644 index 000000000..f0030748f --- /dev/null +++ b/app/qml/filters/components/MMFilterBanner.qml @@ -0,0 +1,66 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick +import QtQuick.Layouts + +import "../../components" as MMComponents + +Rectangle { + id: root + + property string text + property string actionText: "" + + signal actionClicked() + + color: __style.informativeColor + radius: __style.radius8 + implicitHeight: row.implicitHeight + 2 * __style.margin8 + + RowLayout { + id: row + + anchors { + left: parent.left + right: parent.right + leftMargin: __style.margin12 + rightMargin: __style.margin8 + verticalCenter: parent.verticalCenter + } + + spacing: __style.spacing10 + + MMComponents.MMText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + text: root.text + font: __style.t4 + color: __style.deepOceanColor + wrapMode: Text.Wrap + elide: Text.ElideNone + } + + MMComponents.MMButton { + id: actionButton + + visible: root.actionText !== "" + Layout.alignment: Qt.AlignVCenter + + type: MMComponents.MMButton.Types.Tertiary + size: MMComponents.MMButton.Sizes.ExtraSmall + text: root.actionText + fontColor: __style.skyColor + bgndColor: __style.deepOceanColor + + onClicked: root.actionClicked() + } + } +} diff --git a/app/qml/filters/components/MMFilterBoolInput.qml b/app/qml/filters/components/MMFilterBoolInput.qml new file mode 100644 index 000000000..ac2031d2b --- /dev/null +++ b/app/qml/filters/components/MMFilterBoolInput.qml @@ -0,0 +1,93 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../../components" as MMComponents + +Column { + id: root + + required property string filterName + + required property var currentValue + + property string customLabelForTrue: "" + property string customLabelForFalse: "" + + property var customValueForTrue: null // can be string, number, bool + property var customValueForFalse: null // can be string, number, bool + + width: parent.width + spacing: __style.margin8 + + MMComponents.MMText { + width: parent.width + + text: root.filterName + + font: __style.p6 + color: __style.nightColor + visible: text + } + + MMComponents.MMSegmentControl { + id: segControl + + width: parent.width + backgroundColor: __style.lightGreenColor + + trueText: customLabelForTrue ? customLabelForTrue : qsTr( "True" ) + falseText: customLabelForFalse ? customLabelForFalse : qsTr( "False" ) + + Component.onCompleted: { + if ( root.currentValue && root.currentValue.length === 1 ) + { + if ( root.currentValue[0] === internal.representationForTrue ) + { + selectedIndex = MMComponents.MMSegmentControl.Options.True + } + else if ( root.currentValue[0] === internal.representationForFalse ) + { + selectedIndex = MMComponents.MMSegmentControl.Options.False + } + else + { + selectedIndex = MMComponents.MMSegmentControl.Options.All + } + } + else + { + selectedIndex = MMComponents.MMSegmentControl.Options.All + } + } + + onSelectedIndexChanged: { + if ( selectedIndex === MMComponents.MMSegmentControl.Options.All ) + { + root.currentValue = undefined + } + else if ( selectedIndex === MMComponents.MMSegmentControl.Options.True ) + { + root.currentValue = [internal.representationForTrue] + } + else if ( selectedIndex === MMComponents.MMSegmentControl.Options.False ) + { + root.currentValue = [internal.representationForFalse] + } + } + } + + QtObject { + id: internal + + property var representationForTrue: root.customValueForTrue != null ? root.customValueForTrue : true + property var representationForFalse: root.customValueForFalse != null ? root.customValueForFalse : false + } +} diff --git a/app/qml/filters/components/MMFilterDateRange.qml b/app/qml/filters/components/MMFilterDateRange.qml new file mode 100644 index 000000000..d42cae48d --- /dev/null +++ b/app/qml/filters/components/MMFilterDateRange.qml @@ -0,0 +1,213 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +pragma ComponentBehavior: Bound + +import QtQuick + +import "../../components" +import "../../form/components" as MMFormComponents + +Column { + id: root + + width: parent.width + spacing: __style.margin8 + + required property string filterName + required property var currentValue + required property bool hasTime + + property bool rangeInvalid: { + if ( !currentValue || !currentValue[0] || !currentValue[1] ){ + return false + } + return currentValue[0] > currentValue[1] + } + + MMText { + width: parent.width + text: root.filterName + font: __style.p6 + color: __style.nightColor + } + + Row { + width: parent.width + spacing: __style.margin12 + + Item { + width: ( parent.width - __style.margin12 ) / 2 + height: fromDateInput.height + + MMFilterTextInput { + id: fromDateInput + + width: parent.width + type: MMFilterTextInput.InputType.Date + placeholderText: qsTr( "From" ) + errorMsg: root.rangeInvalid ? qsTr( "\"From\" must be sooner than \"To\"" ) : "" + text: { + if ( !root.currentValue || !root.currentValue[0] ) return "" + if ( root.hasTime ) return Qt.formatDateTime( root.currentValue[0] ) + return Qt.formatDate( root.currentValue[0] ) + } + + onTextClicked: fromCalendarLoader.active = true + onRightContentClicked: { + if (checked) { + textField.clear() + checked = false + if ( root.currentValue[1] ){ + root.currentValue = [undefined, root.currentValue[1]] + } else { + root.currentValue = undefined + } + root.currentValueChanged() + } else { + let currentTimestamp = new Date() + + if (root.hasTime) { + text = Qt.formatDateTime(currentTimestamp) + } else { + text = Qt.formatDate(currentTimestamp) + } + + if (!root.hasTime) { + currentTimestamp.setHours(0, 0, 0, 0) + } + if (!root.currentValue) { + root.currentValue = [currentTimestamp, undefined] + } else { + root.currentValue[0] = currentTimestamp + } + root.currentValueChanged() + } + } + } + + Loader { + id: fromCalendarLoader + active: false + sourceComponent: fromCalendarComponent + } + + Component { + id: fromCalendarComponent + + MMFormComponents.MMCalendarDrawer { + hasDatePicker: true + hasTimePicker: root.hasTime + dateTime: root.currentValue && root.currentValue[0] ? root.currentValue[0] : new Date() + + onPrimaryButtonClicked: { + let currentTimestamp = dateTime + if (!root.hasTime) { + currentTimestamp.setHours(0, 0, 0, 0) + } + if (!root.currentValue){ + root.currentValue = [currentTimestamp, undefined] + } else { + root.currentValue[0] = currentTimestamp + root.currentValueChanged() + } + + fromDateInput.text = root.hasTime ? Qt.formatDateTime(dateTime) : Qt.formatDate(dateTime) + } + onClosed: fromCalendarLoader.active = false + Component.onCompleted: open() + } + } + } + + Item { + width: ( parent.width - __style.margin12 ) / 2 + height: toDateInput.height + + MMFilterTextInput { + id: toDateInput + + width: parent.width + type: MMFilterTextInput.InputType.Date + placeholderText: qsTr( "To" ) + errorMsg: root.rangeInvalid ? qsTr( "\"From\" must be sooner than \"To\"" ) : "" + text: { + if ( !root.currentValue || !root.currentValue[1] ) return "" + if ( root.hasTime ) return Qt.formatDateTime( root.currentValue[1] ) + return Qt.formatDate( root.currentValue[1] ) + } + + onTextClicked: toCalendarLoader.active = true + onRightContentClicked: { + if (checked) { + textField.clear() + checked = false + if ( root.currentValue[0] ){ + root.currentValue = [root.currentValue[0], undefined] + } else { + root.currentValue = undefined + } + root.currentValueChanged() + + } else { + let currentTimestamp = new Date() + + if (root.hasTime) { + text = Qt.formatDateTime(currentTimestamp) + } else { + text = Qt.formatDate(currentTimestamp) + } + + if (!root.hasTime) { + currentTimestamp.setHours(0, 0, 0, 0) + } + if (!root.currentValue) { + root.currentValue = [undefined, currentTimestamp] + } else { + root.currentValue[1] = currentTimestamp + root.currentValueChanged() + } + } + } + } + + Loader { + id: toCalendarLoader + active: false + sourceComponent: toCalendarComponent + } + + Component { + id: toCalendarComponent + + MMFormComponents.MMCalendarDrawer { + hasDatePicker: true + hasTimePicker: root.hasTime + dateTime: root.currentValue && root.currentValue[1] ? root.currentValue[1] : new Date() + + onPrimaryButtonClicked: { + let currentTimestamp = dateTime + if (!root.hasTime) { + currentTimestamp.setHours(0, 0, 0, 0) + } + if (!root.currentValue){ + root.currentValue = [undefined, currentTimestamp] + } else { + root.currentValue[1] = currentTimestamp + } + root.currentValueChanged() + + toDateInput.text = root.hasTime ? Qt.formatDateTime(dateTime) : Qt.formatDate(dateTime) + } + onClosed: toCalendarLoader.active = false + Component.onCompleted: open() + } + } + } + } +} diff --git a/app/qml/filters/components/MMFilterDropdownUniqueValuesInput.qml b/app/qml/filters/components/MMFilterDropdownUniqueValuesInput.qml new file mode 100644 index 000000000..a077cf0c9 --- /dev/null +++ b/app/qml/filters/components/MMFilterDropdownUniqueValuesInput.qml @@ -0,0 +1,134 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import MMInput as MM +import "../../components" as MMComponents + +Column { + id: root + + required property string filterName + required property var currentValue + + required property string vectorLayerId + required property string fieldName + + property bool isMultiSelect: false + + width: parent.width + + spacing: __style.margin8 + + MMComponents.MMText { + width: parent.width + + text: root.filterName + + visible: text + font: __style.p6 + color: __style.nightColor + } + + MMFilterTextInput { + id: dropdownInput + + width: parent.width + + type: MMFilterTextInput.InputType.Dropdown + + checked: text !== "" + + placeholderText: qsTr( "Select..." ) + text: root.currentValue && root.currentValue.length ? qsTr( "%1 selected" ).arg( root.currentValue.length ) : "" + + onTextClicked: openDrawer() + onRightContentClicked: { + if ( root.currentValue && root.currentValue.length ) { + root.currentValue = undefined + root.currentValueChanged() + } + else { + openDrawer() + } + } + } + + Loader { + id: dropdownDrawerLoader + + active: false + + + sourceComponent: MMComponents.MMListMultiselectDrawer { + drawerHeader.title: root.filterName + + withSearch: uniqueValuesModel.count > 5 + multiSelect: root.isMultiSelect + + emptyStateDelegate: Component { + MMComponents.MMListEmptyLoaderDelegate { + isLoading: uniqueValuesModel.isLoading + } + } + + list.model: MM.SearchProxyModel { + id: searchProxyModel + + sourceModel: uniqueValuesModel + } + + textRole: "display" + secondaryTextRole: "" + valueRole: "display" + + onSelectionFinished: function( selectedItems ) { + root.currentValue = selectedItems + + // TODO: this is just a hack, we need to add a dedicated signal instead, Qt does not always get that array length has changed + root.currentValueChanged() + + close() + } + + onSearchTextChanged: ( searchText ) => searchProxyModel.searchString = searchText + + onClosed: dropdownDrawerLoader.active = false + + Component.onCompleted: { + if ( root.currentValue ) { + // preselect choices if any are set + selected = root.currentValue + } + + uniqueValuesModel.populate() + open() + } + } + } + + // + // Intentionally keeping the model outside of loader to keep it instantiated while the + // filtering drawer is opened to avoid reloading features on each dropdown open. + // + // Values are not loaded unless .populate() is called + // + MM.UniqueValuesFilterModel { + id: uniqueValuesModel + + layerId: root.vectorLayerId + fieldName: root.fieldName + } + + function openDrawer() { + dropdownDrawerLoader.active = true + dropdownDrawerLoader.focus = true + } +} diff --git a/app/qml/filters/components/MMFilterDropdownValueMapInput.qml b/app/qml/filters/components/MMFilterDropdownValueMapInput.qml new file mode 100644 index 000000000..f5906b431 --- /dev/null +++ b/app/qml/filters/components/MMFilterDropdownValueMapInput.qml @@ -0,0 +1,122 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import MMInput as MM +import "../../components" as MMComponents + +Column { + id: root + + required property string filterName + required property var widgetConfig + + required property var currentValue + + property bool isMultiSelect: false + + width: parent.width + + spacing: __style.margin8 + + MMComponents.MMText { + width: parent.width + + text: root.filterName + + visible: text + font: __style.p6 + color: __style.nightColor + } + + MMFilterTextInput { + id: dropdownInput + + width: parent.width + + type: MMFilterTextInput.InputType.Dropdown + + checked: text !== "" + + placeholderText: qsTr( "Select..." ) + text: root.currentValue && root.currentValue.length ? qsTr( "%1 selected" ).arg( root.currentValue.length ) : "" + + onTextClicked: openDrawer() + onRightContentClicked: { + if ( root.currentValue && root.currentValue.length ) { + root.currentValue = undefined + root.currentValueChanged() + } + else { + openDrawer() + } + } + } + + Loader { + id: dropdownDrawerLoader + + active: false + + + sourceComponent: MMComponents.MMListMultiselectDrawer { + drawerHeader.title: root.filterName + + withSearch: valueMapModel.count > 5 + multiSelect: root.isMultiSelect + + emptyStateDelegate: Component { + MMComponents.MMListEmptyLoaderDelegate { + isLoading: valueMapModel.isLoading + } + } + + list.model: MM.SearchProxyModel { + id: searchProxyModel + + sourceModel: MM.ValueMapFilterModel { + id: valueMapModel + + config: root.widgetConfig + } + } + + textRole: "display" + secondaryTextRole: "" + valueRole: "Key" + + onSelectionFinished: function( selectedItems ) { + root.currentValue = selectedItems + + // TODO: this is just a hack, we need to add a dedicated signal instead, Qt does not always get that array length has changed + root.currentValueChanged() + + close() + } + + onSearchTextChanged: ( searchText ) => searchProxyModel.searchString = searchText + + onClosed: dropdownDrawerLoader.active = false + + Component.onCompleted: { + if ( root.currentValue ) { + // preselect choices if any are set + selected = root.currentValue + } + open() + } + } + } + + function openDrawer() { + dropdownDrawerLoader.active = true + dropdownDrawerLoader.focus = true + } +} diff --git a/app/qml/filters/components/MMFilterDropdownValueRelationInput.qml b/app/qml/filters/components/MMFilterDropdownValueRelationInput.qml new file mode 100644 index 000000000..b1e506a85 --- /dev/null +++ b/app/qml/filters/components/MMFilterDropdownValueRelationInput.qml @@ -0,0 +1,126 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import mm 1.0 as MM +import "../../components" as MMComponents + +Column { + id: root + + required property string filterName + required property var currentValue + + required property var widgetConfig + + property bool isMultiSelect: false + + width: parent.width + + spacing: __style.margin8 + + MMComponents.MMText { + width: parent.width + + text: root.filterName + + visible: text + font: __style.p6 + color: __style.nightColor + } + + MMFilterTextInput { + id: dropdownInput + + width: parent.width + + type: MMFilterTextInput.InputType.Dropdown + + checked: text !== "" + + placeholderText: qsTr( "Select..." ) + text: root.currentValue && root.currentValue.length ? qsTr( "%1 selected" ).arg( root.currentValue.length ) : "" + + onTextClicked: openDrawer() + onRightContentClicked: { + if ( root.currentValue && root.currentValue.length ) { + root.currentValue = undefined + root.currentValueChanged() + } + else { + openDrawer() + } + } + } + + Loader { + id: dropdownDrawerLoader + + active: false + + + sourceComponent: MMComponents.MMListMultiselectDrawer { + drawerHeader.title: root.filterName + + withSearch: vrDropdownModel.count > 5 + multiSelect: root.isMultiSelect + + emptyStateDelegate: Component { + MMComponents.MMListEmptyLoaderDelegate { + isLoading: vrDropdownModel.isLoading + } + } + + list.model: MM.ValueRelationFeaturesModel { + id: vrDropdownModel + + config: root.widgetConfig + } + + textRole: "FeatureTitle" + valueRole: "Key" + + onSelectionFinished: function( selectedItems ) { + + // + // Large fids could be converted to scientific notation on their way to cpp, + // so we convert them to string first in JS. + // + selectedItems = selectedItems.map( x => x.toString() ) + + root.currentValue = selectedItems + + // TODO: this is just a hack, we need to add a dedicated signal instead, Qt does not always get that array length has changed + root.currentValueChanged() + + close() + } + + onSearchTextChanged: ( searchText ) => vrDropdownModel.searchExpression = searchText + + onClosed: dropdownDrawerLoader.active = false + + Component.onCompleted: { + if ( root.currentValue ) { + // preselect choices if any are set + selected = root.currentValue + } + + open() + vrDropdownModel.reloadFeatures() + } + } + } + + function openDrawer() { + dropdownDrawerLoader.active = true + dropdownDrawerLoader.focus = true + } +} diff --git a/app/qml/filters/components/MMFilterRangeInput.qml b/app/qml/filters/components/MMFilterRangeInput.qml new file mode 100644 index 000000000..38774a6dd --- /dev/null +++ b/app/qml/filters/components/MMFilterRangeInput.qml @@ -0,0 +1,98 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../../components" + +Column { + id: root + + width: parent.width + spacing: __style.margin8 + + required property string filterName + required property var currentValue + + MMText { + width: parent.width + text: root.filterName + font: __style.p6 + color: __style.nightColor + } + + Row { + id: rangeRow + + width: parent.width + spacing: __style.margin12 + + property bool rangeInvalid: { + let fromVal = parseFloat( fromInput.text ) + let toVal = parseFloat( toInput.text ) + return !isNaN( fromVal ) && !isNaN( toVal ) && fromVal > toVal + } + + MMFilterTextInput { + id: fromInput + + width: ( parent.width - __style.margin12 ) / 2 + type: MMFilterTextInput.InputType.Number + placeholderText: qsTr( "Min" ) + text: root.currentValue && root.currentValue[0] ? root.currentValue[0] : "" + errorMsg: rangeRow.rangeInvalid ? qsTr( "\"Min\" must be less than \"Max\"" ) : "" + + onTextChanged: { + debounceTimer.restart() + } + } + + MMFilterTextInput { + id: toInput + + width: ( parent.width - __style.margin12 ) / 2 + type: MMFilterTextInput.InputType.Number + placeholderText: qsTr( "Max" ) + text: root.currentValue && root.currentValue[1] ? root.currentValue[1] : "" + errorMsg: rangeRow.rangeInvalid ? qsTr( "\"Min\" must be less than \"Max\"" ) : "" + + onTextChanged: { + debounceTimer.restart() + } + } + } + + Timer { + id: debounceTimer + interval: 300 + repeat: false + onTriggered: { + let newValues = [] + const valueFrom = parseFloat(fromInput.text) + if ( !isNaN(valueFrom) ) { + newValues[0] = valueFrom + } else { + newValues[0] = undefined + } + + const valueTo = parseFloat(toInput.text) + if ( !isNaN(valueTo) ) { + newValues[1] = valueTo + } else { + newValues[1] = undefined + } + + if ( newValues[0] === undefined && newValues[1] === undefined ) { + root.currentValue = undefined + } else { + root.currentValue = newValues + } + } + } +} diff --git a/app/qml/filters/components/MMFilterTextEditor.qml b/app/qml/filters/components/MMFilterTextEditor.qml new file mode 100644 index 000000000..68f072d88 --- /dev/null +++ b/app/qml/filters/components/MMFilterTextEditor.qml @@ -0,0 +1,55 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../../components" + +Column { + id: root + + width: parent.width + spacing: __style.margin8 + + required property string filterName + required property var currentValue + + MMText { + width: parent.width + + text: root.filterName + + font: __style.p6 + color: __style.nightColor + } + + MMFilterTextInput { + id: filterInput + + width: parent.width + type: MMFilterTextInput.InputType.Text + placeholderText: qsTr( "Type to filter..." ) + text: root.currentValue && root.currentValue[0] ? root.currentValue[0] : "" + + onTextChanged: debounceTimer.restart() + } + + Timer { + id: debounceTimer + interval: 300 + repeat: false + onTriggered: { + if (filterInput.text) { + root.currentValue = [filterInput.text] + } else { + root.currentValue = undefined + } + } + } +} diff --git a/app/qml/filters/components/MMFilterTextInput.qml b/app/qml/filters/components/MMFilterTextInput.qml new file mode 100644 index 000000000..360f3cec0 --- /dev/null +++ b/app/qml/filters/components/MMFilterTextInput.qml @@ -0,0 +1,93 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../../components" as MMComponents +import "../../components/private" as MMPrivateComponents + +/** + * Single-line input for the filters panel. + * See MMBaseSingleLineInput for additional properties. + */ +MMPrivateComponents.MMBaseSingleLineInput { + id: root + + enum InputType { Text, Number, Date, Dropdown } + + property bool checked: false + property int type: MMFilterTextInput.InputType.Text + + // date and dropdown types open pickers instead of accepting keyboard input + textField.readOnly: root.type === MMFilterTextInput.InputType.Date || root.type === MMFilterTextInput.InputType.Dropdown + textField.color: { + if ( root.editState === "readOnly" ) return __style.nightColor + if ( root.editState === "enabled" ) return __style.nightColor + if ( root.editState === "disabled" ) return __style.mediumGreyColor + return __style.forestColor + } + + // error state takes priority over checked, checked over default + textFieldBackground.color: { + if ( root.validationState === "error" ) return __style.negativeUltraLightColor + if ( root.checked ) return __style.positiveColor + return __style.lightGreenColor + } + + textFieldBackground.border.color: { + if ( root.validationState === "error" ) return __style.negativeColor + if ( root.checked ) return __style.darkGreenColor + return __style.polarColor + } + + textFieldBackground.border.width: { + if ( root.validationState === "error" ) return __style.width2 + if ( root.checked ) return 1 * __dp + return 0 + } + + // close icon when checked, type-specific icon otherwise + rightContent: MMComponents.MMIcon { + size: __style.icon24 + source: { + if ( root.checked ) return __style.closeIcon + if ( root.type === MMFilterTextInput.InputType.Date ) return __style.calendarIcon + if ( root.type === MMFilterTextInput.InputType.Dropdown ) return __style.arrowDownIcon + return __style.closeIcon + } + color: __style.forestColor + } + + // picker types always show the icon, editable types only when there is a value + rightContentVisible: { + if ( root.type === MMFilterTextInput.InputType.Text || root.type === MMFilterTextInput.InputType.Number ) return root.checked + return true + } + + // keep checked in sync with whether the field has a value + onTextChanged: { + if ( root.type === MMFilterTextInput.InputType.Text || root.type === MMFilterTextInput.InputType.Number ) { + root.checked = false + } + } + + // clear the field when tapping the close icon + onRightContentClicked: { + if ( ( root.type === MMFilterTextInput.InputType.Text || root.type === MMFilterTextInput.InputType.Number ) && root.checked ) { + textField.clear() + root.checked = false + } + } + + Component.onCompleted: { + if ( root.text ) { + root.checked = true + } + } +} diff --git a/app/qml/form/components/MMFeaturesListPageDrawer.qml b/app/qml/form/components/MMFeaturesListPageDrawer.qml index da8797332..594274993 100644 --- a/app/qml/form/components/MMFeaturesListPageDrawer.qml +++ b/app/qml/form/components/MMFeaturesListPageDrawer.qml @@ -9,9 +9,11 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import "../../inputs" as MMInputs import "../../components" as MMComponents +import "../../filters/components" as MMFilters // // Special type of drawer/page -> looks like page, but it is actually Drawer @@ -51,7 +53,7 @@ Drawer { width: parent.width height: parent.height - Column { + ColumnLayout { id: contentColumn width: parent.width @@ -59,27 +61,41 @@ Drawer { spacing: 0 - MMComponents.MMListSpacer { height: __style.spacing20 } + MMComponents.MMListSpacer { Layout.preferredHeight: __style.spacing20 } MMInputs.MMSearchInput { id: searchBar delayedSearch: true - width: parent.width + Layout.fillWidth: true placeholderText: qsTr("Search for features...") onSearchTextChanged: root.searchTextChanged( searchBar.searchText ) } - MMComponents.MMListSpacer { height: __style.spacing20 } + MMComponents.MMListSpacer { Layout.preferredHeight: __style.spacing20 } + + MMFilters.MMFilterBanner { + id: filterBanner + + visible: __activeProject.filterController?.hasActiveFilterOnLayer( listView.model.layer.id ) + + Layout.fillWidth: true + text: qsTr( "Some features may be hidden by active filters" ) + } + + MMComponents.MMListSpacer { + visible: filterBanner.visible + Layout.preferredHeight: __style.spacing20 + } MMComponents.MMListView { id: listView - width: parent.width - height: parent.height - 2 * __style.spacing20 - searchBar.height + Layout.fillWidth: true + Layout.fillHeight: true clip: true diff --git a/app/qml/form/editors/MMFormGalleryEditor.qml b/app/qml/form/editors/MMFormGalleryEditor.qml index bfa98db84..a3be90c5b 100644 --- a/app/qml/form/editors/MMFormGalleryEditor.qml +++ b/app/qml/form/editors/MMFormGalleryEditor.qml @@ -30,6 +30,10 @@ MMPrivateComponents.MMBaseInput { title: _fieldShouldShowTitle ? _fieldTitle : "" + infoMsg: root._fieldAssociatedRelation && __activeProject.filterController?.hasActiveFilterOnLayer( rmodel.layer.id ) + ? qsTr( "Some features may be hidden by active filters" ) + : "" + inputContent: MMComponents.MMListView { id: rowView diff --git a/app/qml/form/editors/MMFormRelationEditor.qml b/app/qml/form/editors/MMFormRelationEditor.qml index c2206f970..dcf94d2e3 100644 --- a/app/qml/form/editors/MMFormRelationEditor.qml +++ b/app/qml/form/editors/MMFormRelationEditor.qml @@ -42,6 +42,10 @@ MMPrivateComponents.MMBaseInput { title: _fieldShouldShowTitle ? _fieldTitle : "" + infoMsg: root._fieldAssociatedRelation && __activeProject.filterController?.hasActiveFilterOnLayer( rmodel.layer.id ) + ? qsTr( "Some features may be hidden by active filters" ) + : "" + inputContent: Rectangle { width: parent.width height: privates.itemHeight * privates.rows + 2 * flow.spacing + 2 * __style.margin12 diff --git a/app/qml/form/editors/MMFormSwitchEditor.qml b/app/qml/form/editors/MMFormSwitchEditor.qml index 55e717ce7..93f5578f2 100644 --- a/app/qml/form/editors/MMFormSwitchEditor.qml +++ b/app/qml/form/editors/MMFormSwitchEditor.qml @@ -56,7 +56,7 @@ MMSwitchInput { text: _fieldHasMixedValues ? "" : checked ? internal.checkedStateValue : internal.uncheckedStateValue placeholderText: _fieldHasMixedValues ? _fieldValue : "" - checked: _fieldHasMixedValues ? internal.uncheckedStateValue : _fieldValue === internal.checkedStateValue + checked: !_fieldHasMixedValues && internal.checkedStateValue !== "" && _fieldValue == internal.checkedStateValue onToggled: { let newVal = checked ? internal.checkedStateValue : internal.uncheckedStateValue diff --git a/app/qml/layers/MMFeaturesListPage.qml b/app/qml/layers/MMFeaturesListPage.qml index 822d45de5..035d67bd7 100644 --- a/app/qml/layers/MMFeaturesListPage.qml +++ b/app/qml/layers/MMFeaturesListPage.qml @@ -15,6 +15,7 @@ import mm 1.0 as MM import "../inputs" import "../components" as MMComponents +import "../filters/components" as MMFilterComponents MMComponents.MMPage { id: root @@ -40,20 +41,39 @@ MMComponents.MMPage { anchors.topMargin: __style.spacing20 width: parent.width - delayedSearch: true onSearchTextChanged: featuresModel.searchExpression = searchBar.text } + MMFilterComponents.MMFilterBanner { + id: filterBanner + + anchors.top: searchBar.bottom + anchors.topMargin: __style.spacing20 + + width: parent.width + + visible: root.selectedLayer && __activeProject.filterController?.hasActiveFilterOnLayer(root.selectedLayer.id) + + text: qsTr("Active filters applied") + actionText: qsTr("Reset") + + onActionClicked: { + __activeProject.filterController?.clearAllFilters() + featuresModel.reloadFeatures() + visible = false + } + } + MMComponents.MMListView { id: listView width: parent.width anchors { - top: searchBar.bottom + top: filterBanner.visible ? filterBanner.bottom : searchBar.bottom bottom: parent.bottom - topMargin: __style.spacing20 + topMargin: filterBanner.visible ? __style.spacing10 : __style.spacing20 } model: MM.LayerFeaturesModel { diff --git a/app/qml/main.qml b/app/qml/main.qml index d417de897..b09da1575 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -27,6 +27,7 @@ import "./project" import "./settings" import "./gps" import "./form" +import "./filters" ApplicationWindow { id: window @@ -268,6 +269,11 @@ ApplicationWindow { streamingModeDialog.open() } + onOpenFiltersPanel: { + stateManager.state = "misc" + filtersDrawerLoader.active = true + } + Component.onCompleted: { __activeProject.mapSettings = map.mapSettings __iosUtils.positionKit = PositionKit @@ -352,6 +358,15 @@ ApplicationWindow { } } + MMToolbarButton { + text: qsTr("Filters") + iconSource: __style.filterIcon + onClicked: { + stateManager.state = "misc" + filtersDrawerLoader.active = true + } + } + MMToolbarButton { id: positionTrackingButton @@ -497,6 +512,18 @@ ApplicationWindow { } } + Loader { + id: filtersDrawerLoader + + active: false + sourceComponent: MMFiltersPanel { + onClosed: { + filtersDrawerLoader.active = false + stateManager.state = "map" + } + } + } + Component { id: gpsDataDrawerComponent diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index ae892281d..1aa023f19 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -79,6 +79,7 @@ Item { signal openTrackingPanel() signal openStreamingPanel() + signal openFiltersPanel() states: [ State { @@ -856,6 +857,30 @@ Item { } } + MMMapButton { + id: filterIndicatorButton + + visible: root.state === "view" && __activeProject.filterController?.filteringAvailable + iconSource: __style.filterIcon + bgndColor: __activeProject.filterController?.filteringEnabled ? __style.positiveColor : __style.polarColor + + onClicked: { + root.openFiltersPanel() + } + + onClickAndHold: { + if ( __activeProject.filterController ) { + const enabling = !__activeProject.filterController.filteringEnabled + __activeProject.filterController.filteringEnabled = enabling + if ( enabling ) { + __notificationModel.addSuccess( qsTr( "All filters have been re-enabled" ) ) + } else { + __notificationModel.addWarning( qsTr( "All filters have been temporarily disabled. Press and hold to re-enable them" ) ) + } + } + } + } + MMMapButton { id: gpsButton diff --git a/app/qml/map/components/MMMapButton.qml b/app/qml/map/components/MMMapButton.qml index a974f9b32..4f0074b2e 100644 --- a/app/qml/map/components/MMMapButton.qml +++ b/app/qml/map/components/MMMapButton.qml @@ -18,6 +18,7 @@ Item { height: __style.mapItemHeight property alias iconSource: icon.source + property color bgndColor: __style.polarColor signal clicked signal clickAndHold @@ -26,7 +27,7 @@ Item { width: parent.width height: parent.height radius: control.height / 2 - color: __style.polarColor + color: control.bgndColor layer.enabled: true layer.effect: MMShadow {} diff --git a/app/qml/settings/MMSettingsPage.qml b/app/qml/settings/MMSettingsPage.qml index 26435fddc..8d7c69fd9 100644 --- a/app/qml/settings/MMSettingsPage.qml +++ b/app/qml/settings/MMSettingsPage.qml @@ -206,6 +206,27 @@ MMPage { Item { width: 1; height: 1 } + Text { + text: qsTr("Map") + wrapMode: Text.WordWrap + width: parent.width + font: __style.h3 + color: __style.forestColor + } + + Item { width: 1; height: 1 } + + MMSettingsComponents.MMSettingsSwitch { + width: parent.width + title: qsTr("Always show filter button") + description: qsTr("Shows the filter button on the map even when no filters are active") + checked: AppSettings.alwaysShowFilterButton + + onClicked: AppSettings.alwaysShowFilterButton = !checked + } + + Item { width: 1; height: 1 } + Text { text: qsTr("General") wrapMode: Text.WordWrap diff --git a/app/valuerelationfeaturesmodel.cpp b/app/valuerelationfeaturesmodel.cpp index d2f8db682..3b41aa4ef 100644 --- a/app/valuerelationfeaturesmodel.cpp +++ b/app/valuerelationfeaturesmodel.cpp @@ -18,6 +18,11 @@ ValueRelationFeaturesModel::ValueRelationFeaturesModel( QObject *parent ) { } +bool ValueRelationFeaturesModel::isLoading() const +{ + return fetchingResults(); +} + ValueRelationFeaturesModel::~ValueRelationFeaturesModel() = default; void ValueRelationFeaturesModel::setupFeatureRequest( QgsFeatureRequest &request ) @@ -48,6 +53,33 @@ void ValueRelationFeaturesModel::setupFeatureRequest( QgsFeatureRequest &request } } +QHash ValueRelationFeaturesModel::roleNames() const +{ + QHash roles = LayerFeaturesModel::roleNames(); + roles[KeyRole] = QStringLiteral( "Key" ).toLatin1(); + + return roles; +} + +QVariant ValueRelationFeaturesModel::data( const QModelIndex &index, int role ) const +{ + int row = index.row(); + if ( row < 0 || row >= mFeatures.count() ) + return QVariant(); + + if ( !index.isValid() ) + return QVariant(); + + const FeatureLayerPair pair = mFeatures.at( index.row() ); + + if ( role == KeyRole ) + { + return pair.feature().attribute( mKeyField ); + } + + return LayerFeaturesModel::data( index, role ); +} + void ValueRelationFeaturesModel::setup() { if ( mConfig.isEmpty() ) @@ -155,7 +187,7 @@ QVariant ValueRelationFeaturesModel::convertFromQgisType( QVariant qgsValue, Mod for ( int ix = 0; ix < rowCount(); ++ix ) { - QgsFeature f = data( index( ix, 0 ), Feature ).value(); + QgsFeature f = mFeatures.at( ix ).feature(); if ( keyMap.contains( f.attribute( mKeyField ).toString() ) ) { diff --git a/app/valuerelationfeaturesmodel.h b/app/valuerelationfeaturesmodel.h index 50e75a3fc..d6adc553e 100644 --- a/app/valuerelationfeaturesmodel.h +++ b/app/valuerelationfeaturesmodel.h @@ -26,12 +26,23 @@ class ValueRelationFeaturesModel : public LayerFeaturesModel Q_PROPERTY( FeatureLayerPair pair READ pair WRITE setPair NOTIFY pairChanged ) Q_PROPERTY( QVariantMap config READ config WRITE setConfig NOTIFY configChanged ) + Q_PROPERTY( bool isLoading READ isLoading NOTIFY fetchingResultsChanged ) public: + enum ValueRelationFeaturesModelRoles + { + KeyRole = LayerFeaturesModel::LastRole + 1, // the key-column value + LastRole = KeyRole + }; + Q_ENUM( ValueRelationFeaturesModelRoles ); + explicit ValueRelationFeaturesModel( QObject *parent = nullptr ); ~ValueRelationFeaturesModel() override; + QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const override; + QHash roleNames() const override; + void setup() override; void reset() override; void setupFeatureRequest( QgsFeatureRequest &request ) override; @@ -47,6 +58,8 @@ class ValueRelationFeaturesModel : public LayerFeaturesModel QVariantMap config() const; void setConfig( const QVariantMap &newConfig ); + bool isLoading() const; + signals: void pairChanged( const FeatureLayerPair &pair ); void configChanged( const QVariantMap &config ); diff --git a/docs/filtering-wireframes.jpg b/docs/filtering-wireframes.jpg new file mode 100644 index 000000000..7d365e075 Binary files /dev/null and b/docs/filtering-wireframes.jpg differ diff --git a/gallery/hotreload.cpp b/gallery/hotreload.cpp index a6025c2d4..9cbd55af2 100644 --- a/gallery/hotreload.cpp +++ b/gallery/hotreload.cpp @@ -14,18 +14,14 @@ #include #include -// TODO: not needed to sync dirs every second, just when a file was changed QString HotReload::syncScript() const { - return "#!/bin/sh \n\ -echo running hot reload sync directories ... \n\ -while true; do \n\ - rsync -ra " GALLERY_SOURCE_DIR "/qml/ HotReload/qml/ \n\ - rsync -ra " GALLERY_SOURCE_DIR "/../app/qml/ HotReload/app/qml/ \n\ - sleep 1 \n\ -done"; + return QString( "#!/bin/sh \n" + "echo 'Syncing modified files...' \n" + "rsync -rau \"%1/qml/\" HotReload/qml/ \n" + "rsync -rau \"%1/../app/qml/\" HotReload/app/qml/ \n" ) + .arg( GALLERY_SOURCE_DIR ); } - HotReload::HotReload( QQmlApplicationEngine &engine, QObject *parent ): _engine( engine ) { @@ -66,28 +62,54 @@ void HotReload::clearCache() void HotReload::startHotReload() { + _debounceTimer = new QTimer( this ); + _debounceTimer->setSingleShot( true ); + _debounceTimer->setInterval( 300 ); + + // when the timer starts, run the sync script ONCE, then reload + connect( _debounceTimer, &QTimer::timeout, this, [this]() + { + // run the sync synchronously so it finishes before reloading + QProcess::execute( "./syncGallery.sh" ); + emit watchedSourceChanged(); + } ); + _watcher = new QFileSystemWatcher( this ); - _watcher->addPath( "HotReload/qml" ); - _watcher->addPath( "HotReload/qml/Pages" ); - _watcher->addPath( "HotReload/app/qml/account" ); - _watcher->addPath( "HotReload/app/qml/account/components" ); - _watcher->addPath( "HotReload/app/qml/components" ); - _watcher->addPath( "HotReload/app/qml/dialogs" ); - _watcher->addPath( "HotReload/app/qml/form" ); - _watcher->addPath( "HotReload/app/qml/form/components" ); - _watcher->addPath( "HotReload/app/qml/form/editors" ); - _watcher->addPath( "HotReload/app/qml/gps" ); - _watcher->addPath( "HotReload/app/qml/inputs" ); - _watcher->addPath( "HotReload/app/qml/layers" ); - _watcher->addPath( "HotReload/app/qml/map" ); - _watcher->addPath( "HotReload/app/qml/project" ); - _watcher->addPath( "HotReload/app/qml/project/components" ); - _watcher->addPath( "HotReload/app/qml/settings" ); - _watcher->addPath( "HotReload/app/qml/settings/components" ); - // send signal for hot reloading + // Set up base paths for your source code + QString gallerySrc = QString( GALLERY_SOURCE_DIR ) + "/qml"; + QString appSrc = QString( GALLERY_SOURCE_DIR ) + "/../app/qml"; + + // Watch the SOURCE directories instead of the destination + _watcher->addPath( gallerySrc ); + _watcher->addPath( gallerySrc + "/Pages" ); + _watcher->addPath( gallerySrc + "/pages" ); + _watcher->addPath( gallerySrc + "/components" ); + + _watcher->addPath( appSrc + "/account" ); + _watcher->addPath( appSrc + "/account/components" ); + _watcher->addPath( appSrc + "/components" ); + _watcher->addPath( appSrc + "/dialogs" ); + _watcher->addPath( appSrc + "/form" ); + _watcher->addPath( appSrc + "/form/components" ); + _watcher->addPath( appSrc + "/form/editors" ); + _watcher->addPath( appSrc + "/gps" ); + _watcher->addPath( appSrc + "/inputs" ); + _watcher->addPath( appSrc + "/layers" ); + _watcher->addPath( appSrc + "/map" ); + _watcher->addPath( appSrc + "/project" ); + _watcher->addPath( appSrc + "/project/components" ); + _watcher->addPath( appSrc + "/settings" ); + _watcher->addPath( appSrc + "/settings/components" ); + + // when you save the file, start the debounce timer connect( _watcher, &QFileSystemWatcher::directoryChanged, this, [this]( const QString & path ) { - emit watchedSourceChanged(); + _debounceTimer->start(); + } ); + + connect( _watcher, &QFileSystemWatcher::fileChanged, this, [this]( const QString & path ) + { + _debounceTimer->start(); } ); } diff --git a/gallery/hotreload.h b/gallery/hotreload.h index b1cecc0f6..fa8b62628 100644 --- a/gallery/hotreload.h +++ b/gallery/hotreload.h @@ -12,6 +12,7 @@ #include #include +#include class QFileSystemWatcher; @@ -34,6 +35,7 @@ class HotReload : public QObject private: QFileSystemWatcher *_watcher; QQmlApplicationEngine &_engine; + QTimer *_debounceTimer = nullptr; }; #endif // HOTRELOAD_H diff --git a/gallery/qml/Main.qml b/gallery/qml/Main.qml index 878a1bf5e..78d05319c 100644 --- a/gallery/qml/Main.qml +++ b/gallery/qml/Main.qml @@ -25,15 +25,29 @@ ApplicationWindow { property string currentPageSource: "InitialGalleryPage.qml" + Timer { + id: reloadTimer + interval: 50 + onTriggered: { + // delete the cache after 50ms + _hotReload.clearCache() + + mainLoader.source = Qt.binding(function () { + return (__isMobile ? "qrc:/qml/pages/" : ("file://" + _qmlWrapperPath)) + window.currentPageSource + }) + mainLoader.active = true + + console.log(new Date().toLocaleTimeString().split(' ')[0] + " ------ App reloaded 🔥 ------ ") + } + } Connections { target: __isMobile ? null : _hotReload enabled: !__isMobile function onWatchedSourceChanged() { mainLoader.active = false + mainLoader.setSource("") _hotReload.clearCache() - mainLoader.setSource("file:///" + _qmlWrapperPath + currentPageSource) - mainLoader.active = true - console.log( new Date().toLocaleTimeString().split(' ')[0] + " ------ App reloaded 🔥 ------ ") + reloadTimer.start() } } diff --git a/gallery/qml/pages/ChecksPage.qml b/gallery/qml/pages/ChecksPage.qml index 9e097a4b1..8c2b5129d 100644 --- a/gallery/qml/pages/ChecksPage.qml +++ b/gallery/qml/pages/ChecksPage.qml @@ -15,9 +15,13 @@ import "../../app/qml/account/components" as MMAccountComponents import "../../app/qml/components" as MMComponents import "../../app/qml/inputs" -Column { - padding: 20 - spacing: 20 +ScrollView { + anchors.fill: parent + + Column { + width: parent.width + padding: 20 + spacing: 20 GroupBox { title: "MMComponents.MMCheckBox" @@ -160,6 +164,52 @@ Column { } } + GroupBox { + title: "MMComponents.MMSegmentControl" + background: Rectangle { + color: "lightGray" + border.color: "gray" + } + label: Label { + color: "black" + text: parent.title + padding: 5 + } + + Column { + spacing: 10 + anchors.fill: parent + + MMComponents.MMSegmentControl {} + + MMComponents.MMSegmentControl { + selectedIndex: MMComponents.MMSegmentControl.Options.True + } + + MMComponents.MMSegmentControl { + selectedIndex: MMComponents.MMSegmentControl.Options.False + onSelectedIndexChanged: { console.log( "selected:", selectedIndex ) } + } + + MMComponents.MMSegmentControl { + enabled: false + } + + MMComponents.MMSegmentControl { + allText: qsTr( "No filter" ) + trueText: qsTr( "Inspected" ) + falseText: qsTr( "Not inspected" ) + selectedIndex: MMComponents.MMSegmentControl.Options.False + } + + MMComponents.MMSegmentControl { + allText: qsTr( "A very long label that should be elided" ) + trueText: qsTr( "Another extremely long true label" ) + falseText: qsTr( "A different very long false label" ) + } + } + } + GroupBox { title: "MMComponents.MMSwitch" background: Rectangle { @@ -196,3 +246,4 @@ Column { } } } +} diff --git a/vcpkg.json b/vcpkg.json index 86d08083b..ba8124308 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -9,7 +9,7 @@ }, "name": "merginmaps-mobile-app", "description": "Collect. Share. Publish.", - "version": "2026.1.2", + "version": "2026.2.0", "homepage": "https://github.com/merginmaps/mobile", "dependencies": [ { diff --git a/vcpkg/ports/qgis/portfile.cmake b/vcpkg/ports/qgis/portfile.cmake index 2a174883f..b335a3e56 100644 --- a/vcpkg/ports/qgis/portfile.cmake +++ b/vcpkg/ports/qgis/portfile.cmake @@ -16,6 +16,8 @@ vcpkg_from_github( cmakelists.patch crssync.patch libxml2.patch + qgis4-project-properties.patch + qgis4_url_encoding.patch ) file(REMOVE ${SOURCE_PATH}/cmake/FindQtKeychain.cmake) diff --git a/vcpkg/ports/qgis/qgis4-project-properties.patch b/vcpkg/ports/qgis/qgis4-project-properties.patch new file mode 100644 index 000000000..0f1149cf5 --- /dev/null +++ b/vcpkg/ports/qgis/qgis4-project-properties.patch @@ -0,0 +1,236 @@ +diff --git a/src/core/project/qgsproject.cpp b/src/core/project/qgsproject.cpp +index d5cd3e3ebb4..819f8809084 100644 +--- a/src/core/project/qgsproject.cpp ++++ b/src/core/project/qgsproject.cpp +@@ -116,21 +116,6 @@ QStringList makeKeyTokens_( const QString &scope, const QString &key ) + // be sure to include the canonical root node + keyTokens.push_front( QStringLiteral( "properties" ) ); + +- //check validy of keys since an invalid xml name will will be dropped upon saving the xml file. If not valid, we print a message to the console. +- for ( int i = 0; i < keyTokens.size(); ++i ) +- { +- const QString keyToken = keyTokens.at( i ); +- +- //invalid chars in XML are found at http://www.w3.org/TR/REC-xml/#NT-NameChar +- //note : it seems \x10000-\xEFFFF is valid, but it when added to the regexp, a lot of unwanted characters remain +- const thread_local QRegularExpression sInvalidRegexp = QRegularExpression( QStringLiteral( "([^:A-Z_a-z\\x{C0}-\\x{D6}\\x{D8}-\\x{F6}\\x{F8}-\\x{2FF}\\x{370}-\\x{37D}\\x{37F}-\\x{1FFF}\\x{200C}-\\x{200D}\\x{2070}-\\x{218F}\\x{2C00}-\\x{2FEF}\\x{3001}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFFD}\\-\\.0-9\\x{B7}\\x{0300}-\\x{036F}\\x{203F}-\\x{2040}]|^[^:A-Z_a-z\\x{C0}-\\x{D6}\\x{D8}-\\x{F6}\\x{F8}-\\x{2FF}\\x{370}-\\x{37D}\\x{37F}-\\x{1FFF}\\x{200C}-\\x{200D}\\x{2070}-\\x{218F}\\x{2C00}-\\x{2FEF}\\x{3001}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFFD}])" ) ); +- if ( keyToken.contains( sInvalidRegexp ) ) +- { +- const QString errorString = QObject::tr( "Entry token invalid : '%1'. The token will not be saved to file." ).arg( keyToken ); +- QgsMessageLog::logMessage( errorString, QString(), Qgis::MessageLevel::Critical ); +- } +- } +- + return keyTokens; + } + +@@ -1311,20 +1296,20 @@ void dump_( const QgsProjectPropertyKey &topQgsPropertyKey ) + * scope. "layers" is a list containing three string values. + * + * \code{.xml} +- * +- * +- * 42 +- * 1 +- * ++ * ++ * ++ * 42 ++ * 1 ++ * + * railroad + * airport +- * +- * 1 +- * 123.456 +- * ++ * ++ * 1 ++ * 123.456 ++ * + * type +- * +- * ++ * ++ * + * + * \endcode + * +@@ -3967,10 +3952,25 @@ bool QgsProject::createEmbeddedLayer( const QString &layerId, const QString &pro + const QDomElement propertiesElem = sProjectDocument.documentElement().firstChildElement( QStringLiteral( "properties" ) ); + if ( !propertiesElem.isNull() ) + { +- const QDomElement absElem = propertiesElem.firstChildElement( QStringLiteral( "Paths" ) ).firstChildElement( QStringLiteral( "Absolute" ) ); +- if ( !absElem.isNull() ) ++ QDomElement e = propertiesElem.firstChildElement( QStringLiteral( "Paths" ) ); ++ if ( e.isNull() ) ++ { ++ e = propertiesElem.firstChildElement( QStringLiteral( "properties" ) ); ++ while ( !e.isNull() && e.attribute( QStringLiteral( "name" ) ) != QStringLiteral( "Paths" ) ) ++ e = e.nextSiblingElement( QStringLiteral( "properties" ) ); ++ ++ e = e.firstChildElement( QStringLiteral( "properties" ) ); ++ while ( !e.isNull() && e.attribute( QStringLiteral( "name" ) ) != QStringLiteral( "Absolute" ) ) ++ e = e.nextSiblingElement( QStringLiteral( "properties" ) ); ++ } ++ else ++ { ++ e = e.firstChildElement( QStringLiteral( "Absolute" ) ); ++ } ++ ++ if ( !e.isNull() ) + { +- useAbsolutePaths = absElem.text().compare( QLatin1String( "true" ), Qt::CaseInsensitive ) == 0; ++ useAbsolutePaths = e.text().compare( QLatin1String( "true" ), Qt::CaseInsensitive ) == 0; + } + } + +diff --git a/src/core/project/qgsprojectproperty.cpp b/src/core/project/qgsprojectproperty.cpp +index ff8024a5260..7691c1b5d53 100644 +--- a/src/core/project/qgsprojectproperty.cpp ++++ b/src/core/project/qgsprojectproperty.cpp +@@ -362,33 +362,41 @@ bool QgsProjectPropertyKey::readXml( const QDomNode &keyNode ) + + while ( i < subkeys.count() ) + { ++ const QDomNode subkey = subkeys.item( i ); ++ QString name; ++ ++ if ( subkey.nodeName() == QStringLiteral( "properties" ) && ++ subkey.hasAttributes() && // if we have attributes ++ subkey.isElement() && // and we're an element ++ subkey.toElement().hasAttribute( QStringLiteral( "name" ) ) ) // and we have a "name" attribute ++ name = subkey.toElement().attribute( QStringLiteral( "name" ) ); ++ else ++ name = subkey.nodeName(); ++ + // if the current node is an element that has a "type" attribute, + // then we know it's a leaf node; i.e., a subkey _value_, and not + // a subkey +- if ( subkeys.item( i ).hasAttributes() && // if we have attributes +- subkeys.item( i ).isElement() && // and we're an element +- subkeys.item( i ).toElement().hasAttribute( QStringLiteral( "type" ) ) ) // and we have a "type" attribute ++ if ( subkey.hasAttributes() && // if we have attributes ++ subkey.isElement() && // and we're an element ++ subkey.toElement().hasAttribute( QStringLiteral( "type" ) ) ) // and we have a "type" attribute + { + // then we're a key value +- delete mProperties.take( subkeys.item( i ).nodeName() ); +- mProperties.insert( subkeys.item( i ).nodeName(), new QgsProjectPropertyValue ); ++ // ++ delete mProperties.take( name ); ++ mProperties.insert( name, new QgsProjectPropertyValue ); + +- QDomNode subkey = subkeys.item( i ); +- +- if ( !mProperties[subkeys.item( i ).nodeName()]->readXml( subkey ) ) ++ if ( !mProperties[name]->readXml( subkey ) ) + { +- QgsDebugError( QStringLiteral( "unable to parse key value %1" ).arg( subkeys.item( i ).nodeName() ) ); ++ QgsDebugError( QStringLiteral( "unable to parse key value %1" ).arg( name ) ); + } + } + else // otherwise it's a subkey, so just recurse on down the remaining keys + { +- addKey( subkeys.item( i ).nodeName() ); +- +- QDomNode subkey = subkeys.item( i ); ++ addKey( name ); + +- if ( !mProperties[subkeys.item( i ).nodeName()]->readXml( subkey ) ) ++ if ( !mProperties[name]->readXml( subkey ) ) + { +- QgsDebugError( QStringLiteral( "unable to parse subkey %1" ).arg( subkeys.item( i ).nodeName() ) ); ++ QgsDebugError( QStringLiteral( "unable to parse subkey %1" ).arg( name ) ); + } + } + +diff --git a/tests/src/python/test_qgsproject.py b/tests/src/python/test_qgsproject.py +index 4da8f330941..752110de78a 100644 +--- a/tests/src/python/test_qgsproject.py ++++ b/tests/src/python/test_qgsproject.py +@@ -63,84 +63,6 @@ class TestQgsProject(QgisTestCase): + QgisTestCase.__init__(self, methodName) + self.messageCaught = False + +- def test_makeKeyTokens_(self): +- # see http://www.w3.org/TR/REC-xml/#d0e804 for a list of valid characters +- +- invalidTokens = [] +- validTokens = [] +- +- # all test tokens will be generated by prepending or inserting characters to this token +- validBase = "valid" +- +- # some invalid characters, not allowed anywhere in a token +- # note that '/' must not be added here because it is taken as a separator by makeKeyTokens_() +- invalidChars = "+*,;<>|!$%()=?#\x01" +- +- # generate the characters that are allowed at the start of a token (and at every other position) +- validStartChars = ":_" +- charRanges = [ +- (ord("a"), ord("z")), +- (ord("A"), ord("Z")), +- (0x00F8, 0x02FF), +- (0x0370, 0x037D), +- (0x037F, 0x1FFF), +- (0x200C, 0x200D), +- (0x2070, 0x218F), +- (0x2C00, 0x2FEF), +- (0x3001, 0xD7FF), +- (0xF900, 0xFDCF), +- (0xFDF0, 0xFFFD), +- # (0x10000, 0xEFFFF), while actually valid, these are not yet accepted by makeKeyTokens_() +- ] +- for r in charRanges: +- for c in range(r[0], r[1]): +- validStartChars += chr(c) +- +- # generate the characters that are only allowed inside a token, not at the start +- validInlineChars = "-.\xB7" +- charRanges = [ +- (ord("0"), ord("9")), +- (0x0300, 0x036F), +- (0x203F, 0x2040), +- ] +- for r in charRanges: +- for c in range(r[0], r[1]): +- validInlineChars += chr(c) +- +- # test forbidden start characters +- for c in invalidChars + validInlineChars: +- invalidTokens.append(c + validBase) +- +- # test forbidden inline characters +- for c in invalidChars: +- invalidTokens.append(validBase[:4] + c + validBase[4:]) +- +- # test each allowed start character +- for c in validStartChars: +- validTokens.append(c + validBase) +- +- # test each allowed inline character +- for c in validInlineChars: +- validTokens.append(validBase[:4] + c + validBase[4:]) +- +- logger = QgsApplication.messageLog() +- logger.messageReceived.connect(self.catchMessage) +- prj = QgsProject.instance() +- +- for token in validTokens: +- self.messageCaught = False +- prj.readEntry("test", token) +- myMessage = f"valid token '{token}' not accepted" +- assert not self.messageCaught, myMessage +- +- for token in invalidTokens: +- self.messageCaught = False +- prj.readEntry("test", token) +- myMessage = f"invalid token '{token}' accepted" +- assert self.messageCaught, myMessage +- +- logger.messageReceived.disconnect(self.catchMessage) +- + def catchMessage(self): + self.messageCaught = True + diff --git a/vcpkg/ports/qgis/qgis4_url_encoding.patch b/vcpkg/ports/qgis/qgis4_url_encoding.patch new file mode 100644 index 000000000..6d1854015 --- /dev/null +++ b/vcpkg/ports/qgis/qgis4_url_encoding.patch @@ -0,0 +1,785 @@ +diff --git a/src/core/network/qgshttpheaders.cpp b/src/core/network/qgshttpheaders.cpp +index de9caeceeee..890c3100852 100644 +--- a/src/core/network/qgshttpheaders.cpp ++++ b/src/core/network/qgshttpheaders.cpp +@@ -73,7 +73,7 @@ bool QgsHttpHeaders::updateUrlQuery( QUrlQuery &uri ) const + { + for ( auto ite = mHeaders.constBegin(); ite != mHeaders.constEnd(); ++ite ) + { +- uri.addQueryItem( QgsHttpHeaders::PARAM_PREFIX + ite.key().toUtf8(), ite.value().toString().toUtf8() ); ++ uri.addQueryItem( QgsHttpHeaders::PARAM_PREFIX + ite.key().toUtf8(), QUrl::toPercentEncoding( ite.value().toString() ) ); + } + return true; + } +diff --git a/src/core/project/qgsprojectstorageregistry.cpp b/src/core/project/qgsprojectstorageregistry.cpp +index a86c4d2bc60..f559bb21112 100644 +--- a/src/core/project/qgsprojectstorageregistry.cpp ++++ b/src/core/project/qgsprojectstorageregistry.cpp +@@ -33,8 +33,7 @@ QgsProjectStorage *QgsProjectStorageRegistry::projectStorageFromUri( const QStri + for ( auto it = mBackends.constBegin(); it != mBackends.constEnd(); ++it ) + { + QgsProjectStorage *storage = it.value(); +- const QString scheme = storage->type() + ':'; +- if ( uri.startsWith( scheme ) ) ++ if ( uri.startsWith( storage->type() + ':' ) || uri.startsWith( storage->type() + "%3A" ) ) + return storage; + } + +diff --git a/src/core/qgsdatasourceuri.cpp b/src/core/qgsdatasourceuri.cpp +index 689690e4003..aaef521e652 100644 +--- a/src/core/qgsdatasourceuri.cpp ++++ b/src/core/qgsdatasourceuri.cpp +@@ -711,7 +711,7 @@ void QgsDataSourceUri::setEncodedUri( const QByteArray &uri ) + + mHttpHeaders.setFromUrlQuery( query ); + +- const auto constQueryItems = query.queryItems(); ++ const auto constQueryItems = query.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ); + for ( const QPair &item : constQueryItems ) + { + if ( !item.first.startsWith( QgsHttpHeaders::PARAM_PREFIX ) && item.first != QgsHttpHeaders::KEY_REFERER ) +diff --git a/src/core/qgsmaplayer.cpp b/src/core/qgsmaplayer.cpp +index e1cfb604055..abd3c19a0fc 100644 +--- a/src/core/qgsmaplayer.cpp ++++ b/src/core/qgsmaplayer.cpp +@@ -3277,8 +3277,9 @@ QString QgsMapLayer::generalHtmlMetadata() const + } + if ( uriComponents.contains( QStringLiteral( "url" ) ) ) + { +- const QString url = uriComponents[QStringLiteral( "url" )].toString(); +- metadata += QStringLiteral( "" ) + tr( "URL" ) + QStringLiteral( "%1" ).arg( QStringLiteral( "%2" ).arg( QUrl( url ).toString(), url ) ) + QStringLiteral( "\n" ); ++ QUrl decodedUri = QUrl::fromPercentEncoding( uriComponents[QStringLiteral( "url" )].toString().toLocal8Bit() ); ++ const QString url = decodedUri.toString(); ++ metadata += QStringLiteral( "" ) + tr( "URL" ) + QStringLiteral( "%1" ).arg( QStringLiteral( "%2" ).arg( url, url ) ) + QStringLiteral( "\n" ); + } + } + +diff --git a/src/core/vectortile/qgsvectortileprovidermetadata.cpp b/src/core/vectortile/qgsvectortileprovidermetadata.cpp +index f7a8b5f1fd9..a6484adde6a 100644 +--- a/src/core/vectortile/qgsvectortileprovidermetadata.cpp ++++ b/src/core/vectortile/qgsvectortileprovidermetadata.cpp +@@ -147,7 +147,7 @@ QString QgsVectorTileProviderMetadata::absoluteToRelativeUri( const QString &uri + // relative path will become "file:./x.txt" + const QString relSrcUrl = context.pathResolver().writePath( sourceUrl.toLocalFile() ); + dsUri.removeParam( QStringLiteral( "url" ) ); // needed because setParam() would insert second "url" key +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( relSrcUrl ).toString() ); ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( relSrcUrl ).toString( QUrl::DecodeReserved ) ); + return dsUri.encodedUri(); + } + } +@@ -176,7 +176,7 @@ QString QgsVectorTileProviderMetadata::relativeToAbsoluteUri( const QString &uri + { + const QString absSrcUrl = context.pathResolver().readPath( sourceUrl.toLocalFile() ); + dsUri.removeParam( QStringLiteral( "url" ) ); // needed because setParam() would insert second "url" key +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( absSrcUrl ).toString() ); ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( absSrcUrl ).toString( QUrl::DecodeReserved ) ); + return dsUri.encodedUri(); + } + } +diff --git a/src/core/vectortile/qgsxyzvectortiledataprovider.cpp b/src/core/vectortile/qgsxyzvectortiledataprovider.cpp +index be607514666..08c45dbe3c5 100644 +--- a/src/core/vectortile/qgsxyzvectortiledataprovider.cpp ++++ b/src/core/vectortile/qgsxyzvectortiledataprovider.cpp +@@ -316,7 +316,7 @@ QString QgsXyzVectorTileDataProviderMetadata::absoluteToRelativeUri( const QStri + // relative path will become "file:./x.txt" + const QString relSrcUrl = context.pathResolver().writePath( sourceUrl.toLocalFile() ); + dsUri.removeParam( QStringLiteral( "url" ) ); // needed because setParam() would insert second "url" key +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( relSrcUrl ).toString() ); ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( relSrcUrl ).toString( QUrl::DecodeReserved ) ); + return dsUri.encodedUri(); + } + +@@ -335,7 +335,7 @@ QString QgsXyzVectorTileDataProviderMetadata::relativeToAbsoluteUri( const QStri + { + const QString absSrcUrl = context.pathResolver().readPath( sourceUrl.toLocalFile() ); + dsUri.removeParam( QStringLiteral( "url" ) ); // needed because setParam() would insert second "url" key +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( absSrcUrl ).toString() ); ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( absSrcUrl ).toString( QUrl::DecodeReserved ) ); + return dsUri.encodedUri(); + } + +diff --git a/src/providers/wms/qgswmsprovider.cpp b/src/providers/wms/qgswmsprovider.cpp +index 8c602f74b2e..538087e0163 100644 +--- a/src/providers/wms/qgswmsprovider.cpp ++++ b/src/providers/wms/qgswmsprovider.cpp +@@ -4957,7 +4957,7 @@ QList QgsWmsProviderMetadata::dataItemProviders() const + QVariantMap QgsWmsProviderMetadata::decodeUri( const QString &uri ) const + { + const QUrlQuery query { uri }; +- const QList> constItems { query.queryItems() }; ++ const QList> constItems { query.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ) }; + QVariantMap decoded; + for ( const QPair &item : constItems ) + { +diff --git a/tests/src/app/testqgsidentify.cpp b/tests/src/app/testqgsidentify.cpp +index 856d5077c15..401e58747d8 100644 +--- a/tests/src/app/testqgsidentify.cpp ++++ b/tests/src/app/testqgsidentify.cpp +@@ -932,7 +932,9 @@ void TestQgsIdentify::identifyVectorTile() + const QString vtPath = QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/vector_tile/{z}-{x}-{y}.pbf" ); + QgsDataSourceUri dsUri; + dsUri.setParam( QStringLiteral( "type" ), QStringLiteral( "xyz" ) ); +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( vtPath ).toString() ); ++ // The values need to be passed to QgsDataSourceUri::setParam() in the same format they are expected to be retrieved. ++ // QUrl::fromPercentEncoding() is needed here because QUrl::fromLocalFile(vtPath).toString() returns the curly braces in an URL-encoded format. ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromPercentEncoding( QUrl::fromLocalFile( vtPath ).toString().toUtf8() ) ); + QgsVectorTileLayer *tempLayer = new QgsVectorTileLayer( dsUri.encodedUri(), QStringLiteral( "testlayer" ) ); + QVERIFY( tempLayer->isValid() ); + +diff --git a/tests/src/core/testqgsdatasourceuri.cpp b/tests/src/core/testqgsdatasourceuri.cpp +index 409b059b488..436216ede80 100644 +--- a/tests/src/core/testqgsdatasourceuri.cpp ++++ b/tests/src/core/testqgsdatasourceuri.cpp +@@ -37,6 +37,7 @@ class TestQgsDataSourceUri : public QObject + void checkParameterKeys(); + void checkRemovePassword(); + void checkUnicodeUri(); ++ void checkUriInUri(); + }; + + void TestQgsDataSourceUri::checkparser_data() +@@ -564,7 +565,7 @@ void TestQgsDataSourceUri::checkAuthParams() + // issue GH #53654 + QgsDataSourceUri uri5; + uri5.setEncodedUri( QStringLiteral( "zmax=14&zmin=0&styleUrl=http://localhost:8000/&f=application%2Fvnd.geoserver.mbstyle%2Bjson" ) ); +- QCOMPARE( uri5.param( QStringLiteral( "f" ) ), QStringLiteral( "application%2Fvnd.geoserver.mbstyle%2Bjson" ) ); ++ QCOMPARE( uri5.param( QStringLiteral( "f" ) ), QStringLiteral( "application/vnd.geoserver.mbstyle+json" ) ); + + uri5.setEncodedUri( QStringLiteral( "zmax=14&zmin=0&styleUrl=http://localhost:8000/&f=application/vnd.geoserver.mbstyle+json" ) ); + QCOMPARE( uri5.param( QStringLiteral( "f" ) ), QStringLiteral( "application/vnd.geoserver.mbstyle+json" ) ); +@@ -611,6 +612,83 @@ void TestQgsDataSourceUri::checkUnicodeUri() + QCOMPARE( uri.param( QStringLiteral( "url" ) ), QStringLiteral( "file:///directory/テスト.mbtiles" ) ); + } + ++void TestQgsDataSourceUri::checkUriInUri() ++{ ++ QString dataUri = QStringLiteral( "dpiMode=7&url=%1&SERVICE=WMS&REQUEST=GetCapabilities&username=username&password=qgis%C3%A8%C3%A9" ); ++ ++ // If the 'url' field references a QGIS server then the 'MAP' parameter can contain an url to the project file. ++ // When the project is saved in a postgresql db, the connection url will also contains '&' and '='. ++ { ++ QgsDataSourceUri uri; ++ // here the project url is encoded but the whole serverUrl is not encoded. ++ // The OGC server will receive a call with this url: http://localhost:8000/ows/?MAP=postgresql://?service=qgis_test&dbname&schema=project&project=luxembourg&SERVICE=WMS&REQUEST=GetCapabilities ++ // from the OGC server POV the 'schema' and 'project' keys will be parsed as main query parameters for 'http://localhost:8000/ows/?' ++ // and not associated to the project file uri. ++ QString project = "postgresql://?service=qgis_test&dbname&schema=project&project=luxembourg"; ++ QString projectEnc = QUrl::toPercentEncoding( project ); ++ QString serverUrl = QString( "http://localhost:8000/ows/?MAP=%1" ); ++ uri.setEncodedUri( dataUri.arg( serverUrl.arg( projectEnc ) ) ); ++ QCOMPARE( uri.param( QStringLiteral( "username" ) ), QStringLiteral( "username" ) ); ++ QCOMPARE( uri.username(), QStringLiteral( "username" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "password" ) ), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri.password(), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "SERVICE" ) ), QStringLiteral( "WMS" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "REQUEST" ) ), QStringLiteral( "GetCapabilities" ) ); ++ // not enough encoded at the beginning ==> bad encoding at the end ++ QCOMPARE( uri.param( QStringLiteral( "url" ) ), serverUrl.arg( project ) ); ++ ++ QgsDataSourceUri uri2; ++ // here the project url is encoded and the whole serverUrl is also encoded. ++ // The OGC server will receive a call with this url: http://localhost:8000/ows/?MAP=postgresql%3A%2F%2F%3Fservice%3Dqgis_test%26dbname%26schema%3Dproject%26project%3Dluxembourg&SERVICE=WMS&REQUEST=GetCapabilities ++ // and will be able to decode all parameters ++ QString serverUrlEnc = QUrl::toPercentEncoding( serverUrl.arg( projectEnc ) ); ++ uri2.setEncodedUri( dataUri.arg( serverUrlEnc ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "username" ) ), QStringLiteral( "username" ) ); ++ QCOMPARE( uri2.username(), QStringLiteral( "username" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "password" ) ), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri2.password(), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "SERVICE" ) ), QStringLiteral( "WMS" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "REQUEST" ) ), QStringLiteral( "GetCapabilities" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "url" ) ), serverUrl.arg( projectEnc ) ); ++ } ++ ++ // same as above but with extra param at the end of the ++ { ++ QgsDataSourceUri uri; ++ // here the project url is encoded but the whole serverUrl is not encoded. ++ // The OGC server will receive a call with this url: https://titiler.xyz/cog/tiles/WebMercatorQuad/16/34060/23336@1x?url=https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif&bidx=1&rescale=1600%2C2100&colormap_name=gist_earth ++ // from the OGC server POV the 'rescale' and 'colormap_name' keys could be parsed as sub query parameters for 'https://data.geo.admin.ch/' ++ QString project = "https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif"; ++ QString projectEnc = QUrl::toPercentEncoding( project ); ++ QString extraParam = "&bidx=1&rescale=1600%2C2100&colormap_name=gist_earth"; ++ QString serverUrl = QString( "https://titiler.xyz/cog/tiles/WebMercatorQuad/16/34060/23336@1x?url=%1" ); ++ ++ uri.setEncodedUri( dataUri.arg( serverUrl.arg( projectEnc ) + extraParam ) ); ++ QCOMPARE( uri.param( QStringLiteral( "username" ) ), QStringLiteral( "username" ) ); ++ QCOMPARE( uri.username(), QStringLiteral( "username" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "password" ) ), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri.password(), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "SERVICE" ) ), QStringLiteral( "WMS" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "REQUEST" ) ), QStringLiteral( "GetCapabilities" ) ); ++ // not enough encoded at the beginning ==> bad encoding at the end ++ QCOMPARE( uri.param( QStringLiteral( "url" ) ), serverUrl.arg( project ) ); ++ ++ QgsDataSourceUri uri2; ++ // here the project url is encoded and the whole serverUrl is also encoded. ++ // The OGC server will receive a call with this url: https://titiler.xyz/cog/tiles/WebMercatorQuad/16/34060/23336@1x?url=https%3A%2F%2Fdata.geo.admin.ch%2Fch.swisstopo.swissalti3d%2Fswissalti3d_2019_2573-1085%2Fswissalti3d_2019_2573-1085_0.5_2056_5728.tif&bidx=1&rescale=1600%2C2100&colormap_name=gist_earth ++ // and will be able to decode all parameters ++ QString serverUrlEnc = QUrl::toPercentEncoding( serverUrl.arg( projectEnc ) + extraParam ); ++ uri2.setEncodedUri( dataUri.arg( serverUrlEnc ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "username" ) ), QStringLiteral( "username" ) ); ++ QCOMPARE( uri2.username(), QStringLiteral( "username" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "password" ) ), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri2.password(), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "SERVICE" ) ), QStringLiteral( "WMS" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "REQUEST" ) ), QStringLiteral( "GetCapabilities" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "url" ) ), serverUrl.arg( projectEnc ) + extraParam ); ++ } ++} ++ + + QGSTEST_MAIN( TestQgsDataSourceUri ) + #include "testqgsdatasourceuri.moc" +diff --git a/tests/src/core/testqgsgdalcloudconnection.cpp b/tests/src/core/testqgsgdalcloudconnection.cpp +index e43c4757ee7..0e69eb210ab 100644 +--- a/tests/src/core/testqgsgdalcloudconnection.cpp ++++ b/tests/src/core/testqgsgdalcloudconnection.cpp +@@ -59,7 +59,7 @@ void TestQgsGdalCloudConnection::encodeDecode() + data.rootPath = QStringLiteral( "some/path" ); + data.credentialOptions = QVariantMap { { "pw", QStringLiteral( "xxxx" ) }, { "key", QStringLiteral( "yyy" ) } }; + +- QCOMPARE( QgsGdalCloudProviderConnection::encodedUri( data ), QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some/path" ) ); ++ QCOMPARE( QgsGdalCloudProviderConnection::encodedUri( data ), QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some%2Fpath" ) ); + + const QgsGdalCloudProviderConnection::Data data2 = QgsGdalCloudProviderConnection::decodedUri( QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some/path" ) ); + QCOMPARE( data2.vsiHandler, QStringLiteral( "vsis3" ) ); +@@ -94,7 +94,7 @@ void TestQgsGdalCloudConnection::testConnections() + + // retrieve stored connection + conn = QgsGdalCloudProviderConnection( QStringLiteral( "my connection" ) ); +- QCOMPARE( conn.uri(), QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some/path" ) ); ++ QCOMPARE( conn.uri(), QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some%2Fpath" ) ); + + // add a second connection + QgsGdalCloudProviderConnection::Data data2; +diff --git a/tests/src/core/testqgshttpheaders.cpp b/tests/src/core/testqgshttpheaders.cpp +index 9c2df3cc20e..78bc5f8be81 100644 +--- a/tests/src/core/testqgshttpheaders.cpp ++++ b/tests/src/core/testqgshttpheaders.cpp +@@ -147,11 +147,14 @@ void TestQgsHttpheaders::createQgsOwsConnection() + + QgsOwsConnection ows( "service", "name" ); + QCOMPARE( ows.connectionInfo(), ",authcfg=,referer=http://test.com" ); +- QCOMPARE( ows.uri().encodedUri(), "url&http-header:other_http_header=value&http-header:referer=http://test.com" ); ++ if ( ows.uri().encodedUri().startsWith( "url=" ) ) ++ QCOMPARE( ows.uri().encodedUri(), "url=&http-header:other_http_header=value&http-header:referer=http%3A%2F%2Ftest.com" ); ++ else ++ QCOMPARE( ows.uri().encodedUri(), "url&http-header:other_http_header=value&http-header:referer=http%3A%2F%2Ftest.com" ); + + QgsDataSourceUri uri( QString( "https://www.ogc.org/?p1=v1" ) ); + QgsDataSourceUri uri2 = ows.addWmsWcsConnectionSettings( uri, "service", "name" ); +- QCOMPARE( uri2.encodedUri(), "https://www.ogc.org/?p1=v1&http-header:other_http_header=value&http-header:referer=http://test.com" ); ++ QCOMPARE( uri2.encodedUri(), "https://www.ogc.org/?p1=v1&http-header:other_http_header=value&http-header:referer=http%3A%2F%2Ftest.com" ); + + // check space separated string + QCOMPARE( uri2.uri(), " https://www.ogc.org/?p1='v1' http-header:other_http_header='value' http-header:referer='http://test.com' referer='http://test.com'" ); +@@ -159,7 +162,7 @@ void TestQgsHttpheaders::createQgsOwsConnection() + QgsDataSourceUri uri3( uri2.uri() ); + QCOMPARE( uri3.httpHeader( QgsHttpHeaders::KEY_REFERER ), "http://test.com" ); + QCOMPARE( uri3.httpHeader( "other_http_header" ), "value" ); +- QCOMPARE( uri3.encodedUri(), "https://www.ogc.org/?p1=v1&referer=http://test.com&http-header:other_http_header=value&http-header:referer=http://test.com" ); ++ QCOMPARE( uri3.encodedUri(), "https://www.ogc.org/?p1=v1&referer=http%3A%2F%2Ftest.com&http-header:other_http_header=value&http-header:referer=http%3A%2F%2Ftest.com" ); + } + + +diff --git a/tests/src/core/testqgsmaplayer.cpp b/tests/src/core/testqgsmaplayer.cpp +index 47e6f2d5a6e..e9d527b186f 100644 +--- a/tests/src/core/testqgsmaplayer.cpp ++++ b/tests/src/core/testqgsmaplayer.cpp +@@ -32,6 +32,7 @@ + #include "qgsmaplayerstore.h" + #include "qgsproject.h" + #include "qgsxmlutils.h" ++#include "qgsvectortilelayer.h" + + /** + * \ingroup UnitTests +@@ -54,6 +55,8 @@ class TestQgsMapLayer : public QObject + void testId(); + void formatName(); + ++ void generalHtmlMetadata(); ++ + void setBlendMode(); + + void isInScaleRange_data(); +@@ -150,6 +153,33 @@ void TestQgsMapLayer::testId() + QCOMPARE( spy3.count(), 1 ); + } + ++void TestQgsMapLayer::generalHtmlMetadata() ++{ ++ { ++ QgsDataSourceUri ds; ++ ds.setParam( QStringLiteral( "type" ), "xyz" ); ++ ds.setParam( QStringLiteral( "zmax" ), "1" ); ++ ds.setParam( QStringLiteral( "url" ), "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" ); ++ std::unique_ptr vl( new QgsVectorTileLayer( ds.encodedUri(), QStringLiteral( "testLayer" ) ) ); ++ QVERIFY( vl->dataProvider() ); ++ QVERIFY( vl->dataProvider()->isValid() ); ++ QCOMPARE( ds.param( QStringLiteral( "url" ) ), "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" ); ++ QVERIFY( vl->generalHtmlMetadata().contains( "URL vl( new QgsVectorTileLayer( ds.encodedUri(), QStringLiteral( "testLayer" ) ) ); ++ QVERIFY( vl->dataProvider() ); ++ QVERIFY( vl->dataProvider()->isValid() ); ++ QCOMPARE( ds.param( QStringLiteral( "url" ) ), QStringLiteral( "%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QVERIFY( vl->generalHtmlMetadata().contains( QStringLiteral( "Path( &conn )->providerKey(), QStringLiteral( "test_provider" ) ); + + // add a second connection +@@ -110,7 +110,7 @@ void TestQgsTiledSceneConnection::testConnections() + data2.httpHeaders.insert( QStringLiteral( "my_header" ), QStringLiteral( "value2" ) ); + // construct connection using encoded uri + QgsTiledSceneProviderConnection conn2( QgsTiledSceneProviderConnection::encodedUri( data2 ), QStringLiteral( "test_provider2" ), {} ); +- QCOMPARE( conn2.uri(), QStringLiteral( "url=http://testurl2&username=my_user2&password=my_pw2&authcfg=my_auth2&http-header:my_header=value2" ) ); ++ QCOMPARE( conn2.uri(), QStringLiteral( "url=http%3A%2F%2Ftesturl2&username=my_user2&password=my_pw2&authcfg=my_auth2&http-header:my_header=value2" ) ); + QCOMPARE( qgis::down_cast( &conn2 )->providerKey(), QStringLiteral( "test_provider2" ) ); + conn2.store( QStringLiteral( "second connection" ) ); + +diff --git a/tests/src/core/testqgsvectortileconnection.cpp b/tests/src/core/testqgsvectortileconnection.cpp +index e539eb0be69..d73454fa428 100644 +--- a/tests/src/core/testqgsvectortileconnection.cpp ++++ b/tests/src/core/testqgsvectortileconnection.cpp +@@ -62,13 +62,13 @@ void TestQgsVectorTileConnection::test_encodedUri() + conn.zMin = 0; + conn.zMax = 18; + QString uri = QgsVectorTileProviderConnection::encodedUri( conn ); +- QCOMPARE( uri, QStringLiteral( "type=xyz&url=https://api.maptiler.com/tiles/v3/%7Bz%7D/%7Bx%7D/%7By%7D.pbf?key%3Dabcdef12345&zmax=18&zmin=0" ) ); ++ QCOMPARE( uri, QStringLiteral( "type=xyz&url=https%3A%2F%2Fapi.maptiler.com%2Ftiles%2Fv3%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.pbf%3Fkey%3Dabcdef12345&zmax=18&zmin=0" ) ); + + conn.url = QStringLiteral( "file:///home/user/tiles.mbtiles" ); + conn.zMin = 0; + conn.zMax = 18; + uri = QgsVectorTileProviderConnection::encodedUri( conn ); +- QCOMPARE( uri, QStringLiteral( "type=mbtiles&url=file:///home/user/tiles.mbtiles&zmax=18&zmin=0" ) ); ++ QCOMPARE( uri, QStringLiteral( "type=mbtiles&url=file%3A%2F%2F%2Fhome%2Fuser%2Ftiles.mbtiles&zmax=18&zmin=0" ) ); + } + + +diff --git a/tests/src/core/testqgsvectortilelayer.cpp b/tests/src/core/testqgsvectortilelayer.cpp +index 4a5f82f0b0d..99c0b503c30 100644 +--- a/tests/src/core/testqgsvectortilelayer.cpp ++++ b/tests/src/core/testqgsvectortilelayer.cpp +@@ -256,11 +256,12 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() + QCOMPARE( vectorTileMetadata->validLayerTypesForUri( QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ), { Qgis::LayerType::VectorTile } ); + + // query sublayers ++ QString localMbtilesPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/vector_tile/mbtiles_vt.mbtiles" ) ) ); + QList sublayers = vectorTileMetadata->querySublayers( QStringLiteral( "%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( !QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); +@@ -269,7 +270,7 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + +@@ -278,7 +279,7 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); +@@ -287,17 +288,19 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + + // fast scan mode means that any mbtile file will be reported, including those with only raster tiles + // (we are skipping a potentially expensive db open and format check) ++ QString localIsleOfManPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/isle_of_man.mbtiles" ) ) ); ++ + sublayers = vectorTileMetadata->querySublayers( QStringLiteral( "%1/isle_of_man.mbtiles" ).arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/isle_of_man.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localIsleOfManPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + +@@ -328,8 +331,9 @@ void TestQgsVectorTileLayer::test_relativePathsMbTiles() + QgsReadWriteContext contextRel; + contextRel.setPathResolver( QgsPathResolver( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/project.qgs" ) ) ); + const QgsReadWriteContext contextAbs; ++ QString localMbtilesPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/vector_tile/mbtiles_vt.mbtiles" ) ) ); + +- const QString srcMbtiles = QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ); ++ const QString srcMbtiles = QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ); + + std::unique_ptr layer = std::make_unique( srcMbtiles ); + QVERIFY( layer->isValid() ); +@@ -337,7 +341,7 @@ void TestQgsVectorTileLayer::test_relativePathsMbTiles() + + // encode source: converting absolute paths to relative + const QString srcMbtilesRel = layer->encodedSource( srcMbtiles, contextRel ); +- QCOMPARE( srcMbtilesRel, QStringLiteral( "type=mbtiles&url=./vector_tile/mbtiles_vt.mbtiles" ) ); ++ QCOMPARE( srcMbtilesRel, QStringLiteral( "type=mbtiles&url=.%2Fvector_tile%2Fmbtiles_vt.mbtiles" ) ); + + // encode source: keeping absolute paths + QCOMPARE( layer->encodedSource( srcMbtiles, contextAbs ), srcMbtiles ); +@@ -377,15 +381,15 @@ void TestQgsVectorTileLayer::test_relativePathsXyz() + contextRel.setPathResolver( QgsPathResolver( "/home/qgis/project.qgs" ) ); + const QgsReadWriteContext contextAbs; + +- const QString srcXyzLocal = "type=xyz&url=file:///home/qgis/%7Bz%7D/%7Bx%7D/%7By%7D.pbf"; +- const QString srcXyzRemote = "type=xyz&url=http://www.example.com/%7Bz%7D/%7Bx%7D/%7By%7D.pbf"; ++ const QString srcXyzLocal = "type=xyz&url=file%3A%2F%2F%2Fhome%2Fqgis%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.pbf"; ++ const QString srcXyzRemote = "type=xyz&url=http%3A%2F%2Fwww.example.com%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.pbf"; + + std::unique_ptr layer = std::make_unique( srcXyzLocal ); + QCOMPARE( layer->providerType(), QStringLiteral( "xyzvectortiles" ) ); + + // encode source: converting absolute paths to relative + const QString srcXyzLocalRel = layer->encodedSource( srcXyzLocal, contextRel ); +- QCOMPARE( srcXyzLocalRel, QStringLiteral( "type=xyz&url=file:./%7Bz%7D/%7Bx%7D/%7By%7D.pbf" ) ); ++ QCOMPARE( srcXyzLocalRel, QStringLiteral( "type=xyz&url=file%3A.%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.pbf" ) ); + QCOMPARE( layer->encodedSource( srcXyzRemote, contextRel ), srcXyzRemote ); + + // encode source: keeping absolute paths +@@ -421,7 +425,8 @@ void TestQgsVectorTileLayer::test_absoluteRelativeUriXyz() + + QString absoluteUri = dsAbs.encodedUri(); + QString relativeUri = dsRel.encodedUri(); +- QCOMPARE( vectorTileMetadata->absoluteToRelativeUri( absoluteUri, context ), relativeUri ); ++ QString absToRelUri = vectorTileMetadata->absoluteToRelativeUri( absoluteUri, context ); ++ QCOMPARE( absToRelUri, relativeUri ); + QCOMPARE( vectorTileMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); + } + +@@ -443,23 +448,23 @@ void TestQgsVectorTileLayer::testVtpkProviderMetadata() + QVERIFY( vectorTileMetadata->querySublayers( QStringLiteral( "type=vtpk&url=%1/points.shp" ).arg( TEST_DATA_DIR ) ).isEmpty() ); + + // vtpk uris +- QCOMPARE( vectorTileMetadata->priorityForUri( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/testvtpk.vtpk" ) ), 100 ); +- QCOMPARE( vectorTileMetadata->validLayerTypesForUri( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/testvtpk.vtpk" ) ), { Qgis::LayerType::VectorTile } ); +- QList sublayers = vectorTileMetadata->querySublayers( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/testvtpk.vtpk" ) ); +- QCOMPARE( sublayers.size(), 1 ); +- QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "vtpkvectortiles" ) ); +- QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "testvtpk" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); +- QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); +- +- QCOMPARE( vectorTileMetadata->priorityForUri( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ), 100 ); +- QCOMPARE( vectorTileMetadata->validLayerTypesForUri( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ), { Qgis::LayerType::VectorTile } ); +- sublayers = vectorTileMetadata->querySublayers( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); +- QCOMPARE( sublayers.size(), 1 ); +- QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "vtpkvectortiles" ) ); +- QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "testvtpk" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); +- QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); ++ QString localVtpkPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/testvtpk.vtpk" ) ) ); ++ ++ for ( auto uriStr : { ++ QStringLiteral( "%1/%2" ).arg( TEST_DATA_DIR ).arg( "testvtpk.vtpk" ), // ++ QStringLiteral( "type=vtpk&url=%1" ).arg( localVtpkPath ), // ++ QStringLiteral( "type=vtpk&url=%1" ).arg( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/testvtpk.vtpk" ) ) ++ } ) ++ { ++ QCOMPARE( vectorTileMetadata->priorityForUri( uriStr ), 100 ); ++ QCOMPARE( vectorTileMetadata->validLayerTypesForUri( uriStr ), { Qgis::LayerType::VectorTile } ); ++ QList sublayers = vectorTileMetadata->querySublayers( uriStr ); ++ QCOMPARE( sublayers.size(), 1 ); ++ QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "vtpkvectortiles" ) ); ++ QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "testvtpk" ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=vtpk&url=%1" ).arg( localVtpkPath ) ); ++ QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); ++ } + + // test that vtpk provider is the preferred provider for vtpk files + QList candidates = QgsProviderRegistry::instance()->preferredProvidersForUri( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); +@@ -485,7 +490,9 @@ void TestQgsVectorTileLayer::test_relativePathsVtpk() + contextRel.setPathResolver( QgsPathResolver( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/project.qgs" ) ) ); + const QgsReadWriteContext contextAbs; + +- const QString srcVtpk = QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ); ++ QString localVtpkPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/testvtpk.vtpk" ) ) ); ++ ++ const QString srcVtpk = QStringLiteral( "type=vtpk&url=%1" ).arg( localVtpkPath ); + + std::unique_ptr layer = std::make_unique( srcVtpk ); + QVERIFY( layer->isValid() ); +@@ -493,7 +500,7 @@ void TestQgsVectorTileLayer::test_relativePathsVtpk() + + // encode source: converting absolute paths to relative + const QString srcVtpkRel = layer->encodedSource( srcVtpk, contextRel ); +- QCOMPARE( srcVtpkRel, QStringLiteral( "type=vtpk&url=./testvtpk.vtpk" ) ); ++ QCOMPARE( srcVtpkRel, QStringLiteral( "type=vtpk&url=.%2Ftestvtpk.vtpk" ) ); + + // encode source: keeping absolute paths + QCOMPARE( layer->encodedSource( srcVtpk, contextAbs ), srcVtpk ); +diff --git a/tests/src/providers/testqgswmsprovider.cpp b/tests/src/providers/testqgswmsprovider.cpp +index d736bfcc38f..3cbaf2578fd 100644 +--- a/tests/src/providers/testqgswmsprovider.cpp ++++ b/tests/src/providers/testqgswmsprovider.cpp +@@ -321,7 +321,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( !QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); +@@ -330,7 +330,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + +@@ -347,16 +347,16 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); + +- sublayers = wmsMetadata->querySublayers( QStringLiteral( "type=mbtiles&url=%1/isle_of_man.mbtiles" ).arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); ++ sublayers = wmsMetadata->querySublayers( u"type=mbtiles&url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles"_s.arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + +@@ -374,7 +374,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/vector_tile/mbtiles_vt.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fvector_tile%2Fmbtiles_vt.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + +@@ -435,22 +435,21 @@ void TestQgsWmsProvider::providerUriUpdates() + QCOMPARE( parts["testParam"], QVariant( "false" ) ); + + QString updatedUri = metadata->encodeUri( parts ); +- QString expectedUri = QStringLiteral( "crs=EPSG:4326&dpiMode=7&" ++ QString expectedUri = QStringLiteral( "crs=EPSG%3A4326&dpiMode=7&" + "layers=testlayer&styles&" + "testParam=false&" +- "url=http://localhost:8380/mapserv" ); ++ "url=http%3A%2F%2Flocalhost%3A8380%2Fmapserv" ); + QCOMPARE( updatedUri, expectedUri ); + } + + void TestQgsWmsProvider::providerUriLocalFile() + { +- QString uriString = QStringLiteral( "url=file:///my/local/tiles.mbtiles&type=mbtiles" ); +- QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( QStringLiteral( "wms" ), uriString ); ++ QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( u"wms"_s, u"url=file:///my/local/tiles.mbtiles&type=mbtiles"_s ); + QVariantMap expectedParts { { QString( "type" ), QVariant( "mbtiles" ) }, { QString( "path" ), QVariant( "/my/local/tiles.mbtiles" ) }, { QString( "url" ), QVariant( "file:///my/local/tiles.mbtiles" ) } }; + QCOMPARE( parts, expectedParts ); + + QString encodedUri = QgsProviderRegistry::instance()->encodeUri( QStringLiteral( "wms" ), parts ); +- QCOMPARE( encodedUri, uriString ); ++ QCOMPARE( encodedUri, u"url=file%3A%2F%2F%2Fmy%2Flocal%2Ftiles.mbtiles&type=mbtiles"_s ); + + QgsProviderMetadata *wmsMetadata = QgsProviderRegistry::instance()->providerMetadata( "wms" ); + QVERIFY( wmsMetadata ); +@@ -475,10 +474,27 @@ void TestQgsWmsProvider::absoluteRelativeUri() + QgsProviderMetadata *wmsMetadata = QgsProviderRegistry::instance()->providerMetadata( "wms" ); + QVERIFY( wmsMetadata ); + +- QString absoluteUri = "type=mbtiles&url=file://" + QStringLiteral( TEST_DATA_DIR ) + "/isle_of_man.mbtiles"; +- QString relativeUri = "type=mbtiles&url=file:./isle_of_man.mbtiles"; +- QCOMPARE( wmsMetadata->absoluteToRelativeUri( absoluteUri, context ), relativeUri ); +- QCOMPARE( wmsMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); ++ // from no encoded absolute url to encoded relative url ++ { ++ QString absoluteUri = QString( "type=mbtiles&url=" ) + "file://" + QStringLiteral( TEST_DATA_DIR ) + "/isle_of_man.mbtiles"; ++ QString relativeUri = "type=mbtiles&url=file%3A.%2Fisle_of_man.mbtiles"; ++ QCOMPARE( wmsMetadata->absoluteToRelativeUri( absoluteUri, context ), relativeUri ); ++ } ++ ++ // from no encoded relative url to encoded absolute url ++ { ++ QString relativeUri = "type=mbtiles&url=file:./isle_of_man.mbtiles"; ++ QString absoluteUri = "type=mbtiles&url=" + QString( QUrl::toPercentEncoding( "file://" + QStringLiteral( TEST_DATA_DIR ) + "/isle_of_man.mbtiles" ) ); ++ QCOMPARE( wmsMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); ++ } ++ ++ // from encoded to encoded ++ { ++ QString absoluteUri = "type=mbtiles&url=" + QString( QUrl::toPercentEncoding( "file://" + QStringLiteral( TEST_DATA_DIR ) + "/isle_of_man.mbtiles" ) ); ++ QString relativeUri = "type=mbtiles&url=file%3A.%2Fisle_of_man.mbtiles"; ++ QCOMPARE( wmsMetadata->absoluteToRelativeUri( absoluteUri, context ), relativeUri ); ++ QCOMPARE( wmsMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); ++ } + } + + void TestQgsWmsProvider::testXyzIsBasemap() +diff --git a/tests/src/python/test_qgsmapboxglconverter.py b/tests/src/python/test_qgsmapboxglconverter.py +index 8f4640eb89c..0031ee3c54d 100644 +--- a/tests/src/python/test_qgsmapboxglconverter.py ++++ b/tests/src/python/test_qgsmapboxglconverter.py +@@ -2406,7 +2406,7 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): + self.assertIsInstance(rl, QgsRasterLayer) + self.assertEqual( + rl.source(), +- "tilePixelRation=1&type=xyz&url=https://yyyyyy/v1/tiles/texturereliefshade/EPSG:3857/%7Bz%7D/%7Bx%7D/%7By%7D.webp&zmax=20&zmin=3", ++ "tilePixelRation=1&type=xyz&url=https%3A%2F%2Fyyyyyy%2Fv1%2Ftiles%2Ftexturereliefshade%2FEPSG%3A3857%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.webp&zmax=20&zmin=3", + ) + self.assertEqual(rl.providerType(), "wms") + +@@ -2418,7 +2418,7 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): + self.assertEqual(raster_layer.name(), "Texture-Relief") + self.assertEqual( + raster_layer.source(), +- "tilePixelRation=1&type=xyz&url=https://yyyyyy/v1/tiles/texturereliefshade/EPSG:3857/%7Bz%7D/%7Bx%7D/%7By%7D.webp&zmax=20&zmin=3", ++ "tilePixelRation=1&type=xyz&url=https%3A%2F%2Fyyyyyy%2Fv1%2Ftiles%2Ftexturereliefshade%2FEPSG%3A3857%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.webp&zmax=20&zmin=3", + ) + self.assertEqual( + raster_layer.pipe() +diff --git a/tests/src/python/test_qgsvectortile.py b/tests/src/python/test_qgsvectortile.py +index a4866d1229b..4c42b630b58 100644 +--- a/tests/src/python/test_qgsvectortile.py ++++ b/tests/src/python/test_qgsvectortile.py +@@ -105,7 +105,7 @@ class TestVectorTile(QgisTestCase): + + parts["path"] = "/my/new/file.mbtiles" + uri = md.encodeUri(parts) +- self.assertEqual(uri, "type=mbtiles&url=/my/new/file.mbtiles") ++ self.assertEqual(uri, "type=mbtiles&url=%2Fmy%2Fnew%2Ffile.mbtiles") + + uri = ( + "type=xyz&url=https://fake.server/%7Bx%7D/%7By%7D/%7Bz%7D.png&zmin=0&zmax=2" +@@ -125,7 +125,7 @@ class TestVectorTile(QgisTestCase): + uri = md.encodeUri(parts) + self.assertEqual( + uri, +- "type=xyz&url=https://fake.new.server/%7Bx%7D/%7By%7D/%7Bz%7D.png&zmax=2&zmin=0", ++ "type=xyz&url=https%3A%2F%2Ffake.new.server%2F%7Bx%7D%2F%7By%7D%2F%7Bz%7D.png&zmax=2&zmin=0", + ) + + uri = "type=xyz&serviceType=arcgis&url=https://fake.server/%7Bx%7D/%7By%7D/%7Bz%7D.png&zmax=2&http-header:referer=https://qgis.org/&styleUrl=https://qgis.org/" +@@ -147,7 +147,7 @@ class TestVectorTile(QgisTestCase): + uri = md.encodeUri(parts) + self.assertEqual( + uri, +- "serviceType=arcgis&styleUrl=https://qgis.org/&type=xyz&url=https://fake.new.server/%7Bx%7D/%7By%7D/%7Bz%7D.png&zmax=2&http-header:referer=https://qgis.org/", ++ "serviceType=arcgis&styleUrl=https%3A%2F%2Fqgis.org%2F&type=xyz&url=https%3A%2F%2Ffake.new.server%2F%7Bx%7D%2F%7By%7D%2F%7Bz%7D.png&zmax=2&http-header:referer=https%3A%2F%2Fqgis.org%2F", + ) + + def testZoomRange(self): +diff --git a/tests/src/server/wms/test_qgsserver_wms_parameters.cpp b/tests/src/server/wms/test_qgsserver_wms_parameters.cpp +index 792325c642b..5aa2ab3bd9f 100644 +--- a/tests/src/server/wms/test_qgsserver_wms_parameters.cpp ++++ b/tests/src/server/wms/test_qgsserver_wms_parameters.cpp +@@ -64,14 +64,14 @@ void TestQgsServerWmsParameters::external_layers() + + QgsWms::QgsWmsParametersLayer layer_params = layers_params[0]; + QCOMPARE( layer_params.mNickname, QString( "external_layer_1" ) ); +- QCOMPARE( layer_params.mExternalUri, QString( "layers=layer_1_name&url=http://url_1" ) ); ++ QCOMPARE( layer_params.mExternalUri, QString( "layers=layer_1_name&url=http%3A%2F%2Furl_1" ) ); + + layer_params = layers_params[1]; + QCOMPARE( layer_params.mNickname, QString( "layer" ) ); + + layer_params = layers_params[2]; + QCOMPARE( layer_params.mNickname, QString( "external_layer_2" ) ); +- QCOMPARE( layer_params.mExternalUri, QString( "layers=layer_2_name&opacities=100&url=http://url_2" ) ); ++ QCOMPARE( layer_params.mExternalUri, QString( "layers=layer_2_name&opacities=100&url=http%3A%2F%2Furl_2" ) ); + + //test if opacities are also applied to external layers + QCOMPARE( layers_params[0].mOpacity, 255 ); +@@ -94,7 +94,7 @@ void TestQgsServerWmsParameters::external_layers() + + QgsWms::QgsWmsParametersLayer layer_params2 = layers_params2[0]; + QCOMPARE( layer_params2.mNickname, QString( "external_layer_1" ) ); +- QCOMPARE( layer_params2.mExternalUri, QString( "layers=layer_1_name&url=http://url_1" ) ); ++ QCOMPARE( layer_params2.mExternalUri, QString( "layers=layer_1_name&url=http%3A%2F%2Furl_1" ) ); + } + + void TestQgsServerWmsParameters::percent_encoding()