diff --git a/CMakeLists.txt b/CMakeLists.txt index bc350f32..fdaee405 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,7 +52,7 @@ ecm_set_disabled_deprecation_versions( find_package(Qt${QT_MAJOR_VERSION} ${QT_MIN_VERSION} REQUIRED NO_MODULE COMPONENTS Core Quick Test Gui QuickControls2 Sql Svg) find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS I18n CoreAddons Kirigami2 Syndication Config ThreadWeaver) -find_package(KF5KirigamiAddons 0.6 REQUIRED) +find_package(KF5KirigamiAddons 0.7 REQUIRED) find_package(Taglib REQUIRED) find_package(Qt${QT_MAJOR_VERSION}Keychain) set_package_properties(Qt${QT_MAJOR_VERSION}Keychain PROPERTIES diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 09c6acf3..35302007 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -23,10 +23,13 @@ set(kasts_srcs updatefeedjob.cpp fetchfeedsjob.cpp systrayicon.cpp + models/abstractepisodemodel.cpp + models/abstractepisodeproxymodel.cpp models/chaptermodel.cpp models/feedsmodel.cpp models/feedsproxymodel.cpp models/entriesmodel.cpp + models/entriesproxymodel.cpp models/queuemodel.cpp models/episodemodel.cpp models/episodeproxymodel.cpp diff --git a/src/feed.cpp b/src/feed.cpp index c4d32d72..25864eb6 100644 --- a/src/feed.cpp +++ b/src/feed.cpp @@ -14,7 +14,6 @@ #include "feed.h" #include "feedlogging.h" #include "fetcher.h" -#include "models/entriesmodel.h" Feed::Feed(const QString &feedurl) : QObject(&DataManager::instance()) @@ -78,7 +77,7 @@ Feed::Feed(const QString &feedurl) } }); - m_entries = new EntriesModel(this); + m_entries = new EntriesProxyModel(this); } Feed::~Feed() diff --git a/src/feed.h b/src/feed.h index 19424f48..86b09182 100644 --- a/src/feed.h +++ b/src/feed.h @@ -13,7 +13,7 @@ #include #include "author.h" -#include "models/entriesmodel.h" +#include "models/entriesproxymodel.h" class Feed : public QObject { @@ -37,7 +37,7 @@ class Feed : public QObject Q_PROPERTY(int newEntryCount READ newEntryCount NOTIFY newEntryCountChanged) Q_PROPERTY(int errorId READ errorId WRITE setErrorId NOTIFY errorIdChanged) Q_PROPERTY(QString errorString READ errorString WRITE setErrorString NOTIFY errorStringChanged) - Q_PROPERTY(EntriesModel *entries MEMBER m_entries CONSTANT) + Q_PROPERTY(EntriesProxyModel *entries MEMBER m_entries CONSTANT) public: Feed(const QString &feedurl); @@ -120,7 +120,7 @@ private: QString m_errorString; int m_unreadEntryCount = -1; - EntriesModel *m_entries; + EntriesProxyModel *m_entries; bool m_refreshing = false; }; diff --git a/src/main.cpp b/src/main.cpp index 0c6a7b70..3c812ce3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -39,10 +39,11 @@ #include "feed.h" #include "fetcher.h" #include "kasts-version.h" +#include "models/abstractepisodemodel.h" +#include "models/abstractepisodeproxymodel.h" #include "models/chaptermodel.h" #include "models/downloadmodel.h" -#include "models/entriesmodel.h" -#include "models/episodemodel.h" +#include "models/entriesproxymodel.h" #include "models/episodeproxymodel.h" #include "models/errorlogmodel.h" #include "models/feedsproxymodel.h" @@ -118,7 +119,7 @@ int main(int argc, char *argv[]) QStringLiteral(KASTS_VERSION_STRING), i18n("Podcast Player"), KAboutLicense::GPL, - i18n("© 2020-2022 KDE Community")); + i18n("© 2020-2023 KDE Community")); about.addAuthor(i18n("Tobias Fella"), QString(), QStringLiteral("fella@posteo.de"), QStringLiteral("https://tobiasfella.de")); about.addAuthor(i18n("Bart De Vries"), QString(), QStringLiteral("bart@mogwai.be")); about.setProgramLogo(QVariant(QIcon(QStringLiteral(":/logo.svg")))); @@ -139,9 +140,10 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.kasts", 1, 0, "PodcastSearchModel"); qmlRegisterType("org.kde.kasts", 1, 0, "ChapterModel"); - qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "EntriesModel", QStringLiteral("Get from Feed")); + qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "AbstractEpisodeProxyModel", QStringLiteral("Only for enums")); + qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "EntriesProxyModel", QStringLiteral("Get from Feed")); qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "Enclosure", QStringLiteral("Only for enums")); - qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "EpisodeModel", QStringLiteral("Only for enums")); + qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "AbstractEpisodeModel", QStringLiteral("Only for enums")); qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "FeedsModel", QStringLiteral("Only for enums")); qmlRegisterSingletonType("org.kde.kasts", 1, 0, "About", [](QQmlEngine *engine, QJSEngine *) -> QJSValue { diff --git a/src/models/abstractepisodemodel.cpp b/src/models/abstractepisodemodel.cpp new file mode 100644 index 00000000..cabcb877 --- /dev/null +++ b/src/models/abstractepisodemodel.cpp @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "models/abstractepisodemodel.h" + +AbstractEpisodeModel::AbstractEpisodeModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +QHash AbstractEpisodeModel::roleNames() const +{ + return { + {TitleRole, "title"}, + {EntryRole, "entry"}, + {IdRole, "id"}, + {ReadRole, "read"}, + {NewRole, "new"}, + {ContentRole, "content"}, + {FeedNameRole, "feedname"}, + }; +} diff --git a/src/models/abstractepisodemodel.h b/src/models/abstractepisodemodel.h new file mode 100644 index 00000000..a84825fc --- /dev/null +++ b/src/models/abstractepisodemodel.h @@ -0,0 +1,35 @@ +/** + * SPDX-FileCopyrightText: 2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include +#include + +class AbstractEpisodeModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + TitleRole = Qt::DisplayRole, + EntryRole = Qt::UserRole + 1, + IdRole, + ReadRole, + NewRole, + ContentRole, + FeedNameRole, + }; + Q_ENUM(Roles) + + explicit AbstractEpisodeModel(QObject *parent = nullptr); + virtual QHash roleNames() const override; + +public Q_SLOTS: + virtual void updateInternalState() = 0; +}; diff --git a/src/models/abstractepisodeproxymodel.cpp b/src/models/abstractepisodeproxymodel.cpp new file mode 100644 index 00000000..c006fd33 --- /dev/null +++ b/src/models/abstractepisodeproxymodel.cpp @@ -0,0 +1,178 @@ +/** + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "models/episodeproxymodel.h" + +#include + +#include "datamanager.h" +#include "entry.h" +#include "models/abstractepisodemodel.h" + +AbstractEpisodeProxyModel::AbstractEpisodeProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + m_searchFlags = SearchFlag::TitleFlag | SearchFlag::ContentFlag | SearchFlag::FeedNameFlag; +} + +bool AbstractEpisodeProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +{ + QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); + + bool accepted = true; + + switch (m_currentFilter) { + case NoFilter: + accepted = true; + break; + case ReadFilter: + accepted = sourceModel()->data(index, AbstractEpisodeModel::Roles::ReadRole).value(); + break; + case NotReadFilter: + accepted = !sourceModel()->data(index, AbstractEpisodeModel::Roles::ReadRole).value(); + break; + case NewFilter: + accepted = sourceModel()->data(index, AbstractEpisodeModel::Roles::NewRole).value(); + break; + case NotNewFilter: + accepted = !sourceModel()->data(index, AbstractEpisodeModel::Roles::NewRole).value(); + break; + default: + accepted = true; + break; + } + + bool found = m_searchFilter.isEmpty(); + if (!m_searchFilter.isEmpty()) { + if (m_searchFlags & SearchFlag::TitleFlag) { + if (sourceModel()->data(index, AbstractEpisodeModel::Roles::TitleRole).value().contains(m_searchFilter, Qt::CaseInsensitive)) { + found |= true; + } + } + if (m_searchFlags & SearchFlag::ContentFlag) { + if (sourceModel()->data(index, AbstractEpisodeModel::Roles::ContentRole).value().contains(m_searchFilter, Qt::CaseInsensitive)) { + found |= true; + } + } + if (m_searchFlags & SearchFlag::FeedNameFlag) { + if (sourceModel()->data(index, AbstractEpisodeModel::Roles::FeedNameRole).value().contains(m_searchFilter, Qt::CaseInsensitive)) { + found |= true; + } + } + } + + accepted = accepted && found; + + return accepted; +} + +AbstractEpisodeProxyModel::FilterType AbstractEpisodeProxyModel::filterType() const +{ + return m_currentFilter; +} + +QString AbstractEpisodeProxyModel::filterName() const +{ + return getFilterName(m_currentFilter); +} + +QString AbstractEpisodeProxyModel::searchFilter() const +{ + return m_searchFilter; +} + +AbstractEpisodeProxyModel::SearchFlags AbstractEpisodeProxyModel::searchFlags() const +{ + return m_searchFlags; +} + +void AbstractEpisodeProxyModel::setFilterType(FilterType type) +{ + if (type != m_currentFilter) { + disconnect(&DataManager::instance(), &DataManager::bulkReadStatusActionFinished, this, nullptr); + disconnect(&DataManager::instance(), &DataManager::bulkNewStatusActionFinished, this, nullptr); + + beginResetModel(); + m_currentFilter = type; + dynamic_cast(sourceModel())->updateInternalState(); + endResetModel(); + + // connect to signals which indicate that read/new statuses have been updated + if (type == ReadFilter || type == NotReadFilter) { + connect(&DataManager::instance(), &DataManager::bulkReadStatusActionFinished, this, [this]() { + beginResetModel(); + dynamic_cast(sourceModel())->updateInternalState(); + endResetModel(); + }); + } else if (type == NewFilter || type == NotNewFilter) { + connect(&DataManager::instance(), &DataManager::bulkNewStatusActionFinished, this, [this]() { + beginResetModel(); + dynamic_cast(sourceModel())->updateInternalState(); + endResetModel(); + }); + } + + Q_EMIT filterTypeChanged(); + } +} + +void AbstractEpisodeProxyModel::setSearchFilter(const QString &searchString) +{ + if (searchString != m_searchFilter) { + beginResetModel(); + m_searchFilter = searchString; + endResetModel(); + + Q_EMIT searchFilterChanged(); + } +} + +void AbstractEpisodeProxyModel::setSearchFlags(AbstractEpisodeProxyModel::SearchFlags searchFlags) +{ + if (searchFlags != m_searchFlags) { + beginResetModel(); + m_searchFlags = searchFlags; + endResetModel(); + } +} + +QString AbstractEpisodeProxyModel::getFilterName(FilterType type) const +{ + switch (type) { + case FilterType::NoFilter: + return i18nc("@label:chooser Choice of filter for episode list", "No Filter"); + case FilterType::ReadFilter: + return i18nc("@label:chooser Choice of filter for episode list", "Played Episodes"); + case FilterType::NotReadFilter: + return i18nc("@label:chooser Choice of filter for episode list", "Unplayed Episodes"); + case FilterType::NewFilter: + return i18nc("@label:chooser Choice of filter for episode list", "Episodes marked as \"New\""); + case FilterType::NotNewFilter: + return i18nc("@label:chooser Choice of filter for episode list", "Episodes not marked as \"New\""); + default: + return QString(); + } +} + +QString AbstractEpisodeProxyModel::getSearchFlagName(SearchFlag flag) const +{ + switch (flag) { + case SearchFlag::TitleFlag: + return i18nc("@label:chooser Choice of fields to search for string", "Title"); + case SearchFlag::ContentFlag: + return i18nc("@label:chooser Choice of fields to search for string", "Description"); + case SearchFlag::FeedNameFlag: + return i18nc("@label:chooser Choice of fields to search for string", "Podcast Title"); + default: + return QString(); + } +} + +// Hack to get a QItemSelection in QML +QItemSelection AbstractEpisodeProxyModel::createSelection(int rowa, int rowb) +{ + return QItemSelection(index(rowa, 0), index(rowb, 0)); +} diff --git a/src/models/abstractepisodeproxymodel.h b/src/models/abstractepisodeproxymodel.h new file mode 100644 index 00000000..6b93a9f2 --- /dev/null +++ b/src/models/abstractepisodeproxymodel.h @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include +#include + +#include "models/episodemodel.h" + +class Entry; + +class AbstractEpisodeProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + enum FilterType { + NoFilter, + ReadFilter, + NotReadFilter, + NewFilter, + NotNewFilter, + }; + Q_ENUM(FilterType) + + enum SearchFlag { + TitleFlag = 0x01, + ContentFlag = 0x02, + FeedNameFlag = 0x04, + }; + Q_ENUM(SearchFlag) + Q_DECLARE_FLAGS(SearchFlags, SearchFlag) + Q_FLAGS(SearchFlags) + + Q_PROPERTY(FilterType filterType READ filterType WRITE setFilterType NOTIFY filterTypeChanged) + Q_PROPERTY(QString filterName READ filterName NOTIFY filterTypeChanged) + Q_PROPERTY(QString searchFilter READ searchFilter WRITE setSearchFilter NOTIFY searchFilterChanged) + Q_PROPERTY(SearchFlags searchFlags READ searchFlags WRITE setSearchFlags NOTIFY searchFlagsChanged) + + explicit AbstractEpisodeProxyModel(QObject *parent = nullptr); + + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + + FilterType filterType() const; + QString filterName() const; + QString searchFilter() const; + SearchFlags searchFlags() const; + + void setFilterType(FilterType type); + void setSearchFilter(const QString &searchString); + void setSearchFlags(SearchFlags searchFlags); + + Q_INVOKABLE QString getFilterName(FilterType type) const; + Q_INVOKABLE QString getSearchFlagName(SearchFlag flag) const; + + Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb); + +Q_SIGNALS: + void filterTypeChanged(); + void searchFilterChanged(); + void searchFlagsChanged(); + +protected: + FilterType m_currentFilter = FilterType::NoFilter; + QString m_searchFilter; + SearchFlags m_searchFlags; +}; + +Q_DECLARE_OPERATORS_FOR_FLAGS(AbstractEpisodeProxyModel::SearchFlags) diff --git a/src/models/entriesmodel.cpp b/src/models/entriesmodel.cpp index 933bcb90..7b8266c8 100644 --- a/src/models/entriesmodel.cpp +++ b/src/models/entriesmodel.cpp @@ -12,10 +12,9 @@ #include "datamanager.h" #include "entry.h" #include "feed.h" -#include "models/episodemodel.h" EntriesModel::EntriesModel(Feed *feed) - : QAbstractListModel(feed) + : AbstractEpisodeModel(feed) , m_feed(feed) { // When feed is updated, the entire model needs to be reset because we @@ -31,27 +30,27 @@ EntriesModel::EntriesModel(Feed *feed) QVariant EntriesModel::data(const QModelIndex &index, int role) const { + Entry *entry = DataManager::instance().getEntry(m_feed, index.row()); switch (role) { - case EpisodeModel::Roles::EntryRole: - return QVariant::fromValue(DataManager::instance().getEntry(m_feed, index.row())); - case Qt::DisplayRole: - case EpisodeModel::Roles::IdRole: + case AbstractEpisodeModel::Roles::TitleRole: + return QVariant::fromValue(entry->title()); + case AbstractEpisodeModel::Roles::EntryRole: + return QVariant::fromValue(entry); + case AbstractEpisodeModel::Roles::IdRole: return QVariant::fromValue(DataManager::instance().getIdList(m_feed)[index.row()]); + case AbstractEpisodeModel::Roles::ReadRole: + return QVariant::fromValue(entry->read()); + case AbstractEpisodeModel::Roles::NewRole: + return QVariant::fromValue(entry->getNew()); + case AbstractEpisodeModel::Roles::ContentRole: + return QVariant::fromValue(entry->content()); + case AbstractEpisodeModel::Roles::FeedNameRole: + return QVariant::fromValue(m_feed->name()); default: return QVariant(); } } -QHash EntriesModel::roleNames() const -{ - return { - {EpisodeModel::Roles::EntryRole, "entry"}, - {EpisodeModel::Roles::IdRole, "id"}, - {EpisodeModel::Roles::ReadRole, "read"}, - {EpisodeModel::Roles::NewRole, "new"}, - }; -} - int EntriesModel::rowCount(const QModelIndex &parent) const { Q_UNUSED(parent) @@ -63,8 +62,7 @@ Feed *EntriesModel::feed() const return m_feed; } -// Hack to get a QItemSelection in QML -QItemSelection EntriesModel::createSelection(int rowa, int rowb) +void EntriesModel::updateInternalState() { - return QItemSelection(index(rowa, 0), index(rowb, 0)); + // nothing to do; DataManager already has the updated data. } diff --git a/src/models/entriesmodel.h b/src/models/entriesmodel.h index 159e20ad..416281c8 100644 --- a/src/models/entriesmodel.h +++ b/src/models/entriesmodel.h @@ -1,36 +1,35 @@ /** * SPDX-FileCopyrightText: 2020 Tobias Fella - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ #pragma once -#include #include -#include +#include #include #include +#include "models/abstractepisodemodel.h" + class Feed; -class EntriesModel : public QAbstractListModel +class EntriesModel : public AbstractEpisodeModel { Q_OBJECT - Q_PROPERTY(Feed *feed READ feed CONSTANT) - public: explicit EntriesModel(Feed *feed); QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; int rowCount(const QModelIndex &parent) const override; Feed *feed() const; - Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb); +public Q_SLOTS: + void updateInternalState() override; -private: +protected: Feed *m_feed; }; diff --git a/src/models/entriesproxymodel.cpp b/src/models/entriesproxymodel.cpp new file mode 100644 index 00000000..4648e930 --- /dev/null +++ b/src/models/entriesproxymodel.cpp @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2020 Tobias Fella + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "models/entriesproxymodel.h" + +#include +#include + +#include "feed.h" +#include "models/entriesmodel.h" + +EntriesProxyModel::EntriesProxyModel(Feed *feed) + : AbstractEpisodeProxyModel(feed) +{ + m_entriesModel = new EntriesModel(feed); + setSourceModel(m_entriesModel); +} + +Feed *EntriesProxyModel::feed() const +{ + return m_entriesModel->feed(); +} diff --git a/src/models/entriesproxymodel.h b/src/models/entriesproxymodel.h new file mode 100644 index 00000000..7a421348 --- /dev/null +++ b/src/models/entriesproxymodel.h @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2020 Tobias Fella + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include + +#include "models/abstractepisodeproxymodel.h" +#include "models/entriesmodel.h" + +class Feed; + +class EntriesProxyModel : public AbstractEpisodeProxyModel +{ + Q_OBJECT + + Q_PROPERTY(Feed *feed READ feed CONSTANT) + +public: + explicit EntriesProxyModel(Feed *feed); + + Feed *feed() const; + +private: + EntriesModel *m_entriesModel; +}; diff --git a/src/models/episodemodel.cpp b/src/models/episodemodel.cpp index e944e6a3..f5777219 100644 --- a/src/models/episodemodel.cpp +++ b/src/models/episodemodel.cpp @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -13,7 +13,7 @@ #include "entry.h" EpisodeModel::EpisodeModel(QObject *parent) - : QAbstractListModel(parent) + : AbstractEpisodeModel(parent) { // When feed is updated, the entire model needs to be reset because we // cannot know where the new entries will be inserted into the list (or that @@ -31,29 +31,25 @@ EpisodeModel::EpisodeModel(QObject *parent) QVariant EpisodeModel::data(const QModelIndex &index, int role) const { switch (role) { - case EntryRole: + case AbstractEpisodeModel::Roles::TitleRole: + return QVariant::fromValue(m_titles[index.row()]); + case AbstractEpisodeModel::Roles::EntryRole: return QVariant::fromValue(DataManager::instance().getEntry(m_entryIds[index.row()])); - case IdRole: + case AbstractEpisodeModel::Roles::IdRole: return QVariant::fromValue(m_entryIds[index.row()]); - case ReadRole: + case AbstractEpisodeModel::Roles::ReadRole: return QVariant::fromValue(m_read[index.row()]); - case NewRole: + case AbstractEpisodeModel::Roles::NewRole: return QVariant::fromValue(m_new[index.row()]); + case AbstractEpisodeModel::Roles::ContentRole: + return QVariant::fromValue(m_contents[index.row()]); + case AbstractEpisodeModel::Roles::FeedNameRole: + return QVariant::fromValue(m_feedNames[index.row()]); default: return QVariant(); } } -QHash EpisodeModel::roleNames() const -{ - return { - {EntryRole, "entry"}, - {IdRole, "id"}, - {ReadRole, "read"}, - {NewRole, "new"}, - }; -} - int EpisodeModel::rowCount(const QModelIndex &parent) const { Q_UNUSED(parent) @@ -65,12 +61,18 @@ void EpisodeModel::updateInternalState() m_entryIds.clear(); m_read.clear(); m_new.clear(); + m_titles.clear(); + m_feedNames.clear(); + QSqlQuery query; - query.prepare(QStringLiteral("SELECT id, read, new FROM Entries ORDER BY updated DESC;")); + query.prepare(QStringLiteral("SELECT id, read, new, title, content, feed FROM Entries ORDER BY updated DESC;")); Database::instance().execute(query); while (query.next()) { m_entryIds += query.value(QStringLiteral("id")).toString(); m_read += query.value(QStringLiteral("read")).toBool(); m_new += query.value(QStringLiteral("new")).toBool(); + m_titles += query.value(QStringLiteral("title")).toString(); + m_contents += query.value(QStringLiteral("content")).toString(); + m_feedNames += DataManager::instance().getFeed(query.value(QStringLiteral("feed")).toString())->name(); } } diff --git a/src/models/episodemodel.h b/src/models/episodemodel.h index cb84ff98..dfd080d5 100644 --- a/src/models/episodemodel.h +++ b/src/models/episodemodel.h @@ -1,40 +1,41 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ #pragma once -#include #include +#include #include #include #include -class EpisodeModel : public QAbstractListModel +#include "models/abstractepisodemodel.h" + +class EpisodeModel : public AbstractEpisodeModel { Q_OBJECT public: - enum Roles { - EntryRole = Qt::UserRole, - IdRole, - ReadRole, - NewRole, - }; - Q_ENUM(Roles) - + static EpisodeModel &instance() + { + static EpisodeModel _instance; + return _instance; + } explicit EpisodeModel(QObject *parent = nullptr); QVariant data(const QModelIndex &index, int role = Qt::UserRole) const override; - QHash roleNames() const override; int rowCount(const QModelIndex &parent) const override; public Q_SLOTS: - void updateInternalState(); + void updateInternalState() override; private: QStringList m_entryIds; QVector m_read; QVector m_new; + QStringList m_titles; + QStringList m_contents; + QStringList m_feedNames; }; diff --git a/src/models/episodeproxymodel.cpp b/src/models/episodeproxymodel.cpp index fb309ce7..6025e002 100644 --- a/src/models/episodeproxymodel.cpp +++ b/src/models/episodeproxymodel.cpp @@ -1,104 +1,14 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ #include "models/episodeproxymodel.h" -#include - -#include "datamanager.h" -#include "entry.h" - EpisodeProxyModel::EpisodeProxyModel(QObject *parent) - : QSortFilterProxyModel(parent) + : AbstractEpisodeProxyModel(parent) { - m_currentFilter = NoFilter; - m_episodeModel = new EpisodeModel(this); + m_episodeModel = &EpisodeModel::instance(); setSourceModel(m_episodeModel); } - -bool EpisodeProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const -{ - QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); - - switch (m_currentFilter) { - case NoFilter: - return true; - case ReadFilter: - return sourceModel()->data(index, EpisodeModel::Roles::ReadRole).value(); - case NotReadFilter: - return !sourceModel()->data(index, EpisodeModel::Roles::ReadRole).value(); - case NewFilter: - return sourceModel()->data(index, EpisodeModel::Roles::NewRole).value(); - case NotNewFilter: - return !sourceModel()->data(index, EpisodeModel::Roles::NewRole).value(); - default: - return true; - } -} - -EpisodeProxyModel::FilterType EpisodeProxyModel::filterType() const -{ - return m_currentFilter; -} - -void EpisodeProxyModel::setFilterType(FilterType type) -{ - if (type != m_currentFilter) { - disconnect(&DataManager::instance(), &DataManager::bulkReadStatusActionFinished, this, nullptr); - disconnect(&DataManager::instance(), &DataManager::bulkNewStatusActionFinished, this, nullptr); - - beginResetModel(); - m_currentFilter = type; - m_episodeModel->updateInternalState(); - endResetModel(); - - // connect to signals which indicate that read/new statuses have been updated - if (type == ReadFilter || type == NotReadFilter) { - connect(&DataManager::instance(), &DataManager::bulkReadStatusActionFinished, this, [this]() { - beginResetModel(); - m_episodeModel->updateInternalState(); - endResetModel(); - }); - } else if (type == NewFilter || type == NotNewFilter) { - connect(&DataManager::instance(), &DataManager::bulkNewStatusActionFinished, this, [this]() { - beginResetModel(); - m_episodeModel->updateInternalState(); - endResetModel(); - }); - } - - Q_EMIT filterTypeChanged(); - } -} - -QString EpisodeProxyModel::filterName() const -{ - return getFilterName(m_currentFilter); -} - -QString EpisodeProxyModel::getFilterName(FilterType type) const -{ - switch (type) { - case NoFilter: - return i18n("No Filter"); - case ReadFilter: - return i18n("Played Episodes"); - case NotReadFilter: - return i18n("Unplayed Episodes"); - case NewFilter: - return i18n("Episodes marked as \"New\""); - case NotNewFilter: - return i18n("Episodes not marked as \"New\""); - default: - return QString(); - } -} - -// Hack to get a QItemSelection in QML -QItemSelection EpisodeProxyModel::createSelection(int rowa, int rowb) -{ - return QItemSelection(index(rowa, 0), index(rowb, 0)); -} diff --git a/src/models/episodeproxymodel.h b/src/models/episodeproxymodel.h index 9a25e8ff..512599fc 100644 --- a/src/models/episodeproxymodel.h +++ b/src/models/episodeproxymodel.h @@ -1,51 +1,23 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ #pragma once -#include -#include +#include +#include "models/abstractepisodeproxymodel.h" #include "models/episodemodel.h" -class Entry; - -class EpisodeProxyModel : public QSortFilterProxyModel +class EpisodeProxyModel : public AbstractEpisodeProxyModel { Q_OBJECT public: - enum FilterType { - NoFilter, - ReadFilter, - NotReadFilter, - NewFilter, - NotNewFilter, - }; - Q_ENUM(FilterType) - - Q_PROPERTY(FilterType filterType READ filterType WRITE setFilterType NOTIFY filterTypeChanged) - Q_PROPERTY(QString filterName READ filterName NOTIFY filterTypeChanged) - explicit EpisodeProxyModel(QObject *parent = nullptr); - bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; - - FilterType filterType() const; - QString filterName() const; - void setFilterType(FilterType type); - - Q_INVOKABLE QString getFilterName(FilterType type) const; - - Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb); - -Q_SIGNALS: - void filterTypeChanged(); - private: EpisodeModel *m_episodeModel; - FilterType m_currentFilter; }; diff --git a/src/models/queuemodel.cpp b/src/models/queuemodel.cpp index 71961f75..b6981abb 100644 --- a/src/models/queuemodel.cpp +++ b/src/models/queuemodel.cpp @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -7,17 +7,17 @@ #include "models/queuemodel.h" #include "models/queuemodellogging.h" -#include #include +#include + #include "audiomanager.h" #include "datamanager.h" #include "entry.h" -#include "models/episodemodel.h" #include "settingsmanager.h" QueueModel::QueueModel(QObject *parent) - : QAbstractListModel(parent) + : AbstractEpisodeModel(parent) { connect(&DataManager::instance(), &DataManager::queueEntryMoved, this, [this](int from, int to_orig) { int to = (from < to_orig) ? to_orig + 1 : to_orig; @@ -50,25 +50,15 @@ QueueModel::QueueModel(QObject *parent) QVariant QueueModel::data(const QModelIndex &index, int role) const { switch (role) { - case EpisodeModel::Roles::EntryRole: + case AbstractEpisodeModel::Roles::EntryRole: return QVariant::fromValue(DataManager::instance().getQueueEntry(index.row())); - case EpisodeModel::Roles::IdRole: + case AbstractEpisodeModel::Roles::IdRole: return QVariant::fromValue(DataManager::instance().queue()[index.row()]); default: return QVariant(); } } -QHash QueueModel::roleNames() const -{ - return { - {EpisodeModel::Roles::EntryRole, "entry"}, - {EpisodeModel::Roles::IdRole, "id"}, - {EpisodeModel::Roles::ReadRole, "read"}, - {EpisodeModel::Roles::NewRole, "new"}, - }; -} - int QueueModel::rowCount(const QModelIndex &parent) const { Q_UNUSED(parent) @@ -80,7 +70,7 @@ int QueueModel::timeLeft() const { int result = 0; QStringList queue = DataManager::instance().queue(); - for (QString item : queue) { + for (const QString &item : queue) { Entry *entry = DataManager::instance().getEntry(item); if (entry->enclosure()) { result += entry->enclosure()->duration() * 1000 - entry->enclosure()->playPosition(); @@ -101,6 +91,11 @@ QString QueueModel::formattedTimeLeft() const return format.formatDuration(timeLeft() / rate); } +void QueueModel::updateInternalState() +{ + // nothing to do; DataManager already has the updated data. +} + // Hack to get a QItemSelection in QML QItemSelection QueueModel::createSelection(int rowa, int rowb) { diff --git a/src/models/queuemodel.h b/src/models/queuemodel.h index b284fd3a..e0f904f8 100644 --- a/src/models/queuemodel.h +++ b/src/models/queuemodel.h @@ -1,22 +1,21 @@ /** - * SPDX-FileCopyrightText: 2021 Bart De Vries + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ #pragma once -#include #include #include +#include #include +#include #include -#include +#include "models/abstractepisodemodel.h" -#include "audiomanager.h" - -class QueueModel : public QAbstractListModel +class QueueModel : public AbstractEpisodeModel { Q_OBJECT @@ -29,16 +28,18 @@ public: static QueueModel _instance; return _instance; } - explicit QueueModel(QObject * = nullptr); - //~QueueModel() override; + explicit QueueModel(QObject *parent = nullptr); QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; int rowCount(const QModelIndex &parent) const override; + int timeLeft() const; QString formattedTimeLeft() const; Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb); +public Q_SLOTS: + void updateInternalState() override; + Q_SIGNALS: void timeLeftChanged(); }; diff --git a/src/qml/Desktop/HeaderBar.qml b/src/qml/Desktop/HeaderBar.qml index 0325576a..e32dae00 100644 --- a/src/qml/Desktop/HeaderBar.qml +++ b/src/qml/Desktop/HeaderBar.qml @@ -35,7 +35,7 @@ FocusScope { var model = pageStack.get(0).queueList.model; for (var i = 0; i < model.rowCount(); i++) { var index = model.index(i, 0); - if (AudioManager.entry == model.data(index, EpisodeModel.EntryRole)) { + if (AudioManager.entry == model.data(index, AbstractEpisodeModel.EntryRole)) { pageStack.get(0).queueList.currentIndex = i; pageStack.get(0).queueList.selectionModel.setCurrentIndex(index, ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows); @@ -117,7 +117,7 @@ FocusScope { property string blurredImage: AudioManager.entry ? AudioManager.entry.cachedImage : "no-image" property string title: AudioManager.entry ? AudioManager.entry.title : i18n("No Track Title") property string feed: AudioManager.entry ? AudioManager.entry.feed.name : i18n("No Track Loaded") - property string authors: AudioManager.entry ? (AudioManager.entry.feed.authors.length !== 0 ? AudioManager.entry.feed.authors[0].name : undefined) : undefined + property string authors: AudioManager.entry ? (AudioManager.entry.feed.authors.length !== 0 ? AudioManager.entry.feed.authors[0].name : "") : "" implicitHeight: headerBar.handlePosition implicitWidth: parent.width diff --git a/src/qml/EpisodeListPage.qml b/src/qml/EpisodeListPage.qml index a3ea4f2d..2d27a178 100644 --- a/src/qml/EpisodeListPage.qml +++ b/src/qml/EpisodeListPage.qml @@ -1,6 +1,6 @@ /** * SPDX-FileCopyrightText: 2020 Tobias Fella - * SPDX-FileCopyrightText: 2021-2022 Bart De Vries + * SPDX-FileCopyrightText: 2021-2023 Bart De Vries * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -8,7 +8,6 @@ import QtQuick 2.14 import QtQuick.Controls 2.14 as Controls import QtQuick.Layouts 1.14 -import QtGraphicalEffects 1.15 import org.kde.kirigami 2.19 as Kirigami import org.kde.kasts 1.0 @@ -17,6 +16,8 @@ Kirigami.ScrollablePage { id: episodeListPage title: i18n("Episode List") + property alias episodeList: episodeList + LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft LayoutMirroring.childrenInherit: true @@ -28,29 +29,56 @@ Kirigami.ScrollablePage { } } - actions.main: Kirigami.Action { - icon.name: "download" - text: i18n("Downloads") - onTriggered: { - pushPage("DownloadListPage") + Keys.onPressed: { + if (event.matches(StandardKey.Find)) { + searchActionButton.checked = true; } } - actions.left: Kirigami.Action { - icon.name: "view-filter" - text: i18n("Filter") - onTriggered: filterTypeOverlay.open(); - } + actions { + main: Kirigami.Action { + icon.name: "download" + text: i18n("Downloads") + onTriggered: { + pushPage("DownloadListPage") + } + } - actions.right: Kirigami.Action { - icon.name: "view-refresh" - text: i18n("Refresh All Podcasts") - onTriggered: refreshing = true - visible: episodeProxyModel.filterType == EpisodeProxyModel.NoFilter + left: Kirigami.Action { + id: searchActionButton + icon.name: "search" + text: i18nc("@action:intoolbar", "Search and Filter") + checkable: true + onToggled: { + if (!checked) { + episodeProxyModel.filterType = AbstractEpisodeProxyModel.NoFilter; + episodeProxyModel.searchFilter = ""; + } + } + } + + right: Kirigami.Action { + icon.name: "view-refresh" + text: i18n("Refresh All Podcasts") + onTriggered: refreshing = true + visible: episodeProxyModel.filterType == AbstractEpisodeProxyModel.NoFilter + } } contextualActions: episodeList.defaultActionList + header: Loader { + anchors.right: parent.right + anchors.left: parent.left + + active: searchActionButton.checked + visible: active + sourceComponent: SearchFilterBar { + proxyModel: episodeProxyModel + parentKey: searchActionButton + } + } + GenericEntryListView { id: episodeList anchors.fill: parent @@ -65,8 +93,6 @@ Kirigami.ScrollablePage { text: i18n("No Episodes Available") } - - model: EpisodeProxyModel { id: episodeProxyModel } @@ -78,71 +104,8 @@ Kirigami.ScrollablePage { } } - Kirigami.InlineMessage { - anchors { - horizontalCenter: parent.horizontalCenter - bottom: parent.bottom - margins: Kirigami.Units.largeSpacing - bottomMargin: Kirigami.Units.largeSpacing + ( errorNotification.visible ? errorNotification.height + Kirigami.Units.largeSpacing : 0 ) + ( updateNotification.visible ? updateNotification.height + Kirigami.Units.largeSpacing : 0 ) + ( updateSyncNotification.visible ? updateSyncNotification.height + Kirigami.Units.largeSpacing : 0 ) - } - type: Kirigami.MessageType.Information - visible: episodeProxyModel.filterType != EpisodeProxyModel.NoFilter - text: textMetrics.text - width: Math.min(textMetrics.width + 2 * Kirigami.Units.largeSpacing + 10 * Kirigami.Units.gridUnit, parent.width - anchors.leftMargin - anchors.rightMargin) - actions: [ - Kirigami.Action { - id: resetButton - icon.name: "edit-delete-remove" - text: i18n("Reset") - onTriggered: { - episodeProxyModel.filterType = EpisodeProxyModel.NoFilter; - } - } - ] - TextMetrics { - id: textMetrics - text: i18n("Filter Active: ") + episodeProxyModel.filterName - } - } - } - - Kirigami.Dialog { - id: filterTypeOverlay - - title: i18n("Select Filter") - preferredWidth: Kirigami.Units.gridUnit * 16 - - ColumnLayout { - spacing: 0 - - Repeater { - model: ListModel { - id: filterModel - // have to use script because i18n doesn't work within ListElement - Component.onCompleted: { - var filterList = [EpisodeProxyModel.NoFilter, - EpisodeProxyModel.ReadFilter, - EpisodeProxyModel.NotReadFilter, - EpisodeProxyModel.NewFilter, - EpisodeProxyModel.NotNewFilter] - for (var i in filterList) { - filterModel.append({"name": episodeProxyModel.getFilterName(filterList[i]), - "filterType": filterList[i]}); - } - } - } - - delegate: Kirigami.BasicListItem { - id: swipeDelegate - Layout.fillWidth: true - highlighted: filterType === episodeProxyModel.filterType - text: name - onClicked: { - episodeProxyModel.filterType = filterType; - filterTypeOverlay.close(); - } - } - } + FilterInlineMessage { + proxyModel: episodeProxyModel } } } diff --git a/src/qml/FeedDetailsPage.qml b/src/qml/FeedDetailsPage.qml index b2021600..66098682 100644 --- a/src/qml/FeedDetailsPage.qml +++ b/src/qml/FeedDetailsPage.qml @@ -29,6 +29,12 @@ Kirigami.ScrollablePage { title: i18n("Podcast Details") + Keys.onPressed: { + if (event.matches(StandardKey.Find)) { + searchActionButton.checked = true; + } + } + supportsRefreshing: true onRefreshingChanged: { @@ -59,11 +65,31 @@ Kirigami.ScrollablePage { } } - // add the default actions through onCompleted to add them to the ones - // defined above - Component.onCompleted: { - for (var i in entryList.defaultActionList) { - contextualActions.push(entryList.defaultActionList[i]); + actions.main: Kirigami.Action { + id: searchActionButton + icon.name: "search" + text: i18nc("@action:intoolbar", "Search and Filter") + checkable: true + enabled: page.feed.entries ? true : false + visible: enabled + onToggled: { + if (!checked && page.feed.entries) { + page.feed.entries.filterType = AbstractEpisodeProxyModel.NoFilter; + page.feed.entries.searchFilter = ""; + } + } + } + + header: Loader { + anchors.right: parent.right + anchors.left: parent.left + + active: searchActionButton.checked + visible: active + + sourceComponent: SearchFilterBar { + proxyModel: page.feed.entries ? page.feed.entries : emptyListModel + parentKey: searchActionButton } } @@ -86,17 +112,12 @@ Kirigami.ScrollablePage { model: page.feed.entries ? page.feed.entries : emptyListModel delegate: entryListDelegate - // OverlayHeader looks nicer, but seems completely broken when flicking the list - //headerPositioning: ListView.OverlayHeader - header: ColumnLayout { id: headerColumn height: (isSubscribed && entryList.count > 0) ? implicitHeight : entryList.height width: entryList.width spacing: 0 - property real headerOverlayProgress: Math.min(1, Math.abs(entryList.contentY) / headerColumn.height) - Kirigami.Theme.inherit: false Kirigami.Theme.colorSet: Kirigami.Theme.Window @@ -119,15 +140,18 @@ Kirigami.ScrollablePage { topPadding: Kirigami.Units.smallSpacing background: Rectangle { - color: Kirigami.Theme.alternateBackgroundColor + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Header + color: Kirigami.Theme.backgroundColor } contentItem: Kirigami.ActionToolBar { + id: feedToolBar alignment: Qt.AlignLeft background: Item {} - // HACK: ActionToolBar loads buttons dynamically, and so the height calculation - // changes the position + // HACK: ActionToolBar loads buttons dynamically, and so the + // height calculation changes the position onHeightChanged: entryList.contentY = entryList.originY actions: [ @@ -161,6 +185,16 @@ Kirigami.ScrollablePage { } } ] + + // add the default actions through onCompleted to add them + // to the ones defined above + Component.onCompleted: { + if (isSubscribed) { + for (var i in entryList.defaultActionList) { + feedToolBar.actions.push(entryList.defaultActionList[i]); + } + } + } } } @@ -333,5 +367,9 @@ Kirigami.ScrollablePage { } } } + + FilterInlineMessage { + proxyModel: page.feed.entries ? page.feed.entries : emptyListModel + } } } diff --git a/src/qml/FilterInlineMessage.qml b/src/qml/FilterInlineMessage.qml new file mode 100644 index 00000000..2026f3a6 --- /dev/null +++ b/src/qml/FilterInlineMessage.qml @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +import QtQuick 2.14 +import QtQuick.Controls 2.14 as Controls +import org.kde.kirigami 2.19 as Kirigami + +import org.kde.kasts 1.0 + + +Kirigami.InlineMessage { + required property var proxyModel + + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + margins: Kirigami.Units.largeSpacing + bottomMargin: Kirigami.Units.largeSpacing + ( errorNotification.visible ? errorNotification.height + Kirigami.Units.largeSpacing : 0 ) + ( updateNotification.visible ? updateNotification.height + Kirigami.Units.largeSpacing : 0 ) + ( updateSyncNotification.visible ? updateSyncNotification.height + Kirigami.Units.largeSpacing : 0 ) + } + type: Kirigami.MessageType.Information + visible: proxyModel.filterType != AbstractEpisodeProxyModel.NoFilter + text: textMetrics.text + width: Math.min(textMetrics.width + 2 * Kirigami.Units.largeSpacing + 10 * Kirigami.Units.gridUnit, parent.width - anchors.leftMargin - anchors.rightMargin) + + actions: [ + Kirigami.Action { + id: resetButton + icon.name: "edit-delete-remove" + text: i18nc("@action:button Reset filters active on ListView", "Reset") + onTriggered: { + proxyModel.filterType = AbstractEpisodeProxyModel.NoFilter; + } + } + ] + + TextMetrics { + id: textMetrics + text: i18nc("@info:status Name of the filter which is active on the ListView", "Filter Active: %1", proxyModel.filterName) + } +} diff --git a/src/qml/GenericEntryListView.qml b/src/qml/GenericEntryListView.qml index 9c2c2dab..64c820ea 100644 --- a/src/qml/GenericEntryListView.qml +++ b/src/qml/GenericEntryListView.qml @@ -31,7 +31,7 @@ ListView { onSelectionForContextMenuChanged: { if (selectionForContextMenu.length === 1) { - singleSelectedEntry = selectionForContextMenu[0].model.data(selectionForContextMenu[0], EpisodeModel.EntryRole); + singleSelectedEntry = selectionForContextMenu[0].model.data(selectionForContextMenu[0], AbstractEpisodeModel.EntryRole); } else { singleSelectedEntry = undefined; } diff --git a/src/qml/GlobalSearchField.qml b/src/qml/GlobalSearchField.qml new file mode 100644 index 00000000..789901d4 --- /dev/null +++ b/src/qml/GlobalSearchField.qml @@ -0,0 +1,167 @@ +/** + * SPDX-FileCopyrightText: 2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as Controls +import QtQuick.Layouts 1.14 +import QtQml.Models 2.15 + +import org.kde.kirigami 2.12 as Kirigami +import org.kde.kirigamiaddons.labs.components 1.0 as Addons + +import org.kde.kasts 1.0 + +Addons.SearchPopupField { + id: globalSearchField + spaceAvailableLeft: false + spaceAvailableRight: true + + autoAccept: false + + popup.width: Math.min(Kirigami.Units.gridUnit * 20, kastsMainWindow.width - Kirigami.Units.gridUnit * 2) + + property string searchFilter: "" + + onAccepted: { + globalSearchField.searchFilter = globalSearchField.text + } + + popup.onClosed: { + globalSearchField.text = "" + } + + onTextChanged: { + if (globalSearchField.text === "") { + globalSearchField.searchFilter = ""; + } + } + + function openEntry(entry) { + pushPage("EpisodeListPage"); + pageStack.push("qrc:/EntryPage.qml", {"entry": entry}); + + /* let's not select the current item for now, since it can take a long + * time and will not automatically scroll to that item either + var episodeModel = pageStack.get(0).episodeList.model + for (var i = 0; i < episodeModel.rowCount(); i++) { + var index = episodeModel.index(i, 0); + if (entry == episodeModel.data(index, AbstractEpisodeModel.EntryRole)) { + pageStack.get(0).episodeList.currentIndex = i; + pageStack.get(0).episodeList.selectionModel.setCurrentIndex(index, ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows); + } + } + */ + } + + Component.onCompleted: { + // rightActions are defined from right-to-left + // if we want to insert the settings action as the rightmost, then it + // must be defined as first action, which means that we need to save the + // default clear action and push that as a second action + var origAction = searchField.rightActions[0]; + searchField.rightActions[0] = searchSettingsButton; + searchField.rightActions.push(origAction); + } + + ListView { + id: searchListView + reuseItems: true + + EpisodeProxyModel { + id: proxyModel + searchFilter: globalSearchField.searchFilter + } + + model: globalSearchField.searchFilter === "" ? null : proxyModel + + delegate: Component { + Kirigami.BasicListItem { + separatorVisible: true + icon: model.entry.cachedImage + label: model.entry.title + subtitle: model.entry.feed.name + onClicked: { + globalSearchField.openEntry(model.entry); + globalSearchField.popup.close(); + } + } + } + Kirigami.PlaceholderMessage { + id: loadingPlaceholder + anchors.fill: parent + visible: searchListView.count === 0 + + text: i18nc("@info Placeholder text in search box", "No Search Results") + } + + Kirigami.Action { + id: searchSettingsButton + visible: globalSearchField.popup.visible + icon.name: "settings-configure" + text: i18nc("@action:intoolbar", "Advanced Search Options") + + onTriggered: { + if (searchSettingsMenu.visible) { + searchSettingsMenu.dismiss(); + } else { + searchSettingsMenu.popup(searchSettingsButton); + } + } + } + + ListModel { + id: searchSettingsModel + + function reload() { + clear(); + var searchList = [AbstractEpisodeProxyModel.TitleFlag, + AbstractEpisodeProxyModel.ContentFlag, + AbstractEpisodeProxyModel.FeedNameFlag] + for (var i in searchList) { + searchSettingsModel.append({"name": proxyModel.getSearchFlagName(searchList[i]), + "searchFlag": searchList[i], + "checked": proxyModel.searchFlags & searchList[i]}); + } + } + + Component.onCompleted: { + reload(); + } + } + + Controls.Menu { + id: searchSettingsMenu + + title: i18nc("@title:menu", "Search Preferences") + + Controls.Label { + padding: Kirigami.Units.smallSpacing + text: i18nc("@title:group Group of fields in which can be searched", "Search in:") + } + + Repeater { + model: searchSettingsModel + + Controls.CheckBox { + padding: Kirigami.Units.smallSpacing + text: model.name + checked: model.checked + onToggled: { + if (checked) { + proxyModel.searchFlags = proxyModel.searchFlags | model.searchFlag; + } else { + proxyModel.searchFlags = proxyModel.searchFlags & ~model.searchFlag; + } + } + } + } + + onOpened: { + searchSettingsModel.reload(); + } + } + } +} diff --git a/src/qml/SearchFilterBar.qml b/src/qml/SearchFilterBar.qml new file mode 100644 index 00000000..fa58f425 --- /dev/null +++ b/src/qml/SearchFilterBar.qml @@ -0,0 +1,201 @@ +/** + * SPDX-FileCopyrightText: 2023 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +import QtQuick 2.14 +import QtQuick.Controls 2.14 as Controls +import QtQuick.Layouts 1.14 +import org.kde.kirigami 2.19 as Kirigami + +import org.kde.kasts 1.0 + +Controls.Control { + id: searchFilterBar + + required property var proxyModel + required property var parentKey + + leftPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing + rightPadding: Kirigami.Units.largeSpacing + topPadding: Kirigami.Units.smallSpacing + bottomPadding: Kirigami.Units.smallSpacing + + background: Rectangle { + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Header + color: Kirigami.Theme.backgroundColor + + Kirigami.Separator { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + } + } + + contentItem: RowLayout { + Kirigami.SearchField { + Layout.fillWidth: true + id: searchField + placeholderText: i18nc("@label:textbox Placeholder text for episode search field", "Search Episodes") + focus: true + autoAccept: false + onAccepted: { + proxyModel.searchFilter = searchField.text; + } + + Kirigami.Action { + id: searchSettingsButton + icon.name: "settings-configure" + text: i18nc("@action:intoolbar", "Advanced Search Options") + + onTriggered: { + if (searchSettingsMenu.visible) { + searchSettingsMenu.dismiss(); + } else { + searchSettingsMenu.popup(searchSettingsButton); + } + } + } + Component.onCompleted: { + // rightActions are defined from right-to-left + // if we want to insert the settings action as the rightmost, then it + // must be defined as first action, which means that we need to save the + // default clear action and push that as a second action + var origAction = searchField.rightActions[0]; + searchField.rightActions[0] = searchSettingsButton; + searchField.rightActions.push(origAction); + } + + Keys.onEscapePressed: { + proxyModel.filterType = AbstractEpisodeProxyModel.NoFilter; + proxyModel.searchFilter = ""; + parentKey.checked = false; + event.accepted = true; + } + Keys.onReturnPressed: { + accepted(); + event.accepted = true; + } + } + + Controls.ToolButton { + id: filterButton + icon.name: "view-filter" + text: i18nc("@action:intoolbar Button to open menu to filter episodes based on their status (played, new, etc.)", "Filter") + display: wideScreen ? Controls.AbstractButton.TextBesideIcon : Controls.AbstractButton.IconOnly + + Controls.ToolTip.visible: hovered + Controls.ToolTip.delay: Qt.styleHints.mousePressAndHoldInterval + Controls.ToolTip.text: i18nc("@info:tooltip", "Filter Episodes by Status") + + onPressed: { + if (filterMenu.visible) { + filterMenu.dismiss(); + } else { + filterMenu.popup(filterButton, filterButton.x, filterButton.y + filterButton.height); + } + } + } + } + + Component.onCompleted: { + searchField.forceActiveFocus(); + } + + ListModel { + id: filterModel + + // have to use script because i18n doesn't work within ListElement + Component.onCompleted: { + var filterList = [AbstractEpisodeProxyModel.NoFilter, + AbstractEpisodeProxyModel.ReadFilter, + AbstractEpisodeProxyModel.NotReadFilter, + AbstractEpisodeProxyModel.NewFilter, + AbstractEpisodeProxyModel.NotNewFilter] + for (var i in filterList) { + filterModel.append({"name": proxyModel.getFilterName(filterList[i]), + "filterType": filterList[i]}); + } + } + } + + Controls.Menu { + id: filterMenu + + title: i18nc("@title:menu", "Select Filter") + + Controls.ButtonGroup { id: filterGroup } + + Repeater { + model: filterModel + + Controls.RadioButton { + padding: Kirigami.Units.smallSpacing + text: model.name + checked: model.filterType === proxyModel.filterType + Controls.ButtonGroup.group: filterGroup + + onToggled: { + if (checked) { + proxyModel.filterType = model.filterType; + } + filterMenu.dismiss(); + } + } + } + } + + ListModel { + id: searchSettingsModel + + function reload() { + clear(); + var searchList = [AbstractEpisodeProxyModel.TitleFlag, + AbstractEpisodeProxyModel.ContentFlag, + AbstractEpisodeProxyModel.FeedNameFlag] + for (var i in searchList) { + searchSettingsModel.append({"name": proxyModel.getSearchFlagName(searchList[i]), + "searchFlag": searchList[i], + "checked": proxyModel.searchFlags & searchList[i]}); + } + } + + Component.onCompleted: { + reload(); + } + } + + Controls.Menu { + id: searchSettingsMenu + + title: i18nc("@title:menu", "Search Preferences") + + Controls.Label { + padding: Kirigami.Units.smallSpacing + text: i18nc("@title:group Group of fields in which can be searched", "Search in:") + } + + Repeater { + model: searchSettingsModel + + Controls.CheckBox { + padding: Kirigami.Units.smallSpacing + text: model.name + checked: model.checked + onToggled: { + if (checked) { + proxyModel.searchFlags = proxyModel.searchFlags | model.searchFlag; + } else { + proxyModel.searchFlags = proxyModel.searchFlags & ~model.searchFlag; + } + } + } + } + + onOpened: { + searchSettingsModel.reload(); + } + } +} diff --git a/src/qml/main.qml b/src/qml/main.qml index 5373483f..4281b641 100644 --- a/src/qml/main.qml +++ b/src/qml/main.qml @@ -131,15 +131,13 @@ Kirigami.ApplicationWindow { sourceComponent: Kirigami.OverlayDrawer { id: drawer modal: false - - readonly property real listViewThreshold: Kirigami.Settings.isMobile ? Kirigami.Units.gridUnit * 22 : Kirigami.Units.gridUnit * 20 + closePolicy: Controls.Popup.NoAutoClose readonly property real pinnedWidth: Kirigami.Units.gridUnit * 3 - readonly property real widescreenSmallWidth: Kirigami.Units.gridUnit * 6 readonly property real widescreenBigWidth: Kirigami.Units.gridUnit * 10 - readonly property int buttonDisplayMode: kastsMainWindow.isWidescreen ? (drawer.height < listViewThreshold ? Kirigami.NavigationTabButton.TextBesideIcon : Kirigami.NavigationTabButton.TextUnderIcon) : Kirigami.NavigationTabButton.IconOnly + readonly property int buttonDisplayMode: kastsMainWindow.isWidescreen ? Kirigami.NavigationTabButton.TextBesideIcon : Kirigami.NavigationTabButton.IconOnly - width: kastsMainWindow.isWidescreen ? (drawer.height < listViewThreshold ? widescreenBigWidth : widescreenSmallWidth) : pinnedWidth + width: kastsMainWindow.isWidescreen ? widescreenBigWidth : pinnedWidth Kirigami.Theme.colorSet: Kirigami.Theme.Window Kirigami.Theme.inherit: false @@ -152,11 +150,16 @@ Kirigami.ApplicationWindow { contentItem: ColumnLayout { spacing: 0 - Loader { - active: Kirigami.Settings.isMobile - visible: active + Controls.ToolBar { Layout.fillWidth: true - sourceComponent: Kirigami.AbstractApplicationHeader { } + Layout.preferredHeight: pageStack.globalToolBar.preferredHeight + + leftPadding: Kirigami.Units.smallSpacing + rightPadding: Kirigami.Units.smallSpacing + topPadding: Kirigami.Units.smallSpacing + bottomPadding: Kirigami.Units.smallSpacing + + contentItem: GlobalSearchField {} } Controls.ScrollView { diff --git a/src/resources.qrc b/src/resources.qrc index 9f0ad47a..aa562e33 100755 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -41,6 +41,9 @@ qml/Settings/ErrorListPage.qml qml/SleepTimerDialog.qml qml/FullScreenImage.qml + qml/GlobalSearchField.qml + qml/SearchFilterBar.qml + qml/FilterInlineMessage.qml qtquickcontrols2.conf ../kasts.svg ../icons/kasts-tray.svg