From 8e75da27ea8cf3e6d65ab44a5feb47bf29d9477c Mon Sep 17 00:00:00 2001 From: Mitternacht822 Date: Mon, 23 Feb 2026 16:38:14 +0400 Subject: [PATCH] refactor: replace groupedRegions with internal list model --- client/core/controllers/coreController.cpp | 3 - client/core/controllers/coreController.h | 2 - .../ui/models/api/apiCountriesRegionModel.cpp | 367 --------------- .../ui/models/api/apiCountriesRegionModel.h | 87 ---- client/ui/models/api/apiCountryModel.cpp | 423 ++++++++++++++++-- client/ui/models/api/apiCountryModel.h | 46 +- .../PageSettingsApiAvailableCountries.qml | 166 ++++--- 7 files changed, 517 insertions(+), 577 deletions(-) delete mode 100644 client/ui/models/api/apiCountriesRegionModel.cpp delete mode 100644 client/ui/models/api/apiCountriesRegionModel.h diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 3ab912fae..26267219a 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -94,9 +94,6 @@ void CoreController::initModels() m_apiCountryModel.reset(new ApiCountryModel(this)); m_engine->rootContext()->setContextProperty("ApiCountryModel", m_apiCountryModel.get()); - m_apiCountriesRegionModel.reset(new ApiCountriesRegionModel(m_apiCountryModel.get(), this)); - m_engine->rootContext()->setContextProperty("ApiCountriesRegionModel", m_apiCountriesRegionModel.get()); - m_apiAccountInfoModel.reset(new ApiAccountInfoModel(this)); m_engine->rootContext()->setContextProperty("ApiAccountInfoModel", m_apiAccountInfoModel.get()); diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 7853510f0..998e7d8d0 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -33,7 +33,6 @@ #endif #include "ui/models/api/apiAccountInfoModel.h" #include "ui/models/api/apiCountryModel.h" -#include "ui/models/api/apiCountriesRegionModel.h" #include "ui/models/api/apiDevicesModel.h" #include "ui/models/api/apiServicesModel.h" #include "ui/models/appSplitTunnelingModel.h" @@ -135,7 +134,6 @@ private: QSharedPointer m_apiServicesModel; QSharedPointer m_apiCountryModel; - QSharedPointer m_apiCountriesRegionModel; QSharedPointer m_apiAccountInfoModel; QSharedPointer m_apiDevicesModel; diff --git a/client/ui/models/api/apiCountriesRegionModel.cpp b/client/ui/models/api/apiCountriesRegionModel.cpp deleted file mode 100644 index 2ed947e6e..000000000 --- a/client/ui/models/api/apiCountriesRegionModel.cpp +++ /dev/null @@ -1,367 +0,0 @@ -#include "apiCountriesRegionModel.h" - -#include -#include -#include - -#include "apiCountryModel.h" - -ApiCountriesRegionModel::ApiCountriesRegionModel(ApiCountryModel *sourceModel, QObject *parent) - : QAbstractListModel(parent), m_sourceModel(sourceModel) -{ - m_regionDefinitions = { - { - "Europe", - { - {"BE", "Belgium", "Бельгия"}, - {"EE", "Estonia", "Эстония"}, - {"FI", "Finland", "Финляндия"}, - {"FR", "France", "Франция"}, - {"GE", "Georgia", "Грузия"}, - {"DE", "Germany", "Германия"}, - {"NL", "Netherlands", "Нидерланды"}, - {"PL", "Poland", "Польша"}, - {"RU", "Russia", "Россия"}, - {"ES", "Spain", "Испания"}, - {"SE", "Sweden", "Швеция"}, - {"CH", "Switzerland", "Швейцария"}, - {"TR", "Turkey", "Турция"}, - }, - }, - { - "America", - { - {"BR", "Brazil", "Бразилия"}, - {"CA", "Canada East", "Канада"}, - {"US", "USA East", "США"}, - {"US", "USA West", "США"}, - }, - }, - { - "Asia", - { - {"AE", "UAE", "ОАЭ"}, - {"JP", "Japan", "Япония"}, - {"KZ", "Kazakhstan", "Казахстан"}, - {"KR", "South Korea", "Южная Корея"}, - {"SG", "Singapore", "Сингапур"}, - }, - }, - { - "Oceania and Africa", - { - {"AU", "Australia", "Австралия"}, - {"NZ", "New Zealand", "Новая Зеландия"}, - {"ZA", "South Africa", "Южная Африка"}, - }, - }, - }; - - if (m_sourceModel) { - connect(m_sourceModel, &QAbstractItemModel::modelReset, this, &ApiCountriesRegionModel::rebuildModel); - connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &ApiCountriesRegionModel::rebuildModel); - connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &ApiCountriesRegionModel::rebuildModel); - connect(m_sourceModel, &QAbstractItemModel::dataChanged, this, &ApiCountriesRegionModel::rebuildModel); - } - - loadRegionExpansionState(); - rebuildModel(); -} - -int ApiCountriesRegionModel::rowCount(const QModelIndex &parent) const -{ - Q_UNUSED(parent) - return m_regions.size(); -} - -QVariant ApiCountriesRegionModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= static_cast(rowCount())) { - return QVariant(); - } - - const RegionData ®ion = m_regions.at(index.row()); - switch (role) { - case RegionNameRole: - return region.regionName; - case CountriesRole: - return region.countries; - default: - return QVariant(); - } -} - -QString ApiCountriesRegionModel::searchText() const -{ - return m_searchText; -} - -void ApiCountriesRegionModel::setSearchText(const QString &text) -{ - if (m_searchText == text) { - return; - } - - m_searchText = text; - emit searchTextChanged(); - rebuildModel(); -} - -bool ApiCountriesRegionModel::isRegionExpanded(const QString ®ionName) const -{ - if (isSearchActive()) { - return true; - } - return m_regionsExpanded.contains(regionName) ? m_regionsExpanded.value(regionName) : true; -} - -void ApiCountriesRegionModel::toggleRegionExpanded(const QString ®ionName) -{ - if (regionName.isEmpty() || isSearchActive()) { - return; - } - - const bool currentValue = isRegionExpanded(regionName); - m_regionsExpanded.insert(regionName, !currentValue); - saveRegionExpansionState(); - - beginResetModel(); - endResetModel(); -} - -QHash ApiCountriesRegionModel::roleNames() const -{ - QHash roles; - roles[RegionNameRole] = "regionName"; - roles[CountriesRole] = "countries"; - return roles; -} - -QString ApiCountriesRegionModel::normalizeCountryCode(const QString &countryCode) const -{ - return countryCode.trimmed().toUpper(); -} - -QString ApiCountriesRegionModel::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 ApiCountriesRegionModel::normalizeCountryName(const QString &countryName) const -{ - return countryName.trimmed().toLower(); -} - -QString ApiCountriesRegionModel::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); - 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 ApiCountriesRegionModel::isCountryMatchingSearch(const QString &countryName, const QString ®ionCountryCode, - const QString &sourceCountryCode, const QString &ruCountryName) const -{ - const QString normalizedSearchText = normalizeSearchComparableText(m_searchText); - if (normalizedSearchText.isEmpty()) { - return true; - } - - const QString normalizedCountryName = normalizeSearchComparableText(countryName); - const QString normalizedRuCountryName = normalizeSearchComparableText(ruCountryName); - const QString normalizedRegionCountryCode = normalizeCountryCode(regionCountryCode).toLower(); - const QString normalizedSourceCountryCode = normalizeCountryCode(sourceCountryCode).toLower(); - - return normalizedCountryName.startsWith(normalizedSearchText) || normalizedRuCountryName.startsWith(normalizedSearchText) || - normalizedRegionCountryCode.startsWith(normalizedSearchText) || - normalizedSourceCountryCode.startsWith(normalizedSearchText); -} - -bool ApiCountriesRegionModel::isSearchActive() const -{ - return !normalizeSearchComparableText(m_searchText).isEmpty(); -} - -QString ApiCountriesRegionModel::getDisplayCountryName(const QString &countryName) const -{ - const QString p2pPrefix = "[P2P] "; - if (countryName.startsWith(p2pPrefix)) { - return countryName.mid(p2pPrefix.size()) + " [P2P]"; - } - return countryName; -} - -bool ApiCountriesRegionModel::getSourceCountry(int sourceIndex, SourceCountry &country) const -{ - if (!m_sourceModel || sourceIndex < 0 || sourceIndex >= m_sourceModel->rowCount()) { - return false; - } - - const QModelIndex modelIndex = m_sourceModel->index(sourceIndex, 0); - if (!modelIndex.isValid()) { - return false; - } - - country.countryName = m_sourceModel->data(modelIndex, ApiCountryModel::CountryNameRole).toString(); - country.countryCode = m_sourceModel->data(modelIndex, ApiCountryModel::CountryCodeRole).toString(); - country.countryImageCode = m_sourceModel->data(modelIndex, ApiCountryModel::CountryImageCodeRole).toString(); - - return !country.countryName.isEmpty() && !country.countryCode.isEmpty(); -} - -int ApiCountriesRegionModel::findCountryIndexByRef(const CountryRef &countryRef, const QHash &usedIndices) const -{ - if (!m_sourceModel) { - return -1; - } - - const QString expectedCode = normalizeCountryCode(countryRef.code); - const QString expectedName = normalizeCountryName(countryRef.name); - - const int countriesCount = m_sourceModel->rowCount(); - for (int i = 0; i < countriesCount; ++i) { - if (usedIndices.value(i)) { - continue; - } - - SourceCountry sourceCountry; - if (!getSourceCountry(i, sourceCountry)) { - continue; - } - - const QString modelCode = normalizeCountryCode(sourceCountry.countryCode); - const QString modelIsoCode = extractCountryIsoCode(sourceCountry.countryCode); - const QString modelName = normalizeCountryName(sourceCountry.countryName); - - if (!expectedName.isEmpty() && modelName == expectedName) { - return i; - } - - if (!expectedCode.isEmpty() && (modelCode == expectedCode || modelIsoCode == expectedCode)) { - return i; - } - } - - return -1; -} - -void ApiCountriesRegionModel::rebuildModel() -{ - beginResetModel(); - - QVector regions; - regions.reserve(m_regionDefinitions.size()); - const auto ®ionDefinitions = std::as_const(m_regionDefinitions); - for (const RegionDefinition ®ionDefinition : regionDefinitions) { - RegionData region; - region.regionName = regionDefinition.regionName; - regions.push_back(region); - } - - QHash usedIndices; - for (int regionIndex = 0; regionIndex < m_regionDefinitions.size(); ++regionIndex) { - const RegionDefinition ®ionDefinition = m_regionDefinitions.at(regionIndex); - for (const CountryRef &countryRef : regionDefinition.countries) { - const int sourceIndex = findCountryIndexByRef(countryRef, usedIndices); - - if (sourceIndex < 0) { - if (isCountryMatchingSearch(countryRef.name, countryRef.code, countryRef.code, countryRef.ruName)) { - QVariantMap fallbackCountry; - fallbackCountry.insert("sourceIndex", -1); - fallbackCountry.insert("countryName", getDisplayCountryName(countryRef.name)); - fallbackCountry.insert("sourceCountryName", countryRef.name); - fallbackCountry.insert("countryCode", countryRef.code); - fallbackCountry.insert("countryImageCode", extractCountryIsoCode(countryRef.code)); - fallbackCountry.insert("isAvailable", false); - regions[regionIndex].countries.push_back(fallbackCountry); - } - continue; - } - - SourceCountry sourceCountry; - if (!getSourceCountry(sourceIndex, sourceCountry)) { - continue; - } - - const QString displayCountryName = getDisplayCountryName(sourceCountry.countryName); - if (!isCountryMatchingSearch(displayCountryName, countryRef.code, sourceCountry.countryCode, countryRef.ruName)) { - continue; - } - - QVariantMap countryData; - countryData.insert("sourceIndex", sourceIndex); - countryData.insert("countryName", displayCountryName); - countryData.insert("sourceCountryName", sourceCountry.countryName); - countryData.insert("countryCode", sourceCountry.countryCode); - countryData.insert("countryImageCode", extractCountryIsoCode(sourceCountry.countryImageCode)); - countryData.insert("isAvailable", true); - regions[regionIndex].countries.push_back(countryData); - usedIndices.insert(sourceIndex, true); - } - } - - m_regions.clear(); - m_regions.reserve(regions.size()); - const auto ®ionsConst = std::as_const(regions); - for (const RegionData ®ion : regionsConst) { - if (!region.countries.isEmpty()) { - m_regions.push_back(region); - } - } - - endResetModel(); -} - -void ApiCountriesRegionModel::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 ApiCountriesRegionModel::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); -} diff --git a/client/ui/models/api/apiCountriesRegionModel.h b/client/ui/models/api/apiCountriesRegionModel.h deleted file mode 100644 index 28c614faf..000000000 --- a/client/ui/models/api/apiCountriesRegionModel.h +++ /dev/null @@ -1,87 +0,0 @@ -#ifndef APICOUNTRIESREGIONMODEL_H -#define APICOUNTRIESREGIONMODEL_H - -#include -#include -#include -#include - -class ApiCountryModel; - -class ApiCountriesRegionModel : public QAbstractListModel -{ - Q_OBJECT - Q_PROPERTY(QString searchText READ searchText WRITE setSearchText NOTIFY searchTextChanged) - -public: - enum Roles { - RegionNameRole = Qt::UserRole + 1, - CountriesRole - }; - - explicit ApiCountriesRegionModel(ApiCountryModel *sourceModel, QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - - QString searchText() const; - void setSearchText(const QString &text); - Q_INVOKABLE bool isRegionExpanded(const QString ®ionName) const; - Q_INVOKABLE void toggleRegionExpanded(const QString ®ionName); - -signals: - void searchTextChanged(); - -protected: - QHash roleNames() const override; - -private: - struct CountryRef - { - QString code; - QString name; - QString ruName; - }; - - struct RegionDefinition - { - QString regionName; - QVector countries; - }; - - struct SourceCountry - { - QString countryName; - QString countryCode; - QString countryImageCode; - }; - - struct RegionData - { - QString regionName; - QVariantList countries; - }; - - 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 ®ionCountryCode, const QString &sourceCountryCode, - const QString &ruCountryName) const; - QString getDisplayCountryName(const QString &countryName) const; - - bool getSourceCountry(int sourceIndex, SourceCountry &country) const; - int findCountryIndexByRef(const CountryRef &countryRef, const QHash &usedIndices) const; - void rebuildModel(); - void loadRegionExpansionState(); - void saveRegionExpansionState() const; - bool isSearchActive() const; - - QVector m_regionDefinitions; - QVector m_regions; - ApiCountryModel *m_sourceModel = nullptr; - QString m_searchText; - QHash m_regionsExpanded; -}; - -#endif // APICOUNTRIESREGIONMODEL_H diff --git a/client/ui/models/api/apiCountryModel.cpp b/client/ui/models/api/apiCountryModel.cpp index 12f4658ef..02094a433 100644 --- a/client/ui/models/api/apiCountryModel.cpp +++ b/client/ui/models/api/apiCountryModel.cpp @@ -1,21 +1,160 @@ #include "apiCountryModel.h" #include +#include +#include #include "core/api/apiDefs.h" -#include "logger.h" namespace { - Logger logger("ApiCountryModel"); +constexpr QLatin1String countryConfig("country_config"); - constexpr QLatin1String countryConfig("country_config"); -} - -ApiCountryModel::ApiCountryModel(QObject *parent) : QAbstractListModel(parent) +struct RegionRowData { + bool isRegionHeader = false; + QString regionName; + bool isExpanded = true; + int sourceIndex = -1; + QString countryName; + QString sourceCountryName; + QString countryCode; + QString countryImageCode; +}; } +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)) +{ + m_regionDefinitions = { + { + "Europe", + { + {"BE", "Belgium", "Бельгия"}, + {"EE", "Estonia", "Эстония"}, + {"FI", "Finland", "Финляндия"}, + {"FR", "France", "Франция"}, + {"GE", "Georgia", "Грузия"}, + {"DE", "Germany", "Германия"}, + {"NL", "Netherlands", "Нидерланды"}, + {"PL", "Poland", "Польша"}, + {"RU", "Russia", "Россия"}, + {"ES", "Spain", "Испания"}, + {"SE", "Sweden", "Швеция"}, + {"CH", "Switzerland", "Швейцария"}, + {"TR", "Turkey", "Турция"}, + }, + }, + { + "America", + { + {"BR", "Brazil", "Бразилия"}, + {"CA", "Canada East", "Канада"}, + {"US", "USA East", "США"}, + }, + }, + { + "Asia", + { + {"AE", "UAE", "ОАЭ"}, + {"JP", "Japan", "Япония"}, + {"KZ", "Kazakhstan", "Казахстан"}, + {"KR", "South Korea", "Южная Корея"}, + {"SG", "Singapore", "Сингапур"}, + }, + }, + { + "Oceania and Africa", + { + {"AU", "Australia", "Австралия"}, + {"NZ", "New Zealand", "Новая Зеландия"}, + {"ZA", "South Africa", "Южная Африка"}, + }, + }, + }; + + loadRegionExpansionState(); + rebuildGroupedRegions(); +} + +ApiCountryModel::~ApiCountryModel() = default; + int ApiCountryModel::rowCount(const QModelIndex &parent) const { Q_UNUSED(parent) @@ -24,32 +163,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 +192,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 +207,7 @@ void ApiCountryModel::updateModel(const QJsonArray &countries, const QString &cu } endResetModel(); + rebuildGroupedRegions(); } void ApiCountryModel::updateIssuedConfigsInfo(const QJsonArray &issuedConfigs) @@ -79,9 +215,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 +246,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 +302,192 @@ 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); + 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 ®ionCountryCode, + const QString &sourceCountryCode, const QString &ruCountryName, + const QString &normalizedSearchText) const +{ + if (normalizedSearchText.isEmpty()) { + return true; + } + + const QString normalizedCountryName = normalizeSearchComparableText(countryName); + const QString normalizedRuCountryName = normalizeSearchComparableText(ruCountryName); + const QString normalizedRegionCountryCode = normalizeCountryCode(regionCountryCode).toLower(); + const QString normalizedSourceCountryCode = normalizeCountryCode(sourceCountryCode).toLower(); + + return normalizedCountryName.startsWith(normalizedSearchText) || normalizedRuCountryName.startsWith(normalizedSearchText) || + normalizedRegionCountryCode.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; +} + +int ApiCountryModel::findCountryIndexByRef(const CountryRef &countryRef, const QHash &usedIndices) const +{ + const QString expectedCode = normalizeCountryCode(countryRef.code); + const QString expectedName = normalizeCountryName(countryRef.name); + const int countriesCount = m_countries.size(); + + for (int i = 0; i < countriesCount; ++i) { + if (usedIndices.value(i)) { + continue; + } + + const CountryInfo &country = m_countries.at(i); + const QString modelCode = normalizeCountryCode(country.countryCode); + const QString modelIsoCode = extractCountryIsoCode(country.countryCode); + const QString modelName = normalizeCountryName(country.countryName); + + if (!expectedName.isEmpty() && modelName == expectedName) { + return i; + } + if (!expectedCode.isEmpty() && (modelCode == expectedCode || modelIsoCode == expectedCode)) { + return i; + } + } + + return -1; +} + +void ApiCountryModel::rebuildGroupedRegions() +{ + QVector rows; + QHash usedIndices; + const QString normalizedSearchText = normalizeSearchComparableText(m_searchText); + + for (int regionIndex = 0; regionIndex < m_regionDefinitions.size(); ++regionIndex) { + const RegionDefinition ®ionDefinition = m_regionDefinitions.at(regionIndex); + QVector countries; + + const auto &countryRefs = std::as_const(regionDefinition.countries); + for (const CountryRef &countryRef : countryRefs) { + const int sourceIndex = findCountryIndexByRef(countryRef, usedIndices); + if (sourceIndex < 0) { + continue; + } + + const CountryInfo &sourceCountry = m_countries.at(sourceIndex); + const QString displayCountryName = getDisplayCountryName(sourceCountry.countryName); + if (!isCountryMatchingSearch(displayCountryName, countryRef.code, sourceCountry.countryCode, countryRef.ruName, + normalizedSearchText)) { + continue; + } + + RegionRowData countryRow; + countryRow.isRegionHeader = false; + countryRow.regionName = regionDefinition.regionName; + countryRow.sourceIndex = sourceIndex; + countryRow.countryName = displayCountryName; + countryRow.sourceCountryName = sourceCountry.countryName; + countryRow.countryCode = sourceCountry.countryCode; + countryRow.countryImageCode = extractCountryIsoCode(sourceCountry.countryCode); + countries.push_back(std::move(countryRow)); + usedIndices.insert(sourceIndex, true); + } + + if (!countries.isEmpty()) { + const bool expanded = isRegionExpanded(regionDefinition.regionName); + + RegionRowData headerRow; + headerRow.isRegionHeader = true; + headerRow.regionName = regionDefinition.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 08ac36858..8292aeefa 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; @@ -55,9 +68,40 @@ private: QString countryCode; }; + struct CountryRef + { + QString code; + QString name; + QString ruName; + }; + + struct RegionDefinition + { + QString regionName; + QVector countries; + }; + QVector m_countries; QHash m_issuedConfigs; - int m_currentIndex; + int m_currentIndex = -1; + QString m_searchText; + QVector m_regionDefinitions; + 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 ®ionCountryCode, const QString &sourceCountryCode, + const QString &ruCountryName, const QString &normalizedSearchText) const; + QString getDisplayCountryName(const QString &countryName) const; + int findCountryIndexByRef(const CountryRef &countryRef, const QHash &usedIndices) const; + void rebuildGroupedRegions(); + void loadRegionExpansionState(); + void saveRegionExpansionState() const; + bool isSearchActive() const; }; #endif // APICOUNTRYMODEL_H diff --git a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml index ed3279cd5..575261791 100644 --- a/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml +++ b/client/ui/qml/Pages2/PageSettingsApiAvailableCountries.qml @@ -48,7 +48,7 @@ PageType { anchors.fill: parent - model: ApiCountriesRegionModel + model: ApiCountryModel.regionRowsModel currentIndex: 0 @@ -152,7 +152,7 @@ PageType { const shouldRestoreFocus = activeFocus const previousCursorPosition = cursorPosition - ApiCountriesRegionModel.searchText = text + ApiCountryModel.searchText = text if (shouldRestoreFocus) { Qt.callLater(function() { @@ -197,7 +197,7 @@ PageType { footer: Item { width: menuContent.width - height: ApiCountriesRegionModel.count === 0 ? emptyStateText.implicitHeight + 32 : 0 + height: ApiCountryModel.hasVisibleRegions ? 0 : emptyStateText.implicitHeight + 32 CaptionTextType { id: emptyStateText @@ -209,7 +209,7 @@ PageType { anchors.top: parent.top anchors.topMargin: 16 - visible: ApiCountriesRegionModel.count === 0 + visible: !ApiCountryModel.hasVisibleRegions color: AmneziaStyle.color.mutedGray font.pixelSize: 15 @@ -219,39 +219,33 @@ PageType { } } - delegate: ColumnLayout { - id: regionContent - + delegate: Item { width: menuContent.width - height: regionContent.implicitHeight - spacing: 0 + implicitHeight: rowType === "region" ? 44 : 88 Item { - Layout.fillWidth: true - Layout.leftMargin: 16 - Layout.rightMargin: 16 - Layout.topMargin: 12 - Layout.bottomMargin: 8 - - implicitHeight: 24 + anchors.fill: parent + visible: rowType === "region" RowLayout { anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 12 + anchors.bottomMargin: 8 spacing: 8 CaptionTextType { Layout.fillWidth: true color: AmneziaStyle.color.mutedGray - text: regionName horizontalAlignment: Text.AlignLeft verticalAlignment: Text.AlignVCenter } Image { - source: ApiCountriesRegionModel.isRegionExpanded(regionName) - ? "qrc:/images/controls/chevron-up.svg" - : "qrc:/images/controls/chevron-down.svg" + source: isExpanded ? "qrc:/images/controls/chevron-up.svg" + : "qrc:/images/controls/chevron-down.svg" } } @@ -259,85 +253,75 @@ PageType { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { - ApiCountriesRegionModel.toggleRegionExpanded(regionName) + ApiCountryModel.toggleRegionExpanded(regionName) } } } - Repeater { - model: ApiCountriesRegionModel.isRegionExpanded(regionName) ? countries : [] + ColumnLayout { + anchors.fill: parent + visible: rowType === "country" + spacing: 0 - delegate: ColumnLayout { - property var countryData: modelData + RowLayout { + Layout.fillWidth: true - width: menuContent.width - spacing: 0 - - RowLayout { - VerticalRadioButton { - id: containerRadioButton - - Layout.fillWidth: true - Layout.leftMargin: 16 - - text: countryData.countryName - - ButtonGroup.group: containersRadioButtonGroup - - imageSource: "qrc:/images/controls/download.svg" - - checked: countryData.sourceIndex >= 0 && countryData.sourceIndex === ApiCountryModel.currentIndex - checkable: countryData.isAvailable && !ConnectionController.isConnected - - onClicked: { - if (!countryData.isAvailable) { - return - } - 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 - } - - if (countryData.sourceIndex !== ApiCountryModel.currentIndex) { - PageController.showBusyIndicator(true) - var prevIndex = ApiCountryModel.currentIndex - ApiCountryModel.currentIndex = countryData.sourceIndex - if (!ApiConfigsController.updateServiceFromGateway(ServersModel.defaultIndex, countryData.countryCode, countryData.sourceCountryName)) { - ApiCountryModel.currentIndex = prevIndex - } - PageController.showBusyIndicator(false) - } - } - - Keys.onEnterPressed: { - if (checkable) { - checked = true - } - containerRadioButton.clicked() - } - Keys.onReturnPressed: { - if (checkable) { - checked = true - } - containerRadioButton.clicked() - } - } - - Image { - Layout.rightMargin: 32 - Layout.alignment: Qt.AlignRight - - source: "qrc:/countriesFlags/images/flagKit/" + countryData.countryImageCode + ".svg" - } - } - - DividerType { + VerticalRadioButton { + id: containerRadioButton Layout.fillWidth: true + Layout.leftMargin: 16 + + text: countryName + ButtonGroup.group: containersRadioButtonGroup + imageSource: "qrc:/images/controls/download.svg" + + checked: sourceIndex >= 0 && sourceIndex === ApiCountryModel.currentIndex + checkable: !ConnectionController.isConnected + + onClicked: { + 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 + } + + 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) + } + } + + Keys.onEnterPressed: { + if (checkable) { + checked = true + } + containerRadioButton.clicked() + } + Keys.onReturnPressed: { + if (checkable) { + checked = true + } + containerRadioButton.clicked() + } } + + Image { + Layout.rightMargin: 32 + Layout.alignment: Qt.AlignRight + source: "qrc:/countriesFlags/images/flagKit/" + countryImageCode + ".svg" + } + } + + DividerType { + Layout.fillWidth: true } } }