diff --git a/src/attica/atticaprovider.cpp b/src/attica/atticaprovider.cpp --- a/src/attica/atticaprovider.cpp +++ b/src/attica/atticaprovider.cpp @@ -17,6 +17,7 @@ #include "atticaprovider_p.h" +#include "commentsmodel.h" #include "question.h" #include "tagsfilterchecker.h" @@ -49,6 +50,8 @@ connect(&m_providerManager, &ProviderManager::providerAdded, this, &AtticaProvider::providerLoaded); connect(&m_providerManager, SIGNAL(authenticationCredentialsMissing(Provider)), SLOT(authenticationCredentialsMissing(Provider))); + connect(this, &Provider::loadComments, this, &AtticaProvider::loadComments); + connect(this, &Provider::loadPerson, this, &AtticaProvider::loadPerson); } AtticaProvider::AtticaProvider(const Attica::Provider &provider, const QStringList &categories) @@ -342,6 +345,78 @@ } } +void AtticaProvider::loadComments(const EntryInternal &entry, int commentsPerPage, int page) +{ + ListJob *job = m_provider.requestComments(Attica::Comment::ContentComment, entry.uniqueId(), QLatin1String("0"), page, commentsPerPage); + connect(job, &BaseJob::finished, this, &AtticaProvider::loadedComments); + job->start(); +} + +QList> getCommentsList(const Attica::Comment::List &comments, std::shared_ptr parent) { + QList> knsComments; + for (const Attica::Comment &comment : comments) { + qDebug() << "Appending comment with id" << comment.id() << ", which has" << comment.childCount() << "children"; + std::shared_ptr knsComment(new KNSCore::Comment()); + knsComment->id = comment.id(); + knsComment->subject = comment.subject(); + knsComment->text = comment.text(); + knsComment->childCount = comment.childCount(); + knsComment->username = comment.user(); + knsComment->date = comment.date(); + knsComment->score = comment.score(); + knsComment->parent = parent; + knsComments << knsComment; + if (comment.childCount() > 0) { + qDebug() << "Getting more comments, as this one has children, and we currently have this number of comments:" << knsComments.count(); + knsComments << getCommentsList(comment.children(), knsComment); + qDebug() << "After getting the children, we now have the following number of comments:" << knsComments.count(); + } + } + return knsComments; +} + +void AtticaProvider::loadedComments(Attica::BaseJob *baseJob) +{ + if (!jobSuccess(baseJob)) { + return; + } + + ListJob *job = static_cast*>(baseJob); + Attica::Comment::List comments = job->itemList(); + + QList> receivedComments = getCommentsList(comments, nullptr); + emit commentsLoaded(receivedComments); +} + +void AtticaProvider::loadPerson(const QString& username) +{ + if (m_provider.hasPersonService()) { + ItemJob *job = m_provider.requestPerson(username); + job->setProperty("username", username); + connect(job, &BaseJob::finished, this, &AtticaProvider::loadedPerson); + job->start(); + } +} + +void AtticaProvider::loadedPerson(Attica::BaseJob *baseJob) +{ + if (!jobSuccess(baseJob)) { + return; + } + + ItemJob *job = static_cast*>(baseJob); + Attica::Person person = job->result(); + + std::shared_ptr author(new KNSCore::Author); + author->setId(job->property("username").toString()); // This is a touch hack-like, but it ensures we actually have the data in case it is not returned by the server + author->setName(QString::fromLatin1("%1 %2").arg(person.firstName()).arg(person.lastName()).trimmed()); + author->setHomepage(person.homepage()); + author->setProfilepage(person.extendedAttribute(QStringLiteral("profilepage"))); + author->setAvatarUrl(person.avatarUrl()); + author->setDescription(person.extendedAttribute(QStringLiteral("description"))); + emit personLoaded(author); +} + void AtticaProvider::accountBalanceLoaded(Attica::BaseJob *baseJob) { if (!jobSuccess(baseJob)) { @@ -502,6 +577,7 @@ entry.setLicense(content.license()); Author author; + author.setId(content.author()); author.setName(content.author()); author.setHomepage(content.attribute(QStringLiteral("profilepage"))); entry.setAuthor(author); diff --git a/src/attica/atticaprovider_p.h b/src/attica/atticaprovider_p.h --- a/src/attica/atticaprovider_p.h +++ b/src/attica/atticaprovider_p.h @@ -65,6 +65,16 @@ void loadEntries(const KNSCore::Provider::SearchRequest &request) override; void loadEntryDetails(const KNSCore::EntryInternal &entry) override; void loadPayloadLink(const EntryInternal &entry, int linkId) override; + /** + * The slot which causes loading of comments for the Attica provider + * @see Provider::loadComments(const EntryInternal &entry, int commentsPerPage, int page) + */ + Q_SLOT void loadComments(const EntryInternal &entry, int commentsPerPage, int page); + /** + * The slot which causes loading of a person's details + * @see Provider::loadPerson(const QString &username) + */ + Q_SLOT void loadPerson(const QString &username); bool userCanVote() override { @@ -88,6 +98,8 @@ void votingFinished(Attica::BaseJob *); void becomeFanFinished(Attica::BaseJob *job); void detailsLoaded(Attica::BaseJob *job); + void loadedComments(Attica::BaseJob *job); + void loadedPerson(Attica::BaseJob *job); private: void checkForUpdates(); diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,5 +1,6 @@ set(KNewStuffCore_SRCS author.cpp + commentsmodel.cpp cache.cpp downloadmanager.cpp engine.cpp diff --git a/src/core/author.h b/src/core/author.h --- a/src/core/author.h +++ b/src/core/author.h @@ -20,6 +20,7 @@ #define KNEWSTUFF3_AUTHOR_P_H #include +#include #include "knewstuffcore_export.h" @@ -39,6 +40,23 @@ class KNEWSTUFFCORE_EXPORT Author { public: + explicit Author(); + Author(const Author &other); + Author& operator=(const Author &other) = default; + Author& operator=(Author&&) = default; + ~Author(); + + /** + * Sets the user ID of the author. + */ + void setId(const QString& id); + + /** + * Retrieve the author's user ID + * @return the author's user ID + */ + QString id() const; + /** * Sets the full name of the author. */ @@ -87,6 +105,40 @@ */ QString homepage() const; + /** + * Sets the profile page of the author, usually located on the server hosting the content. + */ + void setProfilepage(const QString &profilepage); + + /** + * Retrieve the author's profile page. + * + * @return author profile page + */ + QString profilepage() const; + + /** + * Sets the url for the user's avatar image + */ + void setAvatarUrl(const QUrl &avatarUrl); + + /** + * Retrieve the url of the user's avatar image + * + * @return a url for the user's avatar (may be empty) + */ + QUrl avatarUrl() const; + + /** + * Retrieve the user's description text + * + * @return A long(ish)-form text describing this user, usually self-entered + */ + QString description() const; + /** + * Set the user's description + */ + void setDescription(const QString &description); private: QString mName; QString mEmail; diff --git a/src/core/author.cpp b/src/core/author.cpp --- a/src/core/author.cpp +++ b/src/core/author.cpp @@ -19,8 +19,72 @@ #include "author.h" +#include + +// BCI: Add a real d-pointer +namespace KNSCore { +struct AuthorPrivate { +public: + QString id; + QString profilepage; + QUrl avatarUrl; + QString description; +}; +} + using namespace KNSCore; +typedef QHash AuthorPrivateHash; +Q_GLOBAL_STATIC(AuthorPrivateHash, d_func) + +static AuthorPrivate *d(const Author *author) +{ + AuthorPrivate *ret = d_func()->value(author); + if (!ret) { + ret = new AuthorPrivate; + d_func()->insert(author, ret); + } + return ret; +} + +static void delete_d(const Author *author) +{ + if (auto d = d_func()) { + delete d->take(author); + } +} + +Author::Author() +{ +} + +KNSCore::Author::Author(const KNSCore::Author& other) +{ + this->setAvatarUrl(other.avatarUrl()); + this->setDescription(other.description()); + this->setEmail(other.email()); + this->setHomepage(other.homepage()); + this->setId(other.id()); + this->setJabber(other.jabber()); + this->setName(other.name()); + this->setProfilepage(other.profilepage()); +} + +Author::~Author() +{ + delete_d(this); +} + +void KNSCore::Author::setId(const QString& id) +{ + d(this)->id = id; +} + +QString KNSCore::Author::id() const +{ + return d(this)->id; +} + void Author::setName(const QString &_name) { mName = _name; @@ -61,3 +125,32 @@ return mHomepage; } +void Author::setProfilepage(const QString &profilepage) +{ + d(this)->profilepage = profilepage; +} + +QString Author::profilepage() const +{ + return d(this)->profilepage; +} + +void Author::setAvatarUrl(const QUrl& avatarUrl) +{ + d(this)->avatarUrl = avatarUrl; +} + +QUrl Author::avatarUrl() const +{ + return d(this)->avatarUrl; +} + +void Author::setDescription(const QString& description) +{ + d(this)->description = description; +} + +QString Author::description() const +{ + return d(this)->description; +} diff --git a/src/core/commentsmodel.h b/src/core/commentsmodel.h new file mode 100644 --- /dev/null +++ b/src/core/commentsmodel.h @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#ifndef KNSCORE_COMMENTSMODEL_H +#define KNSCORE_COMMENTSMODEL_H + +#include +#include + +#include "engine.h" + +#include "knewstuffcore_export.h" + +namespace KNSCore +{ +class EntryInternal; + +struct Comment { + QString id; + QString subject; + QString text; + int childCount = 0; + QString username; + QDateTime date; + int score = 0; + std::shared_ptr parent; +}; + +/** + * @brief A model which takes care of the comments for a single EntryInternal + * + * This model should preferably be constructed by asking the Engine to give a model + * instance to you for a specific entry using the commentsForEntry function. If you + * insist, you can construct an instance yourself as well, but this is not recommended. + * + * @see Engine::commentsForEntry(KNSCore::EntryInternal) + * @since 5.61 + */ +class KNEWSTUFFCORE_EXPORT CommentsModel : public QAbstractListModel +{ + Q_OBJECT + /** + * The Entry for which this model should handle comments + */ + Q_PROPERTY(KNSCore::EntryInternal entry READ entry WRITE setEntry NOTIFY entryChanged) +public: + /** + * Construct a new CommentsModel instance. + * @note The class is intended to be constructed using the Engine::commentsForEntry function + * @see Engine::commentsForEntry(KNSCore::EntryInternal) + */ + explicit CommentsModel(Engine *parent = nullptr); + virtual ~CommentsModel(); + + enum Roles { + IdRole = Qt::UserRole + 1, + SubjectRole, + TextRole, + ChildCountRole, + UsernameRole, + DateRole, + ScoreRole, + ParentIndexRole, + DepthRole + }; + Q_ENUM(Roles) + + QHash< int, QByteArray > roleNames() const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + Q_INVOKABLE QVariant data(int index, int role = Qt::DisplayRole) const; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + bool canFetchMore(const QModelIndex & parent) const override; + void fetchMore(const QModelIndex & parent) override; + + const KNSCore::EntryInternal &entry() const; + void setEntry(const KNSCore::EntryInternal &newEntry); + Q_SIGNAL void entryChanged(); + +private: + class Private; + Private* d; +}; +} + +#endif//KNSCORE_COMMENTSMODEL_H diff --git a/src/core/commentsmodel.cpp b/src/core/commentsmodel.cpp new file mode 100644 --- /dev/null +++ b/src/core/commentsmodel.cpp @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#include "commentsmodel.h" + +#include "entryinternal.h" +#include "engine.h" +#include "knewstuffcore_debug.h" + +#include + +#include +#include + +namespace KNSCore { +class CommentsModel::Private { +public: + Private(CommentsModel *qq) + : q(qq) + {} + CommentsModel* q = nullptr; + Engine* engine = nullptr; + + EntryInternal entry; + + QList> comments; + + enum FetchOptions { + NoOption, + ClearModel + }; + bool fetchThrottle = false; + void fetch(FetchOptions option = NoOption) { + if (fetchThrottle) { + return; + } + fetchThrottle = true; + QTimer::singleShot(1, q, [this](){ + fetchThrottle = false; + }); + // Sanity checks, because we need a few things to be correct before we can actually fetch comments... + if (!engine) { + qCWarning(KNEWSTUFFCORE) << "CommentsModel must be parented on a KNSCore::Engine instance to be able to fetch comments"; + } + if (!entry.isValid()) { + qCWarning(KNEWSTUFFCORE) << "Without an entry to fetch comments for, CommentsModel cannot fetch comments for it"; + } + + if (engine && entry.isValid()) { + QSharedPointer provider = engine->provider(entry.providerId()); + if (option == ClearModel) { + emit q->beginResetModel(); + comments.clear(); + provider->disconnect(q); + q->connect(provider.data(), &Provider::commentsLoaded, q, [=](const QList> &newComments){ + QList> actualNewComments; + for (std::shared_ptr comment : newComments) { + bool commentIsKnown = false; + for (std::shared_ptr existingComment : comments) { + if (existingComment->id == comment->id) { + commentIsKnown = true; + break; + } + } + if (commentIsKnown) { + continue; + } + actualNewComments << comment; + } + if (actualNewComments.count() > 0) { + q->beginInsertRows(QModelIndex(), comments.count(), comments.count() + actualNewComments.count() - 1); + qDebug() << "Appending" << actualNewComments.count() << "new comments"; + comments.append(actualNewComments); + q->endInsertRows(); + } + }); + emit q->endResetModel(); + } + int commentsPerPage = 100; + int pageToLoad = comments.count() / commentsPerPage; + qDebug() << "Loading comments, page" << pageToLoad << "with current comment count" << comments.count() << "out of a total of" << entry.numberOfComments(); + provider->loadComments(entry, commentsPerPage, pageToLoad); + } + } +}; +} + +KNSCore::CommentsModel::CommentsModel(Engine* parent) + : QAbstractListModel(parent) + , d(new Private(this)) +{ + d->engine = parent; +} + +KNSCore::CommentsModel::~CommentsModel() +{ + delete d; +} + +QHash KNSCore::CommentsModel::roleNames() const +{ + QHash roles; + roles[IdRole] = "id"; + roles[SubjectRole] = "subject"; + roles[TextRole] = "text"; + roles[ChildCountRole] = "childCound"; + roles[UsernameRole] = "username"; + roles[DateRole] = "date"; + roles[ScoreRole] = "score"; + roles[ParentIndexRole] = "parentIndex"; + roles[DepthRole] = "depth"; + return roles; +} + +QVariant KNSCore::CommentsModel::data(const QModelIndex& index, int role) const +{ + QVariant value; + if (index.isValid() && index.row() < d->comments.count()) { + std::shared_ptr comment = d->comments[index.row()]; + switch (role) + { + case IdRole: + value.setValue(comment->id); + break; + case SubjectRole: + value.setValue(comment->subject); + break; + case TextRole: + value.setValue(comment->text); + break; + case ChildCountRole: + value.setValue(comment->childCount); + break; + case UsernameRole: + value.setValue(comment->username); + break; + case DateRole: + value.setValue(comment->date); + break; + case ScoreRole: + value.setValue(comment->score); + break; + case ParentIndexRole: + { + int idx{-1}; + if (comment->parent) { + d->comments.indexOf(std::shared_ptr(comment->parent)); + } + value.setValue(idx); + } + break; + case DepthRole: + { + int depth{0}; + if (comment->parent) { + std::shared_ptr child = comment->parent; + while (child) { + ++depth; + if (child->parent) { + child = child->parent; + } + else { + break; + } + } + } + value.setValue(depth); + } + break; + default: + value.setValue(i18nc("The value returned for an unknown role when requesting data from the model.", "")); + break; + } + } + return value; +} + +QVariant KNSCore::CommentsModel::data(int index, int role) const +{ + return data(CommentsModel::index(index), role); +} + +int KNSCore::CommentsModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + return d->comments.count(); +} + +bool KNSCore::CommentsModel::canFetchMore(const QModelIndex& parent) const +{ + if (parent.isValid()) + return false; + if (d->entry.numberOfComments() > d->comments.count()) + return true; + return false; +} + +void KNSCore::CommentsModel::fetchMore(const QModelIndex& parent) +{ + if(parent.isValid()) + return; + d->fetch(); +} + +const KNSCore::EntryInternal & KNSCore::CommentsModel::entry() const +{ + return d->entry; +} + +void KNSCore::CommentsModel::setEntry(const KNSCore::EntryInternal& newEntry) +{ + d->entry = newEntry; + d->fetch(Private::ClearModel); + emit entryChanged(); +} diff --git a/src/core/engine.h b/src/core/engine.h --- a/src/core/engine.h +++ b/src/core/engine.h @@ -58,6 +58,7 @@ namespace KNSCore { class Cache; +class CommentsModel; class Installation; /** @@ -91,6 +92,13 @@ */ bool init(const QString &configfile); + /** + * The name as defined by the knsrc file + * @return The name associated with the engine's configuration file + * @since 5.61 + */ + QString name() const; + /** * Installs an entry's payload file. This includes verification, if * necessary, as well as decompression and other steps according to the @@ -142,6 +150,12 @@ * @param mode The order you want search results to come back in. */ void setSortMode(Provider::SortMode mode); + /** + * The sort mode set on the current request + * @see setFilter(Provider::SortMode) + * @since 5.61 + */ + Provider::SortMode sortMode() const; /** * Set a filter for results (defaults to none), which will allow you * to show only installed entries, installed entries which have updates, @@ -156,6 +170,12 @@ * @param filter The type of results you wish to see */ void setFilter(Provider::Filter filter); + /** + * The result filter set on the current request + * @see setFilter(Provider::Filter) + * @since 5.61 + */ + Provider::Filter filter() const; /** * Set the categories that will be included in searches @@ -176,6 +196,12 @@ * @param searchString The search term you wish to search for */ void setSearchTerm(const QString &searchString); + /** + * The search term for the current search (empty if none is set) + * @return The current search term + * @since 5.61 + */ + QString searchTerm() const; void reloadEntries(); void requestMoreData(); void requestData(int page, int pageSize); @@ -423,6 +449,27 @@ */ void setConfigLocationFallback(bool enableFallback); + /** + * The Provider instance with the passed ID + * + * @param providerId The ID of the Provider to fetch + * @return The Provider with the passed ID, or null if non such Provider exists + * @since 5.61 + */ + QSharedPointer provider(const QString &providerId) const; + + /** + * This function will return an instance of a model which contains comments for + * the entry passed to it. The model may be empty (if there are no comments for + * the entry, which also covers situations where the entry's provider does not + * support commenting) + * + * @param entry The entry to fetch comments for + * @return A model which contains the comments for the specified entry + * @since 5.61 + */ + CommentsModel* commentsForEntry(const KNSCore::EntryInternal &entry); + Q_SIGNALS: /** * Indicates a message to be added to the ui's log, or sent to a messagebox diff --git a/src/core/engine.cpp b/src/core/engine.cpp --- a/src/core/engine.cpp +++ b/src/core/engine.cpp @@ -22,6 +22,7 @@ #include "engine.h" #include "../entry.h" +#include "commentsmodel.h" #include "installation.h" #include "xmlloader.h" #include "imageloader_p.h" @@ -64,6 +65,7 @@ QStringList tagFilter; QStringList downloadTagFilter; bool configLocationFallback = true; + QString name; }; Engine::Engine(QObject *parent) @@ -142,6 +144,7 @@ return false; } + d->name = group.readEntry("Name", QString()); m_categories = group.readEntry("Categories", QStringList()); m_adoptionCommand = group.readEntry("AdoptionCommand", QString()); @@ -174,6 +177,11 @@ return true; } +QString KNSCore::Engine::name() const +{ + return d->name; +} + QStringList Engine::categories() const { return m_categories; @@ -392,6 +400,11 @@ reloadEntries(); } +Provider::SortMode KNSCore::Engine::sortMode() const +{ + return m_currentRequest.sortMode; +} + void KNSCore::Engine::setFilter(Provider::Filter filter) { if (m_currentRequest.filter != filter) { @@ -401,6 +414,11 @@ reloadEntries(); } +Provider::Filter KNSCore::Engine::filter() const +{ + return m_currentRequest.filter; +} + void KNSCore::Engine::fetchEntryById(const QString& id) { m_searchTimer->stop(); @@ -427,6 +445,11 @@ } } +QString KNSCore::Engine::searchTerm() const +{ + return m_currentRequest.searchTerm; +} + void Engine::setTagFilter(const QStringList &filter) { d->tagFilter = filter; @@ -760,3 +783,17 @@ { d->configLocationFallback = enableFallback; } + +QSharedPointer KNSCore::Engine::provider(const QString& providerId) const +{ + return m_providers.value(providerId); +} + +KNSCore::CommentsModel * KNSCore::Engine::commentsForEntry(const KNSCore::EntryInternal& entry) +{ + CommentsModel* model = new CommentsModel(this); + model->setEntry(entry); + // Cache the models mebby? + // Track onDestroyed and remove from cache... + return model; +} diff --git a/src/core/entryinternal.cpp b/src/core/entryinternal.cpp --- a/src/core/entryinternal.cpp +++ b/src/core/entryinternal.cpp @@ -319,7 +319,7 @@ int EntryInternal::numberOfComments() const { - return d->mRating; + return d->mNumberOfComments; } void EntryInternal::setNumberOfComments (int comments) diff --git a/src/core/installation.cpp b/src/core/installation.cpp --- a/src/core/installation.cpp +++ b/src/core/installation.cpp @@ -535,10 +535,10 @@ if (QFile::exists(installpath) && QDir::tempPath() != installdir) { if (!update) { - Question question(Question::ContinueCancelQuestion); - question.setQuestion(i18n("Overwrite existing file?") + QStringLiteral("\n'") + installpath + QLatin1Char('\'')); - question.setTitle(i18n("Download File")); - if(question.ask() != Question::ContinueResponse) { + Question question(Question::YesNoQuestion); + question.setQuestion(i18n("This file already exists on disk (possibly due to an earlier failed download attempt). Continuing means overwriting it. Do you wish to overwrite the existing file?") + QStringLiteral("\n'") + installpath + QLatin1Char('\'')); + question.setTitle(i18n("Overwrite File")); + if(question.ask() != Question::YesResponse) { return QStringList(); } } diff --git a/src/core/itemsmodel.h b/src/core/itemsmodel.h --- a/src/core/itemsmodel.h +++ b/src/core/itemsmodel.h @@ -40,6 +40,12 @@ int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + /** + * The row of the entry passed to the function, or -1 if the entry is not contained + * within the model. + * @since 5.61 + */ + int row(const EntryInternal& entry) const; void addEntry(const EntryInternal &entry); void removeEntry(const EntryInternal &entry); diff --git a/src/core/itemsmodel.cpp b/src/core/itemsmodel.cpp --- a/src/core/itemsmodel.cpp +++ b/src/core/itemsmodel.cpp @@ -52,6 +52,11 @@ return QVariant::fromValue(entry); } +int ItemsModel::row(const EntryInternal& entry) const +{ + return m_entries.indexOf(entry); +} + void ItemsModel::slotEntriesLoaded(const KNSCore::EntryInternal::List& entries) { for (const KNSCore::EntryInternal &entry : entries) { diff --git a/src/core/provider.h b/src/core/provider.h --- a/src/core/provider.h +++ b/src/core/provider.h @@ -24,6 +24,8 @@ #include #include +#include + #include "entryinternal.h" #include "errorcode.h" @@ -33,6 +35,7 @@ namespace KNSCore { +struct Comment; /** * @short KNewStuff Base Provider class. * @@ -144,6 +147,29 @@ virtual void loadEntries(const KNSCore::Provider::SearchRequest &request) = 0; virtual void loadEntryDetails(const KNSCore::EntryInternal &) {} virtual void loadPayloadLink(const EntryInternal &entry, int linkId) = 0; + /** + * Request a loading of comments from this provider. The engine listens to the + * commentsLoaded() signal for the result + * + * @note Implementation detail: All subclasses should connect to this signal + * and point it at a slot which does the actual work, if they support comments. + * + * TODO: KF6 This should be a virtual function, but can't do it now because BIC + * @see commentsLoaded(const QList> comments) + * @since 5.61 + */ + Q_SIGNAL void loadComments(const EntryInternal &entry, int commentsPerPage, int page); + /** + * Request loading of the details for a specific person with the given username. + * The engine listens to the personLoaded() for the result + * + * @note Implementation detail: All subclasses should connect to this signal + * and point it at a slot which does the actual work, if they support comments. + * + * TODO: KF6 This should be a virtual function, but can't do it now because BIC + * @since 5.61 + */ + Q_SIGNAL void loadPerson(const QString& username); virtual bool userCanVote() { @@ -200,6 +226,18 @@ void entryDetailsLoaded(const KNSCore::EntryInternal &); void payloadLinkLoaded(const KNSCore::EntryInternal &); + /** + * Fired when new comments have been loaded + * @param comments The list of newly loaded comments, in a depth-first order + * @since 5.61 + */ + void commentsLoaded(const QList> comments); + /** + * Fired when the details of a person have been loaded + * @param author The person we've just loaded data for + * @since 5.61 + */ + void personLoaded(const std::shared_ptr author); void signalInformation(const QString &) const; void signalError(const QString &) const; diff --git a/src/core/question.h b/src/core/question.h --- a/src/core/question.h +++ b/src/core/question.h @@ -62,14 +62,16 @@ CancelResponse = 4, OKResponse = YesResponse }; + Q_ENUM(Response) enum QuestionType { YesNoQuestion = 0, ContinueCancelQuestion = 1, InputTextQuestion = 2, SelectFromListQuestion = 3, PasswordQuestion = 4 }; + Q_ENUM(QuestionType) explicit Question(QuestionType = YesNoQuestion, QObject* parent = nullptr); virtual ~Question(); diff --git a/src/qtquick/CMakeLists.txt b/src/qtquick/CMakeLists.txt --- a/src/qtquick/CMakeLists.txt +++ b/src/qtquick/CMakeLists.txt @@ -3,16 +3,23 @@ quickengine.cpp quickitemsmodel.cpp + quickquestionlistener.cpp + author.cpp + categoriesmodel.cpp downloadlinkinfo.cpp ) +ecm_qt_declare_logging_category(qmlplugin_SRCS HEADER knewstuffquick_debug.h IDENTIFIER KNEWSTUFFQUICK CATEGORY_NAME org.kde.knewstuff.quick) + add_library (newstuffqmlplugin ${qmlplugin_SRCS}) target_link_libraries (newstuffqmlplugin Qt5::Core Qt5::Qml Qt5::Quick Qt5::Xml + KF5::ConfigCore + KF5::I18n KF5::NewStuffCore ) diff --git a/src/qtquick/author.h b/src/qtquick/author.h new file mode 100644 --- /dev/null +++ b/src/qtquick/author.h @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#ifndef KNSQUICK_AUTHOR_H +#define KNSQUICK_AUTHOR_H + +#include +#include + +namespace KNewStuffQuick +{ +/** + * @short Encapsulates a KNSCore::Author for use in Qt Quick + * + * This class takes care of initialisation of a KNSCore::Author when assigned an engine, provider ID and username. + * If the data is not yet cached, it will be requested from the provider, and updated for display + */ +class Author : public QObject +{ + Q_OBJECT + /** + * The NewStuffQuickEngine to interact with servers through + */ + Q_PROPERTY(QObject* engine READ engine WRITE setEngine NOTIFY engineChanged) + /** + * The ID of the provider which the user is registered on + */ + Q_PROPERTY(QString providerId READ providerId WRITE setProviderId NOTIFY providerIdChanged) + /** + * The user ID for the user this object represents + */ + Q_PROPERTY(QString username READ username WRITE setUsername NOTIFY usernameChanged) + + Q_PROPERTY(QString name READ name NOTIFY dataChanged) + Q_PROPERTY(QString description READ description NOTIFY dataChanged) + Q_PROPERTY(QString homepage READ homepage NOTIFY dataChanged) + Q_PROPERTY(QString profilepage READ profilepage NOTIFY dataChanged) + Q_PROPERTY(QUrl avatarUrl READ avatarUrl NOTIFY dataChanged) +public: + explicit Author(QObject *parent = nullptr); + virtual ~Author(); + + QObject* engine() const; + void setEngine(QObject* newEngine); + Q_SIGNAL void engineChanged(); + + QString providerId() const; + void setProviderId(const QString &providerId); + Q_SIGNAL void providerIdChanged(); + + QString username() const; + void setUsername(const QString &username); + Q_SIGNAL void usernameChanged(); + + QString name() const; + QString description() const; + QString homepage() const; + QString profilepage() const; + QUrl avatarUrl() const; + Q_SIGNAL void dataChanged(); + +private: + class Private; + Private* d; +}; +} + +#endif//KNSQUICK_AUTHOR_H diff --git a/src/qtquick/author.cpp b/src/qtquick/author.cpp new file mode 100644 --- /dev/null +++ b/src/qtquick/author.cpp @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#include "author.h" + +#include "quickengine.h" + +#include "core/author.h" +#include "core/engine.h" +#include "core/provider.h" + +#include "knewstuffquick_debug.h" + +#include + +namespace KNewStuffQuick { + +typedef QHash> AllAuthorsHash; +Q_GLOBAL_STATIC(AllAuthorsHash, allAuthors) + +class Author::Private { +public: + Private(Author* qq) + : q(qq) + {} + Author* q; + Engine* engine{nullptr}; + QString providerId; + QString username; + + QSharedPointer provider; + void resetConnections() { + if (provider) { + provider->disconnect(q); + } + if (engine && engine->engine()) { + KNSCore::Engine* coreEngine = qobject_cast(engine->engine()); + if (coreEngine) { + provider = coreEngine->provider(providerId); + } + } + if (provider) { + connect(provider.get(), &KNSCore::Provider::personLoaded, q, [=](const std::shared_ptr< KNSCore::Author > author){ + allAuthors()->insert(q->d->username, author); + emit q->dataChanged(); + }); + author(); // Check and make sure... + } + } + + std::shared_ptr author() + { + std::shared_ptr ret; + if (provider && !username.isEmpty()) { + ret = allAuthors()->value(username); + if(!ret.get()) { + provider->loadPerson(username); + } + } + return ret; + } +}; +} + +using namespace KNewStuffQuick; + +Author::Author(QObject* parent) + : QObject(parent) + , d(new Private(this)) +{ +} + +Author::~Author() +{ + delete d; +} + +QObject * Author::engine() const +{ + return d->engine; +} + +void Author::setEngine(QObject* newEngine) +{ + d->engine = qobject_cast(newEngine); + d->resetConnections(); + emit engineChanged(); +} + +QString Author::providerId() const +{ + return d->providerId; +} + +void Author::setProviderId(const QString& providerId) +{ + d->providerId = providerId; + d->resetConnections(); + emit providerIdChanged(); +} + +QString Author::username() const +{ + return d->username; +} + +void Author::setUsername(const QString& username) +{ + d->username = username; + d->resetConnections(); + emit usernameChanged(); +} + +QString Author::name() const +{ + std::shared_ptr author = d->author(); + if (author.get() && !author->name().isEmpty()) { + return author->name(); + } + return d->username; +} + +QString Author::description() const +{ + std::shared_ptr author = d->author(); + if (author.get()) { + return author->description(); + } + return QString{}; +} + +QString Author::homepage() const +{ + std::shared_ptr author = d->author(); + if (author.get()) { + return author->homepage(); + } + return QString{}; +} + +QString Author::profilepage() const +{ + std::shared_ptr author = d->author(); + if (author.get()) { + return author->profilepage(); + } + return QString{}; +} + +QUrl Author::avatarUrl() const +{ + std::shared_ptr author = d->author(); + if (author.get()) { + return author->avatarUrl(); + } + return QUrl{}; +} diff --git a/src/qtquick/qmlplugin.cpp b/src/qtquick/categoriesmodel.h copy from src/qtquick/qmlplugin.cpp copy to src/qtquick/categoriesmodel.h --- a/src/qtquick/qmlplugin.cpp +++ b/src/qtquick/categoriesmodel.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Dan Leinir Turthra Jensen + * Copyright (C) 2019 Dan Leinir Turthra Jensen * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -19,23 +19,40 @@ * */ -#include "qmlplugin.h" +#ifndef CATEGORIESMODEL_H +#define CATEGORIESMODEL_H -#include "quickengine.h" -#include "quickitemsmodel.h" -#include "downloadlinkinfo.h" +#include -#include -#include +#include "provider.h" +#include "quickengine.h" -void QmlPlugins::initializeEngine(QQmlEngine *engine, const char *) +/** + * @short A model which shows the categories found in an Engine + * @since 5.61 + */ +class CategoriesModel : public QAbstractListModel { - Q_UNUSED(engine); -} + Q_OBJECT +public: + explicit CategoriesModel(Engine* parent = nullptr); + virtual ~CategoriesModel(); -void QmlPlugins::registerTypes(const char *uri) -{ - qmlRegisterType(uri, 1, 0, "Engine"); - qmlRegisterType(uri, 1, 0, "ItemsModel"); - qmlRegisterUncreatableType(uri, 1, 0, "DownloadLinkInfo", QStringLiteral("This should only be created by the ItemsModel, and is associated with one entry in that model")); -} + enum Roles { + NameRole = Qt::UserRole + 1, + IdRole, + DisplayNameRole + }; + Q_ENUMS(Roles) + + QHash< int, QByteArray > roleNames() const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + Q_INVOKABLE QVariant data(int index, int role = Qt::DisplayRole) const; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + +private: + class Private; + Private* d; +}; + +#endif//CATEGORIESMODEL_H diff --git a/src/qtquick/categoriesmodel.cpp b/src/qtquick/categoriesmodel.cpp new file mode 100644 --- /dev/null +++ b/src/qtquick/categoriesmodel.cpp @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#include "categoriesmodel.h" + +#include "engine.h" + +#include + +class CategoriesModel::Private { +public: + Private() {} + KNSCore::Engine* engine; +}; + +CategoriesModel::CategoriesModel(Engine* parent) + : QAbstractListModel(parent) + , d(new Private) +{ + d->engine = qobject_cast(parent->engine()); + connect(d->engine, &KNSCore::Engine::signalCategoriesMetadataLoded, this, [this](){ beginResetModel(); endResetModel(); }); +} + +CategoriesModel::~CategoriesModel() +{ + delete d; +} + +QHash CategoriesModel::roleNames() const +{ + QHash roles; + roles[NameRole] = "name"; + roles[IdRole] = "id"; + roles[DisplayNameRole] = "displayName"; + return roles; +} + +int CategoriesModel::rowCount(const QModelIndex& parent) const +{ + if(parent.isValid()) { + return 0; + } + return d->engine->categoriesMetadata().count() + 1; +} + +QVariant CategoriesModel::data(const QModelIndex& index, int role) const +{ + QVariant result; + const QList categoriesMetadata = d->engine->categoriesMetadata(); + if (index.isValid()) { + if (index.row() == 0) { + switch (role) { + case NameRole: + result.setValue(QString::fromLatin1("")); + break; + case IdRole: + result.setValue(0); + break; + case DisplayNameRole: + result.setValue(i18nc("The first entry in the category selection list (also the default value)", "Show All Categories")); + break; + default: + result.setValue(QString::fromLatin1("Unknown role")); + break; + } + } else if (index.row() <= categoriesMetadata.count()) { + const KNSCore::Provider::CategoryMetadata category = categoriesMetadata[index.row() - 1]; + switch (role) { + case NameRole: + result.setValue(category.name); + break; + case IdRole: + result.setValue(category.id); + break; + case DisplayNameRole: + result.setValue(category.displayName); + break; + default: + result.setValue(QString::fromLatin1("Unknown role")); + break; + } + } + } + return result; +} + +QVariant CategoriesModel::data(int index, int role) const +{ + return data(CategoriesModel::index(index), role); +} diff --git a/src/qtquick/qml/ConditionalLoader.qml b/src/qtquick/qml/ConditionalLoader.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/ConditionalLoader.qml @@ -0,0 +1,35 @@ +/*************************************************************************** + * Copyright © 2015 Aleix Pol Gonzalez * + * * + * This program is free software; you can redistribute it and/or * + * modify it under the terms of the GNU General Public License as * + * published by the Free Software Foundation; either version 2 of * + * the License or (at your option) version 3 or any later version * + * accepted by the membership of KDE e.V. (or its successor approved * + * by the membership of KDE e.V.), which shall act as a proxy * + * defined in Section 14 of version 3 of the license. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + ***************************************************************************/ + +import QtQuick 2.1 +import QtQuick.Layouts 1.1 + +Loader +{ + id: root + + property Component componentTrue + property Component componentFalse + property bool condition + + Layout.minimumHeight: item && item.Layout ? item.Layout.minimumHeight : 0 + Layout.minimumWidth: item && item.Layout ? item.Layout.minimumWidth : 0 + sourceComponent: condition ? componentTrue : componentFalse +} diff --git a/src/qtquick/qml/EntryCommentDelegate.qml b/src/qtquick/qml/EntryCommentDelegate.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/EntryCommentDelegate.qml @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +/** + * @brief A card based delegate for showing a comment from a KNewStuffQuick::QuickCommentsModel + */ + +import QtQuick 2.7 + +import org.kde.kirigami 2.7 as Kirigami +import QtQuick.Controls 2.5 as QtControls +import QtQuick.Layouts 1.12 as QtLayouts +import org.kde.newstuff 1.1 as NewStuff + +Kirigami.AbstractCard { + id: component + + /** + * The KNSQuick Engine object which handles all our content + */ + property QtObject engine + + /** + * The username of the author of whatever the comment is attached to + */ + property string entryAuthorId + /** + * The provider ID as supplied by the entry the comment is attached to + */ + property string entryProviderId + + /** + * The username of the comment's author + */ + property string author + /** + * The OCS score, an integer from 1 to 100. It will be interpreted + * as a 5 star rating, with half star support (0-10) + */ + property int score + /** + * The title or subject line for the comment + */ + property string title + /** + * The actual text of the comment + */ + property alias reviewText: reviewLabel.text + /** + * The depth of the comment (in essence, how many parents the comment has) + */ + property int depth + + property QtObject commentAuthor: NewStuff.Author { + engine: component.engine + providerId: component.entryProviderId + username: component.author + } + + anchors { + left: parent.left + right: parent.right + margins: Kirigami.Units.largeSpacing + leftMargin: Kirigami.Units.largeSpacing + Kirigami.Units.largeSpacing * component.depth + } + + header: (component.title === "" && component.score === 0) ? null : headerLayout + property Item headerLayout: QtLayouts.RowLayout { + Kirigami.Heading { + id: titleLabel + text: ((component.title === "") ? i18nc("Placeholder title for when a comment has no subject, but does have a rating", "(no title)") : component.title) + level: 4 + QtLayouts.Layout.fillWidth: true + } + Rating { + id: ratingStars + rating: Math.floor(component.score / 10) + } + } + + contentItem: QtControls.Label { + id: reviewLabel + anchors { + left: parent.left; + right: parent.right + } + wrapMode: Text.Wrap + } + + footer: QtLayouts.RowLayout { + Item { + QtLayouts.Layout.fillWidth: true + } + Kirigami.Icon { + id: authorIcon + QtLayouts.Layout.maximumWidth: height + QtLayouts.Layout.minimumWidth: height + QtLayouts.Layout.preferredHeight: Kirigami.Units.iconSizes.medium + source: (component.commentAuthor.avatarUrl && component.commentAuthor.avatarUrl.toString()) ? component.commentAuthor.avatarUrl : "user" + } + Kirigami.UrlButton { + id: authorLabel + visible: (url !== "") + url: (component.commentAuthor.homepage === "") ? component.commentAuthor.profilepage : component.commentAuthor.homepage + text: (component.author === component.entryAuthorId) ? i18nc("The author label in case the comment was written by the author of the content entry the comment is attached to", "%1 (author)").arg(component.commentAuthor.name) : component.commentAuthor.name + } + QtControls.Label { + visible: !authorLabel.visible + text: authorLabel.text + } + } +} diff --git a/src/qtquick/qml/EntryCommentsPage.qml b/src/qtquick/qml/EntryCommentsPage.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/EntryCommentsPage.qml @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +/** + * @brief A Kirigami.Page component used for displaying a NewStuff entry's comments + */ + +import QtQuick 2.7 + +import org.kde.kirigami 2.7 as Kirigami +import QtQuick.Controls 2.5 as QtControls +import QtQuick.Layouts 1.12 as QtLayouts + +Kirigami.ScrollablePage { + id: component + property string entryName + property string entryAuthorId + property string entryProviderId + property alias commentsModel: commentsView.model + property QtObject engine + title: i18nc("Title for the page containing a view of the comments for the entry", "Comments and Reviews for %1").arg(component.entryName) + ListView { + id: commentsView + QtLayouts.Layout.fillWidth: true + spacing: Kirigami.Units.largeSpacing * 2 + header: Item { + anchors { + left: parent.left + right: parent.right + } + height: Kirigami.Units.largeSpacing + } + delegate: EntryCommentDelegate { + engine: component.engine + entryAuthorId: component.entryAuthorId + entryProviderId: component.entryProviderId + author: model.username + score: 60//model.score + title: model.subject + reviewText: model.text + depth: model.depth + } + } +} diff --git a/src/qtquick/qml/EntryScreenshots.qml b/src/qtquick/qml/EntryScreenshots.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/EntryScreenshots.qml @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2012 Aleix Pol Gonzalez + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Library/Lesser General Public License + * version 2, or (at your option) any later version, as published by the + * Free Software Foundation + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details + * + * You should have received a copy of the GNU Library/Lesser General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import QtQuick 2.1 +import QtQuick.Layouts 1.1 +import QtQuick.Controls 2.1 +import QtGraphicalEffects 1.0 +import org.kde.kirigami 2.5 as Kirigami + +Flickable { + id: root + property alias screenshotsModel: screenshotsRep.model + readonly property alias count: screenshotsRep.count + property int currentIndex: -1 + property Item currentItem: screenshotsRep.itemAt(currentIndex) + Layout.preferredHeight: Kirigami.Units.gridUnit * 13 + contentHeight: height + contentWidth: screenshotsLayout.width + + Popup { + id: overlay + parent: applicationWindow().overlay + modal: true + clip: false + + x: (parent.width - width)/2 + y: (parent.height - height)/2 + readonly property real proportion: overlayImage.sourceSize.width>1 ? overlayImage.sourceSize.height/overlayImage.sourceSize.width : 1 + height: overlayImage.status == Image.Loading ? Kirigami.Units.gridUnit * 5 : Math.min(parent.height * 0.9, (parent.width * 0.9) * proportion, overlayImage.sourceSize.height) + width: height/proportion + + BusyIndicator { + id: indicator + visible: running + running: overlayImage.status == Image.Loading + anchors.fill: parent + } + + Image { + id: overlayImage + anchors.fill: parent + source: root.currentItem ? root.currentItem.imageSource : "" + fillMode: Image.PreserveAspectFit + smooth: true + } + + Button { + anchors { + right: parent.left + verticalCenter: parent.verticalCenter + } + visible: leftAction.visible + icon.name: leftAction.iconName + onClicked: leftAction.triggered(null) + } + + Button { + anchors { + left: parent.right + verticalCenter: parent.verticalCenter + } + visible: rightAction.visible + icon.name: rightAction.iconName + onClicked: rightAction.triggered(null) + } + + Kirigami.Action { + id: leftAction + icon.name: "arrow-left" + enabled: overlay.visible && visible + visible: root.currentIndex >= 1 && !indicator.running + onTriggered: root.currentIndex = (root.currentIndex - 1) % root.count + } + + Kirigami.Action { + id: rightAction + icon.name: "arrow-right" + enabled: overlay.visible && visible + visible: root.currentIndex < (root.count - 1) && !indicator.running + onTriggered: root.currentIndex = (root.currentIndex + 1) % root.count + } + } + + Row { + id: screenshotsLayout + height: root.contentHeight + spacing: Kirigami.Units.largeSpacing + focus: overlay.visible + + Keys.onLeftPressed: if (leftAction.visible) leftAction.trigger() + Keys.onRightPressed: if (rightAction.visible) rightAction.trigger() + + Repeater { + id: screenshotsRep + + delegate: MouseArea { + readonly property url imageSource: modelData + readonly property real proportion: thumbnail.sourceSize.width>1 ? thumbnail.sourceSize.height/thumbnail.sourceSize.width : 1 + width: Math.max(50, height/proportion) + height: screenshotsLayout.height + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + root.currentIndex = index + overlay.open() + } + + DropShadow { + source: thumbnail + anchors.fill: thumbnail + verticalOffset: Kirigami.Units.largeSpacing + horizontalOffset: 0 + radius: 12.0 + samples: 25 + color: Kirigami.Theme.disabledTextColor + cached: true + } + + BusyIndicator { + visible: running + running: thumbnail.status == Image.Loading + anchors.centerIn: parent + } + + Image { + id: thumbnail + source: modelData + height: parent.height + fillMode: Image.PreserveAspectFit + smooth: true + } + } + } + } + clip: true + readonly property var leftShadow: Shadow { + parent: root + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + edge: Qt.LeftEdge + width: Math.max(0, Math.min(root.width/5, root.contentX)) + } + + readonly property var rightShadow: Shadow { + parent: root + anchors { + right: parent.right + top: parent.top + bottom: parent.bottom + } + edge: Qt.RightEdge + width: Math.max(0, Math.min(root.contentWidth - root.contentX - root.width)/5) + } +} diff --git a/src/qtquick/qml/GridTileDelegate.qml b/src/qtquick/qml/GridTileDelegate.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/GridTileDelegate.qml @@ -0,0 +1,183 @@ +/* + Copyright (c) 2015 Marco Martin + Copyright (c) 2019 Dan Leinir Turthra Jensen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License version 2 as published by the Free Software Foundation. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +import QtQuick 2.8 +import QtQuick.Layouts 1.1 +import QtQuick.Controls 2.2 as Controls +import QtQuick.Templates 2.2 as T2 +import QtGraphicalEffects 1.0 + +import org.kde.kirigami 2.2 as Kirigami + +/** + * Base delegate for KControlmodules based on Grid views of thumbnails + * Use the onClicked signal handler for managing the main action when + * the user clicks on the tile, modified from the original GridDelegate + * from the KCM module + * @inherits QtQuick.Templates.ItemDelegate + */ +T2.ItemDelegate { + id: delegate + + /** + * toolTip: string + * string for a tooltip for the whole delegate + */ + property string toolTip + + /** + * tile: Item + * the item actually implementing the tile: the visualization is up to the implementation + */ + property alias tile: thumbnailArea.data + + /** + * thumbnailAvailable: bool + * Set it to true when a tile is actually available: when false, + * a default icon will be shown instead of the actual tile. + */ + property bool thumbnailAvailable: false + + /** + * actions: list + * A list of extra actions for the thumbnails. They will be shown as + * icons on the bottom-right corner of the tile on mouse over + */ + property list actions + + /** + * actionsAnchors: anchors + * The anchors of the actions listing + */ + property alias actionsAnchors: actionsScope.anchors + + width: GridView.view.cellWidth + height: GridView.view.cellHeight + hoverEnabled: true + + Rectangle { + id: tile + anchors.centerIn: parent + width: Kirigami.Settings.isMobile ? delegate.width - Kirigami.Units.gridUnit : Math.min(delegate.GridView.view.implicitCellWidth, delegate.width - Kirigami.Units.gridUnit) + height: Math.min(delegate.GridView.view.implicitCellHeight, delegate.height - Kirigami.Units.gridUnit) + radius: Kirigami.Units.smallSpacing + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.View + + color: { + if (delegate.GridView.isCurrentItem) { + return Kirigami.Theme.highlightColor; + } else if (parent.hovered) { + return Kirigami.Theme.highlightColor; + } else { + return Kirigami.Theme.backgroundColor; + } + } + Behavior on color { + ColorAnimation { + duration: Kirigami.Units.longDuration + easing.type: Easing.OutQuad + } + } + + Rectangle { + id: thumbnailArea + radius: Kirigami.Units.smallSpacing/2 + anchors { + fill: parent + margins: Kirigami.Units.smallSpacing + } + + color: Kirigami.Theme.backgroundColor + Kirigami.Icon { + visible: !delegate.thumbnailAvailable + anchors.centerIn: parent + width: Kirigami.Units.iconSizes.large + height: width + source: delegate.text === i18n("None") ? "edit-none" : "view-preview" + } + } + + Rectangle { + anchors.fill: thumbnailArea + visible: actionsColumn.children.length > 0 + opacity: Kirigami.Settings.isMobile || delegate.hovered || (actionsScope.focus) ? 1 : 0 + radius: Kirigami.Units.smallSpacing + color: Kirigami.Settings.isMobile ? "transparent" : Qt.rgba(1, 1, 1, 0.2) + + Behavior on opacity { + PropertyAnimation { + duration: Kirigami.Units.longDuration + easing.type: Easing.OutQuad + } + } + + FocusScope { + id: actionsScope + + anchors { + right: parent.right + rightMargin: Kirigami.Units.smallSpacing + top: parent.top + topMargin: Kirigami.Units.smallSpacing + } + width: actionsColumn.width + height: actionsColumn.height + + ColumnLayout { + id: actionsColumn + + Repeater { + model: delegate.actions + delegate: Controls.Button { + icon.name: modelData.iconName + text: modelData.text + activeFocusOnTab: focus || delegate.focus + onClicked: { + delegate.clicked() + modelData.trigger() + } + enabled: modelData.enabled + visible: modelData.visible + //NOTE: there aren't any global settings where to take "official" tooltip timeouts + Controls.ToolTip.delay: 1000 + Controls.ToolTip.timeout: 5000 + Controls.ToolTip.visible: (Kirigami.Settings.isMobile ? pressed : hovered) && modelData.tooltip.length > 0 + Controls.ToolTip.text: modelData.tooltip + } + } + } + } + } + // Bug 397367: explicitly using "delegate" as otherwise it crashes when switching between KCMs + layer.enabled: delegate.GraphicsInfo.api === GraphicsInfo.OpenGL + layer.effect: DropShadow { + horizontalOffset: 0 + verticalOffset: 2 + radius: 10 + samples: 32 + color: Qt.rgba(0, 0, 0, 0.3) + } + } + + Controls.ToolTip.delay: 1000 + Controls.ToolTip.timeout: 5000 + Controls.ToolTip.visible: hovered && delegate.toolTip.length > 0 + Controls.ToolTip.text: toolTip +} diff --git a/src/qtquick/qml/NewStuffButton.qml b/src/qtquick/qml/NewStuffButton.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/NewStuffButton.qml @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +/** + * @brief A button which when clicked will open a dialog with a NewStuffPage at the base + * + * This component is equivalent to the old Button + * @see KNewStuff::Button + */ + +import QtQuick 2.7 +import QtQuick.Controls 2.5 as QtControls + +QtControls.Button { + id: component + + /** + * The configuration file to use for this button + */ + property string configFile: ghnsDialog.configFile + + /** + * Set the text that should appear on the button. Will be set as + * i18n("Download New %1"). + * + * @note For the sake of consistency, you should NOT override the text propety, just set this one + */ + property string downloadNewWhat: i18nc("Used to contruct the button's label (which will become Download New 'this value')", "Stuff") + text: i18n("Download New %1").arg(downloadNewWhat) + + /** + * The default view mode of the dialog spawned by this button. This should be + * set using the NewStuffPage.ViewMode enum + * @see NewStuffPage.ViewMode + */ + property alias viewMode: ghnsDialog.viewMode + + /** + * emitted when the Hot New Stuff dialog is about to be shown, usually + * as a result of the user having click on the button + */ + signal aboutToShowDialog(); + + /** + * emitted when the dialog has been closed + */ + signal dialogFinished(var changedEntries); + + /** + * If this is true (default is false), the button will be shown when the Kiosk settings are such + * that Get Hot New Stuff is disallowed (and any other time enabled is set to false). + * Usually you would want to leave this alone, but occasionally you may have a reason to + * leave a button in place that the user is unable to enable. + */ + property bool visibleWhenDisabled: false + + /** + * Show the dialog (same as clicking the button), if allowed by the Kiosk settings + */ + function showDialog() { + if (ghnsDialog.engine.allowedByKiosk) { + ghnsDialog.engine.configFile = component.configFile + component.aboutToShowDialog(); + ghnsDialog.open(); + } else { + // make some noise, because silently doing nothing is a bit annoying + } + } + + onClicked: { showDialog(); } + + icon.name: "get-hot-new-stuff" + visible: enabled || visibleWhenDisabled + enabled: ghnsDialog.engine.allowedByKiosk + + NewStuffDialog { + id: ghnsDialog + onDialogFinished: { component.dialogFinished(changedEntries); } + } +} diff --git a/src/qtquick/qml/NewStuffDialog.qml b/src/qtquick/qml/NewStuffDialog.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/NewStuffDialog.qml @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +/** + * @brief A dialog which has a NewStuffPage at the base + * + * This component is equivalent to the old DownloadDialog, but you should consider + * using NewStuffPage instead for a more modern style of integration into your + * application's flow. + * @see KNewStuff::DownloadDialog + */ + +import QtQuick 2.7 +import QtQuick.Controls 2.5 as QtControls +import QtQuick.Dialogs 1.3 as QtDialogs +import QtQuick.Layouts 1.12 as QtLayouts + +QtDialogs.Dialog { + id: component + + /** + * The configuration file to use for this button + */ + property alias configFile: newStuffPage.configFile + + /** + * Set the text that should appear as the dialog's title. Will be set as + * i18n("Download New %1"). + * + * @default The name defined by your knsrc config file + * @note For the sake of consistency, you should NOT override the text propety, just set this one + */ + property string downloadNewWhat: engine.name + title: i18n("Download New %1").arg(component.downloadNewWhat) + + /** + * The engine which handles the content in this dialog + */ + property alias engine: newStuffPage.engine + + /** + * The default view mode of the dialog spawned by this button. This should be + * set using the NewStuffPage.ViewMode enum + * @see NewStuffPage.ViewMode + */ + property alias viewMode: newStuffPage.viewMode + + /** + * emitted when the Hot New Stuff dialog is about to be shown, usually + * as a result of the user having click on the button + */ + signal aboutToShowDialog(); + + /** + * emitted when the dialog has been closed + */ + signal dialogFinished(var changedEntries); + + contentItem: QtLayouts.ColumnLayout { + NewStuffDialogContent { + id: newStuffPage + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.fillHeight: true + downloadNewWhat: component.downloadNewWhat + } + QtControls.DialogButtonBox { + QtLayouts.Layout.fillWidth: true + QtControls.Button { + action: QtControls.Action { + text: i18n("Close") + shortcut: "esc" + onTriggered: { + newStuffPage.engine.resetChangedEntries(); + component.close(); + component.dialogFinished(newStuffPage.engine.changedEntries); + } + } + QtControls.DialogButtonBox.buttonRole: QtControls.DialogButtonBox.RejectRole // this is a close button, dialog close buttons have a rejection role... + } + } + } +} diff --git a/src/qtquick/qml/NewStuffDialogContent.qml b/src/qtquick/qml/NewStuffDialogContent.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/NewStuffDialogContent.qml @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +/** + * @brief The contents of the NewStuffDialog component + * + * This component is equivalent to the old DownloadWidget, but you should consider + * using NewStuffPage instead for a more modern style of integration into your + * application's flow. + * @see KNewStuff::DownloadWidget + */ + +import QtQuick 2.7 +import QtQuick.Layouts 1.12 as QtLayouts +import org.kde.kirigami 2.7 as Kirigami + +Kirigami.ApplicationItem { + id: component + + property alias downloadNewWhat: newStuffPage.title + /** + * The configuration file to use for this button + */ + property alias configFile: newStuffPage.configFile + + /** + * The engine which handles the content in this dialog + */ + property alias engine: newStuffPage.engine + + /** + * The default view mode of the dialog spawned by this button. This should be + * set using the NewStuffPage.ViewMode enum + * @see NewStuffPage.ViewMode + */ + property alias viewMode: newStuffPage.viewMode + + QtLayouts.Layout.preferredWidth: Kirigami.Units.gridUnit * 50 + QtLayouts.Layout.preferredHeight: Kirigami.Units.gridUnit * 40 + pageStack.defaultColumnWidth: pageStack.width + pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.Auto + pageStack.initialPage: NewStuffPage { + id: newStuffPage + onMessage: component.showPassiveNotification(message); + onIdleMessage: component.showPassiveNotification(message); + onBusyMessage: component.showPassiveNotification(message); + onErrorMessage: component.showPassiveNotification(message); + } +} diff --git a/src/qtquick/qml/NewStuffDownloadItemsSheet.qml b/src/qtquick/qml/NewStuffDownloadItemsSheet.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/NewStuffDownloadItemsSheet.qml @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +import QtQuick 2.2 +import QtQuick.Controls 2.5 as QtControls +import QtQuick.Layouts 1.12 as QtLayouts +import org.kde.kirigami 2.7 as Kirigami + +/** + * @brief An overlay sheet for showing a list of download options for one entry + * + * This is used by the NewStuffPage componet + */ + +Kirigami.OverlaySheet { + id: component + + property string entryId + property alias downloadLinks: itemsView.model + signal itemPicked(string entryId, int downloadItemId, string downloadName) + + showCloseButton: true + header: QtLayouts.ColumnLayout { + spacing: Kirigami.Units.largeSpacing + Kirigami.Heading { + QtLayouts.Layout.fillWidth: true + text: i18n("Pick Your Installation Option") + elide: Text.ElideRight + } + Kirigami.AbstractCard { + QtLayouts.Layout.leftMargin: Kirigami.Units.iconSizes.smallMedium + contentItem: QtControls.Label { + text: i18n("Please select the option you wish to install from the list of downloadable items below. If it is unclear which you should chose out of the available options, please contact the author of this item and ask that they clarify this through the naming of the items.") + wrapMode: Text.Wrap + } + } + } + ListView { + id: itemsView + delegate: Kirigami.BasicListItem { + text: modelData.name + icon: "download" + QtControls.ToolButton { + text: i18n("Install") + icon.name: "install" + QtLayouts.Layout.alignment: Qt.AlignRight + onClicked: { + component.close(); + component.itemPicked(component.entryId, modelData.id, modelData.name); + } + } + } + } +} diff --git a/src/qtquick/qml/NewStuffEntryDetails.qml b/src/qtquick/qml/NewStuffEntryDetails.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/NewStuffEntryDetails.qml @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +/** + * @brief A Kirigami.Page component used for displaying the details for a single entry + * + * This component is equivalent to the details view in the old DownloadDialog + * @see KNewStuff::DownloadDialog + */ + +import QtQuick 2.7 + +import org.kde.kcm 1.2 as KCM +import org.kde.kirigami 2.7 as Kirigami +import QtQuick.Controls 2.5 as QtControls +import QtQuick.Layouts 1.12 as QtLayouts +import org.kde.newstuff 1.1 as NewStuff + +KCM.SimpleKCM { + id: component + property QtObject newStuffModel + property int index + property string name + property var author + property alias shortSummary: shortSummaryItem.text + property alias summary: summaryItem.text; + property alias previews: screenshotsItem.screenshotsModel + property string homepage + property string donationLink + property int status + property int commentsCount + property int rating + property int downloadCount + property var downloadLinks + property string providerId + + NewStuffDownloadItemsSheet { + id: downloadItemsSheet + onItemPicked: { + var entryName = newStuffModel.data(entryId, NewStuff.ItemsModel.NameRole); + applicationWindow().showPassiveNotification(i18nc("A passive notification shown when installation of an item is initiated", "Installing %1 from %2").arg(downloadName).arg(entryName), 1500); + newStuffModel.installItem(entryId, downloadItemId); + } + } + + Connections { + target: newStuffModel + onEntryChanged: { + var status = newStuffModel.data(index, NewStuff.ItemsModel.StatusRole); + if (status == NewStuff.ItemsModel.DownloadableStatus + || status == NewStuff.ItemsModel.InstalledStatus + || status == NewStuff.ItemsModel.UpdateableStatus + || status == NewStuff.ItemsModel.DeletedStatus) { + statusCard.message = ""; + } else if (status == NewStuff.ItemsModel.InstallingStatus) { + statusCard.message = i18nc("Status message to be shown when the entry is in the process of being installed", "Currently installing the item %1 by %2. Please wait...").arg(component.name).arg(entryAuthor.name); + } else if (status == NewStuff.ItemsModel.UpdatingStatus) { + statusCard.message = i18nc("Status message to be shown when the entry is in the process of being updated", "Currently updating the item %1 by %2. Please wait...").arg(component.name).arg(entryAuthor.name); + } else { + statusCard.message = i18nc("Status message which should only be shown when the entry has been given some unknown or invalid status.", "This item is currently in an invalid or unknown state. Please report this to the KDE Community in a bug report."); + } + } + } + + NewStuff.Author { + id: entryAuthor + engine: component.newStuffModel.engine + providerId: component.providerId + username: author.name + } + title: i18nc("Combined title for the entry details page made of the name of the entry, and the author's name", "%1 by %2").arg(component.name).arg(entryAuthor.name) + titleDelegate: QtLayouts.RowLayout { + implicitHeight: title.height + Kirigami.Heading { + id: title + level: 1 + + QtLayouts.Layout.fillWidth: true; + QtLayouts.Layout.preferredWidth: titleTextMetrics.width + QtLayouts.Layout.minimumWidth: titleTextMetrics.width + opacity: component.isCurrentPage ? 1 : 0.4 + maximumLineCount: 1 + elide: Text.ElideRight + text: component.title + TextMetrics { + id: titleTextMetrics + text: component.title + font: title.font + } + } + QtControls.ToolButton { + text: component.downloadCount == 1 ? i18nc("Request installation of this item, available when there is exactly one downloadable item", "Install") : i18nc("Show installation options, where there is more than one downloadable item", "Install..."); + icon.name: "install" + onClicked: { + if (component.downloadCount == 1) { + newStuffModel.installItem(component.index); + } else { + downloadItemsSheet.downloadLinks = component.downloadLinks; + downloadItemsSheet.entryId = component.index; + downloadItemsSheet.open(); + } + } + enabled: component.status == NewStuff.ItemsModel.DownloadableStatus || component.status == NewStuff.ItemsModel.DeletedStatus; + visible: enabled; + } + QtControls.ToolButton { + text: i18nc("Request updating of this item", "Update"); + icon.name: "update" + onClicked: { newStuffModel.installItem(component.index); } + enabled: component.status == NewStuff.ItemsModel.UpdateableStatus; + visible: enabled; + } + QtControls.ToolButton { + text: i18nc("Request uninstallation of this item", "Uninstall"); + icon.name: "uninstall" + onClicked: { newStuffModel.uninstallItem(component.index); } + enabled: component.status == NewStuff.ItemsModel.InstalledStatus + visible: enabled; + } + } + QtLayouts.ColumnLayout { + spacing: Kirigami.Units.smallSpacing + Item { width: parent.width; height: Kirigami.Units.gridUnit * 3; } + Kirigami.AbstractCard { + id: statusCard + property string message; + visible: opacity > 0 + opacity: message.length > 0 ? 1 : 0 + Behavior on opacity { PropertyAnimation { duration: Kirigami.Units.longDuration; } } + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.margins: Kirigami.Units.largeSpacing + Item { + id: statusContent + implicitHeight: statusCard.message.length > 0 ? Math.max(statusBusy.height, statusLabel.height) + Kirigami.Units.largeSpacing * 4 : 0 + implicitWidth: statusCard.width + QtControls.BusyIndicator { + id: statusBusy + anchors { + top: parent.top + left: parent.left + } + running: statusCard.opacity > 0 + Rectangle { anchors.fill: parent; color: "red"; opacity: 0.3; } + } + QtControls.Label { + id: statusLabel + anchors { + top: parent.top + left: statusBusy.right + leftMargin: Kirigami.Units.largeSpacing + right: parent.right + } + text: statusCard.message + wrapMode: Text.Wrap + Rectangle { anchors.fill: parent; color: "blue"; opacity: 0.3; } + } + } + } + EntryScreenshots { + id: screenshotsItem + QtLayouts.Layout.fillWidth: true + } + Kirigami.Heading { + id: shortSummaryItem + QtLayouts.Layout.fillWidth: true + } + Kirigami.FormLayout { + QtLayouts.Layout.fillWidth: true + Kirigami.LinkButton { + Kirigami.FormData.label: i18n("Comments and Reviews:") + enabled: component.commentsCount > 0 + text: i18nc("A link which, when clicked, opens a new sub page with reviews (comments) for this entry", "%1 Reviews").arg(component.commentsCount) + onClicked: pageStack.push(commentsPage, { commentsModel: newStuffModel.data(index, NewStuff.ItemsModel.CommentsModelRole) } ) + } + Rating { + id: ratingsItem + Kirigami.FormData.label: i18n("Rating:") + rating: Math.floor(component.rating / 10) + } + Kirigami.LinkButton { + Kirigami.FormData.label: i18n("Homepage:") + text: i18nc("A link which, when clicked, opens the website associated with the entry (this could be either one specific to the project, the author's homepage, or any other website they have chosen for the purpose)", "Open the homepage for %2").arg(component.name) + onClicked: Qt.openUrlExternally(component.homepage) + } + Kirigami.LinkButton { + Kirigami.FormData.label: i18n("How To Donate:") + text: i18nc("A link which, when clicked, opens a website with information on donation in support of the entry", "Find out how to donate to this project") + onClicked: Qt.openUrlExternally(component.donationLink) + } + } + QtControls.Label { + id: summaryItem + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.margins: Kirigami.Units.largeSpacing + wrapMode: Text.Wrap + } + } + + Component { + id: commentsPage + EntryCommentsPage { + engine: component.newStuffModel.engine + entryName: component.name + entryAuthorId: component.author.name + entryProviderId: component.providerId + } + } +} diff --git a/src/qtquick/qml/NewStuffItem.qml b/src/qtquick/qml/NewStuffItem.qml --- a/src/qtquick/qml/NewStuffItem.qml +++ b/src/qtquick/qml/NewStuffItem.qml @@ -20,7 +20,7 @@ */ import QtQuick 2.2 -import QtQuick.Controls 1.4 as QtControls +import QtQuick.Controls 2.5 as QtControls import org.kde.kirigami 2.1 as Kirigami import org.kde.newstuff 1.0 as NewStuff @@ -101,7 +101,7 @@ } } } - Kirigami.Label { + QtControls.Label { anchors { verticalCenter: parent.verticalCenter; left: previewContainer.right; @@ -118,7 +118,7 @@ opacity: (model.status == NewStuff.ItemsModel.InstallingStatus || model.status == NewStuff.ItemsModel.UpdatingStatus) ? 1 : 0; Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; } } running: opacity > 0; - Kirigami.Label { + QtControls.Label { anchors { verticalCenter: parent.verticalCenter; right: parent.left; diff --git a/src/qtquick/qml/NewStuffList.qml b/src/qtquick/qml/NewStuffList.qml --- a/src/qtquick/qml/NewStuffList.qml +++ b/src/qtquick/qml/NewStuffList.qml @@ -69,7 +69,7 @@ } model: NewStuff.ItemsModel { id: newStuffModel; - engine: newStuffEngine.engine; + engine: newStuffEngine; } NewStuff.Engine { id: newStuffEngine; diff --git a/src/qtquick/qml/NewStuffPage.qml b/src/qtquick/qml/NewStuffPage.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/NewStuffPage.qml @@ -0,0 +1,719 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +/** + * @brief A Kirigami.Page component used for managing KNS entries + * + * This component is functionally equivalent to the old DownloadDialog + * @see KNewStuff::DownloadDialog + */ + +import QtQuick 2.7 +import QtQuick.Controls 2.5 as QtControls +import QtQuick.Layouts 1.12 as QtLayouts +import QtGraphicalEffects 1.12 as QtEffects +import org.kde.kcm 1.2 as KCM +import org.kde.kirigami 2.7 as Kirigami +import org.kde.newstuff 1.1 as NewStuff + +KCM.GridViewKCM { + id: root; + /** + * @brief The configuration file which describes the application (knsrc) + * + * The format and location of this file is found in the documentation for + * KNS3::DownloadDialog + */ + property alias configFile: newStuffEngine.configFile; + readonly property alias engine: newStuffEngine; + + /** + * Any generic message from the NewStuffEngine + * @param message The message to be shown to the user + */ + signal message(string message); + /** + * A message posted usually describing that whatever action a recent busy + * message said was happening has been completed + * @param message The message to be shown to the user + */ + signal idleMessage(string message); + /** + * A message posted when the engine is busy doing something long duration + * (usually this will be when fetching installation data) + * @param message The message to be shown to the user + */ + signal busyMessage(string message); + /** + * A message posted when something has gone wrong + * @param message The message to be shown to the user + */ + signal errorMessage(string message); + + property string uninstallLabel: i18nc("Request uninstallation of this item", "Uninstall"); + property string useLabel: i18nc("If a knsrc file defines an adoption command, the option to run this command and 'use' an item becomes available. This is the text for an action to do so.", "Use"); + + property int viewMode: NewStuffPage.ViewMode.Tiles + enum ViewMode { + Tiles, + Icons, + Preview + } + + title: newStuffEngine.name + NewStuff.Engine { + id: newStuffEngine; + onMessage: root.message(message); + onIdleMessage: root.idleMessage(message); + onBusyMessage: root.busyMessage(message); + onErrorMessage: root.errorMessage(message); + } + NewStuffQuestionAsker {} + + titleDelegate: QtLayouts.RowLayout { + Kirigami.Heading { + id: title + level: 1 + + QtLayouts.Layout.fillWidth: true; + QtLayouts.Layout.preferredWidth: titleTextMetrics.width + QtLayouts.Layout.minimumWidth: titleTextMetrics.width + opacity: root.isCurrentPage ? 1 : 0.4 + maximumLineCount: 1 + elide: Text.ElideRight + text: root.title + TextMetrics { + id: titleTextMetrics + text: root.title + font: title.font + } + } + QtControls.ButtonGroup { + id: displayModeGroup + buttons: [displayModeTiles, displayModeIcons] + } + QtControls.ToolButton { + id: displayModeTiles + icon.name: "view-list-details" + onClicked: { root.viewMode = NewStuffPage.ViewMode.Tiles; } + checked: root.viewMode == NewStuffPage.ViewMode.Tiles + } + QtControls.ToolButton { + id: displayModeIcons + icon.name: "view-list-icons" + onClicked: { root.viewMode = NewStuffPage.ViewMode.Icons; } + checked: root.viewMode == NewStuffPage.ViewMode.Icons + } + QtControls.ToolButton { + id: displayPreview + icon.name: "view-preview" + onClicked: { root.viewMode = NewStuffPage.ViewMode.Preview; } + checked: root.viewMode == NewStuffPage.ViewMode.Preview + } + Kirigami.ActionTextField { + id: searchField + placeholderText: i18n("Search...") + focusSequence: "Ctrl+F" + rightActions: [ + Kirigami.Action { + iconName: "edit-clear" + visible: searchField.text !== "" + onTriggered: { + searchField.text = ""; + searchField.accepted(); + } + } + ] + onAccepted: { + newStuffEngine.searchTerm = searchField.text; + } + } + } + + footer: QtLayouts.RowLayout { + QtControls.ComboBox { + id: categoriesCombo + QtLayouts.Layout.fillWidth: true + model: newStuffEngine.categories + textRole: "displayName" + onCurrentIndexChanged: { + newStuffEngine.categoriesFilter = model.data(currentIndex, NewStuff.CategoriesModel.NameRole); + } + } + QtControls.ComboBox { + id: filterCombo + QtLayouts.Layout.fillWidth: true + model: ListModel {} + Component.onCompleted: { + filterCombo.model.append({ text: i18nc("List option which will set the filter to show everything", "Show Everything") }); + filterCombo.model.append({ text: i18nc("List option which will set the filter so only installed items are shown", "Installed Only") }); + filterCombo.model.append({ text: i18nc("List option which will set the filter so only installed items with updates available are shown", "Updateable Only") }); + filterCombo.currentIndex = newStuffEngine.filter; + } + onCurrentIndexChanged: { + newStuffEngine.filter = currentIndex; + } + } + QtControls.ComboBox { + id: sortCombo + QtLayouts.Layout.fillWidth: true + model: ListModel { } + Component.onCompleted: { + sortCombo.model.append({ text: i18nc("List option which will set the sort order to based on when items were most recently updated", "Show most recent first") }); + sortCombo.model.append({ text: i18nc("List option which will set the sort order to be alphabetical based on the name", "Sort alphabetically") }); + sortCombo.model.append({ text: i18nc("List option which will set the sort order to based on user ratings", "Show highest rated first") }); + sortCombo.model.append({ text: i18nc("List option which will set the sort order to based on number of downloads", "Show most downloaded first") }); + sortCombo.currentIndex = newStuffEngine.sortOrder; + } + onCurrentIndexChanged: { + newStuffEngine.sortOrder = currentIndex; + } + } + } + + view.model: NewStuff.ItemsModel { + id: newStuffModel; + engine: newStuffEngine; + } + NewStuffDownloadItemsSheet { + id: downloadItemsSheet + onItemPicked: { + var entryName = newStuffModel.data(entryId, NewStuff.ItemsModel.NameRole); + applicationWindow().showPassiveNotification(i18nc("A passive notification shown when installation of an item is initiated", "Installing %1 from %2").arg(downloadName).arg(entryName), 1500); + newStuffModel.installItem(entryId, downloadItemId); + } + } + + view.implicitCellWidth: root.viewMode == NewStuffPage.ViewMode.Tiles ? Kirigami.Units.gridUnit * 30 : (root.viewMode == NewStuffPage.ViewMode.Preview ? Kirigami.Units.gridUnit * 25 : Kirigami.Units.gridUnit * 10) + view.implicitCellHeight: root.viewMode == NewStuffPage.ViewMode.Tiles ? Math.round(view.implicitCellWidth / 3) : (root.viewMode == NewStuffPage.ViewMode.Preview ? Kirigami.Units.gridUnit * 25 : Math.round(view.implicitCellWidth / 1.6) + Kirigami.Units.gridUnit*2) + view.delegate: root.viewMode == NewStuffPage.ViewMode.Tiles ? tileDelegate : (root.viewMode == NewStuffPage.ViewMode.Preview ? bigPreviewDelegate : thumbDelegate) + + Component { + id: bigPreviewDelegate + GridTileDelegate { + id: bigPreviewDelegateRoot + actionsAnchors.topMargin: bigPreview.height + Kirigami.Units.smallSpacing * 2 + function showDetails() { + pageStack.push(detailsPage, { + newStuffModel: root.view.model, + index: model.index, + name: model.name, + author: model.author, + previews: model.previews, + shortSummary: model.shortSummary, + summary: model.summary, + homepage: model.homepage, + donationLink: model.donationLink, + status: model.status, + commentsCount: model.numberOfComments, + rating: model.rating, + downloadCount: model.downloadCount, + downloadLinks: model.downloadLinks, + providerId: model.providerId + }); + } + actions: [ + Kirigami.Action { + text: root.useLabel + iconName: "dialog-ok-apply" + onTriggered: { model.adopt(model.index); } + enabled: (model.status == NewStuff.ItemsModel.InstalledStatus || model.status == NewStuff.ItemsModel.UpdateableStatus) && newStuffEngine.hasAdoptionCommand + visible: enabled + }, + Kirigami.Action { + text: model.downloadCount == 1 ? i18nc("Request installation of this item, available when there is exactly one downloadable item", "Install") : i18nc("Show installation options, where there is more than one downloadable item", "Install..."); + iconName: "install" + onTriggered: { + if (model.downloadCount == 1) { + newStuffModel.installItem(model.index, 1); + } else { + downloadItemsSheet.downloadLinks = model.downloadLinks; + downloadItemsSheet.entryId = model.index; + downloadItemsSheet.open(); + } + } + enabled: model.status == NewStuff.ItemsModel.DownloadableStatus || model.status == NewStuff.ItemsModel.DeletedStatus; + visible: enabled; + }, + Kirigami.Action { + text: i18nc("Request updating of this item", "Update"); + iconName: "update" + onTriggered: { newStuffModel.installItem(model.index); } + enabled: model.status == NewStuff.ItemsModel.UpdateableStatus; + visible: enabled; + }, + Kirigami.Action { + text: root.uninstallLabel + iconName: "uninstall" + onTriggered: { newStuffModel.uninstallItem(model.index); } + enabled: model.status == NewStuff.ItemsModel.InstalledStatus + visible: enabled; + } + ] + thumbnailAvailable: model.previewsSmall.length > 0 + tile: Item { + anchors { + fill: parent + margins: Kirigami.Units.smallSpacing + } + QtLayouts.ColumnLayout { + anchors.fill: parent; + Item { + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.minimumHeight: width / 1.8 + QtLayouts.Layout.maximumHeight: width / 1.8 + Image { + id: bigPreview + asynchronous: true; + fillMode: Image.PreserveAspectCrop; + source: thumbnailAvailable ? model.previews[0] : ""; + anchors.fill: parent + } + QtEffects.DropShadow { + anchors.fill: bigPreview + horizontalOffset: 0 + verticalOffset: 0 + radius: Kirigami.Units.largeSpacing + samples: radius * 2 + color: "#80000000" + source: bigPreview + } + Kirigami.Icon { + id: updateAvailableBadge; + opacity: (model.status == NewStuff.ItemsModel.UpdateableStatus) ? 1 : 0; + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; } } + anchors { + top: parent.top; + left: parent.left; + margins: -Kirigami.Units.smallSpacing; + } + height: Kirigami.Units.iconSizes.medium; + width: height; + source: "package-installed-outdated"; + } + Kirigami.Icon { + id: installedBadge; + opacity: (model.status == NewStuff.ItemsModel.InstalledStatus) ? 1 : 0; + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; } } + anchors { + top: parent.top; + left: parent.left; + } + height: Kirigami.Units.iconSizes.medium; + width: height; + source: "package-installed-updated"; + } + Item { + anchors { + top: parent.top; + right: parent.right; + } + height: Kirigami.Units.iconSizes.large; + width: height; + Kirigami.Icon { + id: infoIcon + source: "documentinfo" + anchors.fill: parent + } + MouseArea { + anchors.fill: parent + onClicked: { bigPreviewDelegateRoot.showDetails(); } + } + QtEffects.Glow { + anchors.fill: infoIcon + radius: 1 + samples: 2 + spread: 0.3 + color: "white" + source: infoIcon + } + } + } + Rating { + QtLayouts.Layout.fillWidth: true + rating: model.rating / 10 + } + Kirigami.Heading { + QtLayouts.Layout.fillWidth: true + level: 5 + elide: Text.ElideRight + text: i18nc("The number of times the item has been downloaded", "%1 downloads").arg(model.downloadCount) + } + Kirigami.Heading { + QtLayouts.Layout.fillWidth: true + elide: Text.ElideRight + level: 3 + text: model.name + } + Kirigami.Heading { + QtLayouts.Layout.fillWidth: true + elide: Text.ElideRight + level: 4 + textFormat: Text.StyledText + text: i18nc("Subheading for the tile view, located immediately underneath the name of the item", "By %1").arg(model.author.name) + } + QtControls.Label { + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.fillHeight: true + wrapMode: Text.Wrap + text: model.shortSummary.length > 0 ? model.shortSummary : model.summary + elide: Text.ElideRight + } + } + Item { + anchors.fill: parent + opacity: (model.status == NewStuff.ItemsModel.InstallingStatus || model.status == NewStuff.ItemsModel.UpdatingStatus) ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration; } } + Rectangle { + anchors.fill: parent + color: Kirigami.Theme.backgroundColor + opacity: 0.5; + } + QtControls.BusyIndicator { + anchors.centerIn: parent + running: parent.opacity > 0 + } + } + MouseArea { + anchors.fill: parent; + onClicked: bigPreviewDelegateRoot.showDetails(); + } + } + } + } + Component { + id: tileDelegate + GridTileDelegate { + id: tileDelegateRoot + function showDetails() { + pageStack.push(detailsPage, { + newStuffModel: root.view.model, + index: model.index, + name: model.name, + author: model.author, + previews: model.previews, + shortSummary: model.shortSummary, + summary: model.summary, + homepage: model.homepage, + donationLink: model.donationLink, + status: model.status, + commentsCount: model.numberOfComments, + rating: model.rating, + downloadCount: model.downloadCount, + downloadLinks: model.downloadLinks, + providerId: model.providerId + }); + } + actions: [ + Kirigami.Action { + text: root.useLabel + iconName: "dialog-ok-apply" + onTriggered: { model.adopt(model.index); } + enabled: (model.status == NewStuff.ItemsModel.InstalledStatus || model.status == NewStuff.ItemsModel.UpdateableStatus) && newStuffEngine.hasAdoptionCommand + visible: enabled + }, + Kirigami.Action { + text: model.downloadCount == 1 ? i18nc("Request installation of this item, available when there is exactly one downloadable item", "Install") : i18nc("Show installation options, where there is more than one downloadable item", "Install..."); + iconName: "install" + onTriggered: { + if (model.downloadCount == 1) { + newStuffModel.installItem(model.index, 1); + } else { + downloadItemsSheet.downloadLinks = model.downloadLinks; + downloadItemsSheet.entryId = model.index; + downloadItemsSheet.open(); + } + } + enabled: model.status == NewStuff.ItemsModel.DownloadableStatus || model.status == NewStuff.ItemsModel.DeletedStatus; + visible: enabled; + }, + Kirigami.Action { + text: i18nc("Request updating of this item", "Update"); + iconName: "update" + onTriggered: { newStuffModel.installItem(model.index); } + enabled: model.status == NewStuff.ItemsModel.UpdateableStatus; + visible: enabled; + }, + Kirigami.Action { + text: root.uninstallLabel + iconName: "uninstall" + onTriggered: { newStuffModel.uninstallItem(model.index); } + enabled: model.status == NewStuff.ItemsModel.InstalledStatus + visible: enabled; + }, + Kirigami.Action { + text: i18nc("Show a page with details for this item", "Details...") + iconName: "documentinfo" + onTriggered: { tileDelegateRoot.showDetails(); } + } + ] + thumbnailAvailable: model.previewsSmall.length > 0 + tile: Item { + anchors { + fill: parent + margins: Kirigami.Units.smallSpacing + } + QtLayouts.GridLayout { + anchors.fill: parent; + columns: 2 + QtLayouts.ColumnLayout { + QtLayouts.Layout.minimumWidth: view.implicitCellWidth / 5 + QtLayouts.Layout.maximumWidth: view.implicitCellWidth / 5 + Item { + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.minimumHeight: width + QtLayouts.Layout.maximumHeight: width + Image { + id: tilePreview + asynchronous: true; + fillMode: Image.PreserveAspectFit; + source: thumbnailAvailable ? model.previewsSmall[0] : ""; + anchors { + fill: parent + margins: Kirigami.Units.smallSpacing + } + verticalAlignment: Image.AlignTop + } + QtEffects.DropShadow { + anchors.fill: tilePreview + horizontalOffset: 0 + verticalOffset: 0 + radius: Kirigami.Units.largeSpacing + samples: radius * 2 + color: "#80000000" + source: tilePreview + } + Kirigami.Icon { + id: updateAvailableBadge; + opacity: (model.status == NewStuff.ItemsModel.UpdateableStatus) ? 1 : 0; + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; } } + anchors { + top: parent.top; + left: parent.left; + margins: -Kirigami.Units.smallSpacing; + } + height: Kirigami.Units.iconSizes.smallMedium; + width: height; + source: "package-installed-outdated"; + } + Kirigami.Icon { + id: installedBadge; + opacity: (model.status == NewStuff.ItemsModel.InstalledStatus) ? 1 : 0; + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; } } + anchors { + top: parent.top; + left: parent.left; + margins: -Kirigami.Units.smallSpacing; + } + height: Kirigami.Units.iconSizes.smallMedium; + width: height; + source: "package-installed-updated"; + } + } + Item { + QtLayouts.Layout.fillHeight: true + } + } + QtLayouts.ColumnLayout { + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.fillHeight: true + Kirigami.Heading { + QtLayouts.Layout.fillWidth: true + elide: Text.ElideRight + level: 3 + text: model.name + } + Kirigami.Heading { + QtLayouts.Layout.fillWidth: true + elide: Text.ElideRight + level: 4 + textFormat: Text.StyledText + text: i18nc("Subheading for the tile view, located immediately underneath the name of the item", "By %1").arg(model.author.name) + } + QtControls.Label { + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.fillHeight: true + wrapMode: Text.Wrap + text: model.shortSummary.length > 0 ? model.shortSummary : model.summary + elide: Text.ElideRight + } + } + Rating { + QtLayouts.Layout.fillWidth: true + rating: model.rating / 10 + } + Kirigami.Heading { + QtLayouts.Layout.fillWidth: true + level: 5 + elide: Text.ElideRight + text: i18nc("The number of times the item has been downloaded", "%1 downloads").arg(model.downloadCount) + } + } + Item { + anchors.fill: parent + opacity: (model.status == NewStuff.ItemsModel.InstallingStatus || model.status == NewStuff.ItemsModel.UpdatingStatus) ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration; } } + Rectangle { + anchors.fill: parent + color: Kirigami.Theme.backgroundColor + opacity: 0.5; + } + QtControls.BusyIndicator { + anchors.centerIn: parent + running: parent.opacity > 0 + } + } + MouseArea { + anchors.fill: parent; + onClicked: tileDelegateRoot.showDetails(); + } + } + } + } + Component { + id: thumbDelegate + KCM.GridDelegate { + text: model.name + // onClicked: pageStack.push(detailsPage, { + // name: model.name, + // author: model.author, + // previews: model.previews, + // shortSummary: model.shortSummary, + // summary: model.summary, + // homepage: model.homepage, + // donationLink: model.donationLink + // }); + actions: [ + Kirigami.Action { + text: root.useLabel + iconName: "dialog-ok-apply" + onTriggered: { model.adopt(model.index); } + enabled: (model.status == NewStuff.ItemsModel.InstalledStatus || model.status == NewStuff.ItemsModel.UpdateableStatus) && newStuffEngine.hasAdoptionCommand + visible: enabled + }, + Kirigami.Action { + text: model.downloadCount == 1 ? i18nc("Request installation of this item, available when there is exactly one downloadable item", "Install") : i18nc("Show installation options, where there is more than one downloadable item", "Install..."); + iconName: "install" + onTriggered: { + if (model.downloadCount == 1) { + newStuffModel.installItem(model.index); + } else { + downloadItemsSheet.downloadLinks = model.downloadLinks; + downloadItemsSheet.entryId = model.index; + downloadItemsSheet.open(); + } + } + enabled: model.status == NewStuff.ItemsModel.DownloadableStatus || model.status == NewStuff.ItemsModel.DeletedStatus; + visible: enabled; + }, + Kirigami.Action { + text: i18nc("Request updating of this item", "Update"); + iconName: "update" + onTriggered: { newStuffModel.installItem(model.index); } + enabled: model.status == NewStuff.ItemsModel.UpdateableStatus; + visible: enabled; + }, + Kirigami.Action { + text: root.uninstallLabel + iconName: "uninstall" + onTriggered: { newStuffModel.uninstallItem(model.index); } + enabled: model.status == NewStuff.ItemsModel.InstalledStatus + visible: enabled; + } + ] + thumbnailAvailable: model.previewsSmall.length > 0 + thumbnail: Image { + anchors { + fill: parent; + margins: Kirigami.Units.smallSpacing; + } + asynchronous: true; + fillMode: Image.PreserveAspectFit; + source: thumbnailAvailable ? model.previewsSmall[0] : ""; + Kirigami.Icon { + id: updateAvailableBadge; + opacity: (model.status == NewStuff.ItemsModel.UpdateableStatus) ? 1 : 0; + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; } } + anchors { + top: parent.top; + right: parent.right; + margins: -Kirigami.Units.smallSpacing; + } + height: Kirigami.Units.iconSizes.smallMedium; + width: height; + source: "package-installed-outdated"; + } + Kirigami.Icon { + id: installedBadge; + opacity: (model.status == NewStuff.ItemsModel.InstalledStatus) ? 1 : 0; + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration; } } + anchors { + top: parent.top; + right: parent.right; + margins: -Kirigami.Units.smallSpacing; + } + height: Kirigami.Units.iconSizes.smallMedium; + width: height; + source: "package-installed-updated"; + } + Item { + anchors.fill: parent + opacity: (model.status == NewStuff.ItemsModel.InstallingStatus || model.status == NewStuff.ItemsModel.UpdatingStatus) ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration; } } + Rectangle { + anchors.fill: parent + color: Kirigami.Theme.backgroundColor + opacity: 0.5; + } + QtControls.BusyIndicator { + anchors.centerIn: parent + running: parent.opacity > 0 + } + } + MouseArea { + anchors.fill: parent; + onClicked: pageStack.push(detailsPage, { + newStuffModel: root.view.model, + index: model.index, + name: model.name, + author: model.author, + previews: model.previews, + shortSummary: model.shortSummary, + summary: model.summary, + homepage: model.homepage, + donationLink: model.donationLink, + status: model.status, + commentsCount: model.numberOfComments, + rating: model.rating, + downloadCount: model.downloadCount, + downloadLinks: model.downloadLinks, + providerId: model.providerId + }); + } + } + } + } + + Component { + id: detailsPage; + NewStuffEntryDetails { } + } +} diff --git a/src/qtquick/qml/NewStuffQuestionAsker.qml b/src/qtquick/qml/NewStuffQuestionAsker.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/NewStuffQuestionAsker.qml @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +/** + * @brief A component used to forward questions from KNewStuff's engine to the UI + * + * This component is equivalent to the WidgetQuestionListener + * @see KNewStuff::WidgetQuestionListener + * @see KNewStuffCore::Question + */ + +import QtQuick 2.7 +import QtQuick.Controls 2.5 as QtControls +import QtQuick.Layouts 1.12 as QtLayouts +import org.kde.kirigami 2.7 as Kirigami +import org.kde.newstuff 1.1 as NewStuff +import org.kde.newstuff.core 1.1 as NewStuffCore + +QtControls.Dialog { + id: dialog + modal: true + focus: true + property int questionType + anchors.centerIn: QtControls.Overlay.overlay + margins: Kirigami.Units.largeSpacing + padding: Kirigami.Units.largeSpacing + standardButtons: { + switch (questionType) { + case NewStuffCore.Question.SelectFromListQuestion: + case NewStuffCore.Question.InputTextQuestion: + case NewStuffCore.Question.PasswordQuestion: + case NewStuffCore.Question.ContinueCancelQuestion: + // QtControls Dialog standardButtons does not have a Continue button... + return QtControls.Dialog.Ok | QtControls.Dialog.Cancel; + break; + case NewStuffCore.Question.YesNoQuestion: + return QtControls.Dialog.Yes | QtControls.Dialog.No; + break; + default: + break; + } + } + + Connections { + target: NewStuff.QuickQuestionListener + onAskListQuestion: { + dialog.questionType = NewStuffCore.Question.SelectFromListQuestion; + dialog.title = title; + questionLabel.text = question; + for (var i = 0; i < list.length; i++) { + listView.model.append({ text: list[i] }); + } + listView.currentIndex = 0; + listView.visible = true; + dialog.open(); + } + onAskContinueCancelQuestion: { + dialog.questionType = NewStuffCore.Question.ContinueCancelQuestion; + dialog.title = title; + questionLabel.text = question; + dialog.open(); + } + onAskTextInputQuestion: { + dialog.questionType = NewStuffCore.Question.InputTextQuestion; + dialog.title = title; + questionLabel.text = question; + textInput.visible = true; + dialog.open(); + } + onAskPasswordQuestion: { + dialog.questionType = NewStuffCore.Question.PasswordQuestion; + dialog.title = title; + questionLabel.text = question; + textInput.echoMode = QtControls.TextInput.PasswordEchoOnEdit; + textInput.visible = true; + dialog.open(); + } + onAskYesNoQuestion: { + dialog.questionType = NewStuffCore.Question.YesNoQuestion; + dialog.title = title; + questionLabel.text = question; + dialog.open(); + } + } + Connections { + target: applicationWindow() + // Since dialogs in QML don't automatically reject when the application is closed, + // we just do that little job for it (and then we don't end up blocking everything + // when the application is shut without the question being answered) + onClosing: { + if (dialog.opened === true) { + passResponse(false); + } + } + } + function passResponse(responseIsContinue) { + var input = ""; + switch(dialog.questionType) { + case NewStuffCore.Question.SelectFromListQuestion: + input = listView.currentItem.text; + listView.model.clear(); + listView.visible = false; + break; + case NewStuffCore.Question.InputTextQuestion: + input = textInput.text; + textInput.text = ""; + textInput.visible = false; + break; + case NewStuffCore.Question.PasswordQuestion: + input = textInput.text; + textInput.text = ""; + textInput.visible = false; + textInput.echoMode = QtControls.TextInput.Normal; + break; + case NewStuffCore.Question.ContinueCancelQuestion: + case NewStuffCore.Question.YesNoQuestion: + default: + // Nothing special to do for these types of question, we just pass along the positive or negative response + break; + } + NewStuff.QuickQuestionListener.passResponse(responseIsContinue, input); + } + + QtLayouts.ColumnLayout { + anchors.fill: parent + property int maxWidth: applicationWindow().width - (dialog.leftPadding + dialog.leftMargin + dialog.rightMargin + dialog.rightPadding) + QtControls.Label { + id: questionLabel + QtLayouts.Layout.maximumWidth: parent.maxWidth + wrapMode: Text.Wrap + } + ListView { + id: listView + visible: false + QtLayouts.Layout.maximumWidth: parent.maxWidth + QtLayouts.Layout.fillWidth: true + QtLayouts.Layout.minimumHeight: Kirigami.Units.gridUnit * 6 + model: ListModel { } + delegate: Kirigami.BasicListItem { + reserveSpaceForIcon: false + text: model.text + } + } + QtControls.TextField { + id: textInput + visible: false + QtLayouts.Layout.maximumWidth: parent.maxWidth + QtLayouts.Layout.fillWidth: true + } + } + onAccepted: { + passResponse(true); + } + onRejected: { + passResponse(false); + } +} diff --git a/src/qtquick/qml/Rating.qml b/src/qtquick/qml/Rating.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/Rating.qml @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2012 Aleix Pol Gonzalez + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Library/Lesser General Public License + * version 2, or (at your option) any later version, as published by the + * Free Software Foundation + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details + * + * You should have received a copy of the GNU Library/Lesser General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import QtQuick 2.1 +import QtQuick.Layouts 1.1 +import org.kde.kirigami 2.0 as Kirigami + +RowLayout +{ + id: view + property bool editable: false + property int max: 10 + property int rating: 0 + property real starSize: Kirigami.Units.gridUnit + + clip: true + spacing: 0 + + readonly property var ratingIndex: (theRepeater.count/view.max)*view.rating + + Repeater { + id: theRepeater + model: 5 + delegate: Kirigami.Icon { + Layout.minimumWidth: view.starSize + Layout.minimumHeight: view.starSize + Layout.preferredWidth: view.starSize + Layout.preferredHeight: view.starSize + + width: height + source: "rating" + opacity: (view.editable && mouse.item.containsMouse ? 0.7 + : index>=view.ratingIndex ? 0.2 + : 1) + + ConditionalLoader { + id: mouse + + anchors.fill: parent + condition: view.editable + componentTrue: MouseArea { + hoverEnabled: true + onClicked: rating = (max/theRepeater.model*(index+1)) + } + componentFalse: null + } + } + } +} diff --git a/src/qtquick/qml/Shadow.qml b/src/qtquick/qml/Shadow.qml new file mode 100644 --- /dev/null +++ b/src/qtquick/qml/Shadow.qml @@ -0,0 +1,48 @@ +/* + * Copyright 2018 Aleix Pol Gonzalez + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Library General Public License as + * published by the Free Software Foundation; either version 2, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Library General Public License for more details + * + * You should have received a copy of the GNU Library General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import QtQuick 2.1 +import QtGraphicalEffects 1.0 +import org.kde.kirigami 2.2 + +LinearGradient { + id: shadow + property int edge: Qt.LeftEdge + + width: Units.gridUnit/2 + height: Units.gridUnit/2 + + start: Qt.point((edge !== Qt.RightEdge ? 0 : width), (edge !== Qt.BottomEdge ? 0 : height)) + end: Qt.point((edge !== Qt.LeftEdge ? 0 : width), (edge !== Qt.TopEdge ? 0 : height)) + gradient: Gradient { + GradientStop { + position: 0.0 + color: Theme.backgroundColor + } + GradientStop { + position: 0.3 + color: Qt.rgba(0, 0, 0, 0.1) + } + GradientStop { + position: 1.0 + color: "transparent" + } + } +} + diff --git a/src/qtquick/qmldir b/src/qtquick/qmldir --- a/src/qtquick/qmldir +++ b/src/qtquick/qmldir @@ -2,3 +2,10 @@ plugin newstuffqmlplugin NewStuffList 1.0 qml/NewStuffList.qml NewStuffItem 1.0 qml/NewStuffItem.qml +NewStuffPage 1.1 qml/NewStuffPage.qml +NewStuffEntryDetails 1.1 qml/NewStuffEntryDetails.qml +NewStuffQuestionAsker 1.1 qml/NewStuffQuestionAsker.qml +NewStuffDownloadItemsSheet 1.1 qml/NewStuffDownloadItemsSheet.qml +NewStuffButton 1.1 qml/NewStuffButton.qml +NewStuffDialog 1.1 qml/NewStuffDialog.qml +NewStuffDialogContent 1.1 qml/NewStuffDialogContent.qml diff --git a/src/qtquick/qmlplugin.cpp b/src/qtquick/qmlplugin.cpp --- a/src/qtquick/qmlplugin.cpp +++ b/src/qtquick/qmlplugin.cpp @@ -23,8 +23,14 @@ #include "quickengine.h" #include "quickitemsmodel.h" +#include "quickquestionlistener.h" +#include "author.h" +#include "categoriesmodel.h" #include "downloadlinkinfo.h" +#include "provider.h" +#include "question.h" + #include #include @@ -37,5 +43,14 @@ { qmlRegisterType(uri, 1, 0, "Engine"); qmlRegisterType(uri, 1, 0, "ItemsModel"); + qmlRegisterType(uri, 1, 1, "Author"); qmlRegisterUncreatableType(uri, 1, 0, "DownloadLinkInfo", QStringLiteral("This should only be created by the ItemsModel, and is associated with one entry in that model")); + qmlRegisterUncreatableType(uri, 1, 0, "CategoriesModel", QStringLiteral("This should only be created by the Engine, and provides the categories available in that engine")); + qmlRegisterUncreatableMetaObject(KNSCore::Provider::staticMetaObject, "org.kde.newstuff.core", 1, 1, "Provider", QLatin1String("Error: this only exists to forward enums")); + qmlRegisterUncreatableMetaObject(KNSCore::Question::staticMetaObject, "org.kde.newstuff.core", 1, 1, "Question", QLatin1String("Error: this only exists to forward enums")); + qmlRegisterSingletonType(uri, 1, 1, "QuickQuestionListener", [](QQmlEngine *engine, QJSEngine *scriptEngine) -> QObject * { + Q_UNUSED(engine) + Q_UNUSED(scriptEngine) + return KNewStuffQuick::QuickQuestionListener::instance(); + }); } diff --git a/src/qtquick/quickengine.h b/src/qtquick/quickengine.h --- a/src/qtquick/quickengine.h +++ b/src/qtquick/quickengine.h @@ -23,6 +23,7 @@ #define ENGINE_H #include +#include /** * @short Encapsulates a KNSCore::Engine for use in Qt Quick @@ -35,19 +36,58 @@ class Engine : public QObject { Q_OBJECT + Q_PROPERTY(bool allowedByKiosk READ allowedByKiosk CONSTANT) Q_PROPERTY(QString configFile READ configFile WRITE setConfigFile NOTIFY configFileChanged) Q_PROPERTY(QObject* engine READ engine NOTIFY engineChanged) + Q_PROPERTY(bool hasAdoptionCommand READ hasAdoptionCommand NOTIFY engineInitialized) + Q_PROPERTY(QString name READ name NOTIFY engineInitialized) + Q_PROPERTY(QObject* categories READ categories NOTIFY categoriesChanged) + Q_PROPERTY(QStringList categoriesFilter READ categoriesFilter WRITE setCategoriesFilter RESET resetCategoriesFilter NOTIFY categoriesFilterChanged) + Q_PROPERTY(int filter READ filter WRITE setFilter NOTIFY filterChanged) + Q_PROPERTY(int sortOrder READ sortOrder WRITE setSortOrder NOTIFY sortOrderChanged) + Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm RESET resetSearchTerm NOTIFY searchTermChanged) + Q_PROPERTY(KNSCore::EntryInternal::List changedEntries READ changedEntries RESET resetChangedEntries NOTIFY changedEntriesChanged) public: explicit Engine(QObject* parent = nullptr); virtual ~Engine(); + bool allowedByKiosk() const; + QString configFile() const; void setConfigFile(const QString& newFile); Q_SIGNAL void configFileChanged(); QObject* engine() const; Q_SIGNAL void engineChanged(); + bool hasAdoptionCommand() const; + QString name() const; + Q_SIGNAL void engineInitialized(); + + QObject* categories() const; + Q_SIGNAL void categoriesChanged(); + + QStringList categoriesFilter() const; + void setCategoriesFilter(const QStringList& newCategoriesFilter); + Q_INVOKABLE void resetCategoriesFilter(); + Q_SIGNAL void categoriesFilterChanged(); + + int filter() const; + void setFilter(int newFilter); + Q_SIGNAL void filterChanged(); + + int sortOrder() const; + void setSortOrder(int newSortOrder); + Q_SIGNAL void sortOrderChanged(); + + QString searchTerm() const; + void setSearchTerm(const QString& newSearchTerm); + Q_INVOKABLE void resetSearchTerm(); + Q_SIGNAL void searchTermChanged(); + + KNSCore::EntryInternal::List changedEntries() const; + Q_INVOKABLE void resetChangedEntries(); + Q_SIGNAL void changedEntriesChanged(); Q_SIGNALS: void message(const QString& message); void idleMessage(const QString& message); diff --git a/src/qtquick/quickengine.cpp b/src/qtquick/quickengine.cpp --- a/src/qtquick/quickengine.cpp +++ b/src/qtquick/quickengine.cpp @@ -21,16 +21,25 @@ #include "quickengine.h" +#include +#include + +#include "categoriesmodel.h" +#include "quickquestionlistener.h" + #include "engine.h" class Engine::Private { public: Private() : engine(nullptr) + , categoriesModel(nullptr) {} KNSCore::Engine* engine; + CategoriesModel* categoriesModel; QString configFile; + KNSCore::EntryInternal::List changedEntries; }; Engine::Engine(QObject* parent) @@ -44,6 +53,11 @@ delete d; } +bool Engine::allowedByKiosk() const +{ + return KAuthorized::authorize(QStringLiteral("ghns")); +} + QString Engine::configFile() const { return d->configFile; @@ -54,18 +68,145 @@ d->configFile = newFile; emit configFileChanged(); - if(!d->engine) { - d->engine = new KNSCore::Engine(this); - connect(d->engine, &KNSCore::Engine::signalMessage, this, &Engine::message); - connect(d->engine, &KNSCore::Engine::signalIdle, this, &Engine::idleMessage); - connect(d->engine, &KNSCore::Engine::signalBusy, this, &Engine::busyMessage); - connect(d->engine, &KNSCore::Engine::signalError, this, &Engine::errorMessage); - emit engineChanged(); + if (allowedByKiosk()) { + if (!d->engine) { + d->engine = new KNSCore::Engine(this); + connect(d->engine, &KNSCore::Engine::signalMessage, this, &Engine::message); + connect(d->engine, &KNSCore::Engine::signalIdle, this, &Engine::idleMessage); + connect(d->engine, &KNSCore::Engine::signalBusy, this, &Engine::busyMessage); + connect(d->engine, &KNSCore::Engine::signalError, this, &Engine::errorMessage); + connect(d->engine, &KNSCore::Engine::signalEntryChanged, this, [this](const KNSCore::EntryInternal& entry){ + d->changedEntries << entry; + emit changedEntriesChanged(); + }); + emit engineChanged(); + KNewStuffQuick::QuickQuestionListener::instance(); + d->categoriesModel = new CategoriesModel(this); + emit categoriesChanged(); + } + d->engine->init(d->configFile); + d->engine->setSortMode(KNSCore::Provider::Downloads); + emit engineInitialized(); + } else { + // This is not an error message in the proper sense, and the message is not intended to look like an error (as there is really + // nothing the user can do to fix it, and we just tell them so they're not wondering what's wrong) + emit message(i18nc("An informational message which is shown to inform the user they are not authorized to use GetHotNewStuff functionality", "You are not authorized to Get Hot New Stuff. If you think this is in error, please contact the person in charge of your permissions.")); } - d->engine->init(d->configFile); } QObject * Engine::engine() const { return d->engine; } + +bool Engine::hasAdoptionCommand() const +{ + if (d->engine) { + return d->engine->hasAdoptionCommand(); + } + return false; +} + +QString Engine::name() const +{ + if (d->engine) { + return d->engine->name(); + } + return QString{}; +} + +QObject* Engine::categories() const +{ + return d->categoriesModel; +} + +QStringList Engine::categoriesFilter() const +{ + if (d->engine) { + return d->engine->categoriesFilter(); + } + return QStringList{}; +} + +void Engine::setCategoriesFilter(const QStringList& newCategoriesFilter) +{ + if (d->engine) { + // This ensures that if we somehow end up with any empty entries (such as the default + // option in the categories dropdowns), our list will remain empty. + QStringList filter{newCategoriesFilter}; + filter.removeAll({}); + d->engine->setCategoriesFilter(filter); + emit categoriesFilterChanged(); + } +} + +void Engine::resetCategoriesFilter() +{ + if (d->engine) { + d->engine->setCategoriesFilter(d->engine->categories()); + } +} + +int Engine::filter() const +{ + if (d->engine) { + d->engine->filter(); + } + return 0; +} + +void Engine::setFilter(int newFilter) +{ + if (d->engine) { + d->engine->setFilter(static_cast(newFilter)); + emit filterChanged(); + } +} + +int Engine::sortOrder() const +{ + if (d->engine) { + return d->engine->sortMode(); + } + return 0; +} + +void Engine::setSortOrder(int newSortOrder) +{ + if (d->engine) { + d->engine->setSortMode(static_cast(newSortOrder)); + emit sortOrderChanged(); + } +} + +QString Engine::searchTerm() const +{ + if (d->engine) { + return d->engine->searchTerm(); + } + return QString{}; +} + +void Engine::setSearchTerm(const QString& newSearchTerm) +{ + if (d->engine) { + d->engine->setSearchTerm(newSearchTerm); + emit searchTermChanged(); + } +} + +void Engine::resetSearchTerm() +{ + setSearchTerm(QString{}); +} + +KNSCore::EntryInternal::List Engine::changedEntries() const +{ + return d->changedEntries; +} + +void Engine::resetChangedEntries() +{ + d->changedEntries.clear(); + emit changedEntriesChanged(); +} diff --git a/src/qtquick/quickitemsmodel.h b/src/qtquick/quickitemsmodel.h --- a/src/qtquick/quickitemsmodel.h +++ b/src/qtquick/quickitemsmodel.h @@ -35,11 +35,14 @@ * Most data in the model is simple, but the DownloadLinks role will return a list of * DownloadLinkInfo entries, which you will need to manage in some way. * - * You might also look at NewStuffList and NewStuffItem to see some more detail on what can be - * done with the data. + * You might also look at NewStuffList, NewStuffItem, and the other items, to see some more + * detail on what can be done with the data. * * @see NewStuffList * @see NewStuffItem + * @see NewStuffPage + * @see NewStuffEntryDetails + * @see NewStuffEntryComments * * \code import org.kde.newstuff 1.0 as NewStuff @@ -62,6 +65,9 @@ class ItemsModel : public QAbstractListModel { Q_OBJECT + /** + * The NewStuffQuickEngine to show items from + */ Q_PROPERTY(QObject* engine READ engine WRITE setEngine NOTIFY engineChanged) public: explicit ItemsModel(QObject* parent = nullptr); @@ -96,8 +102,10 @@ DonationLinkRole, ProviderIdRole, SourceRole, - StatusRole + StatusRole, + CommentsModelRole }; + Q_ENUM(Roles) enum ItemStatus { InvalidStatus, DownloadableStatus, @@ -111,6 +119,7 @@ QHash< int, QByteArray > roleNames() const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + Q_INVOKABLE QVariant data(int index, int role = Qt::DisplayRole) const; int rowCount(const QModelIndex& parent = QModelIndex()) const override; bool canFetchMore(const QModelIndex & parent) const override; void fetchMore(const QModelIndex & parent) override; @@ -128,7 +137,7 @@ * * @param index The index of the item to install or update */ - Q_INVOKABLE void installItem(int index); + Q_INVOKABLE void installItem(int index, int linkId); /** * @brief Uninstall an already installed item * @@ -139,6 +148,22 @@ * @param index The index of the item to be uninstalled */ Q_INVOKABLE void uninstallItem(int index); + + /** + * @brief Run the adoption command on an already installed item + * + * @note This will simply fail quietly if the item is not installed + * + * @param index The intex of the item to be adopted + */ + Q_INVOKABLE void adoptItem(int index); + + /** + * @brief Fired when an entry's data changes + * + * @param index The index of the item which has changed + */ + Q_SIGNAL void entryChanged(int index); private: class Private; Private* d; diff --git a/src/qtquick/quickitemsmodel.cpp b/src/qtquick/quickitemsmodel.cpp --- a/src/qtquick/quickitemsmodel.cpp +++ b/src/qtquick/quickitemsmodel.cpp @@ -21,41 +21,58 @@ #include "quickitemsmodel.h" #include "quickengine.h" +#include "knewstuffquick_debug.h" #include "itemsmodel.h" #include "engine.h" #include "downloadlinkinfo.h" +#include "commentsmodel.h" + +#include +#include +#include class ItemsModel::Private { public: Private(ItemsModel* qq) : q(qq) , model(nullptr) , engine(nullptr) + , coreEngine(nullptr) {} + ~Private() + { + qDeleteAll(commentsModels); + } ItemsModel* q; KNSCore::ItemsModel* model; - KNSCore::Engine* engine; + Engine* engine; + KNSCore::Engine* coreEngine; + + QHash commentsModels; bool initModel() { - if(model) { + if (model) { return true; } - if(!engine) { + if (!coreEngine) { return false; } - model = new KNSCore::ItemsModel(engine, q); + model = new KNSCore::ItemsModel(coreEngine, q); - q->connect(engine, &KNSCore::Engine::signalProvidersLoaded, engine, &KNSCore::Engine::reloadEntries); + q->connect(coreEngine, &KNSCore::Engine::signalProvidersLoaded, coreEngine, &KNSCore::Engine::reloadEntries); // Entries have been fetched and should be shown: - q->connect(engine, &KNSCore::Engine::signalEntriesLoaded, model, &KNSCore::ItemsModel::slotEntriesLoaded); + q->connect(coreEngine, &KNSCore::Engine::signalEntriesLoaded, model, &KNSCore::ItemsModel::slotEntriesLoaded); // An entry has changes - eg because it was installed - q->connect(engine, &KNSCore::Engine::signalEntryChanged, model, &KNSCore::ItemsModel::slotEntryChanged); + q->connect(coreEngine, &KNSCore::Engine::signalEntryChanged, model, &KNSCore::ItemsModel::slotEntryChanged); + q->connect(coreEngine, &KNSCore::Engine::signalEntryChanged, q, [=](const KNSCore::EntryInternal& entry){ + emit q->entryChanged(model->row(entry)); + }); - q->connect(engine, &KNSCore::Engine::signalResetView, model, &KNSCore::ItemsModel::clearEntries); - q->connect(engine, &KNSCore::Engine::signalEntryPreviewLoaded, model, &KNSCore::ItemsModel::slotEntryPreviewLoaded); + q->connect(coreEngine, &KNSCore::Engine::signalResetView, model, &KNSCore::ItemsModel::clearEntries); + q->connect(coreEngine, &KNSCore::Engine::signalEntryPreviewLoaded, model, &KNSCore::ItemsModel::slotEntryPreviewLoaded); q->connect(model, &KNSCore::ItemsModel::rowsInserted, q, &ItemsModel::rowsInserted); q->connect(model, &KNSCore::ItemsModel::rowsRemoved, q, &ItemsModel::rowsRemoved); @@ -116,20 +133,20 @@ int ItemsModel::rowCount(const QModelIndex& parent) const { - if(parent.isValid()) + if (parent.isValid()) return 0; - if(d->initModel()) + if (d->initModel()) return d->model->rowCount(QModelIndex()); return 0; } QVariant ItemsModel::data(const QModelIndex& index, int role) const { QVariant data; - if(index.isValid() && d->initModel()) + if (index.isValid() && d->initModel()) { KNSCore::EntryInternal entry = d->model->data(d->model->index(index.row()), Qt::UserRole).value(); - switch(role) + switch (role) { case NameRole: case Qt::DisplayRole: @@ -148,10 +165,13 @@ { KNSCore::Author author = entry.author(); QVariantMap returnAuthor; + returnAuthor[QStringLiteral("id")] = author.id(); returnAuthor[QStringLiteral("name")] = author.name(); returnAuthor[QStringLiteral("email")] = author.email(); returnAuthor[QStringLiteral("homepage")] = author.homepage(); returnAuthor[QStringLiteral("jabber")] = author.jabber(); + returnAuthor[QStringLiteral("avatarUrl")] = author.avatarUrl(); + returnAuthor[QStringLiteral("description")] = author.description(); data.setValue<>(returnAuthor); } break; @@ -303,28 +323,45 @@ } } break; + case CommentsModelRole: + { + KNSCore::CommentsModel* commentsModel{nullptr}; + if (!d->commentsModels.contains(entry.uniqueId())) { + commentsModel = d->coreEngine->commentsForEntry(entry); + d->commentsModels[entry.uniqueId()] = commentsModel; + } else { + commentsModel = d->commentsModels[entry.uniqueId()]; + } + data.setValue(commentsModel); + } + break; default: data.setValue(QStringLiteral("Unknown role")); break; } } return data; } +QVariant ItemsModel::data(int index, int role) const +{ + return data(ItemsModel::index(index), role); +} + bool ItemsModel::canFetchMore(const QModelIndex& parent) const { - if(parent.isValid()) { - return false; + if (!parent.isValid() && d->coreEngine && d->coreEngine->categoriesMetadata().count() > 0) { + return true; } - return true; + return false; } void ItemsModel::fetchMore(const QModelIndex& parent) { - if(parent.isValid()) { + if (parent.isValid() || !d->coreEngine) { return; } - d->engine->requestMoreData(); + d->coreEngine->requestMoreData(); } QObject * ItemsModel::engine() const @@ -335,33 +372,53 @@ void ItemsModel::setEngine(QObject* newEngine) { beginResetModel(); - Engine* test = qobject_cast(newEngine); - if(test) { - d->engine = qobject_cast(test->engine()); - } - else { - d->engine = qobject_cast(newEngine); + d->engine = qobject_cast(newEngine); + d->model->deleteLater(); + d->model = nullptr; + d->coreEngine = nullptr; + if (d->engine) { + d->coreEngine = qobject_cast(d->engine->engine()); } + connect(d->engine, &Engine::engineChanged, this, [this](){ + beginResetModel(); + d->model->deleteLater(); + d->model = nullptr; + d->coreEngine = qobject_cast(d->engine->engine()); + endResetModel(); + }); emit engineChanged(); endResetModel(); } -void ItemsModel::installItem(int index) +void ItemsModel::installItem(int index, int linkId) { - if(d->engine) { + if (d->coreEngine) { KNSCore::EntryInternal entry = d->model->data(d->model->index(index), Qt::UserRole).value(); if(entry.isValid()) { - d->engine->install(entry); + d->coreEngine->install(entry, linkId); } } } void ItemsModel::uninstallItem(int index) { - if(d->engine) { + if (d->coreEngine) { KNSCore::EntryInternal entry = d->model->data(d->model->index(index), Qt::UserRole).value(); if(entry.isValid()) { - d->engine->uninstall(entry); + d->coreEngine->uninstall(entry); + } + } +} + +void ItemsModel::adoptItem(int index) +{ + if (d->coreEngine) { + KNSCore::EntryInternal entry = d->model->data(d->model->index(index), Qt::UserRole).value(); + if (entry.isValid()) { + QStringList args = KShell::splitArgs(d->coreEngine->adoptionCommand(entry)); + qCDebug(KNEWSTUFFQUICK) << "executing AdoptionCommand" << args; + QProcess::startDetached(args.takeFirst(), args); + d->engine->idleMessage(i18n("Using %1").arg(entry.name())); } } } diff --git a/src/qtquick/quickquestionlistener.h b/src/qtquick/quickquestionlistener.h new file mode 100644 --- /dev/null +++ b/src/qtquick/quickquestionlistener.h @@ -0,0 +1,51 @@ +/* + This file is part of KNewStuffQuick. + + Copyright (c) 2019 Dan Leinir Turthra Jensen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . +*/ + +#ifndef KNSQ_QUICKQUESTIONLISTENER_H +#define KNSQ_QUICKQUESTIONLISTENER_H + +#include "core/questionlistener.h" + +namespace KNewStuffQuick +{ +class QuickQuestionListener : public KNSCore::QuestionListener +{ + Q_OBJECT + Q_DISABLE_COPY(QuickQuestionListener) +public: + static QuickQuestionListener *instance(); + virtual ~QuickQuestionListener(); + + Q_SLOT void askQuestion(KNSCore::Question *question) override; + + Q_SIGNAL void askListQuestion(QString title, QString question, QStringList list); + Q_SIGNAL void askContinueCancelQuestion(QString title, QString question); + Q_SIGNAL void askTextInputQuestion(QString title, QString question); + Q_SIGNAL void askPasswordQuestion(QString title, QString question); + Q_SIGNAL void askYesNoQuestion(QString title, QString question); + + Q_SLOT void passResponse(bool responseIsContinue, QString input); +private: + QuickQuestionListener(); + class Private; + Private *d; +}; +} + +#endif//KNSQ_QUICKQUESTIONLISTENER_H diff --git a/src/qtquick/quickquestionlistener.cpp b/src/qtquick/quickquestionlistener.cpp new file mode 100644 --- /dev/null +++ b/src/qtquick/quickquestionlistener.cpp @@ -0,0 +1,126 @@ +/* + This file is part of KNewStuffQuick. + + Copyright (c) 2016 Dan Leinir Turthra Jensen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . +*/ + +#include "quickquestionlistener.h" + +#include "core/question.h" + +using namespace KNewStuffQuick; + +class QuickQuestionListenerHelper { +public: + QuickQuestionListenerHelper() : q(nullptr) {} + ~QuickQuestionListenerHelper() { delete q; } + QuickQuestionListenerHelper(const QuickQuestionListenerHelper &) = delete; + QuickQuestionListenerHelper& operator=(const QuickQuestionListenerHelper &) = delete; + QuickQuestionListener *q; +}; +Q_GLOBAL_STATIC(QuickQuestionListenerHelper, s_kns3_quickQuestionListener) + +class QuickQuestionListener::Private { +public: + Private() {} + KNSCore::Question* question = nullptr; +}; + +QuickQuestionListener* QuickQuestionListener::instance() +{ + if(!s_kns3_quickQuestionListener()->q) { + new QuickQuestionListener; + } + return s_kns3_quickQuestionListener()->q; +} + +QuickQuestionListener::QuickQuestionListener() + : KNSCore::QuestionListener(nullptr) + , d(new Private) +{ + s_kns3_quickQuestionListener()->q = this; +} + +QuickQuestionListener::~QuickQuestionListener() +{ + if (d->question) { + d->question->setResponse(KNSCore::Question::CancelResponse); + } + delete d; +} + +void QuickQuestionListener::askQuestion(KNSCore::Question* question) +{ + d->question = question; + switch(question->questionType()) + { + case KNSCore::Question::SelectFromListQuestion: + emit askListQuestion(question->title(), question->question(), question->list()); + break; + case KNSCore::Question::ContinueCancelQuestion: + emit askContinueCancelQuestion(d->question->title(), d->question->question()); + break; + case KNSCore::Question::InputTextQuestion: + emit askTextInputQuestion(d->question->title(), d->question->question()); + break; + case KNSCore::Question::PasswordQuestion: + emit askPasswordQuestion(d->question->title(), d->question->question()); + break; + case KNSCore::Question::YesNoQuestion: + default: + emit askYesNoQuestion(d->question->title(), d->question->question()); + break; + } +} + +void KNewStuffQuick::QuickQuestionListener::passResponse(bool responseIsContinue, QString input) +{ + if (d->question) { + if (responseIsContinue) { + d->question->setResponse(input); + switch(d->question->questionType()) + { + case KNSCore::Question::ContinueCancelQuestion: + d->question->setResponse(KNSCore::Question::ContinueResponse); + break; + case KNSCore::Question::YesNoQuestion: + d->question->setResponse(KNSCore::Question::YesResponse); + break; + case KNSCore::Question::SelectFromListQuestion: + case KNSCore::Question::InputTextQuestion: + case KNSCore::Question::PasswordQuestion: + default: + d->question->setResponse(KNSCore::Question::OKResponse); + break; + } + } else { + switch(d->question->questionType()) + { + case KNSCore::Question::YesNoQuestion: + d->question->setResponse(KNSCore::Question::NoResponse); + break; + case KNSCore::Question::SelectFromListQuestion: + case KNSCore::Question::InputTextQuestion: + case KNSCore::Question::PasswordQuestion: + case KNSCore::Question::ContinueCancelQuestion: + default: + d->question->setResponse(KNSCore::Question::CancelResponse); + break; + } + } + d->question = nullptr; + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -19,6 +19,7 @@ khotnewstuff khotnewstuff_upload khotnewstuff_test + khotnewstuff-dialog ) # FIXME: port to new API #knewstuff_executable_tests( diff --git a/tests/khotnewstuff-dialog-ui/main.qml b/tests/khotnewstuff-dialog-ui/main.qml new file mode 100644 --- /dev/null +++ b/tests/khotnewstuff-dialog-ui/main.qml @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2019 Dan Leinir Turthra Jensen + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) version 3, or any + * later version accepted by the membership of KDE e.V. (or its + * successor approved by the membership of KDE e.V.), which shall + * act as a proxy defined in Section 6 of version 3 of the license. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +import QtQuick 2.7 +import QtQuick.Dialogs 1.3 as QtDialogs +import QtQuick.Layouts 1.12 as QtLayouts +import org.kde.kirigami 2.5 as Kirigami +import org.kde.newstuff 1.1 as NewStuff + +Kirigami.ApplicationWindow { + id: root; + title: "KNewStuff Dialog" + + globalDrawer: Kirigami.GlobalDrawer { + title: "KNewStuff Dialog" + titleIcon: "get-hot-new-stuff" + drawerOpen: true; + modal: false; + topContent: NewStuff.NewStuffButton { + id: newStuffButton + QtLayouts.Layout.fillWidth: true + configFile: knsrcfile + } + + actions: [ + Kirigami.Action { + text: "Find Different Hot New Stuff..." + icon.name: "document-open" + onTriggered: { + fileDialog.open(); + } + } + ] + } + contextDrawer: Kirigami.ContextDrawer { + id: contextDrawer + } + + pageStack.defaultColumnWidth: pageStack.width + Component { + id: mainPageComponent + NewStuff.NewStuffPage { } + } + Component.onCompleted: { + pageStack.push(mainPageComponent, { configFile: knsrcfile } ); + } + + QtDialogs.FileDialog { + id: fileDialog + title: "Open KNewStuff configuration file" + folder: knsrcFilesLocation + nameFilters: [ "KNewStuff Configuration Files (*.knsrc)", "All Files (*)" ] + onAccepted: { + newStuffButton.configFile = fileDialog.fileUrl.toString().substring(7) + pageStack.clear(); + pageStack.push(mainPageComponent, { configFile: newStuffButton.configFile }); + } + } +} diff --git a/tests/khotnewstuff-dialog.cpp b/tests/khotnewstuff-dialog.cpp new file mode 100644 --- /dev/null +++ b/tests/khotnewstuff-dialog.cpp @@ -0,0 +1,52 @@ +/* + This file is part of KNewStuff2. + Copyright (c) 2019 Dan Leinir Turthra Jensen + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library. If not, see . +*/ + +#include + +#include +#include +#include +#include +#include + +#include "engine.h" + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("khotnewstuff-dialog")); + QCoreApplication::setApplicationVersion(QStringLiteral("0.1")); + QCoreApplication::setOrganizationDomain(QStringLiteral("kde.org")); + + QCommandLineParser *parser = new QCommandLineParser; + parser->addHelpOption(); + parser->addPositionalArgument(QStringLiteral("knsrcfile"), i18n("The KNSRC file you want to show. If none is passed, we will use khotnewstuff_test.knsrc, which must be installed.")); + parser->process(app); + + QQmlApplicationEngine *appengine = new QQmlApplicationEngine(); + if (parser->positionalArguments().count() > 0) { + appengine->rootContext()->setContextProperty(QLatin1String("knsrcfile"), parser->positionalArguments().first()); + } else { + appengine->rootContext()->setContextProperty(QLatin1String("knsrcfile"), QString::fromLatin1("%1/khotnewstuff_test.knsrc").arg(QStringLiteral(KNSBUILDDIR))); + } + appengine->rootContext()->setContextProperty(QLatin1String("knsrcFilesLocation"), KNSCore::Engine::configSearchLocations().last()); + + appengine->load(QUrl::fromLocalFile(QString::fromLatin1("%1/khotnewstuff-dialog-ui/main.qml").arg(QStringLiteral(KNSSRCDIR)))); + + return app.exec(); +}