15.1.359. tablet_qt/menulib/menuwindow.cpp

/*
    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
*/

// #define DEBUG_SELECTIONS
// #define OFFER_LAYOUT_DEBUG_BUTTON
// #define SHOW_PID_TO_DEBUG_STREAM  // should be disabled for production
#include "lib/widgetfunc.h"
#define SHOW_TASK_TIMING

#include "menuwindow.h"
#include <QDebug>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QPushButton>
#include "common/cssconst.h"
#include "common/uiconst.h"
#include "db/dbnestabletransaction.h"
#include "dbobjects/patient.h"
#include "dialogs/scrollmessagebox.h"
#include "lib/layoutdumper.h"
#include "lib/slowguiguard.h"
#include "lib/stringfunc.h"
#include "lib/uifunc.h"
#include "lib/widgetfunc.h"
#include "menulib/menuheader.h"
#include "questionnairelib/questionnaire.h"
#include "questionnairelib/questionnairefunc.h"
#include "tasklib/task.h"
#include "widgets/horizontalline.h"

const int BAD_INDEX = -1;


MenuWindow::MenuWindow(CamcopsApp& app,
                       const QString& icon, const bool top,
                       const bool offer_search) :
    m_app(app),
    m_icon(icon),
    m_top(top),
    m_offer_search(offer_search)
{
    setEscapeKeyCanAbort(!top, true);

    loadStyleSheet();
    setObjectName(cssconst::MENU_WINDOW_OUTER_OBJECT);

    // ------------------------------------------------------------------------
    // Layout
    // ------------------------------------------------------------------------

    /*
    For no clear reason, I have been unable to set the background colour
    of the widget that goes inside the QStackedLayout, either by class name
    or via setObjectName(), or with setAutoFillBackground(true).

    However, it works perfectly well to set the  background colour of inner
    widgets. So instead of this:

        QStackedLayout (main app)
            QWidget (MainWindow or Questionnaire)  <-- can't set bg colour
                m_mainlayout
                    widgets of interest

    it seems we have to do this:

        QStackedLayout (main app)
            QWidget (MenuWindow or Questionnaire)
                dummy_layout
                    dummy_widget  <-- set background colour of this one
                        m_mainlayout
                            widgets of interest
    */

#ifdef MENUWINDOW_USE_HFW_LAYOUT
    auto dummy_layout = new VBoxLayout();
#else
    auto dummy_layout = new QVBoxLayout();
#endif
    dummy_layout->setContentsMargins(uiconst::NO_MARGINS);
    setLayout(dummy_layout);
    auto dummy_widget = new QWidget();  // doesn't need to be BaseWidget; contains scrolling list
    dummy_widget->setObjectName(cssconst::MENU_WINDOW_BACKGROUND);
    dummy_layout->addWidget(dummy_widget);

#ifdef MENUWINDOW_USE_HFW_LAYOUT
    m_mainlayout = new VBoxLayout();
#else
    m_mainlayout = new QVBoxLayout();
#endif
    m_mainlayout->setContentsMargins(uiconst::NO_MARGINS);
    dummy_widget->setLayout(m_mainlayout);

    // QListWidget objects scroll themselves.
    // But we want everything to scroll within a QScrollArea.
    // https://forum.qt.io/topic/2058/expanding-qlistview-within-qscrollarea/2
    // It turns out to be very fiddly, and it's also perfectly reasonable to
    // keep the menu header visible, and have scroll bars showing the position
    // within the list view (both for menus and questionnaires, I'd think).
    // So we'll stick with a simple layout.

    // ------------------------------------------------------------------------
    // Rest of layout
    // ------------------------------------------------------------------------
    // When the framework calls build(), that'll set up the layout, etc.

    // ------------------------------------------------------------------------
    // Other signals
    // ------------------------------------------------------------------------

    // Do this in main constructor, not build(), since build() can be called
    // from this signal!
    connect(&m_app, &CamcopsApp::lockStateChanged,
            this, &MenuWindow::lockStateChanged,
            Qt::UniqueConnection);
}


void MenuWindow::setIcon(const QString& icon)
{
    m_icon = icon;
    m_p_header->setIcon(icon);
}


void MenuWindow::loadStyleSheet()
{
    setStyleSheet(m_app.getSubstitutedCss(uiconst::CSS_CAMCOPS_MENU));
}


void MenuWindow::reloadStyleSheet()
{
    loadStyleSheet();
    widgetfunc::repolish(this);
}


void MenuWindow::rebuild(bool rebuild_header)
{
    if (rebuild_header) {
        makeLayout();
    }
    makeItems();
    build();
}


void MenuWindow::makeLayout()
{
    // ------------------------------------------------------------------------
    // Clear any existing layout (in case we're rebuilding)
    // ------------------------------------------------------------------------
    widgetfunc::clearLayout(m_mainlayout);

    // ------------------------------------------------------------------------
    // Header
    // ------------------------------------------------------------------------

#ifdef OFFER_LAYOUT_DEBUG_BUTTON
    const bool offer_debug_layout = true;
#else
    const bool offer_debug_layout = false;
#endif
    m_p_header = new MenuHeader(this, m_app, m_top, "", m_icon,
                                offer_debug_layout);
    // ... we'll set its title later in build()
    m_mainlayout->addWidget(m_p_header);

    // header to us
    connect(m_p_header, &MenuHeader::backClicked,
            this, &MenuWindow::finished,
            Qt::UniqueConnection);  // unique as we may rebuild... safer.
    connect(m_p_header, &MenuHeader::debugLayout,
            this, &MenuWindow::debugLayout,
            Qt::UniqueConnection);
    connect(m_p_header, &MenuHeader::viewClicked,
            this, &MenuWindow::viewItem,
            Qt::UniqueConnection);
    connect(m_p_header, &MenuHeader::editClicked,
            this, &MenuWindow::editItem,
            Qt::UniqueConnection);
    connect(m_p_header, &MenuHeader::deleteClicked,
            this, &MenuWindow::deleteItem,
            Qt::UniqueConnection);
    connect(m_p_header, &MenuHeader::finishFlagClicked,
            this, &MenuWindow::toggleFinishFlag,
            Qt::UniqueConnection);

    // us to header
    connect(this, &MenuWindow::offerAdd,
            m_p_header, &MenuHeader::offerAdd,
            Qt::UniqueConnection);
    connect(this, &MenuWindow::offerView,
            m_p_header, &MenuHeader::offerView,
            Qt::UniqueConnection);
    connect(this, &MenuWindow::offerEditDelete,
            m_p_header, &MenuHeader::offerEditDelete,
            Qt::UniqueConnection);
    connect(this, &MenuWindow::offerFinishFlag,
            m_p_header, &MenuHeader::offerFinishFlag,
            Qt::UniqueConnection);

    // ------------------------------------------------------------------------
    // Search box
    // ------------------------------------------------------------------------
    // Given that we are working with a QListWidget or derivative, searching
    // is a bit less intuitive. However...
    // [See also https://stackoverflow.com/questions/2695878/creating-a-qlineedit-search-field-for-items-displayed-in-a-qlistview]

    if (m_offer_search) {
        // Label
        auto searchlabel = new QLabel(tr("Type to filter:"));
        m_mainlayout->addWidget(searchlabel);
        // Search box
        m_search_box = new QLineEdit();
        m_mainlayout->addWidget(m_search_box);
        // Signals
        connect(m_search_box.data(), &QLineEdit::textChanged,
                this, &MenuWindow::searchTextChanged);
    }

    // ------------------------------------------------------------------------
    // List
    // ------------------------------------------------------------------------

#ifdef MENUWINDOW_USE_HFW_LISTWIDGET
    m_p_listwidget = new HeightForWidthListWidget();
#else
    m_p_listwidget = new QListWidget();
#endif
    m_mainlayout->addWidget(m_p_listwidget);

    connect(m_p_listwidget, &QListWidget::itemSelectionChanged,
            this, &MenuWindow::menuItemSelectionChanged,
            Qt::UniqueConnection);
    connect(m_p_listwidget, &QListWidget::itemClicked,
            this, &MenuWindow::menuItemClicked,
            Qt::UniqueConnection);
    connect(m_p_listwidget, &QListWidget::itemActivated,
            this, &MenuWindow::menuItemClicked,
            Qt::UniqueConnection);

    uifunc::applyScrollGestures(m_p_listwidget->viewport());

    // ------------------------------------------------------------------------
    // Subclass specialization of layout
    // ------------------------------------------------------------------------

    extraLayoutCreation();
}


void MenuWindow::build()
{
    // qDebug() << Q_FUNC_INFO;

    if (m_items.isEmpty()) {  // First time through
        makeLayout();
        makeItems();
    }

    m_p_header->setTitle(title());

    m_p_listwidget->clear();

    // Method 1: QListWidget, QListWidgetItem
    // Size hints: https://forum.qt.io/topic/17481/easiest-way-to-have-a-simple-list-with-custom-items/4
    // Note that the widgets call setSizePolicy.
    bool preselected = false;
    const int app_selected_patient_id = m_app.selectedPatientId();
    for (int i = 0; i < m_items.size(); ++i) {
        MenuItem item = m_items.at(i);
        QWidget* row = item.rowWidget(m_app);
        auto listitem = new QListWidgetItem("", m_p_listwidget);
        listitem->setData(Qt::UserRole, QVariant(i));
#ifdef MENUWINDOW_USE_HFW_LISTWIDGET
        listitem->setSizeHint(m_p_listwidget->widgetSizeHint(row));
#else
        listitem->setSizeHint(row->sizeHint());
#endif
        m_p_listwidget->setItemWidget(listitem, row);
        if (item.patient()
                && item.patient()->id() == app_selected_patient_id) {
#ifdef DEBUG_SELECTIONS
            qDebug() << Q_FUNC_INFO << "preselecting patient at index" << i;
#endif
            // m_p_listwidget->item(i)->setSelected(true);
            m_p_listwidget->setCurrentItem(listitem);

            // DO NOT just setSelected(); that leaves currentItem() and the
            // (obviously) visible selection out of sync, which leads to
            // major user errors.
            // setCurrentItem() will also select the item;
            // https://doc.qt.io/qt-6.5/qlistwidget.html#setCurrentItem

            preselected = true;
        }
    }
    menuItemSelectionChanged();
    if (preselected) {
        m_p_listwidget->setFocus();
        // http://stackoverflow.com/questions/23065151/how-to-set-an-item-in-a-qlistwidget-as-initially-highlighted
    } else if (m_search_box) {
        m_search_box->setFocus();
    }

    // Method 2: QListView, QStandardItemModel, custom delegate
    // https://doc.qt.io/qt-6.5/qlistview.html
    // argh!

    // Stretch not necessary, even if the menu is short (the QListWidget
    // seems to handle this fine).

    afterBuild();
}


QString MenuWindow::subtitle() const
{
    return "";
}


QString MenuWindow::icon() const
{
    return m_icon;
}


void MenuWindow::menuItemSelectionChanged()
{
    // Set the verb buttons

    // WHAT'S BEEN CHOSEN?
    QList<QListWidgetItem*> selected_items = m_p_listwidget->selectedItems();
    if (selected_items.isEmpty()) {
#ifdef DEBUG_SELECTIONS
        qDebug() << Q_FUNC_INFO << "Nothing selected";
#endif
        emit offerView(false);
        emit offerEditDelete(false, false);
        emit offerFinishFlag(false);
        return;
    }
    QListWidgetItem* item = selected_items.at(0);
    const QVariant v = item->data(Qt::UserRole);
    const int i = v.toInt();
    if (i < 0 || i >= m_items.size()) {
        qWarning() << Q_FUNC_INFO << "Selection out of range:" << i
                   << "(vector size:" << m_items.size() << ")";
        return;
    }
    MenuItem& m = m_items[i];
#ifdef DEBUG_SELECTIONS
    qInfo() << "Selected:" << m;
#endif
    TaskPtr task = m.task();
    PatientPtr patient = m.patient();

    if (task) {
        // Notify the header (with its verb buttons). Leave it selected.
        emit offerView(true);
        emit offerEditDelete(task->isEditable(), true);
        emit offerFinishFlag(task->isAnonymous());
    } else if (patient) {
        bool selected = true;
        emit offerView(selected);
        emit offerEditDelete(selected, selected);
        emit offerFinishFlag(true);
    } else {
        emit offerView(false);
        emit offerEditDelete(false, false);
        // ... in case a task was selected before
        emit offerFinishFlag(false);
    }

    // The finish-flag button allows the user to mark either PATIENTS or
    // ANONYMOUS TASKS for removal from the tablet even if the user picks
    // the "copy" style of upload.
}


void MenuWindow::menuItemClicked(QListWidgetItem* item)
{
    // Act on a click

    const QVariant v = item->data(Qt::UserRole);
    const int i = v.toInt();
    if (i < 0 || i >= m_items.size()) {
        qWarning() << Q_FUNC_INFO << "Selection out of range:" << i
                   << "(vector size:" << m_items.size() << ")";
        return;
    }
    MenuItem& m = m_items[i];
    qInfo().noquote().nospace() << "Clicked: " << m.info();
    TaskPtr task = m.task();
    PatientPtr patient = m.patient();

    if (task) {
        // Nothing to do; see menuItemSelectionChanged()
    } else if (patient) {
        // qDebug() << Q_FUNC_INFO << "non-null patient pointer =" << patient
        //          << ", this =" << this;
        bool selected = false;
        if (m_app.selectedPatientId() == patient->id()) {
            // Clicked on currently selected patient; deselect it.
            m_app.deselectPatient();
            m_p_listwidget->clearSelection();
        } else {
            selected = true;
            m_app.setSelectedPatient(patient->id());
        }
        emit offerView(selected);
        emit offerEditDelete(selected, selected);
    } else {
        // ACT ON IT. And clear the selection.
        m.act(m_app);
        m_p_listwidget->clearSelection();
    }
}


void MenuWindow::lockStateChanged(CamcopsApp::LockState lockstate)
{
    Q_UNUSED(lockstate)
    // mark as unused; http://stackoverflow.com/questions/1486904/how-do-i-best-silence-a-warning-about-unused-variables
    // qDebug() << Q_FUNC_INFO;
    build();  // calls down to derived class
}


bool MenuWindow::event(QEvent* e)
{
    const bool result = OpenableWidget::event(e);  // call parent
    const QEvent::Type type = e->type();
    if (type == QEvent::Type::LanguageChange) {
        rebuild();  // including rebuilding the header
    }
    return result;
}


void MenuWindow::viewItem()
{
    viewTask();
}


void MenuWindow::viewTask()
{
    TaskPtr task = currentTask();
    if (!task) {
        return;
    }
    const bool facsimile_available = task->isEditable();
#ifdef SHOW_PID_TO_DEBUG_STREAM
    const bool with_pid = true;
#else
    const bool with_pid = false;
#endif
    const QString instance_title = task->instanceTitle(with_pid);
    ScrollMessageBox msgbox(
                QMessageBox::Question,
                tr("View task"),
                tr("View in what format?"),
                this);
    QAbstractButton* summary = msgbox.addButton(tr("Summary"), QMessageBox::YesRole);
    QAbstractButton* detail = msgbox.addButton(tr("Detail"), QMessageBox::NoRole);
    msgbox.addButton(TextConst::cancel(), QMessageBox::RejectRole);  // e.g. Cancel
    QAbstractButton* facsimile = nullptr;
    if (facsimile_available) {
        facsimile = msgbox.addButton(tr("Facsimile"), QMessageBox::AcceptRole);
    }
    msgbox.exec();
    QAbstractButton* reply = msgbox.clickedButton();
    if (facsimile_available && reply == facsimile) {
        qInfo() << "View as facsimile:" << instance_title;
        OpenableWidget* widget = task->editor(true);
        if (!widget) {
            complainTaskNotOfferingEditor();
            return;
        }
        m_app.openSubWindow(widget, task);

    } else if (reply == detail) {
        qInfo() << "View detail:" << instance_title;
        QString detail = stringfunc::joinHtmlLines(task->detail());
#ifdef SHOW_TASK_TIMING
        detail += QString("<br><br>Editing time: <b>%1</b> s")
                .arg(task->editingTimeSeconds());
#endif
        uifunc::alert(detail, instance_title);

    } else if (reply == summary) {
        qInfo() << "View summary:" << instance_title;
        uifunc::alert(task->summary(), instance_title);
    }
}


void MenuWindow::editItem()
{
    editTask();
}


void MenuWindow::editTask()
{
    TaskPtr task = currentTask();
    if (!task || !task->isEditable()) {
        return;
    }
    const QString instance_title = task->instanceTitle();
    ScrollMessageBox msgbox(
                QMessageBox::Question,
                tr("Edit"),
                tr("Edit this task?") + "\n\n" +  instance_title,
                this);
    QAbstractButton* yes = msgbox.addButton(tr("Yes, edit"),
                                            QMessageBox::YesRole);
    msgbox.addButton(tr("No, cancel"), QMessageBox::NoRole);
    msgbox.exec();
    if (msgbox.clickedButton() != yes) {
        return;
    }
    editTaskConfirmed(task);
}


void MenuWindow::editTaskConfirmed(const TaskPtr& task)
{
#ifdef SHOW_PID_TO_DEBUG_STREAM
    const bool with_pid = true;
#else
    const bool with_pid = false;
#endif
    const QString instance_title = task->instanceTitle(with_pid);
    qInfo() << "Edit:" << instance_title;
    OpenableWidget* widget = task->editor(false);
    if (!widget) {
        complainTaskNotOfferingEditor();
        return;
    }
    connectQuestionnaireToTask(widget, task.data());
    m_app.openSubWindow(widget, task, true);
}


void MenuWindow::complainTaskNotOfferingEditor()
{
    uifunc::alert(tr("Task has declined to supply an editor!"),
                  tr("Can't edit/view task"));
}


void MenuWindow::connectQuestionnaireToTask(OpenableWidget* widget, Task* task)
{
    if (!widget || !task) {
        qWarning() << Q_FUNC_INFO << "null widget or null task";
        return;
    }
    auto questionnaire = dynamic_cast<Questionnaire*>(widget);
    if (!questionnaire) {
        return;
    }
    questionnairefunc::connectQuestionnaireToTask(questionnaire, task);
}


void MenuWindow::deleteItem()
{
    deleteTask();
}


void MenuWindow::deleteTask()
{
    TaskPtr task = currentTask();
    if (!task) {
        return;
    }
    const QString instance_title_for_user = task->instanceTitle();
    ScrollMessageBox msgbox(
                QMessageBox::Warning,
                tr("Delete"),
                tr("Delete this task?") + "\n\n" +  instance_title_for_user,
                this);
    QAbstractButton* yes = msgbox.addButton(tr("Yes, delete"),
                                            QMessageBox::YesRole);
    msgbox.addButton(tr("No, cancel"), QMessageBox::NoRole);
    msgbox.exec();
    if (msgbox.clickedButton() != yes) {
        return;
    }
    {
        SlowGuiGuard guard = m_app.getSlowGuiGuard(tr("Deleting task"),
                                                   TextConst::pleaseWait());
#ifdef SHOW_PID_TO_DEBUG_STREAM
        const QString& instance_title_for_debug = instance_title_for_user;
#else
        const QString instance_title_for_debug = task->instanceTitle(false);
#endif
        qInfo() << "Delete:" << instance_title_for_debug;
        DbNestableTransaction trans(m_app.db());
        task->deleteFromDatabase();
        rebuild();
    }
}


void MenuWindow::toggleFinishFlag()
{
    TaskPtr task = currentTask();
    PatientPtr patient = currentPatient();
    if (task && task->isAnonymous()) {
        DbNestableTransaction trans(m_app.db());
        task->toggleMoveOffTablet();
        build();
    } else if (patient) {
        patient->toggleMoveOffTablet();
        build();
    }
}


int MenuWindow::currentIndex() const
{
    QListWidgetItem* item = m_p_listwidget->currentItem();
    if (!item) {
        return BAD_INDEX;
    }
    const QVariant v = item->data(Qt::UserRole);
    const int i = v.toInt();
    if (i >= m_items.size() || i <= -1) {
        // Out of bounds; coerce to -1
        return BAD_INDEX;
    }
    return i;
}


TaskPtr MenuWindow::currentTask() const
{
    const int index = currentIndex();
    if (index == BAD_INDEX) {
        return TaskPtr(nullptr);
    }
    const MenuItem& item = m_items[index];
    return item.task();
}


PatientPtr MenuWindow::currentPatient() const
{
    const int index = currentIndex();
#ifdef DEBUG_SELECTIONS
    qDebug() << Q_FUNC_INFO << "index =" << index;
#endif
    if (index == BAD_INDEX) {
        qDebug() << Q_FUNC_INFO << "... bad index";
        return PatientPtr(nullptr);
    }
    const MenuItem& item = m_items[index];
    return item.patient();
}


void MenuWindow::debugLayout()
{
    layoutdumper::dumpWidgetHierarchy(this);
}


void MenuWindow::searchTextChanged(const QString& text)
{
    // qDebug() << "Search text:" << text;
    const bool search_empty = text.isEmpty();
    const QString search_text_lower = text.toLower();
    const int n_items = m_items.size();
    for (int i = 0; i < n_items; ++i) {
        const bool show = search_empty ||
                m_items.at(i).matchesSearch(search_text_lower);
        QListWidgetItem* item = m_p_listwidget->item(i);
        item->setHidden(!show);
    }
}