Commit fff2caa3 authored by Volker Krause's avatar Volker Krause
Browse files

Add a location history model

This is useful as a building block for a location picker, as e.g. KTrip
does this already. Compared to the KTrip model this allows to remove
individual locations, provides most recently used/most often used data
for sorting and is sharing the history globally.
parent e8f44793
......@@ -26,6 +26,7 @@ ecm_add_test(backendtest.cpp LINK_LIBRARIES Qt5::Test KPublicTransport)
ecm_add_test(linemetadatatest.cpp LINK_LIBRARIES Qt5::Test KPublicTransport)
ecm_add_test(networkconfigtest.cpp LINK_LIBRARIES Qt5::Test)
ecm_add_test(vehiclelayoutquerymodeltest.cpp LINK_LIBRARIES Qt5::Test KPublicTransport)
ecm_add_test(locationhistorymodeltest.cpp LINK_LIBRARIES Qt5::Test KPublicTransport)
ecm_add_test(navitiaparsertest.cpp LINK_LIBRARIES Qt5::Test KPublicTransport)
ecm_add_test(hafasmgateparsertest.cpp LINK_LIBRARIES Qt5::Test KPublicTransport)
......
/*
SPDX-FileCopyrightText: 2021 Volker Krause <vkrause@kde.org>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include <KPublicTransport/LocationHistoryModel>
#include <QAbstractItemModelTester>
#include <QStandardPaths>
#include <QTest>
using namespace KPublicTransport;
class LocationHistoryModelTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void initTestCase()
{
QStandardPaths::setTestModeEnabled(true);
}
void testHistoryModel()
{
{
LocationHistoryModel model;
QAbstractItemModelTester modelTest(&model);
model.clear();
QCOMPARE(model.rowCount(), 0);
Location loc;
loc.setName(QStringLiteral("Randa"));
loc.setCoordinate(46.09901, 7.78315);
model.addLocation(loc);
QCOMPARE(model.rowCount(), 1);
auto idx = model.index(0, 0);
QCOMPARE(model.data(idx, LocationHistoryModel::LocationRole).value<Location>().name(), QLatin1String("Randa"));
QCOMPARE(model.data(idx, LocationHistoryModel::LastUsedRole).value<QDateTime>().date(), QDate::currentDate());
QCOMPARE(model.data(idx, LocationHistoryModel::UseCountRole).toInt(), 1);
loc.setIdentifier(QStringLiteral("uic"), QStringLiteral("8501687"));
model.addLocation(loc);
QCOMPARE(model.rowCount(), 1);
idx = model.index(0, 0);
QCOMPARE(model.data(idx, LocationHistoryModel::LocationRole).value<Location>().name(), QLatin1String("Randa"));
QCOMPARE(model.data(idx, LocationHistoryModel::LocationRole).value<Location>().identifier(QStringLiteral("uic")), QLatin1String("8501687"));
QCOMPARE(model.data(idx, LocationHistoryModel::LastUsedRole).value<QDateTime>().date(), QDate::currentDate());
QCOMPARE(model.data(idx, LocationHistoryModel::UseCountRole).toInt(), 2);
Location loc2;
loc2.setName(QStringLiteral("Brussels Gare du Midi"));
loc2.setCoordinate(50.83588, 4.33620);
model.addLocation(loc2);
QCOMPARE(model.rowCount(), 2);
idx = model.index(1, 0);
QCOMPARE(model.data(idx, LocationHistoryModel::LocationRole).value<Location>().name(), QLatin1String("Brussels Gare du Midi"));
QCOMPARE(model.data(idx, LocationHistoryModel::UseCountRole).toInt(), 1);
QVERIFY(model.removeRow(1));
QCOMPARE(model.rowCount(), 1);
idx = model.index(0, 0);
QCOMPARE(model.data(idx, LocationHistoryModel::LocationRole).value<Location>().name(), QLatin1String("Randa"));
}
{
LocationHistoryModel model;
QAbstractItemModelTester modelTest(&model);
QCOMPARE(model.rowCount(), 1);
auto idx = model.index(0, 0);
QCOMPARE(model.data(idx, LocationHistoryModel::LocationRole).value<Location>().name(), QLatin1String("Randa"));
QCOMPARE(model.data(idx, LocationHistoryModel::LastUsedRole).value<QDateTime>().date(), QDate::currentDate());
QCOMPARE(model.data(idx, LocationHistoryModel::UseCountRole).toInt(), 2);
}
}
};
QTEST_GUILESS_MAIN(LocationHistoryModelTest)
#include "locationhistorymodeltest.moc"
......@@ -105,6 +105,7 @@ target_sources(KPublicTransport PRIVATE
models/abstractquerymodel.cpp
models/backendmodel.cpp
models/journeyquerymodel.cpp
models/locationhistorymodel.cpp
models/locationquerymodel.cpp
models/pathmodel.cpp
models/stopoverquerymodel.cpp
......@@ -197,6 +198,7 @@ ecm_generate_headers(KPublicTransport_Models_FORWARDING_HEADERS
BackendModel
DepartureQueryModel
JourneyQueryModel
LocationHistoryModel
LocationQueryModel
PathModel
StopoverQueryModel
......
/*
SPDX-FileCopyrightText: 2021 Volker Krause <vkrause@kde.org>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "locationhistorymodel.h"
#include "logging.h"
#include <QDirIterator>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
using namespace KPublicTransport;
static QString basePath()
{
return QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QLatin1String("/org.kde.kpublictransport/location-history/");
}
LocationHistoryModel::LocationHistoryModel(QObject *parent)
: QAbstractListModel(parent)
{
rescan();
}
LocationHistoryModel::~LocationHistoryModel() = default;
int LocationHistoryModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_locations.size();
}
QVariant LocationHistoryModel::data(const QModelIndex &index, int role) const
{
if (!checkIndex(index)) {
return {};
}
switch (role) {
case LocationRole: return m_locations[index.row()].loc;
case LocationNameRole: return m_locations[index.row()].loc.name();
case LastUsedRole: return m_locations[index.row()].lastUse;
case UseCountRole: return m_locations[index.row()].useCount;
}
return {};
}
QHash<int, QByteArray> LocationHistoryModel::roleNames() const
{
auto r = QAbstractListModel::roleNames();
r.insert(LocationRole, "location");
r.insert(LocationNameRole, "locationName");
r.insert(LastUsedRole, "lastUsed");
r.insert(UseCountRole, "useCount");
return r;
}
bool LocationHistoryModel::removeRow(int row, const QModelIndex& parent)
{
return QAbstractListModel::removeRow(row, parent);
}
bool LocationHistoryModel::removeRows(int row, int count, const QModelIndex &parent)
{
if (parent.isValid()) {
return false;
}
const auto path = basePath();
beginRemoveRows({}, row, row + count - 1);
for (int i = row; i < row + count; ++i) {
QFile::remove(path + m_locations[i].id);
}
m_locations.erase(m_locations.begin() + row, m_locations.begin() + row + count);
endRemoveRows();
return true;
}
void LocationHistoryModel::addLocation(const Location &loc)
{
for (auto it = m_locations.begin(); it != m_locations.end(); ++it) {
if (Location::isSame((*it).loc, loc)) {
(*it).loc = Location::merge((*it).loc, loc);
(*it).lastUse = QDateTime::currentDateTime();
(*it).useCount++;
store(*it);
const auto idx = index(std::distance(m_locations.begin(), it));
Q_EMIT dataChanged(idx, idx);
return;
}
}
Data data;
data.id = QUuid::createUuid().toString();
data.loc = loc;
data.lastUse = QDateTime::currentDateTime();
data.useCount = 1;
store(data);
beginInsertRows({}, m_locations.size(), m_locations.size());
m_locations.push_back(std::move(data));
endInsertRows();
}
void LocationHistoryModel::clear()
{
beginResetModel();
const auto path = basePath();
for (const auto &data : m_locations) {
QFile::remove(path + data.id);
}
m_locations.clear();
endResetModel();
}
void LocationHistoryModel::rescan()
{
beginResetModel();
for(QDirIterator it(basePath(), QDir::Files); it.hasNext();) {
QFile f(it.next());
if (!f.open(QFile::ReadOnly)) {
qCWarning(Log) << "Unable to read history entry:" << f.fileName() << f.errorString();
continue;
}
const auto doc = QJsonDocument::fromJson(f.readAll());
const auto obj = doc.object();
Data data;
data.id = it.fileInfo().baseName();
data.loc = Location::fromJson(obj.value(QLatin1String("location")).toObject());
data.lastUse = QDateTime::fromString(obj.value(QLatin1String("lastUse")).toString(), Qt::ISODate);
data.useCount = obj.value(QLatin1String("useCount")).toInt();
m_locations.push_back(std::move(data));
}
endResetModel();
}
void LocationHistoryModel::store(const LocationHistoryModel::Data &data) const
{
const auto path = basePath();
QDir().mkpath(path);
QFile f(path + data.id);
if (!f.open(QFile::WriteOnly)) {
qCWarning(Log) << "Unable to write history entry:" << f.fileName() << f.errorString();
return;
}
QJsonObject obj;
obj.insert(QLatin1String("location"), Location::toJson(data.loc));
obj.insert(QLatin1String("lastUse"), data.lastUse.toString(Qt::ISODate));
obj.insert(QLatin1String("useCount"), data.useCount);
f.write(QJsonDocument(obj).toJson(QJsonDocument::Compact));
}
/*
SPDX-FileCopyrightText: 2021 Volker Krause <vkrause@kde.org>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
#ifndef KPUBLICTRANSPORT_LOCATIONHISTORYMODEL_H
#define KPUBLICTRANSPORT_LOCATIONHISTORYMODEL_H
#include "kpublictransport_export.h"
#include <KPublicTransport/Location>
#include <QAbstractTableModel>
#include <QDateTime>
namespace KPublicTransport {
/** Model of frequently/recently used locations.
* Content is persisted globally, ie. all applications sharing this see
* the same data.
*/
class KPUBLICTRANSPORT_EXPORT LocationHistoryModel : public QAbstractListModel
{
Q_OBJECT
public:
explicit LocationHistoryModel(QObject *parent = nullptr);
~LocationHistoryModel();
enum Role {
LocationRole = Qt::UserRole,
LocationNameRole,
LastUsedRole,
UseCountRole,
};
Q_ENUM(Role)
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE bool removeRow(int row, const QModelIndex &parent = QModelIndex()); // not exported to QML by default...
Q_INVOKABLE bool removeRows(int row, int count, const QModelIndex &parent = {}) override;
public Q_SLOTS:
/** Add a location to the history.
* If already present, just the usage data will be updated.
*/
void addLocation(const KPublicTransport::Location &loc);
/** Delete the entire history content. */
void clear();
private:
struct Data {
QString id;
Location loc;
QDateTime lastUse;
int useCount = 0;
};
void rescan();
void store(const Data &data) const;
std::vector<Data> m_locations;
};
}
#endif // KPUBLICTRANSPORT_LOCATIONHISTORYMODEL_H
......@@ -17,6 +17,7 @@
#include <KPublicTransport/JourneyQueryModel>
#include <KPublicTransport/JourneyRequest>
#include <KPublicTransport/Line>
#include <KPublicTransport/LocationHistoryModel>
#include <KPublicTransport/LocationQueryModel>
#include <KPublicTransport/LocationRequest>
#include <KPublicTransport/Manager>
......@@ -67,6 +68,7 @@ void KPublicTransportQmlPlugin::registerTypes(const char*)
qmlRegisterType<KPublicTransport::Manager>("org.kde.kpublictransport", 1, 0, "Manager");
qmlRegisterType<KPublicTransport::JourneyQueryModel>("org.kde.kpublictransport", 1, 0, "JourneyQueryModel");
qmlRegisterType<KPublicTransport::LocationHistoryModel>("org.kde.kpublictransport", 1, 0, "LocationHistoryModel");
qmlRegisterType<KPublicTransport::LocationQueryModel>("org.kde.kpublictransport", 1, 0, "LocationQueryModel");
qmlRegisterType<KPublicTransport::BackendModel>("org.kde.kpublictransport", 1, 0, "BackendModel");
qmlRegisterType<KPublicTransport::StopoverQueryModel>("org.kde.kpublictransport", 1, 0, "StopoverQueryModel");
......
/*
SPDX-FileCopyrightText: 2021 Volker Krause <vkrause@kde.org>
SPDX-License-Identifier: LGPL-2.0-or-later
*/
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15 as QQC2
import org.kde.kirigami 2.17 as Kirigami
import org.kde.kitemmodels 1.0
import org.kde.kpublictransport 1.0
Kirigami.ApplicationWindow {
title: "Location Picker Example"
reachableModeEnabled: false
width: 540
height: 720
pageStack.initialPage: locationPickerPage
Manager {
id: ptMgr
}
Component {
id: locationPickerPage
Kirigami.ScrollablePage {
actions.contextualActions: [
Kirigami.Action {
text: "Remove All"
onTriggered: locationHistoryModel.clear()
}
]
header: ColumnLayout {
Kirigami.SearchField {
id: queryTextField
Layout.fillWidth: true
onAccepted: {
if (text !== "") {
locationQueryModel.request.name = text;
locationQueryModel.request.backends = [ "de_db" ]; // TODO
}
}
}
QQC2.ButtonGroup { buttons: sortGroup.children }
RowLayout {
id: sortGroup
QQC2.RadioButton {
text: "Name"
onCheckedChanged: {
historySortModel.sortRole = "locationName";
historySortModel.sortOrder = Qt.AscendingOrder;
}
}
QQC2.RadioButton {
checked: true
text: "Most Recent"
onCheckedChanged: {
historySortModel.sortRole = "lastUsed";
historySortModel.sortOrder = Qt.DescendingOrder;
}
}
QQC2.RadioButton {
text: "Most Often"
onCheckedChanged: {
historySortModel.sortRole = "useCount";
historySortModel.sortOrder = Qt.DescendingOrder;
}
}
}
}
LocationQueryModel {
id: locationQueryModel
manager: ptMgr
}
LocationHistoryModel {
id: locationHistoryModel
}
KSortFilterProxyModel {
id: historySortModel
sourceModel: locationHistoryModel
sortRole: "lastUsed"
sortOrder: Qt.DescendingOrder
}
Component {
id: historyDelegate
Kirigami.SwipeListItem {
readonly property var sourceModel: ListView.view.model
QQC2.Label {
text: model.location.name
}
actions: [
Kirigami.Action {
iconName: "edit-delete"
text: "Remove history entry"
onTriggered: {
sourceModel.removeRows(model.index, 1)
}
}
]
onClicked: {
locationHistoryModel.addLocation(model.location);
}
}
}
Component {
id: queryResultDelegate
Kirigami.BasicListItem {
text: model.location.name
onClicked: {
locationHistoryModel.addLocation(model.location);
queryTextField.text = "";
}
}
}
ListView {
id: historyView
model: queryTextField.text === "" ? historySortModel : locationQueryModel
delegate: queryTextField.text === "" ? historyDelegate : queryResultDelegate
QQC2.BusyIndicator {
anchors.centerIn: parent
running: locationQueryModel.loading
}
QQC2.Label {
anchors.centerIn: parent
width: parent.width
text: locationQueryModel.errorMessage
color: Kirigami.Theme.negativeTextColor
wrapMode: Text.Wrap
}
}
}
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment