Implement episode search functionality

Add search capabilities to the main window toolbar and the episode lists.

BUG: 459983
This commit is contained in:
Bart De Vries 2023-02-17 21:14:57 +00:00
parent 3e6df78991
commit e5e046e412
28 changed files with 997 additions and 329 deletions

View File

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

View File

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

View File

@ -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()

View File

@ -13,7 +13,7 @@
#include <QVector>
#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;
};

View File

@ -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<PodcastSearchModel>("org.kde.kasts", 1, 0, "PodcastSearchModel");
qmlRegisterType<ChapterModel>("org.kde.kasts", 1, 0, "ChapterModel");
qmlRegisterUncreatableType<EntriesModel>("org.kde.kasts", 1, 0, "EntriesModel", QStringLiteral("Get from Feed"));
qmlRegisterUncreatableType<AbstractEpisodeProxyModel>("org.kde.kasts", 1, 0, "AbstractEpisodeProxyModel", QStringLiteral("Only for enums"));
qmlRegisterUncreatableType<EntriesProxyModel>("org.kde.kasts", 1, 0, "EntriesProxyModel", QStringLiteral("Get from Feed"));
qmlRegisterUncreatableType<Enclosure>("org.kde.kasts", 1, 0, "Enclosure", QStringLiteral("Only for enums"));
qmlRegisterUncreatableType<EpisodeModel>("org.kde.kasts", 1, 0, "EpisodeModel", QStringLiteral("Only for enums"));
qmlRegisterUncreatableType<AbstractEpisodeModel>("org.kde.kasts", 1, 0, "AbstractEpisodeModel", QStringLiteral("Only for enums"));
qmlRegisterUncreatableType<FeedsModel>("org.kde.kasts", 1, 0, "FeedsModel", QStringLiteral("Only for enums"));
qmlRegisterSingletonType("org.kde.kasts", 1, 0, "About", [](QQmlEngine *engine, QJSEngine *) -> QJSValue {

View File

@ -0,0 +1,25 @@
/**
* SPDX-FileCopyrightText: 2023 Bart De Vries <bart@mogwai.be>
*
* 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<int, QByteArray> AbstractEpisodeModel::roleNames() const
{
return {
{TitleRole, "title"},
{EntryRole, "entry"},
{IdRole, "id"},
{ReadRole, "read"},
{NewRole, "new"},
{ContentRole, "content"},
{FeedNameRole, "feedname"},
};
}

View File

@ -0,0 +1,35 @@
/**
* SPDX-FileCopyrightText: 2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QObject>
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<int, QByteArray> roleNames() const override;
public Q_SLOTS:
virtual void updateInternalState() = 0;
};

View File

@ -0,0 +1,178 @@
/**
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "models/episodeproxymodel.h"
#include <KLocalizedString>
#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<bool>();
break;
case NotReadFilter:
accepted = !sourceModel()->data(index, AbstractEpisodeModel::Roles::ReadRole).value<bool>();
break;
case NewFilter:
accepted = sourceModel()->data(index, AbstractEpisodeModel::Roles::NewRole).value<bool>();
break;
case NotNewFilter:
accepted = !sourceModel()->data(index, AbstractEpisodeModel::Roles::NewRole).value<bool>();
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<QString>().contains(m_searchFilter, Qt::CaseInsensitive)) {
found |= true;
}
}
if (m_searchFlags & SearchFlag::ContentFlag) {
if (sourceModel()->data(index, AbstractEpisodeModel::Roles::ContentRole).value<QString>().contains(m_searchFilter, Qt::CaseInsensitive)) {
found |= true;
}
}
if (m_searchFlags & SearchFlag::FeedNameFlag) {
if (sourceModel()->data(index, AbstractEpisodeModel::Roles::FeedNameRole).value<QString>().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<AbstractEpisodeModel *>(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<AbstractEpisodeModel *>(sourceModel())->updateInternalState();
endResetModel();
});
} else if (type == NewFilter || type == NotNewFilter) {
connect(&DataManager::instance(), &DataManager::bulkNewStatusActionFinished, this, [this]() {
beginResetModel();
dynamic_cast<AbstractEpisodeModel *>(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));
}

View File

@ -0,0 +1,74 @@
/**
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QItemSelection>
#include <QSortFilterProxyModel>
#include <QString>
#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)

View File

@ -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<int, QByteArray> 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.
}

View File

@ -1,36 +1,35 @@
/**
* SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QAbstractListModel>
#include <QHash>
#include <QItemSelection>
#include <QModelIndex>
#include <QObject>
#include <QVariant>
#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<int, QByteArray> 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;
};

View File

@ -0,0 +1,26 @@
/**
* SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "models/entriesproxymodel.h"
#include <QModelIndex>
#include <QString>
#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();
}

View File

@ -0,0 +1,30 @@
/**
* SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QObject>
#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;
};

View File

@ -1,5 +1,5 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* 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<int, QByteArray> 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();
}
}

View File

@ -1,40 +1,41 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QAbstractListModel>
#include <QHash>
#include <QModelIndex>
#include <QObject>
#include <QVariant>
#include <QVector>
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<int, QByteArray> roleNames() const override;
int rowCount(const QModelIndex &parent) const override;
public Q_SLOTS:
void updateInternalState();
void updateInternalState() override;
private:
QStringList m_entryIds;
QVector<bool> m_read;
QVector<bool> m_new;
QStringList m_titles;
QStringList m_contents;
QStringList m_feedNames;
};

View File

@ -1,104 +1,14 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include "models/episodeproxymodel.h"
#include <KLocalizedString>
#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<bool>();
case NotReadFilter:
return !sourceModel()->data(index, EpisodeModel::Roles::ReadRole).value<bool>();
case NewFilter:
return sourceModel()->data(index, EpisodeModel::Roles::NewRole).value<bool>();
case NotNewFilter:
return !sourceModel()->data(index, EpisodeModel::Roles::NewRole).value<bool>();
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));
}

View File

@ -1,51 +1,23 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QItemSelection>
#include <QSortFilterProxyModel>
#include <QObject>
#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;
};

View File

@ -1,5 +1,5 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* 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 <QString>
#include <QThread>
#include <KFormat>
#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<int, QByteArray> 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)
{

View File

@ -1,22 +1,21 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#pragma once
#include <QAbstractListModel>
#include <QHash>
#include <QItemSelection>
#include <QModelIndex>
#include <QObject>
#include <QString>
#include <QVariant>
#include <KFormat>
#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<int, QByteArray> 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();
};

View File

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

View File

@ -1,6 +1,6 @@
/**
* SPDX-FileCopyrightText: 2020 Tobias Fella <fella@posteo.de>
* SPDX-FileCopyrightText: 2021-2022 Bart De Vries <bart@mogwai.be>
* SPDX-FileCopyrightText: 2021-2023 Bart De Vries <bart@mogwai.be>
*
* 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
}
}
}

View File

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

View File

@ -0,0 +1,43 @@
/**
* SPDX-FileCopyrightText: 2023 Bart De Vries <bart@mogwai.be>
*
* 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)
}
}

View File

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

View File

@ -0,0 +1,167 @@
/**
* SPDX-FileCopyrightText: 2023 Bart De Vries <bart@mogwai.be>
*
* 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();
}
}
}
}

201
src/qml/SearchFilterBar.qml Normal file
View File

@ -0,0 +1,201 @@
/**
* SPDX-FileCopyrightText: 2023 Bart De Vries <bart@mogwai.be>
*
* 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();
}
}
}

View File

@ -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 {

View File

@ -41,6 +41,9 @@
<file alias="ErrorListPage.qml">qml/Settings/ErrorListPage.qml</file>
<file alias="SleepTimerDialog.qml">qml/SleepTimerDialog.qml</file>
<file alias="FullScreenImage.qml">qml/FullScreenImage.qml</file>
<file alias="GlobalSearchField.qml">qml/GlobalSearchField.qml</file>
<file alias="SearchFilterBar.qml">qml/SearchFilterBar.qml</file>
<file alias="FilterInlineMessage.qml">qml/FilterInlineMessage.qml</file>
<file>qtquickcontrols2.conf</file>
<file alias="logo.svg">../kasts.svg</file>
<file alias="kasts-tray.svg">../icons/kasts-tray.svg</file>