feat: email grouping by date + stability fixes

- EmailTreeModel: hierarchical model for grouped email view (QTreeView)
- DateGroupProxyModel: proxy model for date-based grouping logic
- MailListView: migrated from QTableView to QTreeView with grouping support
- ReaderView: subject frame with shadow, avatar moved to header, body inside header
- MainWindow: frameless with rounded corners, 1px border, edge resize, no status bar

- Stability fixes for grouping mode switch:
  * Proper tree cleanup (no double-delete of root)
  * Bounds checking on all array accesses
  * Null pointer checks in index/parent/data methods
  * Date validity checks before grouping
  * Index validation before accessing m_emails
This commit is contained in:
2026-08-24 01:04:00 +02:00
parent e28ab35fa9
commit 2281b01f49
19 changed files with 1998 additions and 219 deletions
+101 -74
View File
@@ -1,7 +1,18 @@
#include "ui/maillistview.h"
#include <QDateTime>
#include "ui/models/EmailListModel.h"
#include "ui/models/EmailTreeModel.h"
#include <QSortFilterProxyModel>
#include <QVBoxLayout>
#include <QPushButton>
#include <QLabel>
#include <QLineEdit>
#include <QCheckBox>
#include <QHBoxLayout>
#include <QComboBox>
#include <QHeaderView>
MailListView::MailListView(QWidget *parent) : QWidget(parent) {
MailListView::MailListView(QWidget *parent) : QWidget(parent), m_sourceModel(nullptr) {
setupUI();
}
@@ -10,29 +21,6 @@ void MailListView::setupUI() {
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
// Header bar
QWidget *headerBar = new QWidget();
headerBar->setFixedHeight(48);
headerBar->setStyleSheet("background-color: #1976D2;");
QHBoxLayout *headerLayout = new QHBoxLayout(headerBar);
headerLayout->setContentsMargins(16, 0, 10, 0);
QLabel *title = new QLabel("Wino Mail");
title->setStyleSheet("color: white; font-size: 18px; font-weight: bold;");
headerLayout->addWidget(title);
headerLayout->addStretch();
m_composeButton = new QPushButton("");
m_composeButton->setFixedSize(36, 36);
m_composeButton->setStyleSheet(
"QPushButton { background-color: #e0e0e0; border-radius: 4px; font-size: 18px; color: #333; }"
"QPushButton:hover { background-color: #d0d0d0; }"
);
headerLayout->addWidget(m_composeButton);
connect(m_composeButton, &QPushButton::clicked, this, &MailListView::composeRequested);
layout->addWidget(headerBar);
// Search bar
QWidget *searchBar = new QWidget();
searchBar->setFixedHeight(44);
@@ -76,83 +64,122 @@ void MailListView::setupUI() {
filterLayout->addStretch();
connect(m_unreadOnlyCheck, &QCheckBox::toggled, this, &MailListView::onFilterChanged);
connect(m_flaggedOnlyCheck, &QCheckBox::toggled, this, &MailListView::onFilterChanged);
connect(m_hasAttachmentsCheck, &QCheckBox::toggled, this, &MailListView::onFilterChanged);
// Group by combo
m_groupByCombo = new QComboBox();
m_groupByCombo->addItem("Sin agrupación");
m_groupByCombo->addItem("Agrupar por fecha");
m_groupByCombo->setFixedWidth(180);
m_groupByCombo->setStyleSheet(
"QComboBox { border: 1px solid #d1d1d6; border-radius: 4px; padding: 4px 8px; "
"background: white; min-height: 20px; }"
"QComboBox:hover { border-color: #bdbdbd; }"
"QComboBox:focus { border-color: #1976D2; }"
);
connect(m_groupByCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &MailListView::onGroupByChanged);
filterLayout->addWidget(m_groupByCombo);
layout->addWidget(filterBar);
// Table
m_tableView = new QTableView();
m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows);
m_tableView->setSelectionMode(QAbstractItemView::SingleSelection);
m_tableView->setShowGrid(false);
m_tableView->setAlternatingRowColors(true);
m_tableView->verticalHeader()->hide();
m_tableView->horizontalHeader()->setStretchLastSection(true);
m_tableView->horizontalHeader()->setSectionsClickable(true);
m_tableView->setSortingEnabled(true);
m_tableView->setFrameShape(QFrame::NoFrame);
m_tableView->setStyleSheet(
"QTableView { background-color: #ffffff; alternate-background-color: #f9f9fb; border: none; }"
"QTableView::item { padding: 8px; border-bottom: 1px solid #e8e8ed; }"
"QTableView::item:selected { background-color: #e3f2fd; color: #1a1a2e; }"
// Tree View
m_treeView = new QTreeView();
m_treeView->setSelectionBehavior(QAbstractItemView::SelectRows);
m_treeView->setSelectionMode(QAbstractItemView::SingleSelection);
m_treeView->setAlternatingRowColors(true);
m_treeView->header()->setStretchLastSection(false);
m_treeView->header()->setSectionsClickable(true);
m_treeView->setSortingEnabled(false); // We handle sorting in the model
m_treeView->setFrameShape(QFrame::NoFrame);
m_treeView->setRootIsDecorated(true);
m_treeView->setItemsExpandable(true);
m_treeView->setUniformRowHeights(true);
m_treeView->setStyleSheet(
"QTreeView { background-color: #ffffff; alternate-background-color: #f9f9fb; border: none; }"
"QTreeView::item { padding: 8px; border-bottom: 1px solid #e8e8ed; }"
"QTreeView::item:selected { background-color: #e3f2fd; color: #1a1a2e; }"
"QTreeView::branch { background: transparent; }"
"QHeaderView::section { background-color: #f5f5f7; padding: 8px; border: none; border-bottom: 1px solid #d1d1d6; font-weight: 600; color: #555; }"
);
// Tree model
m_treeModel = new EmailTreeModel(this);
m_proxyModel = new QSortFilterProxyModel(this);
m_proxyModel->setSortRole(EmailListModel::DateRole);
m_proxyModel->setSortCaseSensitivity(Qt::CaseInsensitive);
m_proxyModel->setDynamicSortFilter(true);
m_proxyModel->setSourceModel(m_treeModel);
m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
m_proxyModel->setFilterKeyColumn(-1); // Search all columns
m_tableView->setModel(m_proxyModel);
m_treeView->setModel(m_proxyModel);
connect(m_tableView, &QTableView::clicked, this, &MailListView::onRowSelected);
connect(m_tableView, &QTableView::doubleClicked, this, [this](const QModelIndex &index) {
// Column widths
m_treeView->header()->setSectionResizeMode(EmailTreeModel::ColSubject, QHeaderView::Stretch);
m_treeView->header()->setSectionResizeMode(EmailTreeModel::ColSender, QHeaderView::Stretch);
m_treeView->header()->setSectionResizeMode(EmailTreeModel::ColDate, QHeaderView::ResizeToContents);
connect(m_treeView, &QTreeView::clicked, this, &MailListView::onRowSelected);
connect(m_treeView, &QTreeView::doubleClicked, this, [this](const QModelIndex &index) {
if (!index.isValid()) return;
QModelIndex sourceIndex = m_proxyModel->mapToSource(index);
int mailId = sourceIndex.data(EmailListModel::IdRole).toInt();
emit emailOpenRequested(mailId);
QModelIndex proxyIdx = m_proxyModel->mapToSource(index);
if (proxyIdx.isValid() && proxyIdx.data(EmailTreeModel::MailIdRole).isValid()) {
int mailId = proxyIdx.data(EmailTreeModel::MailIdRole).toInt();
emit emailOpenRequested(mailId);
}
});
layout->addWidget(m_tableView);
// Expand all groups by default
m_treeView->expandAll();
layout->addWidget(m_treeView);
// Connect filter checkboxes
connect(m_unreadOnlyCheck, &QCheckBox::toggled, this, &MailListView::onFilterChanged);
connect(m_flaggedOnlyCheck, &QCheckBox::toggled, this, &MailListView::onFilterChanged);
connect(m_hasAttachmentsCheck, &QCheckBox::toggled, this, &MailListView::onFilterChanged);
}
void MailListView::setModel(EmailListModel *model) {
m_proxyModel->setSourceModel(model);
// Hide columns we don't want to show (by index now, not by role)
// Columns: 0=Subject, 1=Sender, 2=Date
// All visible for now - we can hide via header data
m_sourceModel = model;
// Default sort by date descending (column 2)
m_tableView->sortByColumn(EmailListModel::ColDate, Qt::DescendingOrder);
// Connect to source model changes to refresh tree
connect(m_sourceModel, &QAbstractItemModel::modelReset, this, &MailListView::refreshTreeModel);
connect(m_sourceModel, &QAbstractItemModel::layoutChanged, this, &MailListView::refreshTreeModel);
connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this, &MailListView::refreshTreeModel);
connect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this, &MailListView::refreshTreeModel);
// Initial population
refreshTreeModel();
}
// Set column resize modes
m_tableView->horizontalHeader()->setSectionResizeMode(EmailListModel::ColSubject, QHeaderView::Stretch);
m_tableView->horizontalHeader()->setSectionResizeMode(EmailListModel::ColSender, QHeaderView::Stretch);
m_tableView->horizontalHeader()->setSectionResizeMode(EmailListModel::ColDate, QHeaderView::ResizeToContents);
void MailListView::refreshTreeModel() {
if (!m_sourceModel) return;
const QVector<MailItem> &emails = m_sourceModel->emails();
m_treeModel->setEmails(emails);
m_treeView->expandAll();
}
void MailListView::onRowSelected(const QModelIndex &index) {
if (!index.isValid()) return;
QModelIndex sourceIndex = m_proxyModel->mapToSource(index);
int mailId = sourceIndex.data(EmailListModel::IdRole).toInt();
emit emailSelected(mailId);
QModelIndex proxyIdx = m_proxyModel->mapToSource(index);
if (proxyIdx.isValid() && proxyIdx.data(EmailTreeModel::MailIdRole).isValid()) {
int mailId = proxyIdx.data(EmailTreeModel::MailIdRole).toInt();
emit emailSelected(mailId);
}
}
void MailListView::onSearchTextChanged(const QString &text) {
if (auto *model = qobject_cast<EmailListModel*>(m_proxyModel->sourceModel())) {
model->setSearchFilter(text);
}
m_proxyModel->setFilterFixedString(text);
}
void MailListView::onFilterChanged() {
if (auto *model = qobject_cast<EmailListModel*>(m_proxyModel->sourceModel())) {
model->setShowUnreadOnly(m_unreadOnlyCheck->isChecked());
model->setShowFlaggedOnly(m_flaggedOnlyCheck->isChecked());
model->setShowHasAttachments(m_hasAttachmentsCheck->isChecked());
}
if (!m_sourceModel) return;
m_sourceModel->setShowUnreadOnly(m_unreadOnlyCheck->isChecked());
m_sourceModel->setShowFlaggedOnly(m_flaggedOnlyCheck->isChecked());
m_sourceModel->setShowHasAttachments(m_hasAttachmentsCheck->isChecked());
// refreshTreeModel will be called automatically via modelReset signal
}
void MailListView::onGroupByChanged(int index) {
bool enabled = (index == 1); // 0 = Sin agrupación, 1 = Agrupar por fecha
m_treeModel->setGroupMode(enabled ? EmailTreeModel::GroupByDate : EmailTreeModel::NoGrouping);
m_treeView->expandAll();
}
+10 -3
View File
@@ -1,7 +1,7 @@
#pragma once
#include <QWidget>
#include <QTableView>
#include <QTreeView>
#include <QHeaderView>
#include <QSortFilterProxyModel>
#include <QVBoxLayout>
@@ -10,8 +10,10 @@
#include <QLineEdit>
#include <QCheckBox>
#include <QHBoxLayout>
#include <QComboBox>
#include "ui/models/EmailListModel.h"
#include "ui/models/EmailTreeModel.h"
class MailListView : public QWidget {
Q_OBJECT
@@ -21,7 +23,7 @@ public:
~MailListView() override = default;
void setModel(EmailListModel *model);
QTableView* tableView() const { return m_tableView; }
QTreeView* treeView() const { return m_treeView; }
signals:
void emailSelected(int mailId);
@@ -32,15 +34,20 @@ private slots:
void onRowSelected(const QModelIndex &index);
void onSearchTextChanged(const QString &text);
void onFilterChanged();
void onGroupByChanged(int index);
private:
void setupUI();
void refreshTreeModel();
QTableView *m_tableView;
QTreeView *m_treeView;
EmailTreeModel *m_treeModel;
QSortFilterProxyModel *m_proxyModel;
EmailListModel *m_sourceModel = nullptr;
QPushButton *m_composeButton;
QLineEdit *m_searchEdit;
QCheckBox *m_unreadOnlyCheck;
QCheckBox *m_flaggedOnlyCheck;
QCheckBox *m_hasAttachmentsCheck;
QComboBox *m_groupByCombo;
};
+1 -1
View File
@@ -385,7 +385,7 @@ void MainMainWindow::onFolderSelected(const QModelIndex &index)
m_emailModel->refresh();
// Clear selection and show placeholder
m_currentMailId = -1;
m_mailListView->tableView()->clearSelection();
m_mailListView->treeView()->clearSelection();
m_viewerStack->setCurrentIndex(0); // placeholder
}
}
+173
View File
@@ -0,0 +1,173 @@
#include "DateGroupProxyModel.h"
#include <QDateTime>
#include <QDate>
#include <QDebug>
#include "EmailListModel.h"
DateGroupProxyModel::DateGroupProxyModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
setDynamicSortFilter(true);
}
void DateGroupProxyModel::setGroupByDate(bool enabled)
{
if (m_groupByDate == enabled) return;
m_groupByDate = enabled;
invalidate();
}
bool DateGroupProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
if (!m_groupByDate) {
return QSortFilterProxyModel::filterAcceptsRow(sourceRow, sourceParent);
}
// When grouping, we don't filter rows, we just organize them
return true;
}
bool DateGroupProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
// First sort by group, then by date within group (descending)
if (m_groupByDate) {
int leftGroup = groupForRow(left.row());
int rightGroup = groupForRow(right.row());
if (leftGroup != rightGroup) {
return leftGroup < rightGroup;
}
// Same group - sort by date descending (newest first)
QVariant leftDate = sourceModel()->data(left, EmailListModel::DateRole);
QVariant rightDate = sourceModel()->data(right, EmailListModel::DateRole);
if (leftDate.isValid() && rightDate.isValid()) {
return leftDate.toDateTime() > rightDate.toDateTime();
}
}
return QSortFilterProxyModel::lessThan(left, right);
}
QString DateGroupProxyModel::groupNameForDate(const QDateTime &date)
{
if (!date.isValid()) return "Sin fecha";
QDate emailDate = date.date();
QDate today = QDate::currentDate();
int daysDiff = today.daysTo(emailDate);
if (daysDiff == 0) {
return "Hoy";
} else if (daysDiff == -1) {
return "Ayer";
} else if (daysDiff > -7 && daysDiff < 0) {
// This week (Monday to Sunday)
return emailDate.dayOfWeek() == 1 ? "Esta semana (Lunes)" : "Esta semana";
} else if (daysDiff >= -13 && daysDiff <= -7) {
return "Semana pasada";
} else if (daysDiff >= -20 && daysDiff <= -14) {
return "Hace dos semanas";
} else if (emailDate.month() == today.month() && emailDate.year() == today.year()) {
return "Este mes";
} else if (emailDate.year() == today.year()) {
// Month name
static const QString months[] = {
"Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"
};
return months[emailDate.month() - 1];
} else {
return QString("%1").arg(emailDate.year());
}
}
void DateGroupProxyModel::updateGroupCache() const
{
m_groupRowCounts.clear();
m_groupStartRows.clear();
int rowCount = this->rowCount();
if (rowCount == 0) return;
QString lastGroup = "";
int currentGroup = -1;
for (int i = 0; i < rowCount; ++i) {
QModelIndex idx = index(i, 0);
QString group = idx.data(GroupRole).toString();
if (group != lastGroup) {
currentGroup++;
lastGroup = group;
m_groupStartRows.append(i);
m_groupRowCounts.append(1);
} else {
m_groupRowCounts[currentGroup]++;
}
}
}
int DateGroupProxyModel::groupForRow(int row) const
{
if (row < 0 || row >= rowCount()) return -1;
if (m_groupStartRows.isEmpty()) {
updateGroupCache();
}
// Binary search for group
int group = 0;
for (int i = m_groupStartRows.size() - 1; i >= 0; --i) {
if (row >= m_groupStartRows[i]) {
return i;
}
}
return 0;
}
QString DateGroupProxyModel::groupNameForRow(int row) const
{
if (row < 0 || row >= rowCount()) return "";
QModelIndex idx = index(row, 0);
return idx.data(GroupRole).toString();
}
QVariant DateGroupProxyModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid()) return QVariant();
if (role == GroupRole) {
// Return group name for this row
QModelIndex sourceIdx = mapToSource(index);
QVariant dateVar = sourceModel()->data(sourceIdx, EmailListModel::DateRole);
if (dateVar.isValid()) {
return groupNameForDate(dateVar.toDateTime());
}
return "Sin fecha";
}
if (role == IsGroupHeaderRole) {
// This would be used if we inserted header rows - not implemented yet
return false;
}
if (role == GroupSectionRole) {
return groupForRow(index.row());
}
return QSortFilterProxyModel::data(index, role);
}
QVariant DateGroupProxyModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (role == GroupRole && orientation == Qt::Horizontal && m_groupByDate) {
// For the date column, we might want to show group headers
// This is for column headers, not row grouping
}
return QSortFilterProxyModel::headerData(section, orientation, role);
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <QSortFilterProxyModel>
#include <QDateTime>
#include <QVector>
#include <QPair>
class DateGroupProxyModel : public QSortFilterProxyModel
{
Q_OBJECT
public:
enum GroupBy {
GroupByDate = 0
};
explicit DateGroupProxyModel(QObject *parent = nullptr);
// Grouping methods
void setGroupByDate(bool enabled);
bool isGroupByDateEnabled() const { return m_groupByDate; }
// Override to provide group headers
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
// For grouping - we'll use a custom role for group identification
static constexpr int GroupRole = Qt::UserRole + 100;
static constexpr int IsGroupHeaderRole = Qt::UserRole + 101;
static constexpr int GroupSectionRole = Qt::UserRole + 102;
// Helper to get group name for a date
static QString groupNameForDate(const QDateTime &date);
protected:
bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
private:
bool m_groupByDate = false;
mutable QVector<int> m_groupRowCounts; // cached group sizes
mutable QVector<int> m_groupStartRows; // cached start rows for each group
void updateGroupCache() const;
int groupForRow(int row) const;
QString groupNameForRow(int row) const;
};
+3
View File
@@ -53,6 +53,9 @@ public:
void setShowFlaggedOnly(bool show);
void setShowHasAttachments(bool show);
// Get all emails currently in the model (after filtering)
const QVector<MailItem>& emails() const { return m_emails; }
private:
QVector<MailItem> m_emails;
int m_folderId{-1}; // -1 means all folders
+325
View File
@@ -0,0 +1,325 @@
#include "EmailTreeModel.h"
#include <QDateTime>
#include <QDate>
#include <QDebug>
#include <algorithm>
EmailTreeModel::EmailTreeModel(QObject *parent)
: QAbstractItemModel(parent), m_rootItem(new TreeItem(TreeItem::Group))
{
m_rootItem->type = TreeItem::Group;
m_rootItem->groupName = "Root";
}
EmailTreeModel::~EmailTreeModel()
{
deleteTree(m_rootItem);
}
void EmailTreeModel::deleteTree(TreeItem* item)
{
if (!item) return;
qDeleteAll(item->children);
delete item;
}
QModelIndex EmailTreeModel::index(int row, int column, const QModelIndex &parent) const
{
if (!hasIndex(row, column, parent))
return QModelIndex();
TreeItem* parentItem;
if (!parent.isValid())
parentItem = m_rootItem;
else
parentItem = static_cast<TreeItem*>(parent.internalPointer());
// Verify parentItem is valid
if (!parentItem)
return QModelIndex();
if (row < 0 || row >= parentItem->children.size())
return QModelIndex();
TreeItem* childItem = parentItem->children[row];
if (!childItem)
return QModelIndex();
return createIndex(row, column, childItem);
}
QModelIndex EmailTreeModel::parent(const QModelIndex &child) const
{
if (!child.isValid())
return QModelIndex();
TreeItem* childItem = static_cast<TreeItem*>(child.internalPointer());
if (!childItem)
return QModelIndex();
TreeItem* parentItem = childItem->parent;
if (!parentItem || parentItem == m_rootItem)
return QModelIndex();
return createIndex(parentItem->row, 0, parentItem);
}
int EmailTreeModel::rowCount(const QModelIndex &parent) const
{
if (parent.column() > 0)
return 0;
TreeItem* parentItem;
if (!parent.isValid())
parentItem = m_rootItem;
else
parentItem = static_cast<TreeItem*>(parent.internalPointer());
return parentItem->children.size();
}
int EmailTreeModel::columnCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return ColCount;
}
QVariant EmailTreeModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid())
return QVariant();
TreeItem* item = static_cast<TreeItem*>(index.internalPointer());
if (role == MailIdRole) {
if (item->type == TreeItem::Mail) {
const MailItem& mail = m_emails[item->mailIndex];
return mail.id();
}
return QVariant();
}
if (role == IsGroupRole) {
return item->type == TreeItem::Group;
}
if (role == GroupNameRole && item->type == TreeItem::Group) {
return item->groupName;
}
if (role == GroupIndexRole && item->type == TreeItem::Group) {
return item->groupIndex;
}
if (role == MailIndexRole && item->type == TreeItem::Mail) {
return item->mailIndex;
}
if (role != Qt::DisplayRole && role != Qt::ToolTipRole)
return QVariant();
if (item->type == TreeItem::Group) {
if (index.column() == ColSubject) {
return QString("%1 (%2)").arg(item->groupName).arg(item->children.size());
}
return QVariant();
}
// Mail item
if (item->type == TreeItem::Mail) {
if (item->mailIndex < 0 || item->mailIndex >= m_emails.size()) {
return QVariant();
}
const MailItem& mail = m_emails[item->mailIndex];
switch (index.column()) {
case ColSubject:
return mail.subject().isEmpty() ? "(Sin asunto)" : mail.subject();
case ColSender:
return mail.sender();
case ColDate: {
if (!mail.date().isValid()) return QVariant();
QDateTime dt = mail.date();
QDate today = QDate::currentDate();
if (dt.date() == today) {
return dt.toString("HH:mm");
} else if (dt.date() == today.addDays(-1)) {
return "Ayer " + dt.toString("HH:mm");
} else if (dt.date().year() == today.year()) {
return dt.toString("ddd dd/MM HH:mm");
} else {
return dt.toString("dd/MM/yyyy HH:mm");
}
}
default:
return QVariant();
}
}
return QVariant();
}
QVariant EmailTreeModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (orientation == Qt::Horizontal && role == Qt::DisplayRole) {
switch (section) {
case ColSubject: return "Asunto";
case ColSender: return "De";
case ColDate: return "Fecha";
default: return QVariant();
}
}
return QAbstractItemModel::headerData(section, orientation, role);
}
Qt::ItemFlags EmailTreeModel::flags(const QModelIndex &index) const
{
if (!index.isValid())
return Qt::NoItemFlags;
TreeItem* item = static_cast<TreeItem*>(index.internalPointer());
Qt::ItemFlags f = Qt::ItemIsEnabled | Qt::ItemIsSelectable;
if (item->type == TreeItem::Group) {
// Groups don't need special flags for expand/collapse in Qt6
}
return f;
}
void EmailTreeModel::setEmails(const QVector<MailItem> &emails)
{
beginResetModel();
m_emails = emails;
// Clear tree properly without deleting root
qDeleteAll(m_rootItem->children);
m_rootItem->children.clear();
setupModelData();
endResetModel();
}
void EmailTreeModel::clear()
{
beginResetModel();
m_emails.clear();
deleteTree(m_rootItem);
m_rootItem = new TreeItem(TreeItem::Group);
m_rootItem->type = TreeItem::Group;
m_rootItem->groupName = "Root";
endResetModel();
}
void EmailTreeModel::setGroupMode(GroupMode mode)
{
if (m_groupMode == mode) return;
// Clear existing tree first
beginResetModel();
m_groupMode = mode;
qDeleteAll(m_rootItem->children);
m_rootItem->children.clear();
setupModelData();
endResetModel();
}
void EmailTreeModel::setupModelData()
{
if (m_groupMode == NoGrouping) {
// Flat list - all mails directly under root
for (int i = 0; i < m_emails.size(); ++i) {
if (i < 0 || i >= m_emails.size()) continue;
TreeItem* mailItem = new TreeItem(TreeItem::Mail, m_rootItem);
mailItem->mailIndex = i;
mailItem->row = m_rootItem->children.size();
m_rootItem->children.append(mailItem);
}
} else {
// Grouped by date
buildGroups();
}
}
void EmailTreeModel::buildGroups()
{
// Sort emails by date descending (newest first)
QVector<int> indices(m_emails.size());
std::iota(indices.begin(), indices.end(), 0);
std::sort(indices.begin(), indices.end(), [this](int a, int b) {
const QDateTime& dateA = m_emails[a].date();
const QDateTime& dateB = m_emails[b].date();
if (!dateA.isValid() && !dateB.isValid()) return false;
if (!dateA.isValid()) return false;
if (!dateB.isValid()) return true;
return dateA > dateB;
});
// Group by date
QMap<QString, QVector<int>> groups; // groupName -> mail indices
for (int idx : indices) {
if (idx < 0 || idx >= m_emails.size()) continue;
const QDateTime& dt = m_emails[idx].date();
if (!dt.isValid()) continue;
QString groupName = groupNameForDate(dt);
groups[groupName].append(idx);
}
// Create tree items in group order
int groupIdx = 0;
for (auto it = groups.constBegin(); it != groups.constEnd(); ++it) {
const QString& groupName = it.key();
const QVector<int>& mailIndices = it.value();
if (mailIndices.isEmpty()) continue;
TreeItem* groupItem = new TreeItem(TreeItem::Group, m_rootItem);
groupItem->groupName = groupName;
groupItem->groupIndex = groupIdx++;
groupItem->row = m_rootItem->children.size();
m_rootItem->children.append(groupItem);
for (int mailIdx : mailIndices) {
if (mailIdx < 0 || mailIdx >= m_emails.size()) continue;
TreeItem* mailItem = new TreeItem(TreeItem::Mail, groupItem);
mailItem->mailIndex = mailIdx;
mailItem->row = groupItem->children.size();
groupItem->children.append(mailItem);
}
}
}
QString EmailTreeModel::groupNameForDate(const QDateTime &date) const
{
if (!date.isValid()) return "Sin fecha";
QDate emailDate = date.date();
QDate today = QDate::currentDate();
int daysDiff = today.daysTo(emailDate);
if (daysDiff == 0) {
return "Hoy";
} else if (daysDiff == -1) {
return "Ayer";
} else if (daysDiff > -7 && daysDiff < 0) {
// This week
return "Esta semana";
} else if (daysDiff >= -13 && daysDiff <= -7) {
return "Semana pasada";
} else if (daysDiff >= -20 && daysDiff <= -14) {
return "Hace dos semanas";
} else if (emailDate.month() == today.month() && emailDate.year() == today.year()) {
return "Este mes";
} else if (emailDate.year() == today.year()) {
static const QString months[] = {
"Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"
};
return months[emailDate.month() - 1];
} else {
return QString("%1").arg(emailDate.year());
}
}
int EmailTreeModel::groupIndexForDate(const QDateTime &date) const
{
// Not used directly but kept for reference
return 0;
}
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <QAbstractItemModel>
#include <QModelIndex>
#include <QVariant>
#include <QVector>
#include <QDateTime>
#include <QDate>
#include "core/mailitem.h"
class EmailTreeModel : public QAbstractItemModel
{
Q_OBJECT
public:
enum Columns {
ColSubject = 0,
ColSender = 1,
ColDate = 2,
ColCount = 3
};
enum Roles {
MailIdRole = Qt::UserRole + 1,
IsGroupRole = Qt::UserRole + 2,
GroupNameRole = Qt::UserRole + 3,
GroupIndexRole = Qt::UserRole + 4,
MailIndexRole = Qt::UserRole + 5
};
explicit EmailTreeModel(QObject *parent = nullptr);
~EmailTreeModel() override;
// Basic model interface
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
QModelIndex parent(const QModelIndex &child) const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
// Email data management
void setEmails(const QVector<MailItem> &emails);
void clear();
// Grouping
enum GroupMode {
NoGrouping = 0,
GroupByDate = 1
};
void setGroupMode(GroupMode mode);
GroupMode groupMode() const { return m_groupMode; }
private:
struct TreeItem {
enum Type { Group, Mail } type;
QString groupName; // for Group type
int groupIndex = -1; // for Group type
int mailIndex = -1; // for Mail type (index in m_emails)
QVector<TreeItem*> children;
TreeItem* parent = nullptr;
int row = -1;
TreeItem(Type t, TreeItem* p = nullptr) : type(t), parent(p) {}
~TreeItem() { qDeleteAll(children); }
};
GroupMode m_groupMode = NoGrouping;
QVector<MailItem> m_emails;
TreeItem* m_rootItem = nullptr;
void setupModelData();
void buildGroups();
QString groupNameForDate(const QDateTime &date) const;
int groupIndexForDate(const QDateTime &date) const;
void deleteTree(TreeItem* item);
};
+56 -23
View File
@@ -20,6 +20,7 @@
#include <QFileDialog>
#include <QDir>
#include <functional>
#include <QGraphicsDropShadowEffect>
#include "db/dao/mailitemdao.h"
ReaderView::ReaderView(QWidget *parent) : QWidget(parent) {
@@ -29,7 +30,7 @@ ReaderView::ReaderView(QWidget *parent) : QWidget(parent) {
void ReaderView::setupUI() {
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setContentsMargins(12, 0, 12, 0);
mainLayout->setSpacing(0);
// ===== Toolbar (zoom, find, images) =====
@@ -182,23 +183,31 @@ void ReaderView::setupUI() {
mainLayout->addWidget(m_toolbar);
mainLayout->addWidget(m_findBar);
// ===== Header Section =====
m_headerWidget = new QWidget();
m_headerWidget->setStyleSheet("QWidget { background: white; border-bottom: 1px solid #e0e0e0; }");
QVBoxLayout *headerLayout = new QVBoxLayout(m_headerWidget);
headerLayout->setContentsMargins(16, 12, 16, 12);
headerLayout->setSpacing(8);
// ===== Subject Section (independent, with shadow) =====
QFrame *subjectFrame = new QFrame();
subjectFrame->setObjectName("SubjectFrame");
subjectFrame->setStyleSheet(
"QFrame#SubjectFrame {"
" background: white;"
" border: none;"
" border: 1px solid #e0e0e0;"
" border-radius: 10px;"
"}"
);
// Add shadow effect
QGraphicsDropShadowEffect *shadowEffect = new QGraphicsDropShadowEffect(this);
shadowEffect->setBlurRadius(8);
shadowEffect->setOffset(0, 3);
shadowEffect->setColor(QColor(0, 0, 0, 40));
subjectFrame->setGraphicsEffect(shadowEffect);
QVBoxLayout *subjectLayout = new QVBoxLayout(subjectFrame);
subjectLayout->setContentsMargins(16, 16, 16, 16);
subjectLayout->setSpacing(8);
// Subject row
QHBoxLayout *subjectRow = new QHBoxLayout();
subjectRow->setSpacing(12);
m_avatarLabel = new QLabel();
m_avatarLabel->setFixedSize(40, 40);
m_avatarLabel->setAlignment(Qt::AlignCenter);
m_avatarLabel->setStyleSheet("QLabel { background: #1976D2; color: white; border-radius: 20px; font-weight: bold; font-size: 14px; }");
subjectRow->addWidget(m_avatarLabel);
m_subjectLabel = new QLabel();
m_subjectLabel->setText("(Sin asunto)");
QFont subjectFont = m_subjectLabel->font();
@@ -210,12 +219,33 @@ void ReaderView::setupUI() {
m_subjectLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
subjectRow->addWidget(m_subjectLabel, 1);
headerLayout->addLayout(subjectRow);
subjectLayout->addLayout(subjectRow);
mainLayout->addWidget(subjectFrame);
// Add spacing after subject frame so shadow is visible
mainLayout->addSpacing(12);
// From / To / Date row
// ===== Header Section =====
m_headerWidget = new QWidget();
m_headerWidget->setStyleSheet("QWidget { background: white; border-bottom: 1px solid #e0e0e0; }");
QVBoxLayout *headerLayout = new QVBoxLayout(m_headerWidget);
headerLayout->setContentsMargins(16, 12, 16, 12);
headerLayout->setSpacing(8);
// Avatar + From / To / Date row
QHBoxLayout *metaRow = new QHBoxLayout();
metaRow->setSpacing(16);
// Avatar on the left
m_avatarLabel = new QLabel();
m_avatarLabel->setFixedSize(40, 40);
m_avatarLabel->setAlignment(Qt::AlignCenter);
m_avatarLabel->setStyleSheet("QLabel { background: #1976D2; color: white; border-radius: 20px; font-weight: bold; font-size: 14px; }");
metaRow->addWidget(m_avatarLabel, 0, Qt::AlignTop);
// From / To / Date on the right of avatar
QVBoxLayout *metaRightLayout = new QVBoxLayout();
metaRightLayout->setSpacing(2);
m_fromLabel = new QLabel();
m_fromLabel->setStyleSheet("color: #333; font-size: 13px;");
m_fromLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
@@ -223,17 +253,19 @@ void ReaderView::setupUI() {
connect(m_fromLabel, &QLabel::linkActivated, [this](const QString &link) {
QDesktopServices::openUrl(QUrl(link));
});
metaRow->addWidget(m_fromLabel, 1);
metaRightLayout->addWidget(m_fromLabel);
m_toLabel = new QLabel();
m_toLabel->setStyleSheet("color: #666; font-size: 12px;");
m_toLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_toLabel->setWordWrap(true);
metaRow->addWidget(m_toLabel, 2);
metaRightLayout->addWidget(m_toLabel);
m_dateLabel = new QLabel();
m_dateLabel->setStyleSheet("color: #888; font-size: 12px;");
metaRow->addWidget(m_dateLabel);
metaRightLayout->addWidget(m_dateLabel);
metaRow->addLayout(metaRightLayout, 1);
headerLayout->addLayout(metaRow);
@@ -310,16 +342,17 @@ void ReaderView::setupUI() {
actionsLayout->addWidget(m_moreButton);
headerLayout->addLayout(actionsLayout);
// ===== Body Viewer (inside header widget) =====
setupBodyViewer();
headerLayout->addWidget(m_scrollArea, 1);
mainLayout->addWidget(m_headerWidget);
// ===== Attachments Area =====
setupAttachmentsArea();
mainLayout->addWidget(m_attachmentsFrame);
// ===== Body Viewer =====
setupBodyViewer();
mainLayout->addWidget(m_scrollArea, 1);
// Shortcuts
QShortcut *findShortcut = new QShortcut(QKeySequence(Qt::ControlModifier | Qt::Key_F), this);
connect(findShortcut, &QShortcut::activated, [this]() {