diff --git a/client/ui/models/api/apiCountryModel.cpp b/client/ui/models/api/apiCountryModel.cpp index 12f4658e..6c9b4fc4 100644 --- a/client/ui/models/api/apiCountryModel.cpp +++ b/client/ui/models/api/apiCountryModel.cpp @@ -1,6 +1,8 @@ #include "apiCountryModel.h" #include +#include +#include #include "core/api/apiDefs.h" #include "logger.h" @@ -8,14 +10,143 @@ namespace { Logger logger("ApiCountryModel"); - constexpr QLatin1String countryConfig("country_config"); + constexpr QLatin1String regionEurope("Europe"); + constexpr QLatin1String regionAmerica("America"); + constexpr QLatin1String regionAsia("Asia"); + constexpr QLatin1String regionOceaniaAfrica("Oceania and Africa"); + constexpr QLatin1String regionOther("Other"); + + struct RegionRowData + { + bool isRegionHeader = false; + QString regionName; + bool isExpanded = true; + int sourceIndex = -1; + QString countryName; + QString sourceCountryName; + QString countryCode; + QString countryImageCode; + }; + + QString resolveRegionByIsoCode(const QString &isoCode) + { + static const QHash isoToRegion = { + {"BE", regionEurope}, + {"EE", regionEurope}, + {"FI", regionEurope}, + {"FR", regionEurope}, + {"GE", regionEurope}, + {"DE", regionEurope}, + {"NL", regionEurope}, + {"PL", regionEurope}, + {"RU", regionEurope}, + {"ES", regionEurope}, + {"SE", regionEurope}, + {"CH", regionEurope}, + {"TR", regionEurope}, + {"BR", regionAmerica}, + {"CA", regionAmerica}, + {"US", regionAmerica}, + {"AE", regionAsia}, + {"JP", regionAsia}, + {"KZ", regionAsia}, + {"KR", regionAsia}, + {"SG", regionAsia}, + {"AU", regionOceaniaAfrica}, + {"NZ", regionOceaniaAfrica}, + {"ZA", regionOceaniaAfrica}, + }; + + return isoToRegion.value(isoCode, regionOther); + } } -ApiCountryModel::ApiCountryModel(QObject *parent) : QAbstractListModel(parent) +class ApiCountryModel::RegionRowsModel : public QAbstractListModel { +public: + enum Roles { + RowTypeRole = Qt::UserRole + 1, + RegionNameRole, + IsExpandedRole, + SourceIndexRole, + CountryNameRole, + SourceCountryNameRole, + CountryCodeRole, + CountryImageCodeRole + }; + + explicit RegionRowsModel(QObject *parent = nullptr) : QAbstractListModel(parent) {} + + int rowCount(const QModelIndex &parent = QModelIndex()) const override + { + Q_UNUSED(parent) + return m_rows.size(); + } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override + { + if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) { + return QVariant(); + } + + const RegionRowData &row = m_rows.at(index.row()); + switch (role) { + case RowTypeRole: + return row.isRegionHeader ? "region" : "country"; + case RegionNameRole: + return row.regionName; + case IsExpandedRole: + return row.isExpanded; + case SourceIndexRole: + return row.sourceIndex; + case CountryNameRole: + return row.countryName; + case SourceCountryNameRole: + return row.sourceCountryName; + case CountryCodeRole: + return row.countryCode; + case CountryImageCodeRole: + return row.countryImageCode; + default: + return QVariant(); + } + } + + QHash roleNames() const override + { + QHash roles; + roles[RowTypeRole] = "rowType"; + roles[RegionNameRole] = "regionName"; + roles[IsExpandedRole] = "isExpanded"; + roles[SourceIndexRole] = "sourceIndex"; + roles[CountryNameRole] = "countryName"; + roles[SourceCountryNameRole] = "sourceCountryName"; + roles[CountryCodeRole] = "countryCode"; + roles[CountryImageCodeRole] = "countryImageCode"; + return roles; + } + + void setRows(QVector &&rows) + { + beginResetModel(); + m_rows = std::move(rows); + endResetModel(); + } + +private: + QVector m_rows; +}; + +ApiCountryModel::ApiCountryModel(QObject *parent) + : QAbstractListModel(parent), m_regionRowsModel(std::make_unique(this)) +{ + loadRegionExpansionState(); + rebuildGroupedRegions(); } +ApiCountryModel::~ApiCountryModel() = default; + int ApiCountryModel::rowCount(const QModelIndex &parent) const { Q_UNUSED(parent) @@ -24,32 +155,28 @@ int ApiCountryModel::rowCount(const QModelIndex &parent) const QVariant ApiCountryModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= static_cast(rowCount())) + if (!index.isValid() || index.row() < 0 || index.row() >= static_cast(rowCount())) { return QVariant(); + } - CountryInfo countryInfo = m_countries.at(index.row()); - IssuedConfigInfo issuedConfigInfo = m_issuedConfigs.value(countryInfo.countryCode); - bool isIssued = issuedConfigInfo.sourceType == countryConfig; + const CountryInfo &countryInfo = m_countries.at(index.row()); + const IssuedConfigInfo issuedConfigInfo = m_issuedConfigs.value(countryInfo.countryCode); + const bool isIssued = issuedConfigInfo.sourceType == countryConfig; switch (role) { - case CountryCodeRole: { + case CountryCodeRole: return countryInfo.countryCode; - } - case CountryNameRole: { + case CountryNameRole: return countryInfo.countryName; - } - case CountryImageCodeRole: { + case CountryImageCodeRole: return countryInfo.countryCode.toUpper(); - } - case IsIssuedRole: { + case IsIssuedRole: return isIssued; - } - case IsWorkerExpiredRole: { + case IsWorkerExpiredRole: return issuedConfigInfo.lastDownloaded < issuedConfigInfo.workerLastUpdated; + default: + return QVariant(); } - } - - return QVariant(); } void ApiCountryModel::updateModel(const QJsonArray &countries, const QString ¤tCountryCode) @@ -57,9 +184,9 @@ void ApiCountryModel::updateModel(const QJsonArray &countries, const QString &cu beginResetModel(); m_countries.clear(); - for (int i = 0; i < countries.size(); i++) { + for (int i = 0; i < countries.size(); ++i) { CountryInfo countryInfo; - QJsonObject countryObject = countries.at(i).toObject(); + const QJsonObject countryObject = countries.at(i).toObject(); countryInfo.countryName = countryObject.value(apiDefs::key::serverCountryName).toString(); countryInfo.countryCode = countryObject.value(apiDefs::key::serverCountryCode).toString(); @@ -72,6 +199,7 @@ void ApiCountryModel::updateModel(const QJsonArray &countries, const QString &cu } endResetModel(); + rebuildGroupedRegions(); } void ApiCountryModel::updateIssuedConfigsInfo(const QJsonArray &issuedConfigs) @@ -79,9 +207,9 @@ void ApiCountryModel::updateIssuedConfigsInfo(const QJsonArray &issuedConfigs) beginResetModel(); m_issuedConfigs.clear(); - for (int i = 0; i < issuedConfigs.size(); i++) { + for (int i = 0; i < issuedConfigs.size(); ++i) { IssuedConfigInfo issuedConfigInfo; - QJsonObject issuedConfigObject = issuedConfigs.at(i).toObject(); + const QJsonObject issuedConfigObject = issuedConfigs.at(i).toObject(); if (issuedConfigObject.value(apiDefs::key::sourceType).toString() != countryConfig) { continue; @@ -110,6 +238,52 @@ void ApiCountryModel::setCurrentIndex(const int i) emit currentIndexChanged(m_currentIndex); } +QString ApiCountryModel::searchText() const +{ + return m_searchText; +} + +void ApiCountryModel::setSearchText(const QString &text) +{ + if (m_searchText == text) { + return; + } + + m_searchText = text; + emit searchTextChanged(); + rebuildGroupedRegions(); +} + +QAbstractListModel *ApiCountryModel::regionRowsModel() const +{ + return m_regionRowsModel.get(); +} + +bool ApiCountryModel::hasVisibleRegions() const +{ + return m_regionRowsModel && m_regionRowsModel->rowCount() > 0; +} + +bool ApiCountryModel::isRegionExpanded(const QString ®ionName) const +{ + if (isSearchActive()) { + return true; + } + return m_regionsExpanded.contains(regionName) ? m_regionsExpanded.value(regionName) : true; +} + +void ApiCountryModel::toggleRegionExpanded(const QString ®ionName) +{ + if (regionName.isEmpty() || isSearchActive()) { + return; + } + + const bool currentValue = isRegionExpanded(regionName); + m_regionsExpanded.insert(regionName, !currentValue); + saveRegionExpansionState(); + rebuildGroupedRegions(); +} + QHash ApiCountryModel::roleNames() const { QHash roles; @@ -120,3 +294,169 @@ QHash ApiCountryModel::roleNames() const roles[IsWorkerExpiredRole] = "isWorkerExpired"; return roles; } + +QString ApiCountryModel::normalizeCountryCode(const QString &countryCode) const +{ + return countryCode.trimmed().toUpper(); +} + +QString ApiCountryModel::extractCountryIsoCode(const QString &countryCode) const +{ + const QString normalizedCode = normalizeCountryCode(countryCode); + for (int i = 0; i + 1 < normalizedCode.size(); ++i) { + const QChar first = normalizedCode.at(i); + const QChar second = normalizedCode.at(i + 1); + if (first.isUpper() && second.isUpper()) { + return normalizedCode.mid(i, 2); + } + } + return normalizedCode; +} + +QString ApiCountryModel::normalizeCountryName(const QString &countryName) const +{ + return countryName.trimmed().toLower(); +} + +QString ApiCountryModel::normalizeSearchComparableText(const QString &textValue) const +{ + QString normalizedText = normalizeCountryName(textValue); + normalizedText.replace(QChar(0x0451), QChar(0x0435)); // ё -> е + normalizedText.replace(QChar(0x0439), QChar(0x0438)); // й -> и + + QString result; + result.reserve(normalizedText.size()); + for (int i = 0; i < normalizedText.size(); ++i) { + const QChar currentChar = normalizedText.at(i); + if (currentChar.isSpace()) { + const QChar prevChar = i > 0 ? normalizedText.at(i - 1) : QChar(); + const QChar nextChar = i + 1 < normalizedText.size() ? normalizedText.at(i + 1) : QChar(); + const bool hasSpaceNeighbor = prevChar.isSpace() || nextChar.isSpace(); + if (hasSpaceNeighbor) { + result.append(currentChar); + } + continue; + } + const bool isSeparator = currentChar == '.' || currentChar == '-'; + + if (!isSeparator) { + result.append(currentChar); + continue; + } + + const QChar prevChar = i > 0 ? normalizedText.at(i - 1) : QChar(); + const QChar nextChar = i + 1 < normalizedText.size() ? normalizedText.at(i + 1) : QChar(); + const bool hasSeparatorNeighbor = prevChar == '.' || prevChar == '-' || nextChar == '.' || nextChar == '-'; + if (hasSeparatorNeighbor) { + result.append(currentChar); + } + } + + return result; +} + +bool ApiCountryModel::isCountryMatchingSearch(const QString &countryName, const QString &sourceCountryCode, + const QString &normalizedSearchText) const +{ + if (normalizedSearchText.isEmpty()) { + return true; + } + + const QString normalizedCountryName = normalizeSearchComparableText(countryName); + const QString normalizedSourceCountryCode = normalizeCountryCode(sourceCountryCode).toLower(); + + return normalizedCountryName.startsWith(normalizedSearchText) + || normalizedSourceCountryCode.startsWith(normalizedSearchText); +} + +QString ApiCountryModel::getDisplayCountryName(const QString &countryName) const +{ + const QString p2pPrefix = "[P2P] "; + if (countryName.startsWith(p2pPrefix)) { + return countryName.mid(p2pPrefix.size()) + " [P2P]"; + } + return countryName; +} + +void ApiCountryModel::rebuildGroupedRegions() +{ + QVector rows; + const QString normalizedSearchText = normalizeSearchComparableText(m_searchText); + const QStringList orderedRegions = { + regionEurope, + regionAmerica, + regionAsia, + regionOceaniaAfrica, + regionOther, + }; + + QHash> groupedCountries; + for (int sourceIndex = 0; sourceIndex < m_countries.size(); ++sourceIndex) { + const CountryInfo &sourceCountry = m_countries.at(sourceIndex); + if (!isCountryMatchingSearch(sourceCountry.countryName, sourceCountry.countryCode, normalizedSearchText)) { + continue; + } + + const QString regionName = resolveRegionByIsoCode(extractCountryIsoCode(sourceCountry.countryCode)); + RegionRowData countryRow; + countryRow.isRegionHeader = false; + countryRow.regionName = regionName; + countryRow.sourceIndex = sourceIndex; + countryRow.countryName = getDisplayCountryName(sourceCountry.countryName); + countryRow.sourceCountryName = sourceCountry.countryName; + countryRow.countryCode = sourceCountry.countryCode; + countryRow.countryImageCode = extractCountryIsoCode(sourceCountry.countryCode); + groupedCountries[regionName].push_back(std::move(countryRow)); + } + + for (const QString ®ionName : orderedRegions) { + QVector countries = groupedCountries.value(regionName); + if (countries.isEmpty()) { + continue; + } + + const bool expanded = isRegionExpanded(regionName); + + RegionRowData headerRow; + headerRow.isRegionHeader = true; + headerRow.regionName = regionName; + headerRow.isExpanded = expanded; + rows.push_back(std::move(headerRow)); + + if (expanded) { + for (RegionRowData &countryRow : countries) { + countryRow.isExpanded = expanded; + rows.push_back(std::move(countryRow)); + } + } + } + + m_regionRowsModel->setRows(std::move(rows)); + emit regionRowsChanged(); +} + +void ApiCountryModel::loadRegionExpansionState() +{ + QSettings settings; + const QVariantMap stored = settings.value("PageSettingsApiAvailableCountries/regionsExpanded").toMap(); + m_regionsExpanded.clear(); + for (auto it = stored.constBegin(); it != stored.constEnd(); ++it) { + m_regionsExpanded.insert(it.key(), it.value().toBool()); + } +} + +void ApiCountryModel::saveRegionExpansionState() const +{ + QVariantMap stored; + for (auto it = m_regionsExpanded.constBegin(); it != m_regionsExpanded.constEnd(); ++it) { + stored.insert(it.key(), it.value()); + } + + QSettings settings; + settings.setValue("PageSettingsApiAvailableCountries/regionsExpanded", stored); +} + +bool ApiCountryModel::isSearchActive() const +{ + return !normalizeSearchComparableText(m_searchText).isEmpty(); +} diff --git a/client/ui/models/api/apiCountryModel.h b/client/ui/models/api/apiCountryModel.h index 08ac3685..48516fb5 100644 --- a/client/ui/models/api/apiCountryModel.h +++ b/client/ui/models/api/apiCountryModel.h @@ -4,6 +4,7 @@ #include #include #include +#include class ApiCountryModel : public QAbstractListModel { @@ -19,12 +20,16 @@ public: }; explicit ApiCountryModel(QObject *parent = nullptr); + ~ApiCountryModel() override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; Q_PROPERTY(int currentIndex READ getCurrentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged) + Q_PROPERTY(QString searchText READ searchText WRITE setSearchText NOTIFY searchTextChanged) + Q_PROPERTY(QAbstractListModel *regionRowsModel READ regionRowsModel CONSTANT) + Q_PROPERTY(bool hasVisibleRegions READ hasVisibleRegions NOTIFY regionRowsChanged) public slots: void updateModel(const QJsonArray &countries, const QString ¤tCountryCode); @@ -32,9 +37,17 @@ public slots: int getCurrentIndex(); void setCurrentIndex(const int i); + QString searchText() const; + void setSearchText(const QString &text); + QAbstractListModel *regionRowsModel() const; + bool hasVisibleRegions() const; + Q_INVOKABLE bool isRegionExpanded(const QString ®ionName) const; + Q_INVOKABLE void toggleRegionExpanded(const QString ®ionName); signals: void currentIndexChanged(const int index); + void searchTextChanged(); + void regionRowsChanged(); protected: QHash roleNames() const override; @@ -57,7 +70,23 @@ private: QVector m_countries; QHash m_issuedConfigs; - int m_currentIndex; + int m_currentIndex = -1; + QString m_searchText; + QHash m_regionsExpanded; + class RegionRowsModel; + std::unique_ptr m_regionRowsModel; + + QString normalizeCountryCode(const QString &countryCode) const; + QString extractCountryIsoCode(const QString &countryCode) const; + QString normalizeCountryName(const QString &countryName) const; + QString normalizeSearchComparableText(const QString &textValue) const; + bool isCountryMatchingSearch(const QString &countryName, const QString &sourceCountryCode, + const QString &normalizedSearchText) const; + QString getDisplayCountryName(const QString &countryName) const; + void rebuildGroupedRegions(); + void loadRegionExpansionState(); + void saveRegionExpansionState() const; + bool isSearchActive() const; }; #endif // APICOUNTRYMODEL_H diff --git a/client/ui/qml/DefaultVpn/Pages/PageSettingsApiAvailableCountries.qml b/client/ui/qml/DefaultVpn/Pages/PageSettingsApiAvailableCountries.qml index 9be00643..f97f3fae 100644 --- a/client/ui/qml/DefaultVpn/Pages/PageSettingsApiAvailableCountries.qml +++ b/client/ui/qml/DefaultVpn/Pages/PageSettingsApiAvailableCountries.qml @@ -40,6 +40,69 @@ Page { verticalAlignment: Qt.AlignVCenter } + Rectangle { + Layout.topMargin: 12 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.fillWidth: true + implicitHeight: 44 + radius: 8 + + color: Style.color.white + border.width: 1 + border.color: searchField.activeFocus ? Style.color.accent1 : Style.color.gray3 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 6 + spacing: 8 + + Image { + width: 18 + height: 18 + source: "qrc:/images/controls/search.svg" + } + + TextField { + id: searchField + Layout.fillWidth: true + + background: Item {} + placeholderText: qsTr("country or country code") + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + font.pixelSize: 16 + font.weight: 400 + font.family: Style.font + color: Style.color.black + topPadding: 0 + bottomPadding: 0 + leftPadding: 0 + rightPadding: 0 + verticalAlignment: TextInput.AlignVCenter + + onTextChanged: ApiCountryModel.searchText = text + Keys.onEscapePressed: searchField.text = "" + + ContextMenu.menu: ContextMenuType { + textObj: searchField + } + } + + ButtonType { + visible: searchField.text !== "" + implicitWidth: 32 + implicitHeight: 32 + imageSource: "qrc:/images/controls/close.svg" + defaultBackgroundColor: Style.color.transparent + hoveredBackgroundColor: Style.color.gray1 + pressedBackgroundColor: Style.color.gray2 + + onClicked: searchField.text = "" + } + } + } + ButtonGroup { id: countriesRadioButtonGroup } @@ -51,19 +114,76 @@ Page { Layout.fillHeight: true Layout.fillWidth: true - model: ApiCountryModel - currentIndex: ApiCountryModel.currentIndex + model: ApiCountryModel.regionRowsModel ScrollBar.vertical: ScrollBar {} + footer: Item { + width: countriesListView.width + height: ApiCountryModel.hasVisibleRegions ? 0 : noResultsText.implicitHeight + 32 + + XSmallTextType { + id: noResultsText + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.top: parent.top + anchors.topMargin: 8 + visible: !ApiCountryModel.hasVisibleRegions + text: qsTr("Nothing found. Try a different spelling or switch keyboard layout.") + color: Style.color.gray9 + horizontalAlignment: Qt.AlignLeft + verticalAlignment: Qt.AlignTop + wrapMode: Text.WordWrap + } + } + delegate: Item { + required property string rowType + required property string regionName + required property bool isExpanded + required property int sourceIndex required property string countryName required property string countryCode required property string countryImageCode - required property int index + required property string sourceCountryName implicitWidth: countriesListView.width - implicitHeight: countryItem.implicitHeight + implicitHeight: rowType === "region" ? 42 : countryItem.implicitHeight + + Item { + anchors.fill: parent + visible: rowType === "region" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 10 + anchors.bottomMargin: 6 + spacing: 8 + + XSmallTextType { + Layout.fillWidth: true + text: regionName + color: Style.color.gray9 + horizontalAlignment: Qt.AlignLeft + verticalAlignment: Qt.AlignVCenter + } + + Image { + source: isExpanded ? "qrc:/images/controls/chevron-up.svg" + : "qrc:/images/controls/chevron-down.svg" + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: ApiCountryModel.toggleRegionExpanded(regionName) + } + } RadioButton { id: countryItem @@ -71,10 +191,11 @@ Page { anchors.fill: parent anchors.rightMargin: 16 anchors.leftMargin: 16 + visible: rowType === "country" ButtonGroup.group: countriesRadioButtonGroup - checked: index === countriesListView.currentIndex + checked: sourceIndex >= 0 && sourceIndex === ApiCountryModel.currentIndex indicator: Item { } @@ -117,19 +238,25 @@ Page { } onClicked: function() { + if (ConnectionController.isConnectionInProgress) { + PageController.showNotificationMessage(qsTr("Unable change server location while trying to make an active connection")) + return + } if (ConnectionController.isConnected) { PageController.showNotificationMessage(qsTr("Unable change server location while there is an active connection")) return } - PageController.showBusyIndicator(true) - var prevIndex = ApiCountryModel.currentIndex - ApiCountryModel.currentIndex = index - if (!ApiConfigsController.updateServiceFromGateway(ServersModel.defaultIndex, countryCode, countryName)) { - ApiCountryModel.currentIndex = prevIndex + if (sourceIndex !== ApiCountryModel.currentIndex) { + PageController.showBusyIndicator(true) + var prevIndex = ApiCountryModel.currentIndex + ApiCountryModel.currentIndex = sourceIndex + if (!ApiConfigsController.updateServiceFromGateway(ServersModel.defaultIndex, countryCode, sourceCountryName)) { + ApiCountryModel.currentIndex = prevIndex + } + PageController.showBusyIndicator(false) + PageController.closePage() } - PageController.showBusyIndicator(false) - PageController.closePage() } MouseArea { @@ -138,7 +265,18 @@ Page { enabled: false } } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.bottom: parent.bottom + height: 1 + color: Style.color.gray3 + visible: rowType === "country" + } } } } -} +}