Branch data Line data Source code
1 : : #include "TaskListWidget.h"
2 : :
3 : : #include "ui/ThemeManager.h"
4 : : #include "util/TaskDates.h"
5 : :
6 : : #include <QCursor>
7 : : #include <QFont>
8 : : #include <QDesktopServices>
9 : : #include <QHeaderView>
10 : : #include <QKeyEvent>
11 : : #include <QLocale>
12 : : #include <QMenu>
13 : : #include <QMessageBox>
14 : : #include <QMouseEvent>
15 : : #include <QPainter>
16 : : #include <QPushButton>
17 : : #include <QSettings>
18 : : #include <QTimeZone>
19 : : #include <QVBoxLayout>
20 : :
21 : : #include "data/CalendarStore.h"
22 : : #include "ui/MarkdownHighlighter.h"
23 : : #include "ui/MarkdownRenderer.h"
24 : : #include "ui/PlainTextMessageBox.h"
25 : : #include "ui/UrlSchemeFilter.h"
26 : : #include <QToolButton>
27 : : #include <QFrame>
28 : : #include <QScrollBar>
29 : : #include <QTimer>
30 : : #include <QUuid>
31 : : #include <QUrlQuery>
32 : :
33 : : // 67.B3: all colors come from ThemeManager tokens (docs/DESIGN.md §2);
34 : : // widget stylesheets and detail-view HTML are rebuilt per render with
35 : : // the active palette.
36 : 1710 : static QString tok(const char *token) {
37 [ + - + - ]: 1710 : return ThemeManager::instance().color(QLatin1String(token));
38 : : }
39 : :
40 : : // ═══════════════════════════════════════════════════════
41 : : // TaskListModel (Sprint 37 – T-455: extended columns)
42 : : // ═══════════════════════════════════════════════════════
43 : :
44 : 33 : TaskListModel::TaskListModel(QObject *parent)
45 : 33 : : QAbstractItemModel(parent) {}
46 : :
47 : 104 : void TaskListModel::setTasks(const QList<CalendarTask> &tasks) {
48 : 104 : beginResetModel();
49 : 104 : m_tasks = tasks;
50 : 104 : endResetModel();
51 : 104 : }
52 : :
53 : 1793 : QModelIndex TaskListModel::index(int row, int col,
54 : : const QModelIndex &parent) const {
55 [ + - + - : 1793 : if (parent.isValid() || row < 0 || row >= m_tasks.size() || col < 0 ||
+ - + - -
+ - + ]
56 : : col >= ColCount)
57 : 0 : return {};
58 : 1793 : return createIndex(row, col);
59 : : }
60 : :
61 : 2812 : QModelIndex TaskListModel::parent(const QModelIndex &) const { return {}; }
62 : :
63 : 818 : int TaskListModel::rowCount(const QModelIndex &parent) const {
64 [ + + ]: 818 : return parent.isValid() ? 0 : m_tasks.size();
65 : : }
66 : :
67 : 798 : int TaskListModel::columnCount(const QModelIndex &) const {
68 : 798 : return ColCount;
69 : : }
70 : :
71 : 5775 : QVariant TaskListModel::data(const QModelIndex &index, int role) const {
72 [ + + - + : 5775 : if (!index.isValid() || index.row() >= m_tasks.size())
+ + ]
73 : 1 : return {};
74 : :
75 : 5774 : const auto &task = m_tasks[index.row()];
76 : :
77 [ + + ]: 5774 : if (role == Qt::DisplayRole) {
78 [ + + + + : 749 : switch (index.column()) {
+ + - ]
79 : 149 : case ColStatus: {
80 : 149 : QString icon;
81 [ + + ]: 149 : if (task.status == QStringLiteral("COMPLETED"))
82 : 16 : icon = QStringLiteral("✓");
83 [ + + ]: 133 : else if (task.status == QStringLiteral("IN-PROCESS"))
84 : 25 : icon = QStringLiteral("▶");
85 [ - + ]: 108 : else if (task.status == QStringLiteral("CANCELLED"))
86 : 0 : icon = QStringLiteral("✗");
87 : : else
88 : 108 : icon = QStringLiteral("○");
89 : : // Starred indicator
90 [ + + ]: 149 : if (task.isStarred())
91 [ + - ]: 38 : icon += QStringLiteral(" ★");
92 : 149 : return icon;
93 : 149 : }
94 : 150 : case ColSummary:
95 : 150 : return task.summary;
96 : 3 : case ColProgress:
97 : : // Drawn by delegate, but provide tooltip data
98 : 3 : return task.percentComplete > 0
99 [ + + + - : 7 : ? QString::number(task.percentComplete) +
+ - + + -
- ]
100 [ + + + + : 4 : QStringLiteral("%")
- - - - ]
101 : 3 : : QString();
102 : 149 : case ColDue:
103 [ + + ]: 149 : if (task.due.isValid()) {
104 [ + - ]: 115 : QDate today = QDate::currentDate();
105 [ + - + - ]: 115 : QDate dueDate = task.due.toLocalTime().date();
106 [ - + ]: 115 : if (dueDate == today)
107 : 0 : return QStringLiteral("Heute");
108 [ + - + - ]: 230 : return QLocale().toString(dueDate, QStringLiteral("dd.MM.yy"));
109 : : }
110 : 34 : return QStringLiteral("—");
111 : 149 : case ColPriority:
112 [ + + + + ]: 149 : if (task.priority > 0 && task.priority <= 4)
113 : 38 : return QStringLiteral("!!!");
114 [ + + ]: 111 : if (task.priority == 5)
115 : 29 : return QStringLiteral("!!");
116 [ - + - - ]: 82 : if (task.priority >= 6 && task.priority <= 9)
117 : 0 : return QStringLiteral("!");
118 : 82 : return {};
119 : 149 : case ColCalendar:
120 : 149 : return task.calendarDisplayName.isEmpty()
121 [ + + + - ]: 298 : ? task.calendarPath.section(QLatin1Char('/'), -2, -2)
122 : 149 : : task.calendarDisplayName;
123 : : }
124 : : }
125 : :
126 [ + + ]: 5025 : if (role == Qt::ForegroundRole) {
127 : : // 67.B6: theme tokens instead of fixed RGB — readable in dark mode
128 [ + + - + : 2911 : if (task.status == QStringLiteral("COMPLETED") ||
+ - + + ]
129 [ + + + + : 1415 : task.status == QStringLiteral("CANCELLED")) {
+ - ]
130 [ + - + - ]: 81 : return QColor(tok("@text_muted"));
131 : : }
132 [ + + + + : 667 : if (index.column() == ColDue && task.due.isValid()) {
+ + ]
133 [ + - ]: 105 : QDate today = QDate::currentDate();
134 [ + - + - ]: 105 : QDate dueDate = task.due.toLocalTime().date();
135 [ + + ]: 105 : if (dueDate < today)
136 [ + - + - ]: 33 : return QColor(tok("@danger")); // overdue
137 [ - + ]: 72 : if (dueDate == today)
138 [ # # # # ]: 0 : return QColor(tok("@warning")); // today
139 : : }
140 [ + + + + : 634 : if (index.column() == ColStatus && task.isStarred())
+ + ]
141 [ + - + - ]: 38 : return QColor(tok("@star_active")); // starred
142 : : }
143 : :
144 [ + + ]: 4873 : if (role == Qt::TextAlignmentRole) {
145 [ + + + + : 1198 : if (index.column() == ColStatus || index.column() == ColPriority ||
+ + + + ]
146 : 450 : index.column() == ColProgress)
147 : 301 : return Qt::AlignCenter;
148 : : }
149 : :
150 [ + + ]: 4572 : if (role == Qt::FontRole) {
151 [ + + ]: 748 : if (task.status == QStringLiteral("COMPLETED")) {
152 [ + - ]: 81 : QFont f;
153 [ + - ]: 81 : f.setStrikeOut(true);
154 [ + - ]: 81 : return f;
155 : 81 : }
156 : : }
157 : :
158 [ + + + + : 4491 : if (role == Qt::ToolTipRole && index.column() == ColProgress) {
+ + ]
159 [ + + ]: 3 : if (task.percentComplete > 0)
160 [ + - ]: 2 : return QStringLiteral("Fortschritt: %1%").arg(task.percentComplete);
161 : : }
162 : :
163 : : // Store percentComplete for delegate via UserRole
164 [ + + + + : 4490 : if (role == Qt::UserRole && index.column() == ColProgress)
+ + ]
165 : 59 : return task.percentComplete;
166 : :
167 : : // Store color for delegate via UserRole+1
168 [ + + + - : 4431 : if (role == Qt::UserRole + 1 && index.column() == ColProgress)
+ + ]
169 : 13 : return task.color;
170 : :
171 : : // Store color for calendar column (circle, not square)
172 [ + + + + : 4418 : if (role == Qt::DecorationRole && index.column() == ColCalendar) {
+ + ]
173 [ + + ]: 149 : if (!task.color.isEmpty()) {
174 [ + - ]: 125 : QPixmap px(10, 10);
175 [ + - ]: 125 : px.fill(Qt::transparent);
176 [ + - ]: 125 : QPainter p(&px);
177 [ + - ]: 125 : p.setRenderHint(QPainter::Antialiasing);
178 [ + - + - ]: 125 : p.setBrush(QColor(task.color));
179 [ + - ]: 125 : p.setPen(Qt::NoPen);
180 [ + - ]: 125 : p.drawEllipse(0, 0, 10, 10);
181 [ + - ]: 125 : return px;
182 : 125 : }
183 : : }
184 : :
185 : 4293 : return {};
186 : : }
187 : :
188 : 4578 : QVariant TaskListModel::headerData(int section, Qt::Orientation orientation,
189 : : int role) const {
190 [ + - + + ]: 4578 : if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
191 : 3582 : return {};
192 [ + + + + : 996 : switch (section) {
+ + - ]
193 : 166 : case ColStatus:
194 : 166 : return QStringLiteral(" ");
195 : 166 : case ColSummary:
196 : 166 : return QStringLiteral("Aufgabe");
197 : 166 : case ColProgress:
198 : 166 : return QStringLiteral("◔");
199 : 166 : case ColDue:
200 : 166 : return QStringLiteral("Fällig");
201 : 166 : case ColPriority:
202 : 166 : return QStringLiteral("P");
203 : 166 : case ColCalendar:
204 : 166 : return QStringLiteral("Kalender");
205 : : }
206 : 0 : return {};
207 : : }
208 : :
209 : : // ═══════════════════════════════════════════════════════
210 : : // TaskProgressDelegate (Sprint 37 – T-455)
211 : : // ═══════════════════════════════════════════════════════
212 : :
213 : 56 : void TaskProgressDelegate::paint(QPainter *painter,
214 : : const QStyleOptionViewItem &option,
215 : : const QModelIndex &index) const {
216 : : // Draw background
217 [ + - ]: 56 : QStyledItemDelegate::paint(painter, option, QModelIndex());
218 : :
219 [ + - + - ]: 56 : int percent = index.data(Qt::UserRole).toInt();
220 [ + + ]: 56 : if (percent <= 0)
221 : 43 : return;
222 : :
223 [ + - ]: 13 : painter->save();
224 [ + - ]: 13 : painter->setRenderHint(QPainter::Antialiasing);
225 : :
226 : 13 : int diameter = 14;
227 : : QRectF circleRect(
228 : 13 : option.rect.center().x() - diameter / 2.0,
229 : 13 : option.rect.center().y() - diameter / 2.0,
230 : 39 : diameter, diameter);
231 : :
232 : : // Background circle
233 [ + - ]: 13 : painter->setPen(Qt::NoPen);
234 [ + - + - : 13 : painter->setBrush(QColor(tok("@border_medium")));
+ - ]
235 [ + - ]: 13 : painter->drawEllipse(circleRect);
236 : :
237 : : // Progress arc
238 [ + - + - ]: 13 : QString colorStr = index.data(Qt::UserRole + 1).toString();
239 : : QColor progressColor =
240 [ + + + - : 13 : colorStr.isEmpty() ? QColor(tok("@accent")) : QColor(colorStr);
+ + - - ]
241 [ + - + - ]: 13 : painter->setBrush(progressColor);
242 : :
243 : 13 : int startAngle = 90 * 16; // 12 o'clock
244 : 13 : int spanAngle = -qRound(percent * 360.0 / 100.0) * 16;
245 [ + - ]: 13 : painter->drawPie(circleRect, startAngle, spanAngle);
246 : :
247 : : // Center hole (donut effect) — theme background, not palette base
248 : : // (the palette stays light even in dark mode, 67.B6)
249 : 13 : QRectF inner(circleRect.adjusted(3, 3, -3, -3));
250 [ + - + - : 13 : painter->setBrush(QColor(tok("@bg_main")));
+ - ]
251 [ + - ]: 13 : painter->drawEllipse(inner);
252 : :
253 [ + - ]: 13 : painter->restore();
254 : 13 : }
255 : :
256 : 90 : QSize TaskProgressDelegate::sizeHint(const QStyleOptionViewItem &,
257 : : const QModelIndex &) const {
258 : 90 : return QSize(24, 20);
259 : : }
260 : :
261 : : // ═══════════════════════════════════════════════════════
262 : : // TaskListWidget (Sprint 37 – T-453/454/456)
263 : : // ═══════════════════════════════════════════════════════
264 : :
265 : :
266 [ + - ]: 30 : TaskListWidget::TaskListWidget(QWidget *parent) : QWidget(parent) {
267 [ + - + - : 30 : m_model = new TaskListModel(this);
- + - - ]
268 : :
269 : : // --- 3-Pane Layout ---
270 [ + - + - : 30 : m_mainSplitter = new QSplitter(Qt::Horizontal, this);
- + - - ]
271 [ + - + - : 30 : m_rightSplitter = new QSplitter(Qt::Vertical);
- + - - ]
272 : :
273 [ + - ]: 30 : setupSidebar();
274 [ + - ]: 30 : setupTaskList();
275 [ + - ]: 30 : setupDetailPanel();
276 : :
277 : : // Left: sidebar, Right: list + detail
278 [ + - ]: 30 : m_rightSplitter->addWidget(m_treeView);
279 [ + - ]: 30 : m_rightSplitter->addWidget(m_detailContainer);
280 [ + - ]: 30 : m_rightSplitter->setStretchFactor(0, 3); // list 60%
281 [ + - ]: 30 : m_rightSplitter->setStretchFactor(1, 2); // detail 40%
282 : :
283 [ + - ]: 30 : m_mainSplitter->addWidget(m_sidebarContainer);
284 [ + - ]: 30 : m_mainSplitter->addWidget(m_rightSplitter);
285 [ + - ]: 30 : m_mainSplitter->setStretchFactor(0, 0); // sidebar fixed
286 [ + - ]: 30 : m_mainSplitter->setStretchFactor(1, 1); // right stretch
287 : :
288 [ + - + - : 30 : auto *layout = new QVBoxLayout(this);
- + - - ]
289 [ + - ]: 30 : layout->setContentsMargins(0, 0, 0, 0);
290 [ + - ]: 30 : layout->setSpacing(0);
291 [ + - ]: 30 : layout->addWidget(m_mainSplitter);
292 : :
293 : : // Selection → detail
294 [ + - ]: 30 : connect(m_treeView->selectionModel(),
295 : : &QItemSelectionModel::currentRowChanged, this,
296 [ + - ]: 30 : [this](const QModelIndex ¤t, const QModelIndex &) {
297 [ + - ]: 99 : if (current.isValid())
298 : 99 : showDetail(current.row());
299 : : else
300 [ # # ]: 0 : m_detailBrowser->setHtml(
301 : 0 : QStringLiteral("<p style='color:%1;text-align:center;'>"
302 : : "Keine Aufgabe ausgewählt</p>")
303 [ # # # # ]: 0 : .arg(tok("@text_secondary")));
304 : 99 : });
305 : :
306 [ + - ]: 30 : setFocusProxy(m_treeView);
307 : :
308 : 30 : m_currentFilter = QStringLiteral("all");
309 [ + - ]: 30 : restoreSettings();
310 : 30 : }
311 : :
312 : : // --- Sidebar (T-454) ---
313 : :
314 : 30 : void TaskListWidget::setupSidebar() {
315 [ + - - + : 30 : auto *sidebarContainer = new QWidget();
- - ]
316 [ + - - + : 30 : auto *sidebarLayout = new QVBoxLayout(sidebarContainer);
- - ]
317 : 30 : sidebarLayout->setContentsMargins(0, 0, 0, 0);
318 : 30 : sidebarLayout->setSpacing(0);
319 : :
320 [ + - - + : 30 : m_sidebarList = new QListWidget();
- - ]
321 [ + - ]: 60 : m_sidebarList->setObjectName(QStringLiteral("taskSidebar"));
322 : 30 : m_sidebarList->setMinimumWidth(150);
323 : 30 : m_sidebarList->setMaximumWidth(280);
324 : 30 : m_sidebarList->setFocusPolicy(Qt::ClickFocus);
325 : 30 : m_sidebarList->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
326 : 30 : m_sidebarList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
327 : : // Sprint 69: object name set so main.qss QListWidget#taskSidebar rule applies
328 : : // (was missing before — the QSS rule was dead and inline QSS took over).
329 [ + - ]: 60 : m_sidebarList->setObjectName(QStringLiteral("taskSidebar"));
330 [ + - ]: 30 : sidebarLayout->addWidget(m_sidebarList, 1);
331 : :
332 : : // Sprint 39/56: "Neue Aufgabe" button — uses main.qss QPushButton#primaryButton.
333 [ + - - + : 60 : auto *addBtn = new QPushButton(QStringLiteral("+ Neue Aufgabe"), this);
- - ]
334 [ + - ]: 60 : addBtn->setObjectName(QStringLiteral("primaryButton"));
335 [ + - + - ]: 30 : addBtn->setCursor(Qt::PointingHandCursor);
336 : 30 : connect(addBtn, &QPushButton::clicked, this,
337 [ + - ]: 31 : [this]() { emit taskCreateRequested(); });
338 [ + - ]: 30 : sidebarLayout->addWidget(addBtn);
339 : :
340 : : // Use the container as the sidebar widget
341 : 30 : sidebarContainer->setMinimumWidth(150);
342 : 30 : sidebarContainer->setMaximumWidth(280);
343 : :
344 : 30 : connect(m_sidebarList, &QListWidget::currentRowChanged, this,
345 [ + - ]: 30 : &TaskListWidget::onSidebarClicked);
346 : :
347 : : // T-71.5b: native checkbox toggle for calendar visibility. Meta-folder
348 : : // rows (Alle/Aktuell/…) carry no UserRole path and are skipped. We do NOT
349 : : // call updateSidebar() here — it rebuilds items and would re-trigger
350 : : // itemChanged. The checkbox state itself is the primary indicator; the
351 : : // existing context-menu path still calls updateSidebar for a full resync.
352 : 30 : connect(m_sidebarList, &QListWidget::itemChanged, this,
353 [ + - ]: 30 : [this](QListWidgetItem *item) {
354 [ - + ]: 1 : if (!item) return;
355 [ + - + - ]: 1 : const QString path = item->data(Qt::UserRole).toString();
356 [ - + ]: 1 : if (path.isEmpty()) return; // meta-folder, ignore
357 [ + - ]: 1 : const bool nowHidden = item->checkState() == Qt::Unchecked;
358 : 1 : const bool wasHidden = m_hiddenCalendars.contains(path);
359 [ - + ]: 1 : if (nowHidden == wasHidden) return; // no change
360 [ + - + - ]: 1 : if (nowHidden) m_hiddenCalendars.insert(path);
361 [ # # ]: 0 : else m_hiddenCalendars.remove(path);
362 [ + - ]: 1 : saveSettings();
363 [ + - ]: 1 : applyFilter();
364 [ + - ]: 1 : });
365 : :
366 : : // Sprint 56: Unified sidebar context menu
367 : 30 : m_sidebarList->setContextMenuPolicy(Qt::CustomContextMenu);
368 : 30 : connect(m_sidebarList, &QWidget::customContextMenuRequested, this,
369 [ + - ]: 30 : [this](const QPoint &pos) {
370 [ + - ]: 3 : QMenu menu;
371 : :
372 : : // Show completed toggle (always available)
373 [ + - ]: 3 : auto *showCompletedAction = menu.addAction(
374 [ + - ]: 6 : tr("Show completed tasks\tShift+F"));
375 [ + - ]: 3 : showCompletedAction->setCheckable(true);
376 [ + - ]: 3 : showCompletedAction->setChecked(m_showCompleted);
377 : 3 : connect(showCompletedAction, &QAction::toggled, this,
378 [ + - ]: 3 : &TaskListWidget::setShowCompleted);
379 : :
380 : : // Calendar-specific actions
381 [ + - ]: 3 : auto *item = m_sidebarList->itemAt(pos);
382 [ + + ]: 3 : if (item) {
383 [ + - + - ]: 2 : QString path = item->data(Qt::UserRole).toString();
384 [ + - ]: 2 : if (!path.isEmpty()) {
385 [ + - ]: 2 : menu.addSeparator();
386 : 2 : bool hidden = m_hiddenCalendars.contains(path);
387 [ + - ]: 2 : menu.addAction(
388 [ + - + - ]: 4 : hidden ? tr("Show calendar")
389 : : : tr("Hide calendar"),
390 [ + + - - ]: 4 : [this, path, hidden]() {
391 [ + + ]: 2 : if (hidden)
392 : 1 : m_hiddenCalendars.remove(path);
393 : : else
394 [ + - ]: 1 : m_hiddenCalendars.insert(path);
395 : 2 : saveSettings();
396 : 2 : applyFilter();
397 : 2 : updateSidebar();
398 : 2 : });
399 : : }
400 : 2 : }
401 [ + - + - : 3 : menu.exec(m_sidebarList->viewport()->mapToGlobal(pos));
+ - ]
402 : 3 : });
403 : :
404 : : // Sprint 56: Shift+F shortcut for show-completed toggle
405 [ + - - + : 30 : auto *toggleCompletedAction = new QAction(this);
- - ]
406 [ + - ]: 30 : toggleCompletedAction->setShortcut(
407 [ + - ]: 60 : QKeySequence(Qt::SHIFT | Qt::Key_F));
408 : 30 : connect(toggleCompletedAction, &QAction::triggered, this,
409 [ + - ]: 31 : [this]() { setShowCompleted(!m_showCompleted); });
410 : 30 : addAction(toggleCompletedAction);
411 : :
412 : : // Store container as the actual sidebar widget for splitter
413 : 30 : m_sidebarContainer = sidebarContainer;
414 : 30 : }
415 : :
416 : : // --- Task List (T-453/455) ---
417 : :
418 : 30 : void TaskListWidget::setupTaskList() {
419 [ + - - + : 30 : m_treeView = new QTreeView();
- - ]
420 [ + - ]: 60 : m_treeView->setObjectName(QStringLiteral("taskList"));
421 : 30 : m_treeView->setModel(m_model);
422 : :
423 : 30 : m_treeView->setRootIsDecorated(false);
424 : 30 : m_treeView->setAlternatingRowColors(true);
425 : 30 : m_treeView->setSelectionMode(QAbstractItemView::SingleSelection);
426 : 30 : m_treeView->setSelectionBehavior(QAbstractItemView::SelectRows);
427 : 30 : m_treeView->setSortingEnabled(true);
428 : 30 : m_treeView->setFocusPolicy(Qt::StrongFocus);
429 [ + - ]: 30 : m_treeView->setEditTriggers(QAbstractItemView::NoEditTriggers);
430 : : // Sprint 69: styling via main.qss QTreeView#taskList — no inline QSS.
431 : :
432 : : // Sprint 39: Double-click to edit task
433 : 30 : connect(m_treeView, &QTreeView::doubleClicked, this,
434 [ + - ]: 30 : [this](const QModelIndex &idx) {
435 [ - + ]: 1 : if (!idx.isValid()) return;
436 : 1 : const auto &task = m_model->taskAt(idx.row());
437 [ + - ]: 1 : if (!task.uid.isEmpty())
438 : 1 : emit taskUpdated(task);
439 : : });
440 : :
441 : : // Column widths
442 : 30 : auto *header = m_treeView->header();
443 : 30 : header->setStretchLastSection(false);
444 : 30 : header->setSectionResizeMode(TaskListModel::ColStatus, QHeaderView::Fixed);
445 : 30 : header->setSectionResizeMode(TaskListModel::ColSummary, QHeaderView::Stretch);
446 : 30 : header->setSectionResizeMode(TaskListModel::ColProgress, QHeaderView::Fixed);
447 : 30 : header->setSectionResizeMode(TaskListModel::ColDue, QHeaderView::Fixed);
448 : 30 : header->setSectionResizeMode(TaskListModel::ColPriority, QHeaderView::Fixed);
449 : 30 : header->setSectionResizeMode(TaskListModel::ColCalendar, QHeaderView::Fixed);
450 : 30 : header->resizeSection(TaskListModel::ColStatus, 40);
451 : 30 : header->resizeSection(TaskListModel::ColProgress, 28);
452 : 30 : header->resizeSection(TaskListModel::ColDue, 80);
453 : 30 : header->resizeSection(TaskListModel::ColPriority, 30);
454 : 30 : header->resizeSection(TaskListModel::ColCalendar, 100);
455 : :
456 : : // Progress circle delegate
457 [ + - - + : 30 : m_treeView->setItemDelegateForColumn(
- - ]
458 [ + - ]: 30 : TaskListModel::ColProgress, new TaskProgressDelegate(m_treeView));
459 : :
460 : : // Sprint 56: Click on status column → toggle task
461 : 30 : connect(m_treeView, &QTreeView::clicked, this,
462 [ + - ]: 30 : [this](const QModelIndex &idx) {
463 [ + - ]: 1 : if (idx.column() == TaskListModel::ColStatus)
464 : 1 : toggleCurrentTask();
465 : 1 : });
466 : :
467 : : // Sprint 56: Context menu (edit / delete)
468 : 30 : m_treeView->setContextMenuPolicy(Qt::CustomContextMenu);
469 : 30 : connect(m_treeView, &QWidget::customContextMenuRequested, this,
470 [ + - ]: 30 : [this](const QPoint &pos) {
471 [ + - ]: 1 : auto idx = m_treeView->indexAt(pos);
472 [ - + ]: 1 : if (!idx.isValid()) return;
473 : 1 : const auto &task = m_model->taskAt(idx.row());
474 [ - + ]: 1 : if (task.uid.isEmpty()) return;
475 [ + - ]: 1 : QMenu menu;
476 [ + - + - ]: 1 : menu.addAction(tr("Edit task"), [this, task]() {
477 : 1 : emit taskUpdated(task);
478 : 1 : });
479 [ + - ]: 1 : menu.addSeparator();
480 : :
481 : : // Toggle completion
482 : 1 : bool isDone = (task.status == QStringLiteral("COMPLETED"));
483 [ - - + - : 1 : menu.addAction(isDone ? tr("○ Mark as open")
+ - ]
484 [ - + ]: 1 : : tr("✓ Mark as completed"), [this]() {
485 : 1 : toggleCurrentTask();
486 : 1 : });
487 : :
488 : : // Toggle starred
489 : 1 : bool starred = task.isStarred();
490 [ - - + - : 1 : menu.addAction(starred ? tr("☆ Remove star")
+ - ]
491 [ - + ]: 1 : : tr("★ Add star"), [this, starred]() {
492 [ + - ]: 1 : modifyCurrentTask([starred](CalendarTask &t) {
493 [ - + ]: 1 : t.priority = starred ? 0 : 1;
494 : 1 : });
495 : 1 : });
496 : :
497 [ + - ]: 1 : menu.addSeparator();
498 : :
499 : : // Priority submenu
500 [ + - + - ]: 1 : auto *prioMenu = menu.addMenu(tr("Priority"));
501 : 5 : auto addPrio = [&](const QString &label, int val) {
502 [ + - ]: 5 : auto *a = prioMenu->addAction(label, [this, val]() {
503 [ + - ]: 1 : modifyCurrentTask([val](CalendarTask &t) { t.priority = val; });
504 : 1 : });
505 [ + + ]: 5 : if (task.priority == val) a->setEnabled(false);
506 : 5 : };
507 [ + - + - ]: 1 : addPrio(tr("★ Important (1)"), 1);
508 [ + - + - ]: 1 : addPrio(tr("↑ High (4)"), 4);
509 [ + - + - ]: 1 : addPrio(tr("● Medium (5)"), 5);
510 [ + - + - ]: 1 : addPrio(tr("↓ Low (6)"), 6);
511 [ + - + - ]: 1 : addPrio(tr("○ None (0)"), 0);
512 : :
513 : : // Progress submenu
514 [ + - + - ]: 1 : auto *progMenu = menu.addMenu(tr("Progress"));
515 [ + + ]: 6 : for (int pct : {0, 25, 50, 75, 100}) {
516 [ + - ]: 5 : auto *a = progMenu->addAction(
517 [ + - ]: 20 : QStringLiteral("%1%").arg(pct), [this, pct]() {
518 [ + - ]: 1 : modifyCurrentTask([pct](CalendarTask &t) {
519 : 1 : t.percentComplete = pct;
520 [ - + ]: 1 : if (pct == 100) t.status = QStringLiteral("COMPLETED");
521 [ + - ]: 2 : else if (pct > 0) t.status = QStringLiteral("IN-PROCESS");
522 : 1 : });
523 : 1 : });
524 [ + + + - ]: 5 : if (task.percentComplete == pct) a->setEnabled(false);
525 : : }
526 : :
527 : : // Due date submenu
528 [ + - + - ]: 1 : auto *dueMenu = menu.addMenu(tr("Due"));
529 [ + - ]: 1 : QDateTime now = QDateTime::currentDateTimeUtc();
530 [ + - + - ]: 1 : dueMenu->addAction(tr("Today"), [this, now]() {
531 [ + - + - ]: 1 : modifyCurrentTask([now](CalendarTask &t) {
532 [ + - + - ]: 1 : t.due = TaskDates::endOfLocalDay(QDate::currentDate());
533 : 1 : });
534 : 1 : });
535 [ + - + - ]: 1 : dueMenu->addAction(tr("Tomorrow"), [this, now]() {
536 [ + - + - ]: 1 : modifyCurrentTask([now](CalendarTask &t) {
537 [ + - + - : 1 : t.due = TaskDates::endOfLocalDay(QDate::currentDate().addDays(1));
+ - ]
538 : 1 : });
539 : 1 : });
540 [ + - + - ]: 1 : dueMenu->addAction(tr("Next week"), [this, now]() {
541 [ + - + - ]: 1 : modifyCurrentTask([now](CalendarTask &t) {
542 [ + - + - : 1 : t.due = TaskDates::endOfLocalDay(QDate::currentDate().addDays(7));
+ - ]
543 : 1 : });
544 : 1 : });
545 [ + - + - ]: 1 : if (task.due.isValid()) {
546 [ + - ]: 1 : dueMenu->addSeparator();
547 [ + - + - ]: 1 : dueMenu->addAction(tr("Remove"), [this]() {
548 [ + - ]: 2 : modifyCurrentTask([](CalendarTask &t) { t.due = {}; });
549 : 1 : });
550 : : }
551 : :
552 [ + - ]: 1 : menu.addSeparator();
553 [ + - + - ]: 1 : menu.addAction(tr("Delete task"), [this]() {
554 : 0 : deleteCurrentTask();
555 : 0 : });
556 [ + - + - : 1 : menu.exec(m_treeView->viewport()->mapToGlobal(pos));
+ - ]
557 : 1 : });
558 : 30 : }
559 : :
560 : : // --- Detail Panel (T-456) ---
561 : :
562 : 30 : void TaskListWidget::setupDetailPanel() {
563 : : // Container for header + body/editor stack
564 [ + - - + : 30 : auto *detailContainer = new QWidget();
- - ]
565 [ + - - + : 30 : auto *detailLayout = new QVBoxLayout(detailContainer);
- - ]
566 : 30 : detailLayout->setContentsMargins(0, 0, 0, 0);
567 : 30 : detailLayout->setSpacing(0);
568 : :
569 : : // ── Persistent header (always visible, even during editing) ──
570 [ + - - + : 30 : m_headerBrowser = new QTextBrowser();
- - ]
571 : 30 : m_headerBrowser->setOpenExternalLinks(false);
572 : 30 : m_headerBrowser->setOpenLinks(false);
573 : 30 : m_headerBrowser->setFrameShape(QFrame::NoFrame);
574 : 30 : m_headerBrowser->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
575 : : // Sprint 69: global QTextBrowser rule covers border:none; document margin
576 : : // is set via setDocumentMargin(0) below — no inline QSS needed.
577 : 30 : m_headerBrowser->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
578 : 30 : m_headerBrowser->document()->setDocumentMargin(0);
579 : 30 : m_headerBrowser->setVisible(false);
580 : :
581 : : // Handle action links from the header (status, priority, due, progress, star)
582 : 30 : connect(m_headerBrowser, &QTextBrowser::anchorClicked, this,
583 [ + - ]: 60 : [this](const QUrl &url) {
584 [ + - + + ]: 30 : if (url.scheme() != QStringLiteral("action")) return;
585 [ + - + - ]: 29 : QString act = url.toString().mid(7); // strip "action:"
586 [ + + ]: 29 : if (act == QStringLiteral("toggle-status")) {
587 [ + - ]: 2 : toggleCurrentTask();
588 [ + + ]: 27 : } else if (act == QStringLiteral("toggle-star")) {
589 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) {
590 [ - + ]: 1 : t.priority = t.isStarred() ? 0 : 1;
591 : 1 : });
592 [ + + ]: 26 : } else if (act == QStringLiteral("cycle-priority")) {
593 [ + - ]: 4 : modifyCurrentTask([](CalendarTask &t) {
594 [ + - + + ]: 4 : if (t.priority == 0 || t.priority == 1) t.priority = 4;
595 [ + + ]: 3 : else if (t.priority <= 4) t.priority = 5;
596 [ + + ]: 2 : else if (t.priority == 5) t.priority = 6;
597 : 1 : else t.priority = 0;
598 : 4 : });
599 [ + + ]: 22 : } else if (act == QStringLiteral("progress-up")) {
600 [ + - ]: 10 : modifyCurrentTask([](CalendarTask &t) {
601 : 10 : t.percentComplete = qMin(100, t.percentComplete + 10);
602 [ + + ]: 10 : if (t.percentComplete == 100)
603 : 1 : t.status = QStringLiteral("COMPLETED");
604 [ + - ]: 9 : else if (t.percentComplete > 0)
605 : 9 : t.status = QStringLiteral("IN-PROCESS");
606 : 10 : });
607 [ + + ]: 12 : } else if (act == QStringLiteral("progress-down")) {
608 [ + - ]: 10 : modifyCurrentTask([](CalendarTask &t) {
609 : 10 : t.percentComplete = qMax(0, t.percentComplete - 10);
610 [ + + ]: 10 : if (t.percentComplete == 0)
611 : 1 : t.status = QStringLiteral("NEEDS-ACTION");
612 : 10 : });
613 [ + + ]: 2 : } else if (act == QStringLiteral("due-menu")) {
614 : : // Show due date popup near the browser
615 [ + - ]: 1 : QMenu dueMenu;
616 [ + - + - ]: 1 : dueMenu.addAction(tr("Today"), [this]() {
617 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) {
618 [ + - + - ]: 1 : t.due = TaskDates::endOfLocalDay(QDate::currentDate());
619 : 1 : });
620 : 1 : });
621 [ + - + - ]: 1 : dueMenu.addAction(tr("Tomorrow"), [this]() {
622 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) {
623 [ + - + - : 1 : t.due = TaskDates::endOfLocalDay(QDate::currentDate().addDays(1));
+ - ]
624 : 1 : });
625 : 1 : });
626 [ + - + - ]: 1 : dueMenu.addAction(tr("End of week"), [this]() {
627 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) {
628 [ + - + - ]: 1 : int daysToFri = 5 - QDate::currentDate().dayOfWeek();
629 [ - + ]: 1 : if (daysToFri <= 0) daysToFri += 7;
630 [ + - + - : 1 : t.due = TaskDates::endOfLocalDay(QDate::currentDate().addDays(daysToFri));
+ - ]
631 : 1 : });
632 : 1 : });
633 [ + - + - ]: 1 : dueMenu.addAction(tr("Next week"), [this]() {
634 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) {
635 [ + - + - : 1 : t.due = TaskDates::endOfLocalDay(QDate::currentDate().addDays(7));
+ - ]
636 : 1 : });
637 : 1 : });
638 : : // Check if task has due date for clear option
639 [ + - ]: 1 : auto treeIdx = m_treeView->currentIndex();
640 [ + - ]: 1 : if (treeIdx.isValid()) {
641 : 1 : const auto &curTask = m_model->taskAt(treeIdx.row());
642 [ + - + - ]: 1 : if (curTask.due.isValid()) {
643 [ + - ]: 1 : dueMenu.addSeparator();
644 [ + - + - ]: 1 : dueMenu.addAction(tr("Remove"), [this]() {
645 [ + - ]: 2 : modifyCurrentTask([](CalendarTask &t) { t.due = {}; });
646 : 1 : });
647 : : }
648 : : }
649 [ + - + - ]: 1 : dueMenu.exec(QCursor::pos());
650 [ + - ]: 2 : } else if (act == QStringLiteral("due-clear")) {
651 [ + - ]: 2 : modifyCurrentTask([](CalendarTask &t) { t.due = {}; });
652 : : }
653 : 29 : });
654 : :
655 [ + - ]: 30 : detailLayout->addWidget(m_headerBrowser);
656 : :
657 : : // ── Body/editor stack (toggles between rendered view and editor) ──
658 [ + - - + : 30 : m_detailStack = new QStackedWidget();
- - ]
659 : :
660 : : // Page 0: Rendered Markdown view (body only, no header)
661 [ + - - + : 30 : m_detailBrowser = new QTextBrowser();
- - ]
662 [ + - ]: 60 : m_detailBrowser->setObjectName(QStringLiteral("taskDetailBrowser"));
663 : 30 : m_detailBrowser->setOpenExternalLinks(false);
664 : 30 : m_detailBrowser->setOpenLinks(false);
665 : : // Sprint 69: styling via main.qss QTextBrowser#taskDetailBrowser.
666 [ + - ]: 60 : m_detailBrowser->setObjectName(QStringLiteral("taskDetailBrowser"));
667 : 30 : m_detailBrowser->document()->setDocumentMargin(0);
668 [ + - ]: 60 : m_detailBrowser->setHtml(
669 : 60 : QStringLiteral("<p style='color:%1;text-align:center;'>"
670 : : "Keine Aufgabe ausgewählt</p>")
671 [ + - + - ]: 60 : .arg(tok("@text_secondary")));
672 : : // Handle checkbox toggles and external links (body content)
673 : 30 : connect(m_detailBrowser, &QTextBrowser::anchorClicked, this,
674 [ + - ]: 37 : [this](const QUrl &url) {
675 [ + - + + ]: 7 : if (url.scheme() == QStringLiteral("mailjd-task-toggle")) {
676 [ + - ]: 3 : const QUrlQuery query(url);
677 : 3 : bool indexOk = false;
678 : : const int cbIdx =
679 [ + - + - ]: 3 : query.queryItemValue(QStringLiteral("index")).toInt(&indexOk);
680 : : const QString token =
681 [ + - ]: 3 : query.queryItemValue(QStringLiteral("token"));
682 [ + - + - : 5 : if (!indexOk || token.isEmpty() || token != m_checkboxActionToken ||
+ + + + ]
683 [ - + ]: 2 : !m_renderedCheckboxIndexes.contains(cbIdx))
684 : 1 : return;
685 : :
686 [ + - ]: 2 : auto treeIdx = m_treeView->currentIndex();
687 [ - + ]: 2 : if (!treeIdx.isValid()) return;
688 : 2 : CalendarTask task = m_model->taskAt(treeIdx.row());
689 : : const bool sameTask =
690 : 4 : (m_renderedCheckboxTaskId > 0 &&
691 [ + - - + ]: 2 : task.id == m_renderedCheckboxTaskId) ||
692 [ # # # # ]: 0 : (m_renderedCheckboxTaskId <= 0 &&
693 [ # # ]: 0 : task.uid == m_renderedCheckboxTaskUid &&
694 [ # # ]: 0 : task.accountId == m_renderedCheckboxAccountId &&
695 : 0 : task.calendarPath == m_renderedCheckboxCalendarPath);
696 [ + - - + : 2 : if (!sameTask || task.description != m_renderedCheckboxSource)
- + ]
697 : 0 : return;
698 : : // Toggle checkbox in markdown source. Descriptions are unescaped
699 : : // centrally at parse time (T-79.C1/H5) — real newlines here.
700 [ + - ]: 2 : QStringList lines = task.description.split(QLatin1Char('\n'));
701 : 2 : int cbCount = 0;
702 : : static QRegularExpression cbRe(
703 [ + + + - : 3 : QStringLiteral("^(\\s*- )\\[([ xX])\\](\\s+.*)$"));
+ - - - ]
704 : 2 : bool toggled = false;
705 [ + - ]: 5 : for (int i = 0; i < lines.size(); ++i) {
706 [ + - + - ]: 5 : auto m = cbRe.match(lines[i]);
707 [ + - + + ]: 5 : if (m.hasMatch()) {
708 [ + + ]: 3 : if (cbCount == cbIdx) {
709 [ + - + - ]: 2 : bool wasChecked = m.captured(2).toLower() == QStringLiteral("x");
710 [ + - + - ]: 6 : lines[i] = m.captured(1) +
711 [ + + + - : 10 : (wasChecked ? QStringLiteral("[ ]") : QStringLiteral("[x]")) +
+ + + + -
- - - ]
712 [ + - + - ]: 8 : m.captured(3);
713 : 2 : toggled = true;
714 : 2 : break;
715 : : }
716 : 1 : cbCount++;
717 : : }
718 [ + + ]: 5 : }
719 [ - + ]: 2 : if (!toggled)
720 : 0 : return;
721 [ + - ]: 2 : task.description = lines.join(QLatin1Char('\n'));
722 [ + - ]: 2 : task.lastModified = QDateTime::currentDateTimeUtc();
723 : : // Save scroll position before re-render
724 [ + - + - ]: 2 : int scrollPos = m_detailBrowser->verticalScrollBar()->value();
725 [ + - ]: 2 : emit taskSaveRequested(task);
726 : : // Restore scroll position after the re-render triggered by save
727 [ + - ]: 2 : QTimer::singleShot(0, this, [this, scrollPos]() {
728 : 0 : m_detailBrowser->verticalScrollBar()->setValue(scrollPos);
729 : 0 : });
730 [ + - + - : 8 : } else if (isAllowedExternalScheme(url.scheme().toLower())) {
+ + + + +
- + - + -
+ + ]
731 : 1 : QDesktopServices::openUrl(url);
732 : : }
733 : : });
734 : : // Click on body area → open editor
735 : 30 : m_detailBrowser->viewport()->installEventFilter(this);
736 : :
737 : : // Page 1: Markdown editor with syntax highlighting (Sprint 57)
738 [ + - - + : 30 : auto *editorContainer = new QWidget();
- - ]
739 [ + - - + : 30 : auto *editorLayout = new QVBoxLayout(editorContainer);
- - ]
740 : 30 : editorLayout->setContentsMargins(0, 0, 0, 0);
741 : 30 : editorLayout->setSpacing(0);
742 : :
743 : 30 : setupEditorToolbar();
744 [ + - ]: 30 : editorLayout->addWidget(m_editorToolbar);
745 : :
746 [ + - - + : 30 : m_descriptionEditor = new QTextEdit();
- - ]
747 [ + - ]: 60 : m_descriptionEditor->setObjectName(QStringLiteral("taskDescriptionEditor"));
748 : 30 : m_descriptionEditor->setAcceptRichText(false);
749 [ + - + - ]: 30 : m_descriptionEditor->setPlaceholderText(tr("Description (Markdown)..."));
750 : : // Sprint 69: styling via main.qss (QTextEdit#taskDescriptionEditor).
751 [ + - + - : 30 : m_highlighter = new MarkdownHighlighter(m_descriptionEditor->document());
- + - - ]
752 : 30 : m_descriptionEditor->installEventFilter(this);
753 [ + - ]: 30 : editorLayout->addWidget(m_descriptionEditor, 1);
754 : :
755 : 30 : m_detailStack->addWidget(m_detailBrowser); // page 0
756 : 30 : m_detailStack->addWidget(editorContainer); // page 1
757 : 30 : m_detailStack->setCurrentIndex(0);
758 : :
759 [ + - ]: 30 : detailLayout->addWidget(m_detailStack, 1);
760 : :
761 : : // Store container — used by the right splitter
762 : 30 : m_detailStack->setParent(detailContainer);
763 : : // Replace the old m_detailStack reference for the splitter
764 : : // We add detailContainer to the splitter instead
765 : 30 : m_detailContainer = detailContainer;
766 : 30 : }
767 : :
768 : : // --- Editor Toolbar (Sprint 57) ---
769 : :
770 : 30 : void TaskListWidget::setupEditorToolbar() {
771 [ + - + - : 30 : m_editorToolbar = new QWidget();
- + - - ]
772 [ + - ]: 60 : m_editorToolbar->setObjectName(QStringLiteral("taskEditorToolbar"));
773 [ + - ]: 30 : m_editorToolbar->setFixedHeight(32);
774 : : // Sprint 69: styling via main.qss (QWidget#taskEditorToolbar).
775 : :
776 [ + - + - : 30 : auto *layout = new QHBoxLayout(m_editorToolbar);
- + - - ]
777 [ + - ]: 30 : layout->setContentsMargins(8, 0, 8, 0);
778 [ + - ]: 30 : layout->setSpacing(2);
779 : :
780 : 210 : auto addBtn = [layout](const QString &label, const QString &tooltip,
781 : : auto slot) {
782 [ + - - + : 210 : auto *btn = new QToolButton();
- - ]
783 : 210 : btn->setText(label);
784 : 210 : btn->setToolTip(tooltip);
785 [ + - ]: 210 : QObject::connect(btn, &QToolButton::clicked, slot);
786 [ + - ]: 210 : layout->addWidget(btn);
787 : 210 : return btn;
788 : 30 : };
789 : :
790 [ + - + - ]: 60 : auto *boldBtn = addBtn(QStringLiteral("B"), tr("Bold (Ctrl+B)"), [this]() {
791 [ + - ]: 3 : insertMarkdownWrap(QStringLiteral("**"), QStringLiteral("**"));
792 : 1 : });
793 : 30 : m_boldBtn = boldBtn;
794 [ + - + - ]: 30 : QFont bf = boldBtn->font();
795 [ + - ]: 30 : bf.setBold(true);
796 [ + - ]: 30 : boldBtn->setFont(bf);
797 : :
798 [ + - + - ]: 60 : auto *italicBtn = addBtn(QStringLiteral("I"), tr("Italic (Ctrl+I)"), [this]() {
799 [ + - ]: 3 : insertMarkdownWrap(QStringLiteral("*"), QStringLiteral("*"));
800 : 1 : });
801 : 30 : m_italicBtn = italicBtn;
802 [ + - + - ]: 30 : QFont itf = italicBtn->font();
803 [ + - ]: 30 : itf.setItalic(true);
804 [ + - ]: 30 : italicBtn->setFont(itf);
805 : :
806 [ + - + - ]: 60 : m_codeBtn = addBtn(QStringLiteral("<>"), tr("Code (Ctrl+E)"), [this]() {
807 [ + - ]: 3 : insertMarkdownWrap(QStringLiteral("`"), QStringLiteral("`"));
808 : 1 : });
809 : :
810 : : // Separator
811 [ + - + - : 30 : auto *sep = new QFrame();
- + - - ]
812 [ + - ]: 60 : sep->setObjectName(QStringLiteral("taskToolbarSeparator"));
813 [ + - ]: 30 : sep->setFrameShape(QFrame::VLine);
814 [ + - ]: 30 : sep->setFixedWidth(1);
815 [ + - ]: 30 : layout->addWidget(sep);
816 : :
817 [ + - + - ]: 60 : m_headingBtn = addBtn(QStringLiteral("H"), tr("Heading"), [this]() {
818 [ + - ]: 2 : insertMarkdownPrefix(QStringLiteral("## "));
819 : 1 : });
820 : :
821 [ + - + - ]: 60 : m_checkboxBtn = addBtn(QStringLiteral("\u2610"), tr("Checkbox"), [this]() {
822 [ + - ]: 2 : insertMarkdownPrefix(QStringLiteral("- [ ] "));
823 : 1 : });
824 : :
825 [ + - + - ]: 60 : m_dividerBtn = addBtn(QStringLiteral("\u2014"), tr("Separator"), [this]() {
826 [ + - ]: 1 : QTextCursor cursor = m_descriptionEditor->textCursor();
827 [ + - ]: 1 : cursor.movePosition(QTextCursor::EndOfBlock);
828 [ + - ]: 1 : cursor.insertText(QStringLiteral("\n---\n"));
829 [ + - ]: 1 : m_descriptionEditor->setTextCursor(cursor);
830 : 1 : });
831 : :
832 [ + - + - ]: 60 : m_linkBtn = addBtn(QStringLiteral("\U0001F517"), tr("Link (Ctrl+K)"), [this]() {
833 [ + - ]: 2 : QTextCursor cursor = m_descriptionEditor->textCursor();
834 [ + - + + ]: 2 : if (cursor.hasSelection()) {
835 [ + - ]: 1 : QString sel = cursor.selectedText();
836 [ + - + - ]: 2 : cursor.insertText(QStringLiteral("[%1](url)").arg(sel));
837 : 1 : } else {
838 [ + - ]: 1 : int pos = cursor.position();
839 [ + - ]: 1 : cursor.insertText(QStringLiteral("[Text](url)"));
840 [ + - ]: 1 : cursor.setPosition(pos + 7);
841 [ + - ]: 1 : cursor.setPosition(pos + 10, QTextCursor::KeepAnchor);
842 [ + - ]: 1 : m_descriptionEditor->setTextCursor(cursor);
843 : : }
844 : 2 : });
845 : :
846 [ + - ]: 30 : layout->addStretch();
847 : 30 : }
848 : :
849 : 7 : void TaskListWidget::insertMarkdownWrap(const QString &before,
850 : : const QString &after) {
851 [ + - ]: 7 : QTextCursor cursor = m_descriptionEditor->textCursor();
852 [ + - + + ]: 7 : if (cursor.hasSelection()) {
853 [ + - ]: 1 : QString sel = cursor.selectedText();
854 [ + - + - : 1 : cursor.insertText(before + sel + after);
+ - ]
855 : 1 : } else {
856 [ + - ]: 6 : int pos = cursor.position();
857 [ + - + - ]: 6 : cursor.insertText(before + after);
858 [ + - ]: 6 : cursor.setPosition(pos + before.length());
859 [ + - ]: 6 : m_descriptionEditor->setTextCursor(cursor);
860 : : }
861 [ + - ]: 7 : m_descriptionEditor->setFocus();
862 : 7 : }
863 : :
864 : 2 : void TaskListWidget::insertMarkdownPrefix(const QString &prefix) {
865 [ + - ]: 2 : QTextCursor cursor = m_descriptionEditor->textCursor();
866 [ + - ]: 2 : cursor.movePosition(QTextCursor::StartOfBlock);
867 [ + - ]: 2 : cursor.insertText(prefix);
868 [ + - ]: 2 : cursor.movePosition(QTextCursor::EndOfBlock);
869 [ + - ]: 2 : m_descriptionEditor->setTextCursor(cursor);
870 [ + - ]: 2 : m_descriptionEditor->setFocus();
871 : 2 : }
872 : :
873 : :
874 : :
875 : 27 : void TaskListWidget::setCalendarStore(CalendarStore *store) {
876 : 27 : m_store = store;
877 : 27 : reload();
878 : 27 : }
879 : :
880 : 84 : void TaskListWidget::reload() {
881 [ + - + + : 84 : if (!m_store || !m_store->isOpen()) {
+ + ]
882 [ + - ]: 4 : m_model->setTasks({});
883 : 4 : m_allTasks.clear();
884 [ + - ]: 8 : m_detailBrowser->setHtml(
885 : 8 : QStringLiteral("<p style='color:%1;text-align:center;'>"
886 : : "Keine Aufgabe ausgewählt</p>")
887 [ + - + - ]: 8 : .arg(tok("@text_secondary")));
888 : 4 : return;
889 : : }
890 : : // Sprint 56: Always load all tasks — applyFilter() handles visibility
891 : : // of completed tasks based on m_showCompleted, isSelected, and sidebar filter
892 [ + - ]: 80 : m_allTasks = m_store->allTasks();
893 : 80 : updateSidebar();
894 : 80 : applyFilter();
895 : : }
896 : :
897 : 9 : void TaskListWidget::setShowCompleted(bool show) {
898 [ + + ]: 9 : if (m_showCompleted != show) {
899 : 8 : m_showCompleted = show;
900 : 8 : saveSettings();
901 : 8 : reload();
902 : : }
903 : 9 : }
904 : :
905 : 4 : void TaskListWidget::setFilterText(const QString &text) {
906 : 4 : m_filterText = text;
907 : 4 : applyFilter();
908 : 4 : }
909 : :
910 : 4 : void TaskListWidget::setSearchResults(const QList<CalendarTask> &results) {
911 : 4 : m_model->setTasks(results);
912 [ + - + - ]: 4 : if (m_model->rowCount() > 0)
913 [ + - + - ]: 4 : m_treeView->setCurrentIndex(m_model->index(0, 0));
914 : 4 : }
915 : :
916 : 6 : void TaskListWidget::toggleCurrentTask() {
917 [ + - ]: 12 : modifyCurrentTask([](CalendarTask &t) {
918 [ + + ]: 6 : if (t.status == QStringLiteral("COMPLETED")) {
919 : 1 : t.status = QStringLiteral("NEEDS-ACTION");
920 : 1 : t.percentComplete = 0;
921 : 1 : t.completedAt = {};
922 : : } else {
923 : 5 : t.status = QStringLiteral("COMPLETED");
924 : 5 : t.percentComplete = 100;
925 [ + - ]: 5 : t.completedAt = QDateTime::currentDateTimeUtc();
926 : : }
927 : 6 : });
928 : 6 : }
929 : :
930 : 2 : void TaskListWidget::deleteCurrentTask() {
931 [ + - ]: 2 : auto idx = m_treeView->currentIndex();
932 [ - + ]: 2 : if (!idx.isValid()) return;
933 : 2 : const auto &task = m_model->taskAt(idx.row());
934 [ - + ]: 2 : if (task.uid.isEmpty()) return;
935 : :
936 [ + - ]: 4 : auto answer = PlainTextMessageBox::question(
937 [ + - ]: 4 : this, tr("Delete task"),
938 [ + - + - ]: 6 : tr("Really delete task \"%1\"?").arg(task.summary),
939 : : QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
940 [ + + ]: 2 : if (answer == QMessageBox::Yes)
941 [ + - ]: 1 : emit taskDeleteRequested(task);
942 : : }
943 : :
944 : 51 : void TaskListWidget::modifyCurrentTask(
945 : : const std::function<void(CalendarTask&)> &mutator) {
946 [ + - ]: 51 : auto idx = m_treeView->currentIndex();
947 [ - + ]: 51 : if (!idx.isValid()) return;
948 : 51 : CalendarTask task = m_model->taskAt(idx.row());
949 [ - + ]: 51 : if (task.uid.isEmpty()) return;
950 [ + - ]: 51 : mutator(task);
951 [ + - ]: 51 : task.lastModified = QDateTime::currentDateTimeUtc();
952 [ + - ]: 51 : emit taskSaveRequested(task);
953 [ + - ]: 51 : }
954 : :
955 : 6 : void TaskListWidget::startDescriptionEdit() {
956 [ - + ]: 6 : if (m_editing) return;
957 [ + - ]: 6 : auto idx = m_treeView->currentIndex();
958 [ - + ]: 6 : if (!idx.isValid()) return;
959 : :
960 : 6 : const auto &task = m_model->taskAt(idx.row());
961 : 6 : m_editingTaskId = task.id;
962 : 6 : m_editingUid = task.uid;
963 : 6 : m_editingAccountId = task.accountId;
964 : 6 : m_editingCalendarPath = task.calendarPath;
965 [ + - ]: 6 : m_descriptionEditor->setPlainText(task.description);
966 [ + - ]: 6 : m_detailStack->setCurrentIndex(1);
967 [ + - ]: 6 : m_descriptionEditor->setFocus();
968 : 6 : m_editing = true;
969 : : }
970 : :
971 : 7 : void TaskListWidget::finishDescriptionEdit(bool save) {
972 [ + + ]: 7 : if (!m_editing) return;
973 : 6 : m_editing = false;
974 [ + - ]: 6 : m_detailStack->setCurrentIndex(0);
975 : :
976 [ + + ]: 6 : if (save) {
977 : 4 : CalendarTask task;
978 : 4 : bool found = false;
979 [ + - + - ]: 8 : for (int row = 0; row < m_model->rowCount(); ++row) {
980 : 8 : const auto &candidate = m_model->taskAt(row);
981 [ + - + + ]: 8 : if (matchesEditingTask(candidate)) {
982 : 4 : task = candidate;
983 : 4 : found = true;
984 : 4 : break;
985 : : }
986 : : }
987 [ - + ]: 4 : if (!found) {
988 [ # # # # : 0 : for (const auto &candidate : m_allTasks) {
# # ]
989 [ # # # # ]: 0 : if (matchesEditingTask(candidate)) {
990 : 0 : task = candidate;
991 : 0 : found = true;
992 : 0 : break;
993 : : }
994 : : }
995 : : }
996 : :
997 [ + - ]: 4 : QString newDesc = m_descriptionEditor->toPlainText();
998 [ + - + - : 4 : if (found && newDesc != task.description) {
+ - ]
999 : 4 : task.description = newDesc;
1000 [ + - ]: 4 : task.lastModified = QDateTime::currentDateTimeUtc();
1001 [ + - ]: 4 : emit taskSaveRequested(task);
1002 : : }
1003 : 4 : }
1004 : 6 : m_editingTaskId = 0;
1005 : 6 : m_editingUid.clear();
1006 : 6 : m_editingAccountId.clear();
1007 : 6 : m_editingCalendarPath.clear();
1008 : : // Re-render the detail view
1009 [ + - ]: 6 : auto idx = m_treeView->currentIndex();
1010 [ + - ]: 6 : if (idx.isValid())
1011 [ + - ]: 6 : showDetail(idx.row());
1012 [ + - ]: 6 : m_treeView->setFocus();
1013 : : }
1014 : :
1015 : 8 : bool TaskListWidget::matchesEditingTask(const CalendarTask &task) const {
1016 [ + - + + ]: 8 : if (m_editingTaskId > 0 && task.id == m_editingTaskId)
1017 : 4 : return true;
1018 : :
1019 [ + - + - : 4 : if (m_editingUid.isEmpty() || task.uid != m_editingUid)
+ - ]
1020 : 4 : return false;
1021 : :
1022 [ # # # # : 0 : if (!m_editingAccountId.isEmpty() && task.accountId != m_editingAccountId)
# # ]
1023 : 0 : return false;
1024 [ # # # # ]: 0 : if (!m_editingCalendarPath.isEmpty() &&
1025 [ # # ]: 0 : task.calendarPath != m_editingCalendarPath)
1026 : 0 : return false;
1027 : :
1028 : 0 : return true;
1029 : : }
1030 : :
1031 : 740 : bool TaskListWidget::eventFilter(QObject *obj, QEvent *event) {
1032 : : // Esc in description editor → finish editing (don't propagate to MainWindow)
1033 [ + + + + : 740 : if (obj == m_descriptionEditor && event->type() == QEvent::FocusOut) {
+ + ]
1034 : : // Sprint 57b: Finish editing when clicking outside the editor
1035 : 1 : finishDescriptionEdit(true);
1036 : 1 : return false;
1037 : : }
1038 [ + + + + : 739 : if (obj == m_descriptionEditor && event->type() == QEvent::KeyPress) {
+ + ]
1039 : 20 : auto *ke = static_cast<QKeyEvent *>(event);
1040 [ + + ]: 20 : if (ke->key() == Qt::Key_Escape) {
1041 : 1 : finishDescriptionEdit(true);
1042 : 1 : return true; // consumed — don't propagate
1043 : : }
1044 : : // Sprint 57: Markdown keyboard shortcuts
1045 [ + + ]: 19 : if (ke->modifiers() == Qt::ControlModifier) {
1046 [ + + + + : 12 : switch (ke->key()) {
+ ]
1047 : 1 : case Qt::Key_B:
1048 [ + - ]: 2 : insertMarkdownWrap(QStringLiteral("**"), QStringLiteral("**"));
1049 : 1 : return true;
1050 : 1 : case Qt::Key_I:
1051 [ + - ]: 2 : insertMarkdownWrap(QStringLiteral("*"), QStringLiteral("*"));
1052 : 1 : return true;
1053 : 1 : case Qt::Key_E:
1054 [ + - ]: 2 : insertMarkdownWrap(QStringLiteral("`"), QStringLiteral("`"));
1055 : 1 : return true;
1056 : 3 : case Qt::Key_K: {
1057 [ + - ]: 3 : QTextCursor cursor = m_descriptionEditor->textCursor();
1058 [ + - + + ]: 3 : if (cursor.hasSelection()) {
1059 [ + - ]: 1 : QString sel = cursor.selectedText();
1060 [ + - + - ]: 2 : cursor.insertText(QStringLiteral("[%1](url)").arg(sel));
1061 : 1 : } else {
1062 [ + - ]: 2 : cursor.insertText(QStringLiteral("[Text](url)"));
1063 : : }
1064 [ + - ]: 3 : m_descriptionEditor->setTextCursor(cursor);
1065 : 3 : return true;
1066 : 3 : }
1067 : 6 : default:
1068 : 6 : break;
1069 : : }
1070 : : }
1071 : : // Sprint 57b: Auto-continue lists on Enter
1072 [ + + - + : 13 : if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
+ + ]
1073 [ + - ]: 7 : QTextCursor cursor = m_descriptionEditor->textCursor();
1074 [ + - ]: 7 : cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor);
1075 [ + - ]: 7 : QString currentLine = cursor.selectedText();
1076 [ + - ]: 7 : cursor.clearSelection();
1077 [ + - ]: 7 : cursor.movePosition(QTextCursor::EndOfBlock);
1078 [ + - ]: 7 : m_descriptionEditor->setTextCursor(cursor);
1079 : :
1080 : : // Checkbox: "- [ ] " or "- [x] "
1081 [ + + + - : 8 : static QRegularExpression cbRe(QStringLiteral("^(\\s*)- \\[[xX ]\\]\\s"));
+ - - - ]
1082 [ + - ]: 7 : auto cbMatch = cbRe.match(currentLine);
1083 [ + - + + ]: 7 : if (cbMatch.hasMatch()) {
1084 [ + - + - : 3 : if (currentLine.mid(cbMatch.capturedEnd()).trimmed().isEmpty()) {
+ - + + ]
1085 [ + - ]: 1 : cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor);
1086 [ + - ]: 1 : cursor.removeSelectedText();
1087 : 1 : return true;
1088 : : }
1089 [ + - + - : 4 : cursor.insertText(QStringLiteral("\n%1- [ ] ").arg(cbMatch.captured(1)));
+ - ]
1090 [ + - ]: 2 : m_descriptionEditor->setTextCursor(cursor);
1091 : 2 : return true;
1092 : : }
1093 : :
1094 : : // Unordered list: "- " or "* " or "+ "
1095 [ + + + - : 5 : static QRegularExpression ulRe(QStringLiteral("^(\\s*)([-*+])\\s"));
+ - - - ]
1096 [ + - ]: 4 : auto ulMatch = ulRe.match(currentLine);
1097 [ + - + + ]: 4 : if (ulMatch.hasMatch()) {
1098 [ + - + - : 2 : if (currentLine.mid(ulMatch.capturedEnd()).trimmed().isEmpty()) {
+ - + + ]
1099 [ + - ]: 1 : cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor);
1100 [ + - ]: 1 : cursor.removeSelectedText();
1101 : 1 : return true;
1102 : : }
1103 [ + - + - : 1 : cursor.insertText(QStringLiteral("\n%1%2 ").arg(ulMatch.captured(1), ulMatch.captured(2)));
+ - + - ]
1104 [ + - ]: 1 : m_descriptionEditor->setTextCursor(cursor);
1105 : 1 : return true;
1106 : : }
1107 : :
1108 : : // Ordered list: "1. " -> increment number
1109 [ + + + - : 3 : static QRegularExpression olRe(QStringLiteral("^(\\s*)(\\d+)\\.\\s"));
+ - - - ]
1110 [ + - ]: 2 : auto olMatch = olRe.match(currentLine);
1111 [ + - + - ]: 2 : if (olMatch.hasMatch()) {
1112 [ + - + - : 2 : if (currentLine.mid(olMatch.capturedEnd()).trimmed().isEmpty()) {
+ - + + ]
1113 [ + - ]: 1 : cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor);
1114 [ + - ]: 1 : cursor.removeSelectedText();
1115 : 1 : return true;
1116 : : }
1117 [ + - + - ]: 1 : int nextNum = olMatch.captured(2).toInt() + 1;
1118 [ + - + - : 1 : cursor.insertText(QStringLiteral("\n%1%2. ").arg(olMatch.captured(1), QString::number(nextNum)));
+ - + - ]
1119 [ + - ]: 1 : m_descriptionEditor->setTextCursor(cursor);
1120 : 1 : return true;
1121 : : }
1122 [ - + - + : 27 : }
- + - + -
+ ]
1123 : : }
1124 : :
1125 : : // Click on detail browser body → open editor at click position
1126 [ + + + + : 1030 : if (obj == m_detailBrowser->viewport() &&
+ + ]
1127 : 305 : event->type() == QEvent::MouseButtonPress) {
1128 : 1 : auto *me = static_cast<QMouseEvent *>(event);
1129 [ + - ]: 1 : if (me->button() == Qt::LeftButton) {
1130 : : // Check if clicking on an anchor (checkbox, external link) — let it through
1131 [ + - ]: 1 : QPoint pos = me->pos();
1132 [ + - ]: 1 : QString anchor = m_detailBrowser->anchorAt(pos);
1133 [ - + ]: 1 : if (!anchor.isEmpty())
1134 : 0 : return false; // let anchorClicked handle it
1135 : :
1136 : : // Only start edit if a task is selected
1137 [ + - ]: 1 : auto idx = m_treeView->currentIndex();
1138 [ + - ]: 1 : if (idx.isValid()) {
1139 : : // Get the clicked text from the rendered view for matching
1140 [ + - ]: 1 : QTextCursor htmlCursor = m_detailBrowser->cursorForPosition(pos);
1141 [ + - + - : 1 : QString clickedBlockText = htmlCursor.block().text().trimmed();
+ - ]
1142 [ + - ]: 1 : int clickCol = htmlCursor.positionInBlock();
1143 : :
1144 : : // Count which occurrence of this text we clicked (for duplicates)
1145 : 1 : int occurrence = 0;
1146 : : {
1147 [ + - + - ]: 1 : QTextBlock blk = m_detailBrowser->document()->begin();
1148 [ + - ]: 1 : QTextBlock clickedBlk = htmlCursor.block();
1149 [ + - + - : 1 : while (blk.isValid() && blk != clickedBlk) {
- + - + ]
1150 [ # # # # : 0 : if (blk.text().trimmed() == clickedBlockText)
# # ]
1151 : 0 : ++occurrence;
1152 [ # # ]: 0 : blk = blk.next();
1153 : : }
1154 : : }
1155 : :
1156 [ + - ]: 1 : startDescriptionEdit();
1157 : : // Find the Nth matching line in the raw Markdown
1158 [ + - + - : 1 : if (m_descriptionEditor && !clickedBlockText.isEmpty()) {
+ - ]
1159 : : static QRegularExpression syntaxRe(
1160 : 2 : QStringLiteral("^(#{1,6}\\s+|[-*+]\\s+|\\d+\\.\\s+|"
1161 [ + - + - : 3 : "- \\[[ xX]\\]\\s+|>\\s*)"));
+ - - - ]
1162 [ + - ]: 1 : QString plainMd = m_descriptionEditor->toPlainText();
1163 [ + - ]: 1 : QStringList mdLines = plainMd.split(QLatin1Char('\n'));
1164 : 1 : int targetLine = -1;
1165 : 1 : int matchCount = 0;
1166 [ + - ]: 1 : for (int i = 0; i < mdLines.size(); ++i) {
1167 [ + - + - ]: 1 : QString stripped = mdLines[i].trimmed();
1168 [ + - ]: 1 : stripped.remove(syntaxRe);
1169 [ + - + - ]: 2 : if (!stripped.isEmpty() &&
1170 [ + - - + ]: 1 : (clickedBlockText.contains(stripped) ||
1171 [ # # # # ]: 0 : stripped.contains(clickedBlockText))) {
1172 [ + - ]: 1 : if (matchCount == occurrence) {
1173 : 1 : targetLine = i;
1174 : 1 : break;
1175 : : }
1176 : 0 : ++matchCount;
1177 : : }
1178 [ - + ]: 1 : }
1179 [ + - ]: 1 : if (targetLine >= 0) {
1180 [ + - ]: 1 : QTextCursor editorCursor = m_descriptionEditor->textCursor();
1181 [ + - ]: 1 : editorCursor.movePosition(QTextCursor::Start);
1182 [ - + ]: 1 : for (int i = 0; i < targetLine; ++i) {
1183 [ # # # # ]: 0 : if (!editorCursor.movePosition(QTextCursor::NextBlock))
1184 : 0 : break;
1185 : : }
1186 : : // Column offset: account for Markdown prefix length
1187 [ + - ]: 1 : int prefixLen = mdLines[targetLine].length() -
1188 [ + - + - ]: 1 : mdLines[targetLine].trimmed().length();
1189 [ + - + - ]: 1 : QString stripped = mdLines[targetLine].trimmed();
1190 [ + - ]: 1 : stripped.remove(syntaxRe);
1191 [ + - + - ]: 1 : int syntaxLen = mdLines[targetLine].trimmed().length() -
1192 : 1 : stripped.length();
1193 : 1 : int col = prefixLen + syntaxLen + clickCol;
1194 [ + - + - ]: 1 : int lineLen = editorCursor.block().length() - 1;
1195 [ + - ]: 1 : editorCursor.movePosition(QTextCursor::Right,
1196 : : QTextCursor::MoveAnchor,
1197 : 1 : qMin(col, lineLen));
1198 [ + - ]: 1 : m_descriptionEditor->setTextCursor(editorCursor);
1199 : 1 : }
1200 : 1 : }
1201 : 1 : return true;
1202 : 1 : }
1203 [ - + ]: 1 : }
1204 : : }
1205 : :
1206 : 724 : return QWidget::eventFilter(obj, event);
1207 : : }
1208 : 3 : void TaskListWidget::moveSelectionBy(int delta) {
1209 [ + - ]: 3 : auto idx = m_treeView->currentIndex();
1210 [ + - ]: 3 : int newRow = idx.isValid() ? idx.row() + delta : 0;
1211 [ + - + - ]: 3 : newRow = qBound(0, newRow, m_model->rowCount() - 1);
1212 [ + - + - ]: 3 : if (m_model->rowCount() > 0)
1213 [ + - + - ]: 3 : m_treeView->setCurrentIndex(m_model->index(newRow, 0));
1214 : 3 : }
1215 : :
1216 : : // --- Sidebar updating (T-454) ---
1217 : :
1218 : 82 : void TaskListWidget::updateSidebar() {
1219 : 82 : m_sidebarList->blockSignals(true);
1220 [ + - ]: 82 : int prevRow = m_sidebarList->currentRow();
1221 [ + - ]: 82 : m_sidebarList->clear();
1222 : :
1223 [ + - + - : 82 : if (!m_store || !m_store->isOpen()) {
- + - + ]
1224 : 0 : m_sidebarList->blockSignals(false);
1225 : 0 : return;
1226 : : }
1227 : :
1228 : : // Count tasks for meta-folders (respecting hidden calendars)
1229 : 82 : int totalOpen = 0, currentCount = 0, urgentCount = 0, completedCount = 0;
1230 [ + - ]: 82 : QDateTime now = QDateTime::currentDateTimeUtc();
1231 [ + - ]: 82 : QDateTime thirtyDays = now.addDays(30);
1232 [ + - ]: 82 : QDateTime sevenDays = now.addDays(7);
1233 : :
1234 [ + - + - : 465 : for (const auto &task : m_allTasks) {
+ + ]
1235 [ + + ]: 383 : if (m_hiddenCalendars.contains(task.calendarPath))
1236 : 4 : continue;
1237 [ + + + - ]: 1434 : bool isOpen = task.status != QStringLiteral("COMPLETED") &&
1238 [ + - + + : 676 : task.status != QStringLiteral("CANCELLED");
+ + + - ]
1239 [ + + ]: 379 : if (isOpen) {
1240 : 297 : totalOpen++;
1241 [ + - + + : 318 : if ((task.due.isValid() && task.due <= thirtyDays) ||
+ - + + -
+ ]
1242 [ + + + + : 318 : task.status == QStringLiteral("IN-PROCESS"))
+ + - - -
- ]
1243 : 276 : currentCount++;
1244 [ + - + + : 297 : if (task.due.isValid() && task.due <= sevenDays)
+ - + + +
+ ]
1245 : 276 : urgentCount++;
1246 : : } else {
1247 : 82 : completedCount++;
1248 : : }
1249 : : }
1250 : :
1251 : : // Meta-folders — use color-dot icons for consistent layout
1252 : 328 : auto makeDotIcon = [](const QColor &color) -> QIcon {
1253 [ + - ]: 328 : QPixmap px(16, 16);
1254 [ + - ]: 328 : px.fill(Qt::transparent);
1255 [ + - ]: 328 : QPainter p(&px);
1256 [ + - ]: 328 : p.setRenderHint(QPainter::Antialiasing);
1257 [ + - + - ]: 328 : p.setBrush(color);
1258 [ + - ]: 328 : p.setPen(Qt::NoPen);
1259 [ + - ]: 328 : p.drawEllipse(1, 1, 14, 14);
1260 [ + - ]: 656 : return QIcon(px);
1261 : 328 : };
1262 : :
1263 : 328 : auto addMeta = [this, &makeDotIcon](const QString &label, int count,
1264 : : const QString &filter,
1265 : : const QColor &dotColor) {
1266 : : auto *item = new QListWidgetItem(
1267 [ + - + - : 1312 : QStringLiteral("%1 (%2)").arg(label).arg(count));
+ - - + -
- ]
1268 [ + - ]: 328 : item->setData(Qt::UserRole, QString()); // empty path = meta
1269 [ + - ]: 328 : item->setData(Qt::UserRole + 1, filter);
1270 [ + - + - ]: 328 : item->setIcon(makeDotIcon(dotColor));
1271 : 328 : m_sidebarList->addItem(item);
1272 : 328 : };
1273 [ + - ]: 82 : addMeta(QStringLiteral("Alle"), totalOpen,
1274 [ + - ]: 246 : QStringLiteral("all"), QColor(tok("@text_secondary")));
1275 [ + - ]: 82 : addMeta(QStringLiteral("Aktuell"), currentCount,
1276 [ + - ]: 246 : QStringLiteral("current"), QColor(tok("@accent")));
1277 [ + - ]: 82 : addMeta(QStringLiteral("Dringend"), urgentCount,
1278 [ + - ]: 246 : QStringLiteral("urgent"), QColor(tok("@danger")));
1279 [ + - ]: 82 : addMeta(QStringLiteral("Fertiggestellt"), completedCount,
1280 [ + - ]: 246 : QStringLiteral("completed"), QColor(tok("@success")));
1281 : :
1282 : : // Separator
1283 [ + - + - : 82 : auto *sep = new QListWidgetItem();
- + - - ]
1284 [ + - ]: 82 : sep->setFlags(Qt::NoItemFlags);
1285 [ + - ]: 82 : sep->setSizeHint(QSize(0, 8));
1286 [ + - ]: 82 : m_sidebarList->addItem(sep);
1287 : :
1288 : : // Calendar list
1289 [ + - ]: 82 : auto counts = m_store->openTaskCountByCalendar();
1290 [ + - ]: 82 : auto calendars = m_store->allCalendars();
1291 [ + - + - : 237 : for (const auto &cal : calendars) {
+ + ]
1292 [ + - ]: 155 : int count = counts.value(cal.path, 0);
1293 : 155 : QString label = cal.displayName.isEmpty()
1294 [ - + ]: 155 : ? cal.path.section(QLatin1Char('/'), -2, -2)
1295 [ - - ]: 155 : : cal.displayName;
1296 : : auto *item = new QListWidgetItem(
1297 [ + - + - : 620 : QStringLiteral("%1 (%2)").arg(label).arg(count));
+ - + - -
+ - - ]
1298 [ + - ]: 155 : item->setData(Qt::UserRole, cal.path);
1299 [ + - ]: 155 : item->setData(Qt::UserRole + 1, cal.path);
1300 : :
1301 : : // Color marker
1302 [ - - - + : 310 : QColor calColor = cal.color.isEmpty() ? QColor(tok("@text_muted"))
- - ]
1303 [ - + ]: 155 : : QColor(cal.color);
1304 : : {
1305 [ + - ]: 155 : QPixmap px(16, 16);
1306 [ + - ]: 155 : px.fill(Qt::transparent);
1307 [ + - ]: 155 : QPainter p(&px);
1308 [ + - ]: 155 : p.setRenderHint(QPainter::Antialiasing);
1309 [ + - + - ]: 155 : p.setBrush(calColor);
1310 [ + - ]: 155 : p.setPen(Qt::NoPen);
1311 [ + - ]: 155 : p.drawEllipse(1, 1, 14, 14);
1312 [ + - + - ]: 155 : item->setIcon(QIcon(px));
1313 : 155 : }
1314 : :
1315 : : // Hidden calendar styling
1316 [ + + ]: 155 : if (m_hiddenCalendars.contains(cal.path)) {
1317 [ + - + - : 1 : item->setForeground(QColor(tok("@text_muted")));
+ - ]
1318 [ + - ]: 1 : QFont f = item->font();
1319 [ + - ]: 1 : f.setItalic(true);
1320 [ + - ]: 1 : item->setFont(f);
1321 : 1 : }
1322 : :
1323 : : // T-71.5b: native checkbox mirroring the CheckableFolderCombo pattern.
1324 : : // The check state is the primary visibility indicator; the existing
1325 : : // context-menu toggle (Sprint 56) remains a second way to change it.
1326 : : // Only calendar rows are checkable — meta-folders use addMeta and keep
1327 : : // the default (non-checkable) flags.
1328 [ + - ]: 155 : item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
1329 [ + + + - ]: 155 : item->setCheckState(m_hiddenCalendars.contains(cal.path)
1330 : : ? Qt::Unchecked : Qt::Checked);
1331 : :
1332 [ + - ]: 155 : m_sidebarList->addItem(item);
1333 : 155 : }
1334 : :
1335 : : // Restore selection
1336 [ + + + - : 82 : if (prevRow >= 0 && prevRow < m_sidebarList->count())
+ - + + ]
1337 [ + - ]: 56 : m_sidebarList->setCurrentRow(prevRow);
1338 : : else
1339 [ + - ]: 26 : m_sidebarList->setCurrentRow(0); // "Alle" by default
1340 : :
1341 : 82 : m_sidebarList->blockSignals(false);
1342 : 82 : }
1343 : :
1344 : 6 : void TaskListWidget::onSidebarClicked(int row) {
1345 [ + - - + : 6 : if (row < 0 || row >= m_sidebarList->count())
- + ]
1346 : 0 : return;
1347 : 6 : auto *item = m_sidebarList->item(row);
1348 [ + - + + : 6 : if (!item || !(item->flags() & Qt::ItemIsEnabled))
+ + ]
1349 : 1 : return;
1350 : :
1351 [ + - + - ]: 5 : m_currentFilter = item->data(Qt::UserRole + 1).toString();
1352 : 5 : applyFilter();
1353 : : }
1354 : :
1355 : : // --- Filtering (T-453) ---
1356 : :
1357 : 92 : void TaskListWidget::applyFilter() {
1358 : 92 : QList<CalendarTask> filtered;
1359 : :
1360 : : // Sprint 56: Remember currently selected task so it stays visible
1361 : : // even after marking completed (user stays on it until they navigate away)
1362 : 92 : QString selectedUid;
1363 [ + - ]: 92 : auto selIdx = m_treeView->currentIndex();
1364 [ + + ]: 92 : if (selIdx.isValid())
1365 : 63 : selectedUid = m_model->taskAt(selIdx.row()).uid;
1366 : :
1367 [ + - + - : 510 : for (const auto &task : m_allTasks) {
+ + ]
1368 : : // Hidden calendar check
1369 [ + + ]: 418 : if (m_hiddenCalendars.contains(task.calendarPath))
1370 : 4 : continue;
1371 : :
1372 : : // Meta-folder filter
1373 [ + + - + : 1567 : bool isCompleted = task.status == QStringLiteral("COMPLETED") ||
+ - ]
1374 [ + + + + : 739 : task.status == QStringLiteral("CANCELLED");
+ - ]
1375 : :
1376 : : // Keep the currently selected task visible regardless of filter
1377 [ + + + + ]: 414 : bool isSelected = (!selectedUid.isEmpty() && task.uid == selectedUid);
1378 : :
1379 [ + + ]: 414 : if (m_currentFilter == QStringLiteral("all")) {
1380 : : // "Alle" shows based on showCompleted toggle
1381 [ + + + + : 389 : if (isCompleted && !m_showCompleted && !isSelected)
+ + ]
1382 : 25 : continue;
1383 [ + + ]: 25 : } else if (m_currentFilter == QStringLiteral("current")) {
1384 [ + + + - ]: 5 : if (isCompleted && !isSelected)
1385 : 1 : continue;
1386 [ + - + - ]: 4 : QDateTime thirtyDays = QDateTime::currentDateTimeUtc().addDays(30);
1387 [ + - + - : 4 : bool isCurrent = (task.due.isValid() && task.due <= thirtyDays) ||
+ - - + -
- ]
1388 [ - + - + : 4 : task.status == QStringLiteral("IN-PROCESS");
- - - - ]
1389 [ - + ]: 4 : if (!isCurrent)
1390 : 0 : continue;
1391 [ + - + + ]: 24 : } else if (m_currentFilter == QStringLiteral("urgent")) {
1392 [ + + + - ]: 5 : if (isCompleted && !isSelected)
1393 : 1 : continue;
1394 [ + - + - ]: 4 : QDateTime sevenDays = QDateTime::currentDateTimeUtc().addDays(7);
1395 [ + - + - : 4 : if (!task.due.isValid() || task.due > sevenDays)
+ - - + -
+ ]
1396 : 0 : continue;
1397 [ + - + + ]: 19 : } else if (m_currentFilter == QStringLiteral("completed")) {
1398 [ + + ]: 5 : if (!isCompleted)
1399 : 4 : continue;
1400 : : } else {
1401 : : // Calendar path filter
1402 [ + + ]: 10 : if (task.calendarPath != m_currentFilter)
1403 : 5 : continue;
1404 [ + + + - : 5 : if (isCompleted && !m_showCompleted && !isSelected)
- + ]
1405 : 0 : continue;
1406 : : }
1407 : :
1408 : : // Text filter
1409 [ + + ]: 378 : if (!m_filterText.isEmpty()) {
1410 : : bool matches =
1411 [ + - + + ]: 7 : task.summary.contains(m_filterText, Qt::CaseInsensitive) ||
1412 [ + - - + ]: 3 : task.description.contains(m_filterText, Qt::CaseInsensitive);
1413 [ + + ]: 4 : if (!matches)
1414 : 3 : continue;
1415 : : }
1416 : :
1417 [ + - ]: 375 : filtered.append(task);
1418 : : }
1419 : :
1420 : : // Remember current selection to restore after reload
1421 : 92 : QString prevUid;
1422 [ + - ]: 92 : auto curIdx = m_treeView->currentIndex();
1423 [ + + ]: 92 : if (curIdx.isValid())
1424 : 63 : prevUid = m_model->taskAt(curIdx.row()).uid;
1425 : :
1426 [ + - ]: 92 : m_model->setTasks(filtered);
1427 : : // Restore previous selection if still in list, else select first
1428 : 92 : bool restored = false;
1429 [ + + ]: 92 : if (!prevUid.isEmpty()) {
1430 [ + - + + ]: 139 : for (int i = 0; i < m_model->rowCount(); ++i) {
1431 [ + + ]: 136 : if (m_model->taskAt(i).uid == prevUid) {
1432 [ + - + - ]: 60 : m_treeView->setCurrentIndex(m_model->index(i, 0));
1433 : 60 : restored = true;
1434 : 60 : break;
1435 : : }
1436 : : }
1437 : : }
1438 [ + + + - : 92 : if (!restored && m_model->rowCount() > 0)
+ + + + ]
1439 [ + - + - ]: 25 : m_treeView->setCurrentIndex(m_model->index(0, 0));
1440 : 92 : }
1441 : :
1442 : : // --- Detail Panel (T-456) ---
1443 : :
1444 : 105 : void TaskListWidget::showDetail(int row) {
1445 : : // Sprint 56: Finish any active inline edit before switching
1446 [ + + ]: 105 : if (m_editing)
1447 [ + - ]: 2 : finishDescriptionEdit(true);
1448 : :
1449 [ + - + - : 105 : if (row < 0 || row >= m_model->rowCount()) {
- + - + ]
1450 : 0 : m_checkboxActionToken.clear();
1451 : 0 : m_renderedCheckboxIndexes.clear();
1452 : 0 : m_renderedCheckboxSource.clear();
1453 [ # # ]: 0 : m_detailBrowser->setHtml(
1454 : 0 : QStringLiteral("<p style='color:%1;text-align:center;'>"
1455 : : "Keine Aufgabe ausgewählt</p>")
1456 [ # # # # ]: 0 : .arg(tok("@text_secondary")));
1457 [ # # ]: 0 : m_headerBrowser->setVisible(false);
1458 : 0 : return;
1459 : : }
1460 : 105 : const auto &task = m_model->taskAt(row);
1461 [ + - ]: 105 : emit taskSelected(task);
1462 : :
1463 : : // Build HTML matching MailView's header style; colors come from the
1464 : : // active theme palette (67.B3). Font stack per DESIGN.md §3.
1465 : 210 : QString html = QStringLiteral(
1466 : : "<style>"
1467 : : "body { margin:0; padding:0; "
1468 : : " font-family: 'Inter', 'Noto Sans', sans-serif; }"
1469 : : ".header { background-color:%1;"
1470 : : " border-bottom:1px solid %2;"
1471 : : " padding:8px 8px 6px 8px; }"
1472 : : ".subject { font-size:16px; font-weight:bold; color:%3;"
1473 : : " margin:0 0 4px 0; }"
1474 : : ".meta { font-size:12px; color:%4; padding:1px 0; }"
1475 : : ".meta a { color:inherit; text-decoration:none; cursor:pointer; }"
1476 : : ".meta .val { color:%3; }"
1477 : : ".overdue { color:%5; font-weight:bold; }"
1478 : : ".starred { color:%6; }"
1479 : : ".body { padding:12px 16px; font-size:13px; line-height:1.45;"
1480 : : " color:%7; max-width:720px; }"
1481 : : "</style>")
1482 [ + - + - : 210 : .arg(tok("@bg_sidebar"), tok("@border_light"), tok("@text_primary"),
+ - ]
1483 [ + - + - : 210 : tok("@text_secondary"), tok("@danger"), tok("@star_active"),
+ - ]
1484 [ + - + - ]: 315 : tok("@md_text"));
1485 : :
1486 : : // Header block (like MailView's m_headerFrame)
1487 [ + - ]: 105 : html += QStringLiteral("<div class='header'>");
1488 : :
1489 : : // Task title (like MailView's m_subjectLabel)
1490 : 210 : html += QStringLiteral("<div class='subject'>%1</div>")
1491 [ + - + - : 210 : .arg(task.summary.toHtmlEscaped());
+ - ]
1492 : :
1493 : : // Meta row 1: Status · Priorität · Favorit
1494 : : {
1495 [ + - ]: 105 : html += QStringLiteral("<div class='meta'>");
1496 : :
1497 : : // Status — clickable toggle
1498 : 105 : QString statusText;
1499 [ + + ]: 105 : if (task.status == QStringLiteral("COMPLETED"))
1500 : 16 : statusText = QStringLiteral("\u2713 Abgeschlossen");
1501 [ + + ]: 89 : else if (task.status == QStringLiteral("IN-PROCESS"))
1502 : 11 : statusText = QStringLiteral("\u25b6 In Bearbeitung");
1503 [ - + ]: 78 : else if (task.status == QStringLiteral("CANCELLED"))
1504 : 0 : statusText = QStringLiteral("\u2717 Abgebrochen");
1505 : : else
1506 : 78 : statusText = QStringLiteral("\u25cb Offen");
1507 : 210 : html += QStringLiteral(
1508 : : "Status: <a href='action:toggle-status'>"
1509 : : "<span class='val'>%1</span></a>")
1510 [ + - + - ]: 105 : .arg(statusText);
1511 : :
1512 : : // Priorität — clickable cycle
1513 : 105 : QString prioText;
1514 [ + + ]: 105 : if (task.priority == 1)
1515 : 54 : prioText = QStringLiteral("\u2605 Wichtig");
1516 [ + + + + ]: 51 : else if (task.priority <= 4 && task.priority > 0)
1517 : 1 : prioText = QStringLiteral("\u2191 Hoch");
1518 [ + + ]: 50 : else if (task.priority == 5)
1519 : 5 : prioText = QStringLiteral("\u25cf Mittel");
1520 [ + + ]: 45 : else if (task.priority > 5)
1521 : 1 : prioText = QStringLiteral("\u2193 Niedrig");
1522 : : else
1523 : 44 : prioText = QStringLiteral("\u25cb Keine");
1524 : 210 : html += QStringLiteral(
1525 : : " "
1526 : : "Priorit\u00e4t: <a href='action:cycle-priority'>"
1527 : : "<span class='val'>%1</span></a>")
1528 [ + - + - ]: 105 : .arg(prioText);
1529 : :
1530 : : // Starred — clickable toggle
1531 [ + + ]: 105 : if (task.isStarred()) {
1532 [ + - ]: 54 : html += QStringLiteral(
1533 : : " "
1534 : : "<a href='action:toggle-star'>"
1535 : : "<span class='starred'>\u2605 Favorit</span></a>");
1536 : : }
1537 : :
1538 [ + - ]: 105 : html += QStringLiteral("</div>");
1539 : 105 : }
1540 : :
1541 : : // Meta row 2: Fällig · Fortschritt
1542 : : {
1543 [ + - ]: 105 : html += QStringLiteral("<div class='meta'>");
1544 : :
1545 : : // Due date — clickable
1546 [ + - ]: 105 : html += QStringLiteral("F\u00e4llig: ");
1547 [ + - + + ]: 105 : if (task.due.isValid()) {
1548 [ + - ]: 94 : QString dueStr = QLocale().toString(
1549 [ + - + - ]: 94 : task.due.toLocalTime(), QStringLiteral("dd.MM.yyyy HH:mm"));
1550 [ + - + - : 237 : bool overdue = task.due < QDateTime::currentDateTimeUtc() &&
+ + - - ]
1551 [ + - + + : 143 : task.status != QStringLiteral("COMPLETED");
+ + + - -
- - - ]
1552 [ + + ]: 94 : if (overdue) {
1553 : 98 : html += QStringLiteral(
1554 : : "<a href='action:due-menu'>"
1555 : : "<span class='overdue'>%1 (\u00fcberf\u00e4llig)</span></a>"
1556 : : " <a href='action:due-clear' style='color:%2;"
1557 : : "font-size:10px;'>(\u00d7)</a>")
1558 [ + - + - : 98 : .arg(dueStr, tok("@danger"));
+ - ]
1559 : : } else {
1560 : 90 : html += QStringLiteral(
1561 : : "<a href='action:due-menu'>"
1562 : : "<span class='val'>%1</span></a>"
1563 : : " <a href='action:due-clear' style='color:%2;"
1564 : : "font-size:10px;'>(\u00d7)</a>")
1565 [ + - + - : 90 : .arg(dueStr, tok("@danger"));
+ - ]
1566 : : }
1567 : 94 : } else {
1568 : 22 : html += QStringLiteral(
1569 : : "<a href='action:due-menu' style='color:%1;'>"
1570 [ + - + - : 22 : "+ setzen</a>").arg(tok("@link"));
+ - ]
1571 : : }
1572 : :
1573 : : // Fortschritt — clickable +/-
1574 : : {
1575 : 105 : int pct = task.percentComplete;
1576 : 210 : html += QStringLiteral(
1577 : : " "
1578 : : "Fortschritt: "
1579 : : "<a href='action:progress-down' style='color:%2;'>"
1580 : : "−</a>"
1581 [ + - + - : 315 : " <span class='val'>%1</span>").arg(pct).arg(tok("@link"));
+ - + - ]
1582 : 210 : html += QStringLiteral(
1583 : : "%"
1584 : : " <a href='action:progress-up' style='color:%1;'>"
1585 [ + - + - : 210 : "+</a>").arg(tok("@link"));
+ - ]
1586 : : }
1587 : :
1588 [ + - ]: 105 : html += QStringLiteral("</div>");
1589 : : }
1590 : :
1591 : : // Meta row 3: Kalender · sekundäre Infos (nur wenn Daten vorhanden)
1592 : : {
1593 : 105 : QStringList parts;
1594 : : // Calendar
1595 [ + + + + : 105 : if (!task.calendarDisplayName.isEmpty() || !task.calendarPath.isEmpty()) {
+ + ]
1596 : 104 : QString calName = task.calendarDisplayName.isEmpty()
1597 [ + + ]: 107 : ? task.calendarPath.section(QLatin1Char('/'), -2, -2)
1598 [ + - ]: 107 : : task.calendarDisplayName;
1599 : 104 : QString colorDot;
1600 [ + + ]: 104 : if (!task.color.isEmpty()) {
1601 : : // SEC-2026-07-21-19: Normalize the server-supplied color through
1602 : : // QColor so it cannot inject additional CSS properties into the
1603 : : // style attribute (e.g. "red; background: yellow"). QColor::name()
1604 : : // returns a validated "#RRGGBB" string or an empty string for
1605 : : // invalid input.
1606 : 101 : QColor normalized(task.color);
1607 [ + - ]: 101 : if (normalized.isValid())
1608 : 202 : colorDot = QStringLiteral(
1609 : : "<span style='color:%1;'>\u25cf</span> ")
1610 [ + - + - ]: 202 : .arg(normalized.name());
1611 : : }
1612 : 208 : parts << QStringLiteral("Kalender: <span class='val'>%1%2</span>")
1613 [ + - + - : 104 : .arg(colorDot, calName.toHtmlEscaped());
+ - ]
1614 : 104 : }
1615 [ + - - + ]: 105 : if (task.dtStart.isValid())
1616 [ # # ]: 0 : parts << QStringLiteral("Start: <span class='val'>%1</span>").arg(
1617 [ # # # # : 0 : QLocale().toString(task.dtStart.toLocalTime(),
# # ]
1618 [ # # ]: 0 : QStringLiteral("dd.MM.yyyy")));
1619 [ - + ]: 105 : if (!task.organizer.isEmpty())
1620 : 0 : parts << QStringLiteral("Ersteller: <span class='val'>%1</span>")
1621 [ # # # # : 0 : .arg(task.organizer.toHtmlEscaped());
# # ]
1622 [ + - + + ]: 105 : if (task.created.isValid())
1623 [ + - ]: 16 : parts << QStringLiteral("Erstellt: <span class='val'>%1</span>").arg(
1624 [ + - + - : 16 : QLocale().toString(task.created.toLocalTime(),
+ - ]
1625 [ + - ]: 12 : QStringLiteral("dd.MM.yyyy")));
1626 [ + - + + ]: 105 : if (task.completedAt.isValid())
1627 [ + - + - : 5 : parts << QStringLiteral("Erledigt: <span class='val'>%1</span>").arg(QLocale().toString(
+ - ]
1628 [ + - + - ]: 3 : task.completedAt.toLocalTime(), QStringLiteral("dd.MM.yyyy")));
1629 : :
1630 [ + + ]: 105 : if (!parts.isEmpty())
1631 : 208 : html += QStringLiteral("<div class='secondary'>%1</div>")
1632 [ + - + - : 208 : .arg(parts.join(QStringLiteral(" · ")));
+ - ]
1633 : 105 : }
1634 : :
1635 [ + - ]: 105 : html += QStringLiteral("</div>"); // close .header
1636 : :
1637 : : // Set header HTML and auto-resize to content
1638 [ + - ]: 105 : m_headerBrowser->setHtml(html);
1639 [ + - ]: 105 : m_headerBrowser->setVisible(true);
1640 [ + - + - : 105 : m_headerBrowser->document()->setTextWidth(m_headerBrowser->viewport()->width());
+ - ]
1641 : 105 : int headerHeight = static_cast<int>(
1642 [ + - + - ]: 105 : m_headerBrowser->document()->size().height()) + 4;
1643 [ + - ]: 105 : m_headerBrowser->setFixedHeight(headerHeight);
1644 : :
1645 : : // Body (Markdown rendered) — separate QTextBrowser, click to edit.
1646 : : // Font stack per DESIGN.md §3.
1647 : 210 : QString bodyHtml = QStringLiteral(
1648 : : "<style>"
1649 : : "body { margin:0; padding:0; "
1650 : : " font-family: 'Inter', 'Noto Sans', sans-serif; }"
1651 : : ".body { padding:12px 16px; font-size:13px; line-height:1.45;"
1652 : : " color:%1; max-width:720px; }"
1653 [ + - + - ]: 105 : "</style>").arg(tok("@md_text"));
1654 [ + + ]: 105 : if (!task.description.isEmpty()) {
1655 : : m_checkboxActionToken =
1656 [ + - + - ]: 101 : QUuid::createUuid().toString(QUuid::WithoutBraces);
1657 : : const auto rendered = MarkdownRenderer::toInteractiveHtml(
1658 [ + - ]: 101 : task.description, m_checkboxActionToken);
1659 : 101 : m_renderedCheckboxIndexes = rendered.checkboxIndexes;
1660 : 101 : m_renderedCheckboxTaskId = task.id;
1661 : 101 : m_renderedCheckboxTaskUid = task.uid;
1662 : 101 : m_renderedCheckboxAccountId = task.accountId;
1663 : 101 : m_renderedCheckboxCalendarPath = task.calendarPath;
1664 : 101 : m_renderedCheckboxSource = task.description;
1665 [ + - ]: 101 : bodyHtml += QStringLiteral("<div class='body' "
1666 : : "style='cursor:text;min-height:40px;'>");
1667 [ + - ]: 101 : bodyHtml += rendered.html;
1668 [ + - ]: 101 : bodyHtml += QStringLiteral("</div>");
1669 : 101 : } else {
1670 : 4 : m_checkboxActionToken.clear();
1671 : 4 : m_renderedCheckboxIndexes.clear();
1672 : 4 : m_renderedCheckboxSource.clear();
1673 : 8 : bodyHtml += QStringLiteral(
1674 : : "<div class='body' style='cursor:text;min-height:40px;'>"
1675 : : "<p style='color:%1;font-style:italic;'>"
1676 : : "Klicken, um Beschreibung hinzuzuf\u00fcgen...</p>"
1677 [ + - + - : 8 : "</div>").arg(tok("@text_muted"));
+ - ]
1678 : : }
1679 : :
1680 [ + - ]: 105 : m_detailBrowser->setHtml(bodyHtml);
1681 : 105 : }
1682 : :
1683 : : // --- Settings persistence ---
1684 : :
1685 : 11 : void TaskListWidget::saveSettings() {
1686 [ + - ]: 11 : QSettings s;
1687 [ + - ]: 11 : s.beginGroup(QStringLiteral("taskView"));
1688 [ + - + - ]: 22 : s.setValue(QStringLiteral("splitterH"), m_mainSplitter->saveState());
1689 [ + - + - ]: 22 : s.setValue(QStringLiteral("splitterV"), m_rightSplitter->saveState());
1690 [ + - ]: 22 : s.setValue(QStringLiteral("showCompleted"), m_showCompleted);
1691 [ + - ]: 22 : s.setValue(QStringLiteral("hiddenCalendars"),
1692 [ + - + - : 22 : QStringList(m_hiddenCalendars.begin(), m_hiddenCalendars.end()));
+ - ]
1693 [ + - ]: 11 : s.endGroup();
1694 : 11 : }
1695 : :
1696 : 30 : void TaskListWidget::restoreSettings() {
1697 [ + - ]: 30 : QSettings s;
1698 [ + - ]: 30 : s.beginGroup(QStringLiteral("taskView"));
1699 : :
1700 [ + - + + ]: 30 : if (s.contains(QStringLiteral("splitterH")))
1701 [ + - + - : 34 : m_mainSplitter->restoreState(s.value(QStringLiteral("splitterH")).toByteArray());
+ - ]
1702 : : else
1703 [ + - + - ]: 13 : m_mainSplitter->setSizes({220, 580});
1704 : :
1705 [ + - + + ]: 30 : if (s.contains(QStringLiteral("splitterV")))
1706 [ + - + - : 34 : m_rightSplitter->restoreState(s.value(QStringLiteral("splitterV")).toByteArray());
+ - ]
1707 : :
1708 [ + - + - ]: 60 : m_showCompleted = s.value(QStringLiteral("showCompleted"), false).toBool();
1709 : :
1710 [ + - + - ]: 30 : auto hidden = s.value(QStringLiteral("hiddenCalendars")).toStringList();
1711 [ + - + - : 30 : m_hiddenCalendars = QSet<QString>(hidden.begin(), hidden.end());
+ - ]
1712 : :
1713 [ + - ]: 30 : s.endGroup();
1714 : 30 : }
1715 : :
1716 : : // --- Keyboard navigation ---
1717 : :
1718 : 27 : void TaskListWidget::keyPressEvent(QKeyEvent *event) {
1719 [ + + + - : 27 : switch (event->key()) {
+ + + + +
+ + - + +
+ + + ]
1720 : 2 : case Qt::Key_J:
1721 : : case Qt::Key_Down:
1722 : 2 : moveSelectionBy(1);
1723 : 2 : break;
1724 : 1 : case Qt::Key_K:
1725 : : case Qt::Key_Up:
1726 : 1 : moveSelectionBy(-1);
1727 : 1 : break;
1728 : 2 : case Qt::Key_X:
1729 : : case Qt::Key_Space:
1730 : 2 : toggleCurrentTask();
1731 : 2 : break;
1732 : 0 : case Qt::Key_D:
1733 : 0 : deleteCurrentTask();
1734 : 0 : break;
1735 : : // Sprint 56: Inline property shortcuts
1736 : 1 : case Qt::Key_S:
1737 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) {
1738 [ + - ]: 1 : t.priority = t.isStarred() ? 0 : 1;
1739 : 1 : });
1740 : 1 : break;
1741 : 1 : case Qt::Key_0:
1742 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) { t.priority = 0; });
1743 : 1 : break;
1744 : 1 : case Qt::Key_1:
1745 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) { t.priority = 1; }); // starred
1746 : 1 : break;
1747 : 1 : case Qt::Key_2:
1748 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) { t.priority = 5; }); // mittel
1749 : 1 : break;
1750 : 1 : case Qt::Key_3:
1751 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) { t.priority = 6; }); // niedrig
1752 : 1 : break;
1753 : 1 : case Qt::Key_Plus:
1754 : : case Qt::Key_Equal: // unshifted + on US layouts
1755 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) {
1756 : 1 : t.percentComplete = qMin(100, t.percentComplete + 10);
1757 [ - + ]: 1 : if (t.percentComplete == 100)
1758 : 0 : t.status = QStringLiteral("COMPLETED");
1759 [ + - ]: 1 : else if (t.percentComplete > 0)
1760 : 1 : t.status = QStringLiteral("IN-PROCESS");
1761 : 1 : });
1762 : 1 : break;
1763 : 1 : case Qt::Key_Minus:
1764 [ + - ]: 1 : modifyCurrentTask([](CalendarTask &t) {
1765 : 1 : t.percentComplete = qMax(0, t.percentComplete - 10);
1766 [ + - ]: 1 : if (t.percentComplete == 0)
1767 : 1 : t.status = QStringLiteral("NEEDS-ACTION");
1768 : 1 : });
1769 : 1 : break;
1770 : 0 : case Qt::Key_F:
1771 [ # # # # ]: 0 : if (event->modifiers() & Qt::ShiftModifier)
1772 : 0 : setShowCompleted(!m_showCompleted); // Shift+F → toggle completed
1773 : : else
1774 : 0 : event->ignore(); // f → handled by MainWindow (search)
1775 : 0 : break;
1776 : 1 : case Qt::Key_Return:
1777 : : case Qt::Key_Enter: {
1778 : : // Sprint 39: Edit selected task
1779 [ + - ]: 1 : auto idx = m_treeView->currentIndex();
1780 [ + - ]: 1 : if (idx.isValid()) {
1781 : 1 : const auto &task = m_model->taskAt(idx.row());
1782 [ + - ]: 1 : if (!task.uid.isEmpty())
1783 [ + - ]: 1 : emit taskUpdated(task);
1784 : : }
1785 : 1 : break;
1786 : : }
1787 : 1 : case Qt::Key_E: {
1788 : : // Sprint 39: 'e' = Edit selected task
1789 [ + - ]: 1 : auto idx = m_treeView->currentIndex();
1790 [ + - ]: 1 : if (idx.isValid()) {
1791 : 1 : const auto &task = m_model->taskAt(idx.row());
1792 [ + - ]: 1 : if (!task.uid.isEmpty())
1793 [ + - ]: 1 : emit taskUpdated(task);
1794 : : }
1795 : 1 : break;
1796 : : }
1797 : 2 : case Qt::Key_N: {
1798 : : // Sprint 39: 'n' = New task
1799 : 2 : emit taskCreateRequested();
1800 : 2 : break;
1801 : : }
1802 : 1 : case Qt::Key_Escape:
1803 [ - + ]: 1 : if (m_editing) {
1804 : 0 : finishDescriptionEdit(true);
1805 : 0 : break;
1806 : : }
1807 : 1 : event->ignore(); // Delegate to MainWindow
1808 : 1 : break;
1809 : 10 : default:
1810 : 10 : event->ignore(); // Let MainWindow handle (CommandBar etc.)
1811 : 10 : break;
1812 : : }
1813 : 27 : }
1814 : :
1815 : : // T-76.B3: Runtime language switching
1816 : 36 : void TaskListWidget::changeEvent(QEvent *event) {
1817 : 36 : QWidget::changeEvent(event);
1818 [ - + ]: 36 : if (event->type() == QEvent::LanguageChange)
1819 : 0 : retranslateUi();
1820 : 36 : }
1821 : :
1822 : 0 : void TaskListWidget::retranslateUi() {
1823 [ # # # # ]: 0 : m_descriptionEditor->setPlaceholderText(tr("Description (Markdown)..."));
1824 [ # # # # ]: 0 : m_boldBtn->setToolTip(tr("Bold (Ctrl+B)"));
1825 [ # # # # ]: 0 : m_italicBtn->setToolTip(tr("Italic (Ctrl+I)"));
1826 [ # # # # ]: 0 : m_codeBtn->setToolTip(tr("Code (Ctrl+E)"));
1827 [ # # # # ]: 0 : m_headingBtn->setToolTip(tr("Heading"));
1828 [ # # # # ]: 0 : m_checkboxBtn->setToolTip(tr("Checkbox"));
1829 [ # # # # ]: 0 : m_dividerBtn->setToolTip(tr("Separator"));
1830 [ # # # # ]: 0 : m_linkBtn->setToolTip(tr("Link (Ctrl+K)"));
1831 : 0 : }
|