groupped premium server countries into sections by region and added search field for country

This commit is contained in:
Mitternacht822
2026-03-13 13:06:32 +04:00
parent 1abdd14741
commit 64f05871f7
3 changed files with 543 additions and 36 deletions

View File

@@ -1,6 +1,8 @@
#include "apiCountryModel.h"
#include <QJsonObject>
#include <QSettings>
#include <utility>
#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<QString, QString> 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<int, QByteArray> roleNames() const override
{
QHash<int, QByteArray> 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<RegionRowData> &&rows)
{
beginResetModel();
m_rows = std::move(rows);
endResetModel();
}
private:
QVector<RegionRowData> m_rows;
};
ApiCountryModel::ApiCountryModel(QObject *parent)
: QAbstractListModel(parent), m_regionRowsModel(std::make_unique<RegionRowsModel>(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<int>(rowCount()))
if (!index.isValid() || index.row() < 0 || index.row() >= static_cast<int>(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 &currentCountryCode)
@@ -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 &regionName) const
{
if (isSearchActive()) {
return true;
}
return m_regionsExpanded.contains(regionName) ? m_regionsExpanded.value(regionName) : true;
}
void ApiCountryModel::toggleRegionExpanded(const QString &regionName)
{
if (regionName.isEmpty() || isSearchActive()) {
return;
}
const bool currentValue = isRegionExpanded(regionName);
m_regionsExpanded.insert(regionName, !currentValue);
saveRegionExpansionState();
rebuildGroupedRegions();
}
QHash<int, QByteArray> ApiCountryModel::roleNames() const
{
QHash<int, QByteArray> roles;
@@ -120,3 +294,169 @@ QHash<int, QByteArray> 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<RegionRowData> rows;
const QString normalizedSearchText = normalizeSearchComparableText(m_searchText);
const QStringList orderedRegions = {
regionEurope,
regionAmerica,
regionAsia,
regionOceaniaAfrica,
regionOther,
};
QHash<QString, QVector<RegionRowData>> 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 &regionName : orderedRegions) {
QVector<RegionRowData> 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();
}

View File

@@ -4,6 +4,7 @@
#include <QAbstractListModel>
#include <QHash>
#include <QJsonArray>
#include <memory>
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 &currentCountryCode);
@@ -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 &regionName) const;
Q_INVOKABLE void toggleRegionExpanded(const QString &regionName);
signals:
void currentIndexChanged(const int index);
void searchTextChanged();
void regionRowsChanged();
protected:
QHash<int, QByteArray> roleNames() const override;
@@ -57,7 +70,23 @@ private:
QVector<CountryInfo> m_countries;
QHash<QString, IssuedConfigInfo> m_issuedConfigs;
int m_currentIndex;
int m_currentIndex = -1;
QString m_searchText;
QHash<QString, bool> m_regionsExpanded;
class RegionRowsModel;
std::unique_ptr<RegionRowsModel> 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

View File

@@ -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"
}
}
}
}
}
}