Commit 4ed28b81 authored by Konrad Czapla's avatar Konrad Czapla Committed by Laurent Montel
Browse files

Improve the contacts list

Improving appearance of the main contact list by adding style delegate.
parent 84d959ce
Pipeline #32691 passed with stage
in 17 minutes and 34 seconds
......@@ -59,12 +59,14 @@ set(kaddressbook_LIB_SRCS
aboutdata.cpp
categoryfilterproxymodel.cpp
categoryselectwidget.cpp
contactinfoproxymodel.cpp
contactsorter.cpp
contactswitcher.cpp
globalcontactmodel.cpp
mainwidget.cpp
manageshowcollectionproperties.cpp
modelcolumnmanager.cpp
stylecontactlistdelegate.cpp
widgets/quicksearchwidget.cpp
kaddressbookmigrateapplication.cpp
${kaddressbook_printing_SRCS}
......
/*
This file is part of KAddressBook.
SPDX-FileCopyrightText: 2020 Konrad Czapla <kondzio89dev@gmail.com>
SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "contactinfoproxymodel.h"
#include "kaddressbook_debug.h"
#include <AkonadiCore/EntityTreeModel>
#include <AkonadiCore/Item>
#include <AkonadiCore/ItemFetchJob>
#include <AkonadiCore/ItemFetchScope>
#include <AkonadiCore/Monitor>
#include <KLocalizedString>
#include <kcontacts/addressee.h>
#include <kcontacts/contactgroup.h>
#include <KJob>
#include <QImage>
#include <QJsonObject>
#include <QRegularExpression>
ContactInfoProxyModel::ContactInfoProxyModel(QObject *parent)
: QIdentityProxyModel(parent),
mMonitor(new Akonadi::Monitor(this))
{
mMonitor->setTypeMonitored(Akonadi::Monitor::Items);
mMonitor->itemFetchScope().fetchFullPayload(true);
connect(mMonitor, &Akonadi::Monitor::itemChanged, this, &ContactInfoProxyModel::slotItemChanged);
connect(mMonitor, &Akonadi::Monitor::itemRemoved, this, &ContactInfoProxyModel::slotItemRemoved);
}
QVariant ContactInfoProxyModel::data(const QModelIndex &index, int role) const
{
if (role >= Roles::PictureRole && role <= Roles::DescriptionRole) {
const Akonadi::Item item = index.data(Akonadi::EntityTreeModel::ItemRole).value<Akonadi::Item>();
if (item.isValid()) {
if (item.hasPayload<KContacts::Addressee>()) {
const KContacts::Addressee contact = item.payload<KContacts::Addressee>();
switch (role) {
case Roles::PictureRole:
return contact.photo().data();
case Roles::InitialsRole:
return getInitials(contact);
case Roles::DescriptionRole:
return getDescription(contact);
}
} else if (item.hasPayload<KContacts::ContactGroup>()) {
const KContacts::ContactGroup groupContacts = item.payload<KContacts::ContactGroup>();
if (!mGroupsCache.contains(index)) {
mGroupsCache.insert(index, ContactCacheData::List());
}
updateCache(index, groupContacts);
if (groupFetchDone(index, groupContacts)) {
switch (role) {
case Roles::PictureRole:
return QVariant();
case Roles::InitialsRole:
return getInitials(index, groupContacts);
case Roles::DescriptionRole:
return getDescription(index, groupContacts);
}
} else if (role == Roles::DescriptionRole) {
return i18n("Loading contacts details ...");
}
if (!mPendingGroups.contains(groupContacts.id())) {
QMap<const char*, QVariant> properties{
{"groupPersistentModelIndex", QVariant::fromValue(index)},
{"groupId", QVariant::fromValue(groupContacts.id())},
};
Akonadi::Item::List groupItemsList;
QList<QJsonObject> groupRefIdsList;
for (int idx = 0; idx < groupContacts.contactReferenceCount(); ++idx) {
const KContacts::ContactGroup::ContactReference contactRef = groupContacts.contactReference(idx);
if (findCacheItem(index, contactRef) == mGroupsCache[index].cend()) {
Akonadi::Item newItem;
if (contactRef.gid().isEmpty()) {
newItem.setId(contactRef.uid().toLongLong());
} else {
newItem.setGid(contactRef.gid());
}
if (!mPendingGroups.contains(groupContacts.id())) {
mPendingGroups << groupContacts.id();
}
groupItemsList << newItem;
const QJsonObject refId {
{QStringLiteral("uid"), contactRef.uid()},
{QStringLiteral("gid"), contactRef.gid()}
};
if (!groupRefIdsList.contains(refId)) {
groupRefIdsList << refId;
}
}
}
if (groupItemsList.size() && groupRefIdsList.size()) {
properties.insert("groupRefIdsList", QVariant::fromValue(groupRefIdsList));
fetchItems(groupItemsList, properties);
}
}
return QVariant();
}
}
}
return QIdentityProxyModel::data(index, role);
}
QString ContactInfoProxyModel::getInitials(const KContacts::Addressee &contact) const
{
QString initials;
QString names = contact.realName();
names.remove(contact.prefix());
names.remove(contact.suffix());
names.remove(contact.additionalName());
const QStringList contactListNames = names.split(QRegularExpression(QStringLiteral("\\s+")));
for (const QString &name : contactListNames) {
if (!name.isEmpty()) {
initials.append(name.front());
}
}
return initials.toUpper();
}
QString ContactInfoProxyModel::getInitials(const QModelIndex &index, const KContacts::ContactGroup &groupContacts) const
{
QString initials;
for (int idx = 0; idx < groupContacts.dataCount(); idx++) {
if (!groupContacts.data(idx).name().isEmpty()) {
initials.append(groupContacts.data(idx).name().front());
}
}
for (const ContactCacheData &cacheContact : mGroupsCache[index]) {
if (!cacheContact.name.isEmpty()) {
initials.append(cacheContact.name.front());
}
}
return initials.toUpper();
}
QString ContactInfoProxyModel::getDescription(const KContacts::Addressee &contact) const
{
QString dataSeparator;
QString emailAddress;
QString phone;
if (!contact.preferredEmail().isEmpty()) {
emailAddress = i18n("Email: %1", contact.preferredEmail());
}
const QList<KContacts::PhoneNumber> phoneList = contact.phoneNumbers().toList();
QList<KContacts::PhoneNumber>::const_reverse_iterator itPhone = std::find_if(phoneList.rbegin(),
phoneList.rend(),
[&phoneList](const KContacts::PhoneNumber &phone) {
return phone.isPreferred() || phoneList.at(0) == phone;
});
if (itPhone != phoneList.rend()) {
phone = i18n("Phone: %1", (*itPhone).number());
}
if (!emailAddress.isEmpty() && !phone.isEmpty()) {
dataSeparator = QStringLiteral(",");
}
return i18n("%1%2 %3", emailAddress, dataSeparator, phone).trimmed();
}
QString ContactInfoProxyModel::getDescription(const QModelIndex &index, const KContacts::ContactGroup &groupContacts) const
{
QStringList groupDescription;
QString contactDescription;
for (int idx = 0; idx < groupContacts.dataCount(); idx++) {
QString dataSeparator;
if (!groupContacts.data(idx).name().isEmpty() &&
!groupContacts.data(idx).email().isEmpty()) {
dataSeparator = QStringLiteral("-");
}
contactDescription = i18n("%1 %2 %3", groupContacts.data(idx).name(), dataSeparator, groupContacts.data(idx).email());
groupDescription << contactDescription.trimmed();
contactDescription.clear();
}
for (int idx = 0; idx < groupContacts.contactReferenceCount(); ++idx) {
const KContacts::ContactGroup::ContactReference contactRef = groupContacts.contactReference(idx);
ContactCacheData::ConstListIterator it = findCacheItem(index, contactRef);
if (it != mGroupsCache[index].end()) {
QString cacheSeparator, email;
email = contactRef.preferredEmail().isEmpty() ? it->email : contactRef.preferredEmail();
if (it->name.isEmpty() && email.isEmpty()) {
continue;
} else if (!it->name.isEmpty() && !email.isEmpty()) {
cacheSeparator = QStringLiteral("-");
}
contactDescription = i18n("%1 %2 %3", it->name, cacheSeparator, email);
groupDescription << contactDescription.trimmed();
contactDescription.clear();
}
}
return groupDescription.join(QStringLiteral(", "));
}
void ContactInfoProxyModel::updateCache(const QModelIndex &index, const KContacts::ContactGroup &groupContacts) const
{
mGroupsCache[index].erase(std::remove_if(mGroupsCache[index].begin(), mGroupsCache[index].end(),
[&groupContacts](const ContactCacheData &cacheContact) -> bool {
for (int idx = 0; idx < groupContacts.contactReferenceCount(); ++idx)
{
const KContacts::ContactGroup::ContactReference &reference = groupContacts.contactReference(idx);
if (cacheContact == reference) {
return false;
}
}
return true;
}), mGroupsCache[index].end());
}
bool ContactInfoProxyModel::groupFetchDone(const QModelIndex &index, const KContacts::ContactGroup &groupContacts) const
{
QStringList contactRefIds;
QStringList contactCacheIds;
auto sortFunc = [](const QString &lhs, const QString &rhs) -> bool {
return lhs.toLongLong() < rhs.toLongLong();
};
for (int idx = 0; idx < groupContacts.contactReferenceCount(); ++idx) {
const KContacts::ContactGroup::ContactReference &reference = groupContacts.contactReference(idx);
contactRefIds += reference.gid().isEmpty() ? reference.uid() : reference.gid();
}
std::sort(contactRefIds.begin(), contactRefIds.end(), sortFunc);
contactRefIds.erase(std::unique(contactRefIds.begin(), contactRefIds.end()), contactRefIds.end());
for (const auto &cacheContact : mGroupsCache[index]) {
contactCacheIds += cacheContact.gid.isEmpty() ? cacheContact.uid : cacheContact.gid;
}
std::sort(contactCacheIds.begin(), contactCacheIds.end(), sortFunc);
return std::equal(contactRefIds.begin(), contactRefIds.end(), contactCacheIds.begin(), contactCacheIds.end());
}
ContactInfoProxyModel::ContactCacheData::ListIterator ContactInfoProxyModel::findCacheItem(const QModelIndex &index,
const ContactInfoProxyModel::ContactCacheData &cacheContact)
{
ContactCacheData::ListIterator it = std::find_if(mGroupsCache[index].begin(), mGroupsCache[index].end(),
[&cacheContact](const ContactCacheData &contact) -> bool
{ return contact == cacheContact; });
return it;
}
ContactInfoProxyModel::ContactCacheData::ConstListIterator ContactInfoProxyModel::findCacheItem(const QModelIndex &index,
const ContactInfoProxyModel::ContactCacheData &cacheContact) const
{
ContactCacheData::ConstListIterator it = std::find_if(mGroupsCache[index].cbegin(), mGroupsCache[index].cend(),
[&cacheContact](const ContactCacheData &contact) -> bool
{ return contact == cacheContact; });
return it;
}
void ContactInfoProxyModel::fetchItems(const Akonadi::Item::List &items, const QMap<const char*, QVariant> &properties) const
{
Akonadi::ItemFetchJob *job = new Akonadi::ItemFetchJob(items);
job->fetchScope().fetchFullPayload();
job->fetchScope().setIgnoreRetrievalErrors(true);
for (const auto &property : properties.toStdMap()) {
job->setProperty(property.first, property.second);
}
connect(job, &Akonadi::ItemFetchJob::result, this, &ContactInfoProxyModel::slotFetchJobFinished);
}
void ContactInfoProxyModel::slotFetchJobFinished(KJob *job)
{
if (job->error()) {
qCWarning(KADDRESSBOOK_LOG) << " error during fetching items" << job->errorString();
return;
}
Akonadi::ItemFetchJob *fetchJob = qobject_cast<Akonadi::ItemFetchJob *>(job);
const QPersistentModelIndex index = job->property("groupPersistentModelIndex").value<QPersistentModelIndex>();
const QString groupId = job->property("groupId").value<QString>();
const QList<QJsonObject> groupRefIdsList = job->property("groupRefIdsList").value<QList<QJsonObject>>();
for (const QJsonObject &refId : groupRefIdsList) {
ContactCacheData cacheContact;
cacheContact.gid = refId[QStringLiteral("gid")].toString();
cacheContact.uid = refId[QStringLiteral("uid")].toString();
for (const Akonadi::Item &item : fetchJob->items()) {
if (item.isValid()) {
if ((!item.gid().isEmpty() && refId[QStringLiteral("gid")].toString() == item.gid()) ||
QString::number(item.id()) == refId[QStringLiteral("uid")].toString()) {
if (item.hasPayload<KContacts::Addressee>()) {
mMonitor->setItemMonitored(item);
const KContacts::Addressee contact = item.payload<KContacts::Addressee>();
cacheContact.name = contact.realName();
cacheContact.email = contact.preferredEmail();
}
}
}
}
if (mGroupsCache.contains(index)) {
mGroupsCache[index].append(cacheContact);
}
}
Q_EMIT dataChanged(index, index, mKrole);
if (mPendingGroups.contains(groupId)) {
mPendingGroups.removeOne(groupId);
}
}
void ContactInfoProxyModel::slotItemChanged(const Akonadi::Item &item, const QSet<QByteArray> &partIdentifiers)
{
Q_UNUSED(partIdentifiers)
for (const QPersistentModelIndex &index : mGroupsCache.keys()) {
ContactCacheData::ListIterator it = findCacheItem(index, item);
if (it != mGroupsCache[index].end()) {
if (item.isValid()) {
if (item.hasPayload<KContacts::Addressee>()) {
const KContacts::Addressee contact = item.payload<KContacts::Addressee>();
it->uid = QString::number(item.id());
it->gid = item.gid();
it->name = contact.realName();
it->email = contact.preferredEmail();
Q_EMIT dataChanged(index, index, mKrole);
}
}
}
}
}
void ContactInfoProxyModel::slotItemRemoved(const Akonadi::Item &item)
{
if (item.isValid()) {
for (const QPersistentModelIndex &index : mGroupsCache.keys()) {
ContactCacheData::List::iterator it = findCacheItem(index, item);
if (it != mGroupsCache[index].end()) {
mGroupsCache[index].erase(it);
Q_EMIT dataChanged(index, index, mKrole);
}
}
}
}
bool operator==(const ContactInfoProxyModel::ContactCacheData &lhs, const ContactInfoProxyModel::ContactCacheData &rhs)
{
return !lhs.gid.isEmpty() ? lhs.gid == rhs.gid : !lhs.uid.isEmpty() ? lhs.uid == rhs.uid : false;
}
/*
This file is part of KAddressBook.
SPDX-FileCopyrightText: 2020 Konrad Czapla <kondzio89dev@gmail.com>
SPDX-License-Identifier: GPL-2.0-or-later
*/
#ifndef CONTACTINFOPROXYMODEL_H
#define CONTACTINFOPROXYMODEL_H
#include <AkonadiCore/EntityTreeModel>
#include <kcontacts/contactgroup.h>
#include <QObject>
#include <QIdentityProxyModel>
#include <QPersistentModelIndex>
namespace Akonadi
{
class Item;
class Monitor;
}
namespace KContacts
{
class Addressee;
class ContactGroup;
}
class ContactInfoProxyModel : public QIdentityProxyModel
{
Q_OBJECT
public:
enum Roles {
PictureRole = Akonadi::EntityTreeModel::UserRole,
InitialsRole,
DescriptionRole,
};
explicit ContactInfoProxyModel(QObject *parent = nullptr);
QVariant data(const QModelIndex &index, int role) const override;
private:
class ContactCacheData
{
public:
ContactCacheData() = default;
~ContactCacheData() = default;
ContactCacheData(const ContactCacheData &) = default;
ContactCacheData(const KContacts::ContactGroup::ContactReference &other)
: uid(other.uid()), gid(other.gid()) {
}
ContactCacheData(const Akonadi::Item &other) {
uid = QString::number(other.id());
gid = other.gid();
}
friend bool operator==(const ContactCacheData &lhs, const ContactCacheData &rhs);
using List = QVector<ContactCacheData>;
using ListIterator = ContactCacheData::List::iterator;
using ConstListIterator = ContactCacheData::List::ConstIterator;
QString uid;
QString gid;
QString name;
QString email;
};
Q_REQUIRED_RESULT QString getInitials(const KContacts::Addressee &contact) const;
Q_REQUIRED_RESULT QString getInitials(const QModelIndex &index, const KContacts::ContactGroup &groupContacts) const;
Q_REQUIRED_RESULT QString getDescription(const KContacts::Addressee &contact) const;
Q_REQUIRED_RESULT QString getDescription(const QModelIndex &index, const KContacts::ContactGroup &groupContacts) const;
void updateCache(const QModelIndex &index, const KContacts::ContactGroup &groupContacts) const;
Q_REQUIRED_RESULT bool groupFetchDone(const QModelIndex &index, const KContacts::ContactGroup &groupContacts) const;
Q_REQUIRED_RESULT ContactCacheData::ListIterator findCacheItem(const QModelIndex &index, const ContactCacheData &cacheContact);
Q_REQUIRED_RESULT ContactCacheData::ConstListIterator findCacheItem(const QModelIndex &index, const ContactCacheData &cacheContact) const;
void fetchItems(const Akonadi::Item::List &items, const QMap<const char*, QVariant> &properties) const;
void slotFetchJobFinished(KJob *job);
void slotItemChanged(const Akonadi::Item &item, const QSet<QByteArray> &partIdentifiers);
void slotItemRemoved(const Akonadi::Item &item);
friend bool operator==(const ContactCacheData &lhs, const ContactCacheData &rhs);
using Cache = QMap<QPersistentModelIndex, ContactCacheData::List>;
mutable Cache mGroupsCache;
mutable QStringList mPendingGroups;
const QVector<int>mKrole {PictureRole, InitialsRole, DescriptionRole};
Akonadi::Monitor *mMonitor = nullptr;
};
#endif // CONTACTINFOPROXYMODEL_H
......@@ -8,6 +8,8 @@
#include "mainwidget.h"
#include "stylecontactlistdelegate.h"
#include "contactinfoproxymodel.h"
#include "contactswitcher.h"
#include "globalcontactmodel.h"
#include "modelcolumnmanager.h"
......@@ -243,6 +245,9 @@ MainWidget::MainWidget(KXMLGUIClient *guiClient, QWidget *parent)
mContactsFilterModel = new Akonadi::ContactsFilterProxyModel(this);
mContactsFilterModel->setSourceModel(mCategoryFilterModel);
ContactInfoProxyModel *contactInfoProxyModel = new ContactInfoProxyModel(this);
contactInfoProxyModel->setSourceModel(mContactsFilterModel);
connect(mQuickSearchWidget, &QuickSearchWidget::filterStringChanged,
mContactsFilterModel, &Akonadi::ContactsFilterProxyModel::setFilterString);
......@@ -250,7 +255,8 @@ MainWidget::MainWidget(KXMLGUIClient *guiClient, QWidget *parent)
this, &MainWidget::selectFirstItem);
connect(mQuickSearchWidget, &QuickSearchWidget::arrowDownKeyPressed,
this, &MainWidget::setFocusToTreeView);
mItemView->setModel(mContactsFilterModel);
mItemView->setModel(contactInfoProxyModel);
mItemView->setItemDelegate(new StyleContactListDelegate(this));
mItemView->setXmlGuiClient(guiClient);
mItemView->setSelectionMode(QAbstractItemView::ExtendedSelection);
mItemView->setRootIsDecorated(false);
......
/*
This file is part of KAddressBook.
SPDX-FileCopyrightText: 2020 Konrad Czapla <kondzio89dev@gmail.com>
SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "stylecontactlistdelegate.h"
#include "contactinfoproxymodel.h"
#include <Akonadi/Contact/ContactsTreeModel>
#include <KLocalizedString>
#include <QApplication>
#include <QPainter>
#include <QPainterPath>
#include <QImage>
StyleContactListDelegate::StyleContactListDelegate(QObject *parent)
: QStyledItemDelegate(parent),
mKImageSize(50, 50)
{
}
void StyleContactListDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
Q_ASSERT(index.isValid());
if (Akonadi::ContactsTreeModel::Column::FullName == index.column()) {
QApplication::style()->drawPrimitive(QStyle::PE_PanelItemViewItem, &option, painter);
const QRectF optionRect = option.rect.marginsRemoved(QMargins() + static_cast<int>(mKMargin));
QRectF pictureRect = QRectF(optionRect.topLeft(), mKImageSize);
if (mKImageSize.width() > optionRect.size().width()) {
const qreal width = option.rect.size().width();
const qreal height = option.rect.size().height();
const QMargins fitMargins(0, (qMax(width, height) - qMin(width, height)) / qreal(2),
0, (qMax(width, height) - qMin(width, height)) / qreal(2));
pictureRect = optionRect.marginsRemoved(fitMargins);
}
QRectF nameTextRect(optionRect.topLeft(), QSize(optionRect.width(), optionRect.height() / qreal(2)));
QRectF descriptionTextRect = nameTextRect;
descriptionTextRect.moveBottomLeft(optionRect.bottomLeft());
QMargins textMargin;
switch (static_cast<int>(option.widget->layoutDirection())) {
case Qt::LayoutDirection::LeftToRight:
{
textMargin.setLeft(mKMargin);
nameTextRect.setLeft(pictureRect.bottomRight().x());
nameTextRect = nameTextRect.marginsRemoved(textMargin);
descriptionTextRect.setLeft(pictureRect.bottomRight().x());
descriptionTextRect = descriptionTextRect.marginsRemoved(textMargin);
}
break;
case Qt::LayoutDirection::RightToLeft:
{
pictureRect.moveRight(optionRect.bottomRight().x());
textMargin.setRight(mKMargin);
nameTextRect.setRight(pictureRect.bottomLeft().x());
nameTextRect = nameTextRect.marginsRemoved(textMargin);
descriptionTextRect.setRight(pictureRect.bottomLeft().x());
descriptionTextRect = descriptionTextRect.marginsRemoved(textMargin);
}
break;
}
QPainterPath path;
path.addEllipse(pictureRect);
painter->save();
painter->setClipPath(path);
painter->setPen(QPen(Qt::darkGray, qreal(4)));
painter->setRenderHint(QPainter::Antialiasing);
painter->drawPath(path);
painter->setFont(QFont(option.font.family(), 12, QFont::Bold, true));
if (index.data(ContactInfoProxyModel::Roles::PictureRole).value<QImage>().isNull()) {
const QString initials = index.data(ContactInfoProxyModel::Roles::InitialsRole).value<QString>();
painter->drawText(pictureRect, Qt::AlignCenter, painter->fontMetrics().elidedText(initials,
Qt::ElideRight, pictureRect.width() - qreal(10)));