From 93ae4f90ba17acbd1bbb3e9925fa226bec008709 Mon Sep 17 00:00:00 2001 From: Richard Kello Date: Mon, 17 Aug 2026 19:21:58 +0200 Subject: [PATCH 1/4] Add feature drafts --- app/CMakeLists.txt | 5 + app/activeproject.cpp | 8 + app/activeproject.h | 10 + app/attributes/attributecontroller.cpp | 91 ++++++ app/attributes/attributecontroller.h | 19 ++ app/drafts/featuredraftcontroller.cpp | 259 ++++++++++++++++++ app/drafts/featuredraftcontroller.h | 98 +++++++ app/drafts/featuredraftstorage.cpp | 56 ++++ app/drafts/featuredraftstorage.h | 39 +++ app/maptools/recordingmaptool.cpp | 76 +++++ app/maptools/recordingmaptool.h | 14 + app/notificationmodel.cpp | 11 + app/notificationmodel.h | 5 +- app/qml/CMakeLists.txt | 2 + app/qml/dialogs/MMDiscardDraftDialog.qml | 41 +++ app/qml/dialogs/MMResumeDraftDialog.qml | 50 ++++ app/qml/filters/components/MMFilterBanner.qml | 2 + app/qml/form/MMFormController.qml | 33 ++- app/qml/form/MMFormStackController.qml | 4 + app/qml/layers/MMFeaturesListPage.qml | 31 ++- app/qml/layers/MMLayerDetailPage.qml | 5 + app/qml/layers/MMLayersController.qml | 9 + app/qml/layers/MMLayersListPage.qml | 27 +- app/qml/main.qml | 85 +++++- app/qml/map/MMMapController.qml | 13 + 25 files changed, 982 insertions(+), 11 deletions(-) create mode 100644 app/drafts/featuredraftcontroller.cpp create mode 100644 app/drafts/featuredraftcontroller.h create mode 100644 app/drafts/featuredraftstorage.cpp create mode 100644 app/drafts/featuredraftstorage.h create mode 100644 app/qml/dialogs/MMDiscardDraftDialog.qml create mode 100644 app/qml/dialogs/MMResumeDraftDialog.qml diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index ed910b431..d235debb8 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -50,6 +50,8 @@ set(MM_SRCS androidutils.cpp mapsketchingcontroller.cpp appsettings.cpp + drafts/featuredraftstorage.cpp + drafts/featuredraftcontroller.cpp autosynccontroller.cpp bluetoothdiscoverymodel.cpp qrcodedecoder.cpp @@ -147,6 +149,8 @@ set(MM_HDRS androidutils.h mapsketchingcontroller.h appsettings.h + drafts/featuredraftstorage.h + drafts/featuredraftcontroller.h autosynccontroller.h bluetoothdiscoverymodel.h qrcodedecoder.h @@ -361,6 +365,7 @@ target_include_directories( MerginMaps PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/ ${CMAKE_CURRENT_SOURCE_DIR}/attributes + ${CMAKE_CURRENT_SOURCE_DIR}/drafts ${CMAKE_CURRENT_SOURCE_DIR}/filter ${CMAKE_CURRENT_SOURCE_DIR}/map ${CMAKE_CURRENT_SOURCE_DIR}/layer diff --git a/app/activeproject.cpp b/app/activeproject.cpp index 77060f0f6..39c41635f 100644 --- a/app/activeproject.cpp +++ b/app/activeproject.cpp @@ -78,6 +78,9 @@ ActiveProject::ActiveProject( AppSettings &appSettings mFilterController = std::make_unique(); connect( this, &ActiveProject::projectReloaded, mFilterController.get(), &FilterController::loadFilterConfig ); + + mFeatureDraftController = std::make_unique(); + connect( this, &ActiveProject::loadingFinished, mFeatureDraftController.get(), &FeatureDraftController::checkForDraft ); } ActiveProject::~ActiveProject() = default; @@ -683,3 +686,8 @@ FilterController *ActiveProject::filterController() const { return mFilterController.get(); } + +FeatureDraftController *ActiveProject::featureDraftController() const +{ + return mFeatureDraftController.get(); +} diff --git a/app/activeproject.h b/app/activeproject.h index eadfbbc3f..a8fc1c2d8 100644 --- a/app/activeproject.h +++ b/app/activeproject.h @@ -25,6 +25,7 @@ #include "merginprojectmetadata.h" #include "synchronizationoptions.h" #include "filter/filtercontroller.h" +#include "drafts/featuredraftcontroller.h" /** * \brief The ActiveProject class can load a QGIS project and holds its data. @@ -36,6 +37,7 @@ class ActiveProject: public QObject Q_PROPERTY( QgsProject *qgsProject READ qgsProject NOTIFY qgsProjectChanged ) // QgsProject instance of active project, never changes Q_PROPERTY( AutosyncController *autosyncController READ autosyncController NOTIFY autosyncControllerChanged ) Q_PROPERTY( FilterController *filterController READ filterController NOTIFY filterControllerChanged ) + Q_PROPERTY( FeatureDraftController *featureDraftController READ featureDraftController NOTIFY featureDraftControllerChanged ) Q_PROPERTY( InputMapSettings *mapSettings READ mapSettings WRITE setMapSettings NOTIFY mapSettingsChanged ) Q_PROPERTY( QString projectRole READ projectRole WRITE setProjectRole NOTIFY projectRoleChanged ) @@ -156,6 +158,11 @@ class ActiveProject: public QObject */ FilterController *filterController() const; + /** + * Returns featureDraftController, which detects unsaved feature drafts left behind for this project + */ + FeatureDraftController *featureDraftController() const; + signals: void qgsProjectChanged(); void localProjectChanged( LocalProject project ); @@ -193,6 +200,8 @@ class ActiveProject: public QObject void filterControllerChanged( FilterController *controller ); + void featureDraftControllerChanged( FeatureDraftController *controller ); + public slots: // Reloads project if current project path matches given path (it's the same project) bool reloadProject( QString projectDir ); @@ -235,6 +244,7 @@ class ActiveProject: public QObject InputMapSettings *mMapSettings = nullptr; std::unique_ptr mAutosyncController; std::unique_ptr mFilterController; + std::unique_ptr mFeatureDraftController; QString mProjectLoadingLog; QString mProjectRole; diff --git a/app/attributes/attributecontroller.cpp b/app/attributes/attributecontroller.cpp index 6c071485a..c5e5cdcc1 100644 --- a/app/attributes/attributecontroller.cpp +++ b/app/attributes/attributecontroller.cpp @@ -20,6 +20,12 @@ #include #include +#include +#include +#include +#include + +#include "featuredraftstorage.h" #include "qgis.h" #include "qgsproject.h" @@ -41,7 +47,11 @@ AttributeController::AttributeController( QObject *parent ) : QObject( parent ) , mAttributeTabProxyModel( new AttributeTabProxyModel() ) + , mDraftSaveTimer( new QTimer( this ) ) { + mDraftSaveTimer->setSingleShot( true ); + mDraftSaveTimer->setInterval( 1000 ); + connect( mDraftSaveTimer, &QTimer::timeout, this, &AttributeController::saveDraft ); } void AttributeController::reset() @@ -67,8 +77,17 @@ void AttributeController::setFeatureLayerPair( const FeatureLayerPair &pair ) blockSignals( true ); bool hasLayerChanged = mFeatureLayerPair.layer() != pair.layer(); + // geometry edits round-trip back into this same setter (via the live QML + // binding once the geometry-editing map tool hands the feature back) - that + // must not wipe attribute changes already tracked for this same feature + bool isSameFeature = !hasLayerChanged && mFeatureLayerPair.feature().id() == pair.feature().id(); + // Set new active pair mFeatureLayerPair = pair; + if ( !isSameFeature ) + { + mTouchedFieldIndices.clear(); + } if ( hasLayerChanged ) { // layer changed! @@ -646,6 +665,72 @@ bool AttributeController::isNewFeature() const return FID_IS_NEW( id ) || FID_IS_NULL( id ); } +QJsonObject AttributeController::attributeToJson( const QgsFields &fields, const QgsFeature &feature, int fieldIndex ) const +{ + QJsonObject attribute; + attribute[ QStringLiteral( "name" ) ] = fields.at( fieldIndex ).name(); + attribute[ QStringLiteral( "type" ) ] = fields.at( fieldIndex ).typeName(); + attribute[ QStringLiteral( "value" ) ] = QJsonValue::fromVariant( feature.attribute( fieldIndex ) ); + return attribute; +} + +void AttributeController::saveDraft() +{ + if ( !mFeatureLayerPair.layer() ) + return; + + const QgsFeature feature = mFeatureLayerPair.feature(); + const QgsFields fields = feature.fields(); + const bool featureIsNew = isNewFeature(); + + QJsonArray attributes; + + if ( featureIsNew ) + { + for ( int i = 0; i < feature.attributeCount(); ++i ) + { + attributes.append( attributeToJson( fields, feature, i ) ); + } + } + else + { + for ( int fieldIndex : mTouchedFieldIndices ) + { + if ( fieldIndex >= 0 && fieldIndex < feature.attributeCount() ) + { + attributes.append( attributeToJson( fields, feature, fieldIndex ) ); + } + } + } + + QJsonObject draft; + draft[ QStringLiteral( "layerId" ) ] = mFeatureLayerPair.layer()->id(); + draft[ QStringLiteral( "stage" ) ] = QStringLiteral( "attributeForm" ); + draft[ QStringLiteral( "timestamp" ) ] = QDateTime::currentDateTimeUtc().toString( Qt::ISODate ); + draft[ QStringLiteral( "attributes" ) ] = attributes; + + if ( featureIsNew ) + { + // existing-feature geometry edits are drafted separately, by RecordingMapTool + draft[ QStringLiteral( "geometry" ) ] = feature.geometry().asWkt(); + } + else + { + draft[ QStringLiteral( "featureId" ) ] = QJsonValue( static_cast( feature.id() ) ); + } + + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); +} + +void AttributeController::clearDraft() +{ + // a pending debounced write must not be allowed to resurrect the draft + // after we've just told the storage (and possibly the user) it's gone + mDraftSaveTimer->stop(); + + FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); +} + void AttributeController::acquireId() { if ( !mFeatureLayerPair.layer() ) @@ -1203,6 +1288,7 @@ bool AttributeController::deleteFeature() { mFeatureLayerPair = FeatureLayerPair(); emit featureLayerPairChanged(); + clearDraft(); emit changesCommited(); } @@ -1214,6 +1300,8 @@ bool AttributeController::rollback() if ( !mFeatureLayerPair.layer() ) return false; + clearDraft(); + if ( !mFeatureLayerPair.layer()->isEditable() ) { return false; @@ -1281,6 +1369,7 @@ bool AttributeController::save() if ( rv ) { + clearDraft(); emit changesCommited(); } else @@ -1509,6 +1598,8 @@ bool AttributeController::setFormValue( const QUuid &id, QVariant value ) { mFeatureLayerPair.featureRef().setAttribute( item->fieldIndex(), val ); emit formDataChanged( item->id(), { AttributeFormModel::AttributeValue, AttributeFormModel::RawValueIsNull, AttributeFormModel::HasMixedValues } ); + mTouchedFieldIndices.insert( item->fieldIndex() ); + mDraftSaveTimer->start(); } recalculateDerivedItems( true, false ); return true; diff --git a/app/attributes/attributecontroller.h b/app/attributes/attributecontroller.h index 59535feb5..14b2f421a 100644 --- a/app/attributes/attributecontroller.h +++ b/app/attributes/attributecontroller.h @@ -21,8 +21,10 @@ #include #include #include +#include #include #include +#include #include "featurelayerpair.h" #include "attributedata.h" @@ -40,6 +42,7 @@ class AttributeFormModel; class AttributeTabModel; class QgsVectorLayer; +class QTimer; /** * This is implementation of the controller between Attribute*Model @@ -188,6 +191,17 @@ class AttributeController : public QObject bool isNewFeature() const; + // Persists attribute changes as a draft, debounced. New feature: all attributes. + // Existing feature: only touched fields, so an untouched one is never clobbered + // by a concurrent change made elsewhere via sync. + void saveDraft(); + + //! Removes any persisted draft for the current project + void clearDraft(); + + //! Builds the {name, type, value} JSON object for one attribute, used by saveDraft() + QJsonObject attributeToJson( const QgsFields &fields, const QgsFeature &feature, int fieldIndex ) const; + /** * Recalculates visibility & constrains & default values * Note that reevaluate default values is needed only when an attribnute has changed. @@ -249,5 +263,10 @@ class AttributeController : public QObject AttributeController *mParentController = nullptr; // not owned QgsRelation mLinkedRelation; + + QTimer *mDraftSaveTimer = nullptr; // owned by this, debounces saveDraft() + + //! Indices of fields the user has actually changed this session - only valid for existing (edit-mode) features + QSet mTouchedFieldIndices; }; #endif // ATTRIBUTECONTROLLER_H diff --git a/app/drafts/featuredraftcontroller.cpp b/app/drafts/featuredraftcontroller.cpp new file mode 100644 index 000000000..a211bf0b2 --- /dev/null +++ b/app/drafts/featuredraftcontroller.cpp @@ -0,0 +1,259 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "featuredraftcontroller.h" +#include "featuredraftstorage.h" +#include "inpututils.h" + +#include +#include +#include + +#include "qgsproject.h" +#include "qgsvectorlayer.h" + +namespace +{ + constexpr qint64 MAX_DRAFT_AGE_SECS = 10 * 24 * 60 * 60; // 10 days +} + +FeatureDraftController::FeatureDraftController( QObject *parent ) + : QObject( parent ) +{ +} + +bool FeatureDraftController::hasDraft() const +{ + return mHasDraft; +} + +QString FeatureDraftController::draftLayerName() const +{ + return mDraftLayerName; +} + +QgsVectorLayer *FeatureDraftController::draftLayer() const +{ + return mDraftLayer; +} + +QString FeatureDraftController::draftStage() const +{ + return mDraftStage; +} + +bool FeatureDraftController::draftIsEdit() const +{ + return mDraftIsEdit; +} + +QString FeatureDraftController::draftFeatureTitle() const +{ + return mDraftFeatureTitle; +} + +void FeatureDraftController::checkForDraft() +{ + const QString projectId = QgsProject::instance()->homePath(); + const QJsonObject draft = FeatureDraftStorage::loadDraft( projectId ); + + if ( draft.isEmpty() ) + { + setDraft( false ); + return; + } + + QgsVectorLayer *layer = resolveDraftLayer( draft ); + + if ( !layer || !isDraftValid( draft, layer ) ) + { + FeatureDraftStorage::clearDraft( projectId ); + setDraft( false ); + return; + } + + const bool isEdit = draft.contains( QStringLiteral( "featureId" ) ); + QString featureTitle; + + if ( isEdit ) + { + const QgsFeatureId featureId = draft.value( QStringLiteral( "featureId" ) ).toVariant().toLongLong(); + const QgsFeature feature = layer->getFeature( featureId ); + featureTitle = InputUtils::featureTitle( FeatureLayerPair( feature, layer ), QgsProject::instance() ); + } + + setDraft( true, layer, draft.value( QStringLiteral( "stage" ) ).toString(), isEdit, featureTitle ); +} + +FeatureLayerPair FeatureDraftController::resumeDraft() +{ + if ( !mHasDraft ) + return FeatureLayerPair(); + + const QString projectId = QgsProject::instance()->homePath(); + const QJsonObject draft = FeatureDraftStorage::loadDraft( projectId ); + QgsVectorLayer *layer = resolveDraftLayer( draft ); + + if ( !layer || !isDraftValid( draft, layer ) ) + { + // re-validated here too - time passed since the draft was detected + FeatureDraftStorage::clearDraft( projectId ); + setDraft( false ); + return FeatureLayerPair(); + } + + FeatureLayerPair pair; + + if ( draft.contains( QStringLiteral( "featureId" ) ) ) + { + // existing feature: start from the live one, then overlay the draft on top + const QgsFeatureId featureId = draft.value( QStringLiteral( "featureId" ) ).toVariant().toLongLong(); + pair = FeatureLayerPair( layer->getFeature( featureId ), layer ); + + const QString wkt = draft.value( QStringLiteral( "geometry" ) ).toString(); + if ( !wkt.isEmpty() ) + { + QgsGeometry geometry = QgsGeometry::fromWkt( wkt ); + pair.featureRef().setGeometry( geometry ); + + // push into the layer too, so the map shows the resumed shape right away + // instead of the stale committed one until the next vertex edit + layer->startEditing(); + layer->changeGeometry( featureId, geometry ); + layer->triggerRepaint(); + } + } + else + { + pair = InputUtils::createFeatureLayerPair( layer, InputUtils::emptyGeometry(), nullptr ); + + const QString wkt = draft.value( QStringLiteral( "geometry" ) ).toString(); + if ( !wkt.isEmpty() ) + { + pair.featureRef().setGeometry( QgsGeometry::fromWkt( wkt ) ); + } + } + + const QgsFields fields = layer->fields(); + const QJsonArray attributes = draft.value( QStringLiteral( "attributes" ) ).toArray(); + + for ( const QJsonValue &attributeValue : attributes ) + { + const QJsonObject attribute = attributeValue.toObject(); + const int fieldIndex = fields.indexOf( attribute.value( QStringLiteral( "name" ) ).toString() ); + + if ( fieldIndex >= 0 ) + { + pair.featureRef().setAttribute( fieldIndex, attribute.value( QStringLiteral( "value" ) ).toVariant() ); + } + } + + // draft stays in storage - only the pending state (the prompt) is cleared here + setDraft( false ); + + return pair; +} + +QgsGeometry FeatureDraftController::resumeGeometryDraft() +{ + if ( !mHasDraft ) + return QgsGeometry(); + + const QString projectId = QgsProject::instance()->homePath(); + const QJsonObject draft = FeatureDraftStorage::loadDraft( projectId ); + QgsVectorLayer *layer = resolveDraftLayer( draft ); + + if ( !layer || !isDraftValid( draft, layer ) ) + { + FeatureDraftStorage::clearDraft( projectId ); + setDraft( false ); + return QgsGeometry(); + } + + const QString wkt = draft.value( QStringLiteral( "geometry" ) ).toString(); + + // draft stays in storage - only the pending state (the prompt) is cleared here + setDraft( false ); + + if ( wkt.isEmpty() ) + return QgsGeometry(); + + return QgsGeometry::fromWkt( wkt ); +} + +void FeatureDraftController::discardDraft() +{ + if ( !mHasDraft ) + return; + + FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); + setDraft( false ); +} + +QgsVectorLayer *FeatureDraftController::resolveDraftLayer( const QJsonObject &draft ) const +{ + const QString layerId = draft.value( QStringLiteral( "layerId" ) ).toString(); + return qobject_cast( QgsProject::instance()->mapLayer( layerId ) ); +} + +bool FeatureDraftController::isDraftValid( const QJsonObject &draft, QgsVectorLayer *layer ) const +{ + const QDateTime timestamp = QDateTime::fromString( draft.value( QStringLiteral( "timestamp" ) ).toString(), Qt::ISODate ); + + if ( !timestamp.isValid() || timestamp.secsTo( QDateTime::currentDateTimeUtc() ) > MAX_DRAFT_AGE_SECS ) + { + return false; + } + + const QgsFields fields = layer->fields(); + const QJsonArray attributes = draft.value( QStringLiteral( "attributes" ) ).toArray(); + + for ( const QJsonValue &attributeValue : attributes ) + { + const QJsonObject attribute = attributeValue.toObject(); + const int fieldIndex = fields.indexOf( attribute.value( QStringLiteral( "name" ) ).toString() ); + + if ( fieldIndex < 0 ) + { + return false; // field removed or renamed since the draft was written + } + + if ( fields.at( fieldIndex ).typeName() != attribute.value( QStringLiteral( "type" ) ).toString() ) + { + return false; // field type changed since the draft was written + } + } + + if ( draft.contains( QStringLiteral( "featureId" ) ) ) + { + const QgsFeatureId featureId = draft.value( QStringLiteral( "featureId" ) ).toVariant().toLongLong(); + if ( !layer->getFeature( featureId ).isValid() ) + { + return false; // the feature this draft was editing no longer exists + } + } + + return true; +} + +void FeatureDraftController::setDraft( bool hasDraft, QgsVectorLayer *layer, const QString &stage, bool isEdit, const QString &featureTitle ) +{ + const QString layerName = layer ? layer->name() : QString(); + + if ( mHasDraft != hasDraft || mDraftLayer != layer || mDraftStage != stage || mDraftIsEdit != isEdit || mDraftFeatureTitle != featureTitle ) + { + mHasDraft = hasDraft; + mDraftLayer = layer; + mDraftLayerName = layerName; + mDraftStage = stage; + mDraftIsEdit = isEdit; + mDraftFeatureTitle = featureTitle; + emit hasDraftChanged(); + } +} diff --git a/app/drafts/featuredraftcontroller.h b/app/drafts/featuredraftcontroller.h new file mode 100644 index 000000000..771958bc6 --- /dev/null +++ b/app/drafts/featuredraftcontroller.h @@ -0,0 +1,98 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef FEATUREDRAFTCONTROLLER_H +#define FEATUREDRAFTCONTROLLER_H + +#include +#include +#include + +#include "featurelayerpair.h" +#include "qgsgeometry.h" + +class QgsVectorLayer; + +/** + * Checks whether the currently active project has a recoverable feature edit + * draft and exposes it to QML so a notification can be shown. + * + * The actual notification UI is handled elsewhere - this class only detects a draft + * and performs the resume/discard action once the user has decided. + */ +class FeatureDraftController : public QObject +{ + Q_OBJECT + + //! Whether there is a recoverable draft for the currently active project + Q_PROPERTY( bool hasDraft READ hasDraft NOTIFY hasDraftChanged ) + + //! Name of the layer the pending draft belongs to (only meaningful when hasDraft is true) + Q_PROPERTY( QString draftLayerName READ draftLayerName NOTIFY hasDraftChanged ) + + //! The layer the pending draft belongs to (only meaningful when hasDraft is true) + Q_PROPERTY( QgsVectorLayer *draftLayer READ draftLayer NOTIFY hasDraftChanged ) + + //! Which stage the draft was interrupted at: "geometryCapture" or "attributeForm" + Q_PROPERTY( QString draftStage READ draftStage NOTIFY hasDraftChanged ) + + //! Whether the draft belongs to an existing feature being edited, rather than a new one being added + Q_PROPERTY( bool draftIsEdit READ draftIsEdit NOTIFY hasDraftChanged ) + + //! Display title of the draft's feature (via the layer's display expression), empty for a new (add-mode) draft + Q_PROPERTY( QString draftFeatureTitle READ draftFeatureTitle NOTIFY hasDraftChanged ) + + public: + explicit FeatureDraftController( QObject *parent = nullptr ); + ~FeatureDraftController() override = default; + + bool hasDraft() const; + QString draftLayerName() const; + QgsVectorLayer *draftLayer() const; + QString draftStage() const; + bool draftIsEdit() const; + QString draftFeatureTitle() const; + + // Rebuilds the draft as a FeatureLayerPair, geometry/attributes overlaid. Draft + // stays in storage - the resumed form clears or updates it as usual. + Q_INVOKABLE FeatureLayerPair resumeDraft(); + + // For a new-feature geometry-capture draft: returns just the geometry, to feed + // into RecordingMapTool::resumeCapture() instead of opening the form. + Q_INVOKABLE QgsGeometry resumeGeometryDraft(); + + //! Permanently discards the pending draft for the currently active project + Q_INVOKABLE void discardDraft(); + + signals: + void hasDraftChanged(); + + public slots: + //! Checks the active project (QgsProject::instance()) for a pending draft + void checkForDraft(); + + private: + //! Resolves the layer the given draft belongs to, or nullptr if it no longer exists + QgsVectorLayer *resolveDraftLayer( const QJsonObject &draft ) const; + + // Guards: not older than 10 days, referenced fields still match the layer's + // schema, and (for an edit-mode draft) the feature still exists. + bool isDraftValid( const QJsonObject &draft, QgsVectorLayer *layer ) const; + + void setDraft( bool hasDraft, QgsVectorLayer *layer = nullptr, const QString &stage = QString(), bool isEdit = false, const QString &featureTitle = QString() ); + + bool mHasDraft = false; + QString mDraftLayerName; + QgsVectorLayer *mDraftLayer = nullptr; // not owned + QString mDraftStage; + bool mDraftIsEdit = false; + QString mDraftFeatureTitle; +}; + +#endif // FEATUREDRAFTCONTROLLER_H diff --git a/app/drafts/featuredraftstorage.cpp b/app/drafts/featuredraftstorage.cpp new file mode 100644 index 000000000..73da9b71c --- /dev/null +++ b/app/drafts/featuredraftstorage.cpp @@ -0,0 +1,56 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "featuredraftstorage.h" +#include "coreutils.h" + +#include +#include + +const QString FeatureDraftStorage::QSETTINGS_DRAFTS_GROUP_NAME = QStringLiteral( "featureDrafts" ); + +void FeatureDraftStorage::saveDraft( const QString &projectId, const QJsonObject &draft ) +{ + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + settings.setValue( settingsKey( projectId ), QJsonDocument( draft ).toJson( QJsonDocument::Compact ) ); + settings.endGroup(); + + // explicit flush - a draft must survive a crash, not just a normal exit + settings.sync(); +} + +QJsonObject FeatureDraftStorage::loadDraft( const QString &projectId ) +{ + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + const QByteArray raw = settings.value( settingsKey( projectId ) ).toByteArray(); + settings.endGroup(); + + if ( raw.isEmpty() ) + { + return QJsonObject(); + } + + return QJsonDocument::fromJson( raw ).object(); +} + +void FeatureDraftStorage::clearDraft( const QString &projectId ) +{ + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + settings.remove( settingsKey( projectId ) ); + settings.endGroup(); + settings.sync(); +} + +QString FeatureDraftStorage::settingsKey( const QString &projectId ) +{ + return QSETTINGS_DRAFTS_GROUP_NAME + "/" + projectId; +} diff --git a/app/drafts/featuredraftstorage.h b/app/drafts/featuredraftstorage.h new file mode 100644 index 000000000..67f4f04c8 --- /dev/null +++ b/app/drafts/featuredraftstorage.h @@ -0,0 +1,39 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef FEATUREDRAFTSTORAGE_H +#define FEATUREDRAFTSTORAGE_H + +#include +#include + +// Saves/loads/clears an in-progress feature edit ("draft") per project. Stores +// a JSON blob - knows nothing about what's inside it or when it's valid. +class FeatureDraftStorage +{ + public: + explicit FeatureDraftStorage() = default; + ~FeatureDraftStorage() = default; + + // Persists the draft payload for the given project, replacing any previous draft for it. + static void saveDraft( const QString &projectId, const QJsonObject &draft ); + + // Returns the stored draft payload for the given project, or an empty object if none exists. + static QJsonObject loadDraft( const QString &projectId ); + + // Removes the stored draft for the given project, if any. + static void clearDraft( const QString &projectId ); + + private: + static QString settingsKey( const QString &projectId ); + + static const QString QSETTINGS_DRAFTS_GROUP_NAME; +}; + +#endif // FEATUREDRAFTSTORAGE_H diff --git a/app/maptools/recordingmaptool.cpp b/app/maptools/recordingmaptool.cpp index 4c69e06df..628d625e5 100644 --- a/app/maptools/recordingmaptool.cpp +++ b/app/maptools/recordingmaptool.cpp @@ -21,12 +21,19 @@ #include "position/positionkit.h" #include "coreutils.h" +#include "featuredraftstorage.h" #include #include +#include +#include +#include + +#include "qgsproject.h" RecordingMapTool::RecordingMapTool( QObject *parent ) : AbstractMapTool{parent} + , mDraftSaveTimer( new QTimer( this ) ) { connect( this, &RecordingMapTool::activeFeatureChanged, this, &RecordingMapTool::prepareEditing ); connect( this, &RecordingMapTool::recordedGeometryChanged, this, &RecordingMapTool::completeEditOperation ); @@ -34,6 +41,14 @@ RecordingMapTool::RecordingMapTool( QObject *parent ) connect( this, &RecordingMapTool::activeVertexChanged, this, &RecordingMapTool::updateVisibleItems ); connect( this, &RecordingMapTool::activeVertexChanged, this, &RecordingMapTool::updateActiveVertexGeometry ); connect( this, &RecordingMapTool::stateChanged, this, &RecordingMapTool::updateVisibleItems ); + + mDraftSaveTimer->setSingleShot( true ); + mDraftSaveTimer->setInterval( 1000 ); + connect( mDraftSaveTimer, &QTimer::timeout, this, &RecordingMapTool::saveDraft ); + connect( this, &RecordingMapTool::recordedGeometryChanged, this, [ this ]() + { + mDraftSaveTimer->start(); + } ); } RecordingMapTool::~RecordingMapTool() = default; @@ -1082,6 +1097,9 @@ void RecordingMapTool::releaseVertex( const QgsPoint &point ) FeatureLayerPair RecordingMapTool::getFeatureLayerPair() { + mDraftSaveTimer->stop(); + saveDraft(); + bool featureIsValid = FID_IS_NEW( mActiveFeature.id() ) || mActiveFeature.isValid(); if ( mActiveLayer && featureIsValid ) @@ -1115,6 +1133,64 @@ void RecordingMapTool::discardChanges() mActiveLayer->triggerRepaint(); } + + clearDraft(); +} + +void RecordingMapTool::resumeCapture( const QgsGeometry &geometry ) +{ + if ( !mActiveLayer ) + return; + + // register a blank feature first, same as addPoint() does for vertex 1, + // so it gets a real id before we apply the resumed geometry to it + mActiveFeature = QgsFeature(); + mActiveFeature.setFields( mActiveLayer->fields(), true ); + mLastRecordedPoint = QgsPoint(); + + mActiveLayer->beginEditCommand( QStringLiteral( "Add new feature" ) ); + mActiveLayer->addFeature( mActiveFeature ); + mActiveLayer->endEditCommand(); + + mRecordedGeometry = geometry; + mActiveLayer->beginEditCommand( QStringLiteral( "Resume feature" ) ); + emit recordedGeometryChanged( mRecordedGeometry ); +} + +void RecordingMapTool::saveDraft() +{ + if ( !mActiveLayer || !mActiveFeature.isValid() ) + return; + + const bool isExistingFeature = !( FID_IS_NEW( mActiveFeature.id() ) || FID_IS_NULL( mActiveFeature.id() ) ); + + // geometry still matches the feature's original shape - nothing actually + // edited yet (just opened for viewing), so there's nothing to draft + if ( isExistingFeature && mRecordedGeometry.equals( mActiveFeature.geometry() ) ) + { + clearDraft(); + return; + } + + QJsonObject draft; + draft[ QStringLiteral( "layerId" ) ] = mActiveLayer->id(); + draft[ QStringLiteral( "stage" ) ] = QStringLiteral( "geometryCapture" ); + draft[ QStringLiteral( "timestamp" ) ] = QDateTime::currentDateTimeUtc().toString( Qt::ISODate ); + draft[ QStringLiteral( "geometry" ) ] = mRecordedGeometry.asWkt(); + + if ( isExistingFeature ) + { + // editing the geometry of an already-existing feature + draft[ QStringLiteral( "featureId" ) ] = QJsonValue( static_cast( mActiveFeature.id() ) ); + } + + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); +} + +void RecordingMapTool::clearDraft() +{ + mDraftSaveTimer->stop(); + FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); } void RecordingMapTool::onFeatureAdded( QgsFeatureId newFeatureId ) diff --git a/app/maptools/recordingmaptool.h b/app/maptools/recordingmaptool.h index 5d308c27a..559e50d60 100644 --- a/app/maptools/recordingmaptool.h +++ b/app/maptools/recordingmaptool.h @@ -24,6 +24,7 @@ class PositionKit; class QgsVectorLayer; +class QTimer; class Vertex { @@ -165,6 +166,11 @@ class RecordingMapTool : public AbstractMapTool Q_INVOKABLE void discardChanges(); + // Resumes digitizing a new feature interrupted mid-capture: registers a fresh + // feature on the active layer seeded with this geometry, staying in Record + // state so vertices can keep being added. Assumes activeLayer is already set. + Q_INVOKABLE void resumeCapture( const QgsGeometry &geometry ); + /** * Reverts last change from the layer undo stack. */ @@ -337,6 +343,12 @@ class RecordingMapTool : public AbstractMapTool */ void avoidIntersections(); + //! Persists the current feature's in-progress geometry as a draft, debounced + void saveDraft(); + + //! Removes any persisted draft for the current project + void clearDraft(); + QgsGeometry mRecordedGeometry; bool mCenteredToGPS = false; @@ -375,6 +387,8 @@ class RecordingMapTool : public AbstractMapTool QgsFeature mActiveFeature; int mMinUndoStackIndex = 0; // We can not undo more than this index + + QTimer *mDraftSaveTimer = nullptr; // owned by this, debounces saveDraft() }; #endif // RECORDINGMAPTOOL_H diff --git a/app/notificationmodel.cpp b/app/notificationmodel.cpp index d5fc1c74a..6c43b0577 100644 --- a/app/notificationmodel.cpp +++ b/app/notificationmodel.cpp @@ -117,6 +117,11 @@ void NotificationModel::addWarning( const QString &message, const NotificationTy add( message, interval, NotificationType::Warning, NotificationType::ExclamationIcon, action ); } +void NotificationModel::addDraftNotice( const QString &message, const NotificationType::ActionType action, const uint interval ) +{ + add( message, interval, NotificationType::Warning, NotificationType::InfoIcon, action ); +} + // check for auto removing notification void NotificationModel::timerFired() { @@ -156,6 +161,12 @@ void NotificationModel::onNotificationClicked( uint id ) emit showSyncFailedDialogClicked(); break; } + case NotificationType::ActionType::OpenDraftAction: + { + remove( id ); + emit openDraftActionClicked(); + break; + } default: break; } } diff --git a/app/notificationmodel.h b/app/notificationmodel.h index 91711029c..51d8efca3 100644 --- a/app/notificationmodel.h +++ b/app/notificationmodel.h @@ -43,7 +43,8 @@ class NotificationType NoAction, ShowProjectIssuesAction, ShowSwitchWorkspaceAction, - ShowSyncFailedDialog + ShowSyncFailedDialog, + OpenDraftAction }; Q_ENUM( ActionType ) @@ -100,6 +101,7 @@ class NotificationModel : public QAbstractListModel Q_INVOKABLE void addError( const QString &message, NotificationType::ActionType action = NotificationType::ActionType::NoAction, uint interval = DEFAULT_NOTIFICATION_EXPIRATION_SECS ); Q_INVOKABLE void addInfo( const QString &message, NotificationType::ActionType action = NotificationType::ActionType::NoAction, uint interval = DEFAULT_NOTIFICATION_EXPIRATION_SECS ); Q_INVOKABLE void addWarning( const QString &message, NotificationType::ActionType action = NotificationType::ActionType::NoAction, uint interval = DEFAULT_NOTIFICATION_EXPIRATION_SECS ); + Q_INVOKABLE void addDraftNotice( const QString &message, NotificationType::ActionType action = NotificationType::ActionType::NoAction, uint interval = DEFAULT_NOTIFICATION_EXPIRATION_SECS ); Q_INVOKABLE void remove( uint id ); Q_INVOKABLE void onNotificationClicked( uint id ); @@ -112,6 +114,7 @@ class NotificationModel : public QAbstractListModel void showProjectIssuesActionClicked(); void showSwitchWorkspaceActionClicked(); void showSyncFailedDialogClicked(); + void openDraftActionClicked(); private: void add( const QString &message, uint interval, NotificationType::MessageType type = NotificationType::Information, NotificationType::IconType icon = NotificationType::NoneIcon, NotificationType::ActionType action = NotificationType::ActionType::NoAction ); diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index 83ef85a09..4335dab7f 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -93,6 +93,8 @@ set(MM_QML dialogs/MMFormDeleteFeatureDialog.qml dialogs/MMProjErrorDialog.qml dialogs/MMOutOfDateCustomServerDialog.qml + dialogs/MMDiscardDraftDialog.qml + dialogs/MMResumeDraftDialog.qml dialogs/MMDiscardGeometryChangesDialog.qml dialogs/MMProjectLoadErrorDialog.qml dialogs/MMProviderRemoveReceiverDialog.qml diff --git a/app/qml/dialogs/MMDiscardDraftDialog.qml b/app/qml/dialogs/MMDiscardDraftDialog.qml new file mode 100644 index 000000000..60934fae7 --- /dev/null +++ b/app/qml/dialogs/MMDiscardDraftDialog.qml @@ -0,0 +1,41 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../components" + +MMDrawerDialog { + id: root + + property string layerName: "" + + signal discardDraft() + + imageSource: __style.negativeMMSymbolImage + title: qsTr( "Discard unsaved changes?" ) + description: qsTr( "Tapping on 'Discard' deletes your unsaved changes on %1. This cannot be undone." ).arg( layerName ) + + primaryButton.text: qsTr( "Discard changes" ) + primaryButton.bgndColor: __style.negativeColor + primaryButton.bgndColorHover: __style.negativeColor + primaryButton.fontColor: __style.grapeColor + primaryButton.fontColorHover: __style.grapeColor + + secondaryButton.text: qsTr( "Do not discard" ) + + onPrimaryButtonClicked: { + root.discardDraft() + close() + } + + onSecondaryButtonClicked: { + close() + } +} diff --git a/app/qml/dialogs/MMResumeDraftDialog.qml b/app/qml/dialogs/MMResumeDraftDialog.qml new file mode 100644 index 000000000..7bbee582f --- /dev/null +++ b/app/qml/dialogs/MMResumeDraftDialog.qml @@ -0,0 +1,50 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../components" + +MMDrawerDialog { + id: root + + property string featureTitle: "" + property string layerName: "" + + signal resumeClicked() + signal discardClicked() + + imageSource: __style.neutralMMSymbolImage + title: featureTitle !== "" + ? qsTr( "You have unsaved changes on feature %1" ).arg( featureTitle ) + : qsTr( "You have unsaved changes on a new feature" ) + description: qsTr( "The app closed before saving your changes on %1, click resume to start editing them again. If not, click discard." ).arg( layerName ) + + primaryButton.text: qsTr( "Resume editing" ) + primaryButton.bgndColor: __style.warningColor + primaryButton.bgndColorHover: __style.warningColor + primaryButton.fontColor: __style.earthColor + primaryButton.fontColorHover: __style.earthColor + + secondaryButton.text: qsTr( "Discard unsaved changes" ) + secondaryButton.bgndColor: "transparent" + secondaryButton.bgndColorHover: "transparent" + secondaryButton.fontColor: __style.earthColor + secondaryButton.fontColorHover: __style.earthColor + + onPrimaryButtonClicked: { + root.resumeClicked() + close() + } + + onSecondaryButtonClicked: { + root.discardClicked() + close() + } +} diff --git a/app/qml/filters/components/MMFilterBanner.qml b/app/qml/filters/components/MMFilterBanner.qml index 86dd6a6e9..a549fca0d 100644 --- a/app/qml/filters/components/MMFilterBanner.qml +++ b/app/qml/filters/components/MMFilterBanner.qml @@ -18,6 +18,8 @@ Rectangle { property string text property string actionText: "" + property alias actionButton: actionButton + signal actionClicked() color: __style.informativeColor diff --git a/app/qml/form/MMFormController.qml b/app/qml/form/MMFormController.qml index 6b098ac52..10285b31e 100644 --- a/app/qml/form/MMFormController.qml +++ b/app/qml/form/MMFormController.qml @@ -11,6 +11,7 @@ import QtQuick import QtQuick.Controls import "../components" as MMComponents +import "../dialogs" import mm 1.0 as MM import MMInput @@ -42,6 +43,7 @@ Item { signal closed() signal saveRequested() signal editGeometry( var pair ) + signal resumeDraft() signal openLinkedFeature( var linkedFeature ) signal createLinkedFeature( var targetLayer, var parentPair ) signal multiSelectFeature( var feature ) @@ -173,8 +175,15 @@ Item { onOpenFormClicked: root.panelState = "form" onEditClicked: { - root.panelState = "form" - featureForm.state = "edit" + if ( __activeProject.featureDraftController.hasDraft ) { + // only one drawer should ever be active at a time + root.closeDrawer() + resumeDraftDialog.open() + } + else { + root.panelState = "form" + featureForm.state = "edit" + } } onCloseClicked: drawer.close() @@ -248,4 +257,24 @@ Item { if ( panelState === "preview" ) previewPanelChanged( previewPanel.implicitHeight ) } + + MMResumeDraftDialog { + id: resumeDraftDialog + + featureTitle: __activeProject.featureDraftController.draftFeatureTitle + layerName: __activeProject.featureDraftController.draftLayerName + + onResumeClicked: root.resumeDraft() + onDiscardClicked: discardDraftDialog.open() + } + + MMDiscardDraftDialog { + id: discardDraftDialog + + layerName: __activeProject.featureDraftController.draftLayerName + + onDiscardDraft: { + __activeProject.featureDraftController.discardDraft() + } + } } diff --git a/app/qml/form/MMFormStackController.qml b/app/qml/form/MMFormStackController.qml index 134b796e1..d0bc7bc83 100644 --- a/app/qml/form/MMFormStackController.qml +++ b/app/qml/form/MMFormStackController.qml @@ -51,6 +51,7 @@ Item { signal closed() signal editGeometryRequested( var pair ) + signal resumeDraftRequested() signal createLinkedFeatureRequested( var targetLayer, var parentPair ) signal multiSelectFeature( var feature ) signal stakeoutFeature( var feature ) @@ -315,6 +316,9 @@ Item { onEditGeometry: function( pair ) { root.editGeometryRequested( pair ) } + onResumeDraft: { + root.resumeDraftRequested() + } onOpenLinkedFeature: function( linkedFeature ) { root.openLinkedFeature( linkedFeature ) } diff --git a/app/qml/layers/MMFeaturesListPage.qml b/app/qml/layers/MMFeaturesListPage.qml index 3d8e03970..cfad43880 100644 --- a/app/qml/layers/MMFeaturesListPage.qml +++ b/app/qml/layers/MMFeaturesListPage.qml @@ -26,6 +26,7 @@ MMComponents.MMPage { signal featureClicked( var featurePair ) signal addFeatureClicked( var toLayer ) + signal resumeDraftClicked() pageHeader.title: root.selectedLayer ? root.selectedLayer.name + " (" + featuresModel.layerFeaturesCount + ")": "" pageBottomMargin: 0 @@ -46,13 +47,37 @@ MMComponents.MMPage { } MMFilterComponents.MMFilterBanner { - id: filterBanner + id: draftBanner anchors.top: searchBar.bottom anchors.topMargin: __style.spacing20 width: parent.width + visible: root.selectedLayer && __activeProject.featureDraftController.hasDraft && __activeProject.featureDraftController.draftLayer === root.selectedLayer + + color: __style.warningColor + text: __activeProject.featureDraftController.draftIsEdit + ? qsTr( "Unsaved changes on %1" ).arg( __activeProject.featureDraftController.draftFeatureTitle ) + : qsTr( "There is an unsaved feature" ) + actionText: qsTr( "Resume" ) + + actionButton.bgndColor: __style.earthColor + actionButton.bgndColorHover: __style.earthColor + actionButton.fontColor: "white" + actionButton.fontColorHover: "white" + + onActionClicked: root.resumeDraftClicked() + } + + MMFilterComponents.MMFilterBanner { + id: filterBanner + + anchors.top: draftBanner.visible ? draftBanner.bottom : searchBar.bottom + anchors.topMargin: draftBanner.visible ? __style.spacing10 : __style.spacing20 + + width: parent.width + visible: root.selectedLayer && __activeProject.filterController?.filteringAvailable && __activeProject.filterController?.hasActiveFilterOnLayer(root.selectedLayer?.id) text: qsTr("Active filters applied") @@ -71,9 +96,9 @@ MMComponents.MMPage { width: parent.width anchors { - top: filterBanner.visible ? filterBanner.bottom : searchBar.bottom + top: filterBanner.visible ? filterBanner.bottom : ( draftBanner.visible ? draftBanner.bottom : searchBar.bottom ) bottom: parent.bottom - topMargin: filterBanner.visible ? __style.spacing10 : __style.spacing20 + topMargin: ( filterBanner.visible || draftBanner.visible ) ? __style.spacing10 : __style.spacing20 } model: MM.LayerFeaturesModel { diff --git a/app/qml/layers/MMLayerDetailPage.qml b/app/qml/layers/MMLayerDetailPage.qml index 9f29d5dc8..34f219191 100644 --- a/app/qml/layers/MMLayerDetailPage.qml +++ b/app/qml/layers/MMLayerDetailPage.qml @@ -29,6 +29,7 @@ Page { signal close() signal featureClicked( var featurePair ) signal addFeatureClicked( var targetLayer ) + signal resumeDraftClicked() property var layerTreeNode: null @@ -224,6 +225,10 @@ Page { root.addFeatureClicked( toLayer ) } + onResumeDraftClicked: { + root.resumeDraftClicked() + } + onBackClicked: function() { root.closePage() } diff --git a/app/qml/layers/MMLayersController.qml b/app/qml/layers/MMLayersController.qml index 14fa79ff6..83ba67264 100644 --- a/app/qml/layers/MMLayersController.qml +++ b/app/qml/layers/MMLayersController.qml @@ -22,6 +22,7 @@ Item { signal addFeature( var targetLayer ) signal selectFeature( var featurePair ) + signal resumeDraft() MM.LayerTreeSortFilterModel { id: layerTreeProxyModel @@ -106,6 +107,10 @@ Item { let item = pagesStackView.push( searchLayersPage, {}, StackView.Immediate ) item.forceActiveFocus() } + + onResumeDraftClicked: { + root.resumeDraft() + } } } @@ -129,6 +134,10 @@ Item { onAddFeatureClicked: function( targetLayer ) { root.addFeature( targetLayer ) } + + onResumeDraftClicked: { + root.resumeDraft() + } } } diff --git a/app/qml/layers/MMLayersListPage.qml b/app/qml/layers/MMLayersListPage.qml index 343fd885a..c638db057 100644 --- a/app/qml/layers/MMLayersListPage.qml +++ b/app/qml/layers/MMLayersListPage.qml @@ -12,6 +12,7 @@ import QtQuick.Controls import "../components" as MMComponents import "../inputs" +import "../filters/components" as MMFilterComponents MMComponents.MMPage { id: root @@ -23,6 +24,7 @@ MMComponents.MMPage { signal nodeClicked( var node, string nodeType, string nodeName ) signal nodeVisibilityClicked( var node ) signal searchBarClicked() + signal resumeDraftClicked() pageHeader.title: root.pageTitle @@ -49,14 +51,35 @@ MMComponents.MMPage { } } + MMFilterComponents.MMFilterBanner { + id: draftBanner + + anchors.top: searchBar.bottom + anchors.topMargin: __style.spacing20 + width: parent.width + + visible: __activeProject.featureDraftController.hasDraft + + color: __style.warningColor + text: qsTr( "%1 has unsaved changes" ).arg( __activeProject.featureDraftController.draftLayerName ) + actionText: qsTr( "Resume" ) + + actionButton.bgndColor: __style.earthColor + actionButton.bgndColorHover: __style.earthColor + actionButton.fontColor: "white" + actionButton.fontColorHover: "white" + + onActionClicked: root.resumeDraftClicked() + } + MMLayersList { id: layers width: parent.width anchors { - top: searchBar.bottom - topMargin: __style.spacing20 + top: draftBanner.visible ? draftBanner.bottom : searchBar.bottom + topMargin: draftBanner.visible ? __style.spacing10 : __style.spacing20 bottom: parent.bottom } diff --git a/app/qml/main.qml b/app/qml/main.qml index 340f88280..e76fdd2f3 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -318,7 +318,13 @@ ApplicationWindow { onClicked: { if ( __activeProject.projectHasRecordingLayers() ) { stateManager.state = "map" - map.record() + + if ( __activeProject.featureDraftController.hasDraft ) { + resumeDraftDialog.open() + } + else { + map.record() + } } else { __notificationModel.addInfo( qsTr( "No editable layers found." ) ) @@ -507,12 +513,27 @@ ApplicationWindow { } onAddFeature: function( targetLayer ) { - let newPair = __inputUtils.createFeatureLayerPair( targetLayer, __inputUtils.emptyGeometry(), __variablesManager ) - formsStackManager.openForm( newPair, "add", "form" ) + let startAdding = function() { + let newPair = __inputUtils.createFeatureLayerPair( targetLayer, __inputUtils.emptyGeometry(), __variablesManager ) + formsStackManager.openForm( newPair, "add", "form" ) + } + + if ( __activeProject.featureDraftController.hasDraft ) { + resumeDraftDialog.open() + } + else { + startAdding() + } // If we start supporting addition of spatial features from the layer's list, // make sure to change the root state here to "map" } + + onResumeDraft: { + mapPanelsStackView.clear( StackView.PopTransition ) + stateManager.state = "map" + resumeFeatureDraft() + } } } @@ -817,6 +838,11 @@ ApplicationWindow { map.edit( pair ) } + onResumeDraftRequested: { + stateManager.state = "map" + resumeFeatureDraft() + } + onClosed: { if ( mapPanelsStackView.depth ) { // this must be layers panel as it is the only thing on the stackview currently @@ -893,6 +919,26 @@ ApplicationWindow { id: projDialog } + MMDiscardDraftDialog { + id: discardDraftDialog + + layerName: __activeProject.featureDraftController.draftLayerName + + onDiscardDraft: { + __activeProject.featureDraftController.discardDraft() + } + } + + MMResumeDraftDialog { + id: resumeDraftDialog + + featureTitle: __activeProject.featureDraftController.draftFeatureTitle + layerName: __activeProject.featureDraftController.draftLayerName + + onResumeClicked: resumeFeatureDraft() + onDiscardClicked: discardDraftDialog.open() + } + MMOutOfDateCustomServerDialog{ id: migrationDialog @@ -1123,6 +1169,26 @@ ApplicationWindow { } } + //! Resumes whatever feature draft is currently pending, landing back exactly + //! where the user left off - interactive geometry capture, or the form. + function resumeFeatureDraft() { + const controller = __activeProject.featureDraftController + const isEdit = controller.draftIsEdit + + // only a brand new feature needs interactive resume - an existing feature + // always has a form to reopen, geometry already included + if ( controller.draftStage === "geometryCapture" && !isEdit ) { + const layer = controller.draftLayer + const geometry = controller.resumeGeometryDraft() + map.resumeRecording( layer, geometry ) + } else { + const pair = controller.resumeDraft() + formsStackManager.openForm( pair, isEdit ? "edit" : "add", "form" ) + } + + __notificationModel.addInfo( qsTr( "This is your unsaved changes, continue editing or discard them by navigating back." ) ) + } + Connections { target: __inputProjUtils function onProjError( message ) { @@ -1143,6 +1209,19 @@ ApplicationWindow { function onShowSyncFailedDialogClicked() { syncFailedDialog.open() } + function onOpenDraftActionClicked() { + resumeFeatureDraft() + } + } + + Connections { + target: __activeProject.featureDraftController + + function onHasDraftChanged() { + if ( __activeProject.featureDraftController.hasDraft && map.state === "view" ) { + __notificationModel.addDraftNotice( qsTr( "You have unsaved changes. Tap here to open them." ), MM.NotificationType.OpenDraftAction ) + } + } } Connections { diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 1abe26c70..6f4a00b7f 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1331,6 +1331,19 @@ Item { state = "recordInLayer" } + //! Resumes digitizing a new feature that was interrupted mid-capture + function resumeRecording( layer, geometry ) { + __activeProject.setActiveLayer( layer ) + state = "record" + + // recordingToolsLoader only becomes active once state == "record" takes effect + Qt.callLater( function() { + if ( recordingToolsLoader.item ) { + recordingToolsLoader.item.recordingMapTool.resumeCapture( geometry ) + } + } ) + } + function edit( featurepair ) { __activeProject.setActiveLayer( featurepair.layer ) root.centerToPair( featurepair ) From ca31b75194826293ebb1f75de44b427586222d2e Mon Sep 17 00:00:00 2001 From: Richard Kello Date: Tue, 18 Aug 2026 08:32:02 +0200 Subject: [PATCH 2/4] Add redraw for when user goes from form to geometry --- app/qml/map/MMMapController.qml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 6f4a00b7f..7d89fa889 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1351,6 +1351,9 @@ Item { internal.featurePairToEdit = featurepair state = "edit" + + // force a redraw - canvas may not have repainted while covered by the form + mapCanvas.refresh() } function toggleRedraw() { From d02dcfba7da8bb4f6d8e980f6a8fc3dae5ccfe61 Mon Sep 17 00:00:00 2001 From: Richard Kello Date: Wed, 23 Sep 2026 17:55:01 +0200 Subject: [PATCH 3/4] Add code change requests --- app/CMakeLists.txt | 3 + app/activeproject.cpp | 4 + app/attributes/attributecontroller.cpp | 60 ++--- app/attributes/attributecontroller.h | 16 +- app/drafts/featuredraft.h | 53 +++++ app/drafts/featuredraftcontroller.cpp | 140 ++++------- app/drafts/featuredraftcontroller.h | 41 ++-- app/drafts/featuredraftstorage.cpp | 116 +++++++++- app/drafts/featuredraftstorage.h | 33 ++- app/main.cpp | 2 + app/maptools/recordingmaptool.cpp | 26 +-- app/maptools/recordingmaptool.h | 4 +- app/notificationmodel.cpp | 4 +- app/notificationmodel.h | 4 +- app/qml/CMakeLists.txt | 2 +- .../MMListBanner.qml} | 26 ++- app/qml/dialogs/MMDiscardDraftDialog.qml | 4 +- app/qml/dialogs/MMResumeDraftDialog.qml | 6 +- app/qml/form/MMFormController.qml | 10 +- app/qml/form/MMFormStackController.qml | 2 +- .../components/MMFeaturesListPageDrawer.qml | 3 +- app/qml/layers/MMFeaturesListPage.qml | 99 ++++---- app/qml/layers/MMLayerDetailPage.qml | 2 +- app/qml/layers/MMLayersController.qml | 4 +- app/qml/layers/MMLayersListPage.qml | 64 ++--- app/qml/main.qml | 47 ++-- app/qml/map/MMMapController.qml | 5 + app/test/inputtests.cpp | 6 + app/test/testfeaturedraftcontroller.cpp | 218 ++++++++++++++++++ app/test/testfeaturedraftcontroller.h | 33 +++ gallery/qml.qrc | 2 + gallery/qml/pages/DrawerPage.qml | 34 +++ test/CMakeLists.txt | 1 + 33 files changed, 750 insertions(+), 324 deletions(-) create mode 100644 app/drafts/featuredraft.h rename app/qml/{filters/components/MMFilterBanner.qml => components/MMListBanner.qml} (72%) create mode 100644 app/test/testfeaturedraftcontroller.cpp create mode 100644 app/test/testfeaturedraftcontroller.h diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index d235debb8..4add3edf0 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -149,6 +149,7 @@ set(MM_HDRS androidutils.h mapsketchingcontroller.h appsettings.h + drafts/featuredraft.h drafts/featuredraftstorage.h drafts/featuredraftcontroller.h autosynccontroller.h @@ -259,6 +260,7 @@ if (ENABLE_TESTS) test/testprojectchecksumcache.cpp test/testmultieditmanager.cpp test/testfiltercontroller.cpp + test/testfeaturedraftcontroller.cpp ) set(MM_HDRS @@ -286,6 +288,7 @@ if (ENABLE_TESTS) test/testprojectchecksumcache.h test/testmultieditmanager.h test/testfiltercontroller.h + test/testfeaturedraftcontroller.h ) if (NOT USE_MM_SERVER_API_KEY) diff --git a/app/activeproject.cpp b/app/activeproject.cpp index 39c41635f..b4bb67a23 100644 --- a/app/activeproject.cpp +++ b/app/activeproject.cpp @@ -21,6 +21,7 @@ #include "activeproject.h" #include "coreutils.h" +#include "drafts/featuredraftstorage.h" #ifdef ANDROID #include "position/tracking/androidtrackingbroadcast.h" @@ -166,6 +167,9 @@ bool ActiveProject::forceLoad( const QString &filePath, bool force ) emit projectWillBeReloaded(); mActiveLayer.resetActiveLayer(); + // a new project may resolve to a different draft storage key + FeatureDraftStorage::clearCache(); + // path to the authentication configuration file const QDir projectDir = QFileInfo( filePath ).dir(); const QFileInfo cfgFile( projectDir.filePath( CoreUtils::AUTH_CONFIG_FILENAME ) ); diff --git a/app/attributes/attributecontroller.cpp b/app/attributes/attributecontroller.cpp index c5e5cdcc1..5b115fdc7 100644 --- a/app/attributes/attributecontroller.cpp +++ b/app/attributes/attributecontroller.cpp @@ -22,8 +22,6 @@ #include #include #include -#include -#include #include "featuredraftstorage.h" @@ -47,11 +45,10 @@ AttributeController::AttributeController( QObject *parent ) : QObject( parent ) , mAttributeTabProxyModel( new AttributeTabProxyModel() ) - , mDraftSaveTimer( new QTimer( this ) ) { - mDraftSaveTimer->setSingleShot( true ); - mDraftSaveTimer->setInterval( 1000 ); - connect( mDraftSaveTimer, &QTimer::timeout, this, &AttributeController::saveDraft ); + mDraftSaveTimer.setSingleShot( true ); + mDraftSaveTimer.setInterval( 1000 ); + connect( &mDraftSaveTimer, &QTimer::timeout, this, &AttributeController::saveDraft ); } void AttributeController::reset() @@ -87,6 +84,12 @@ void AttributeController::setFeatureLayerPair( const FeatureLayerPair &pair ) if ( !isSameFeature ) { mTouchedFieldIndices.clear(); + + // draft immediately so a crash before the first keystroke still resumes into the form + if ( pair.layer() && isNewFeature() ) + { + saveDraft(); + } } if ( hasLayerChanged ) { @@ -665,13 +668,9 @@ bool AttributeController::isNewFeature() const return FID_IS_NEW( id ) || FID_IS_NULL( id ); } -QJsonObject AttributeController::attributeToJson( const QgsFields &fields, const QgsFeature &feature, int fieldIndex ) const +FeatureDraftAttribute AttributeController::toDraftAttribute( const QgsFields &fields, const QgsFeature &feature, int fieldIndex ) const { - QJsonObject attribute; - attribute[ QStringLiteral( "name" ) ] = fields.at( fieldIndex ).name(); - attribute[ QStringLiteral( "type" ) ] = fields.at( fieldIndex ).typeName(); - attribute[ QStringLiteral( "value" ) ] = QJsonValue::fromVariant( feature.attribute( fieldIndex ) ); - return attribute; + return { fields.at( fieldIndex ).name(), fields.at( fieldIndex ).typeName(), feature.attribute( fieldIndex ) }; } void AttributeController::saveDraft() @@ -683,40 +682,29 @@ void AttributeController::saveDraft() const QgsFields fields = feature.fields(); const bool featureIsNew = isNewFeature(); - QJsonArray attributes; + FeatureDraft draft; + draft.layerId = mFeatureLayerPair.layer()->id(); + draft.stage = FeatureDraft::AttributeForm; + draft.timestamp = QDateTime::currentDateTimeUtc(); - if ( featureIsNew ) - { - for ( int i = 0; i < feature.attributeCount(); ++i ) - { - attributes.append( attributeToJson( fields, feature, i ) ); - } - } - else + // only touched fields are drafted - an untouched one falls back to its + // default value expression on resume rather than a stale recorded value + for ( int fieldIndex : mTouchedFieldIndices ) { - for ( int fieldIndex : mTouchedFieldIndices ) + if ( fieldIndex >= 0 && fieldIndex < feature.attributeCount() ) { - if ( fieldIndex >= 0 && fieldIndex < feature.attributeCount() ) - { - attributes.append( attributeToJson( fields, feature, fieldIndex ) ); - } + draft.attributes.append( toDraftAttribute( fields, feature, fieldIndex ) ); } } - QJsonObject draft; - draft[ QStringLiteral( "layerId" ) ] = mFeatureLayerPair.layer()->id(); - draft[ QStringLiteral( "stage" ) ] = QStringLiteral( "attributeForm" ); - draft[ QStringLiteral( "timestamp" ) ] = QDateTime::currentDateTimeUtc().toString( Qt::ISODate ); - draft[ QStringLiteral( "attributes" ) ] = attributes; - if ( featureIsNew ) { // existing-feature geometry edits are drafted separately, by RecordingMapTool - draft[ QStringLiteral( "geometry" ) ] = feature.geometry().asWkt(); + draft.geometry = feature.geometry(); } else { - draft[ QStringLiteral( "featureId" ) ] = QJsonValue( static_cast( feature.id() ) ); + draft.featureId = feature.id(); } FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); @@ -726,7 +714,7 @@ void AttributeController::clearDraft() { // a pending debounced write must not be allowed to resurrect the draft // after we've just told the storage (and possibly the user) it's gone - mDraftSaveTimer->stop(); + mDraftSaveTimer.stop(); FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); } @@ -1599,7 +1587,7 @@ bool AttributeController::setFormValue( const QUuid &id, QVariant value ) mFeatureLayerPair.featureRef().setAttribute( item->fieldIndex(), val ); emit formDataChanged( item->id(), { AttributeFormModel::AttributeValue, AttributeFormModel::RawValueIsNull, AttributeFormModel::HasMixedValues } ); mTouchedFieldIndices.insert( item->fieldIndex() ); - mDraftSaveTimer->start(); + mDraftSaveTimer.start(); } recalculateDerivedItems( true, false ); return true; diff --git a/app/attributes/attributecontroller.h b/app/attributes/attributecontroller.h index 14b2f421a..438dca2b2 100644 --- a/app/attributes/attributecontroller.h +++ b/app/attributes/attributecontroller.h @@ -24,13 +24,14 @@ #include #include #include -#include +#include #include "featurelayerpair.h" #include "attributedata.h" #include "attributeformproxymodel.h" #include "attributetabproxymodel.h" #include "rememberattributescontroller.h" +#include "featuredraft.h" #include "qgsfeature.h" #include "qgsproject.h" @@ -42,7 +43,6 @@ class AttributeFormModel; class AttributeTabModel; class QgsVectorLayer; -class QTimer; /** * This is implementation of the controller between Attribute*Model @@ -191,16 +191,14 @@ class AttributeController : public QObject bool isNewFeature() const; - // Persists attribute changes as a draft, debounced. New feature: all attributes. - // Existing feature: only touched fields, so an untouched one is never clobbered - // by a concurrent change made elsewhere via sync. + // Persists touched attributes as a draft, debounced. void saveDraft(); //! Removes any persisted draft for the current project void clearDraft(); - //! Builds the {name, type, value} JSON object for one attribute, used by saveDraft() - QJsonObject attributeToJson( const QgsFields &fields, const QgsFeature &feature, int fieldIndex ) const; + //! Builds a FeatureDraftAttribute for one attribute, used by saveDraft() + FeatureDraftAttribute toDraftAttribute( const QgsFields &fields, const QgsFeature &feature, int fieldIndex ) const; /** * Recalculates visibility & constrains & default values @@ -264,9 +262,9 @@ class AttributeController : public QObject AttributeController *mParentController = nullptr; // not owned QgsRelation mLinkedRelation; - QTimer *mDraftSaveTimer = nullptr; // owned by this, debounces saveDraft() + QTimer mDraftSaveTimer; // debounces saveDraft() - //! Indices of fields the user has actually changed this session - only valid for existing (edit-mode) features + //! Indices of fields the user has actually changed this session, used for drafting QSet mTouchedFieldIndices; }; #endif // ATTRIBUTECONTROLLER_H diff --git a/app/drafts/featuredraft.h b/app/drafts/featuredraft.h new file mode 100644 index 000000000..92627e704 --- /dev/null +++ b/app/drafts/featuredraft.h @@ -0,0 +1,53 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef FEATUREDRAFT_H +#define FEATUREDRAFT_H + +#include +#include +#include +#include + +#include "qgsgeometry.h" +#include "qgsfeature.h" + +// One touched attribute captured for a draft - the type name lets isDraftValid() +// detect a field that has since changed shape. +struct FeatureDraftAttribute +{ + QString name; + QString typeName; + QVariant value; +}; + +// In-memory shape of an in-progress feature edit. FeatureDraftStorage is the only +// class that knows how this maps to the on-disk (QSettings/JSON) format. +struct FeatureDraft +{ + enum Stage + { + GeometryCapture, + AttributeForm + }; + + bool isEmpty() const { return layerId.isEmpty(); } + + // Whether this draft belongs to an existing feature being edited, rather than a new one being added + bool isExistingFeature() const { return !FID_IS_NULL( featureId ) && !FID_IS_NEW( featureId ); } + + QString layerId; + Stage stage = AttributeForm; + QDateTime timestamp; + QgsGeometry geometry; + QVector attributes; + QgsFeatureId featureId = FID_NULL; // FID_NULL means a new (not-yet-existing) feature +}; + +#endif // FEATUREDRAFT_H diff --git a/app/drafts/featuredraftcontroller.cpp b/app/drafts/featuredraftcontroller.cpp index a211bf0b2..a89bb7fb2 100644 --- a/app/drafts/featuredraftcontroller.cpp +++ b/app/drafts/featuredraftcontroller.cpp @@ -11,17 +11,12 @@ #include "featuredraftstorage.h" #include "inpututils.h" -#include -#include #include #include "qgsproject.h" #include "qgsvectorlayer.h" -namespace -{ - constexpr qint64 MAX_DRAFT_AGE_SECS = 10 * 24 * 60 * 60; // 10 days -} +constexpr qint64 MAX_DRAFT_AGE_SECS = 10 * 24 * 60 * 60; // 10 days FeatureDraftController::FeatureDraftController( QObject *parent ) : QObject( parent ) @@ -43,14 +38,14 @@ QgsVectorLayer *FeatureDraftController::draftLayer() const return mDraftLayer; } -QString FeatureDraftController::draftStage() const +FeatureDraftController::DraftStage FeatureDraftController::draftStage() const { return mDraftStage; } -bool FeatureDraftController::draftIsEdit() const +bool FeatureDraftController::draftIsExistingFeature() const { - return mDraftIsEdit; + return mDraftIsExistingFeature; } QString FeatureDraftController::draftFeatureTitle() const @@ -61,10 +56,11 @@ QString FeatureDraftController::draftFeatureTitle() const void FeatureDraftController::checkForDraft() { const QString projectId = QgsProject::instance()->homePath(); - const QJsonObject draft = FeatureDraftStorage::loadDraft( projectId ); + const FeatureDraft draft = FeatureDraftStorage::loadDraft( projectId ); if ( draft.isEmpty() ) { + mCachedDraft = FeatureDraft(); setDraft( false ); return; } @@ -74,185 +70,149 @@ void FeatureDraftController::checkForDraft() if ( !layer || !isDraftValid( draft, layer ) ) { FeatureDraftStorage::clearDraft( projectId ); + mCachedDraft = FeatureDraft(); setDraft( false ); return; } - const bool isEdit = draft.contains( QStringLiteral( "featureId" ) ); + mCachedDraft = draft; + QString featureTitle; - if ( isEdit ) + if ( draft.isExistingFeature() ) { - const QgsFeatureId featureId = draft.value( QStringLiteral( "featureId" ) ).toVariant().toLongLong(); - const QgsFeature feature = layer->getFeature( featureId ); + const QgsFeature feature = layer->getFeature( draft.featureId ); featureTitle = InputUtils::featureTitle( FeatureLayerPair( feature, layer ), QgsProject::instance() ); } - setDraft( true, layer, draft.value( QStringLiteral( "stage" ) ).toString(), isEdit, featureTitle ); + setDraft( true, layer, toQmlStage( draft.stage ), draft.isExistingFeature(), featureTitle ); } FeatureLayerPair FeatureDraftController::resumeDraft() { if ( !mHasDraft ) - return FeatureLayerPair(); + return {}; - const QString projectId = QgsProject::instance()->homePath(); - const QJsonObject draft = FeatureDraftStorage::loadDraft( projectId ); - QgsVectorLayer *layer = resolveDraftLayer( draft ); + QgsVectorLayer *layer = mDraftLayer; - if ( !layer || !isDraftValid( draft, layer ) ) + // re-validated against the cached draft - state may have changed since checkForDraft() + if ( !layer || !isDraftValid( mCachedDraft, layer ) ) { - // re-validated here too - time passed since the draft was detected - FeatureDraftStorage::clearDraft( projectId ); + FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); + mCachedDraft = FeatureDraft(); setDraft( false ); - return FeatureLayerPair(); + return {}; } + const FeatureDraft &draft = mCachedDraft; + FeatureLayerPair pair; - if ( draft.contains( QStringLiteral( "featureId" ) ) ) + if ( draft.isExistingFeature() ) { // existing feature: start from the live one, then overlay the draft on top - const QgsFeatureId featureId = draft.value( QStringLiteral( "featureId" ) ).toVariant().toLongLong(); - pair = FeatureLayerPair( layer->getFeature( featureId ), layer ); + pair = FeatureLayerPair( layer->getFeature( draft.featureId ), layer ); - const QString wkt = draft.value( QStringLiteral( "geometry" ) ).toString(); - if ( !wkt.isEmpty() ) + if ( !draft.geometry.isNull() ) { - QgsGeometry geometry = QgsGeometry::fromWkt( wkt ); + QgsGeometry geometry = draft.geometry; pair.featureRef().setGeometry( geometry ); // push into the layer too, so the map shows the resumed shape right away // instead of the stale committed one until the next vertex edit layer->startEditing(); - layer->changeGeometry( featureId, geometry ); + layer->changeGeometry( draft.featureId, geometry ); layer->triggerRepaint(); } } else { - pair = InputUtils::createFeatureLayerPair( layer, InputUtils::emptyGeometry(), nullptr ); - - const QString wkt = draft.value( QStringLiteral( "geometry" ) ).toString(); - if ( !wkt.isEmpty() ) - { - pair.featureRef().setGeometry( QgsGeometry::fromWkt( wkt ) ); - } + const QgsGeometry geometry = draft.geometry.isNull() ? InputUtils::emptyGeometry() : draft.geometry; + pair = InputUtils::createFeatureLayerPair( layer, geometry, nullptr ); } const QgsFields fields = layer->fields(); - const QJsonArray attributes = draft.value( QStringLiteral( "attributes" ) ).toArray(); - for ( const QJsonValue &attributeValue : attributes ) + for ( const FeatureDraftAttribute &attribute : draft.attributes ) { - const QJsonObject attribute = attributeValue.toObject(); - const int fieldIndex = fields.indexOf( attribute.value( QStringLiteral( "name" ) ).toString() ); + const int fieldIndex = fields.indexOf( attribute.name ); if ( fieldIndex >= 0 ) { - pair.featureRef().setAttribute( fieldIndex, attribute.value( QStringLiteral( "value" ) ).toVariant() ); + pair.featureRef().setAttribute( fieldIndex, attribute.value ); } } // draft stays in storage - only the pending state (the prompt) is cleared here + mCachedDraft = FeatureDraft(); setDraft( false ); return pair; } -QgsGeometry FeatureDraftController::resumeGeometryDraft() -{ - if ( !mHasDraft ) - return QgsGeometry(); - - const QString projectId = QgsProject::instance()->homePath(); - const QJsonObject draft = FeatureDraftStorage::loadDraft( projectId ); - QgsVectorLayer *layer = resolveDraftLayer( draft ); - - if ( !layer || !isDraftValid( draft, layer ) ) - { - FeatureDraftStorage::clearDraft( projectId ); - setDraft( false ); - return QgsGeometry(); - } - - const QString wkt = draft.value( QStringLiteral( "geometry" ) ).toString(); - - // draft stays in storage - only the pending state (the prompt) is cleared here - setDraft( false ); - - if ( wkt.isEmpty() ) - return QgsGeometry(); - - return QgsGeometry::fromWkt( wkt ); -} - void FeatureDraftController::discardDraft() { if ( !mHasDraft ) return; FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); + mCachedDraft = FeatureDraft(); setDraft( false ); } -QgsVectorLayer *FeatureDraftController::resolveDraftLayer( const QJsonObject &draft ) const +QgsVectorLayer *FeatureDraftController::resolveDraftLayer( const FeatureDraft &draft ) { - const QString layerId = draft.value( QStringLiteral( "layerId" ) ).toString(); - return qobject_cast( QgsProject::instance()->mapLayer( layerId ) ); + return qobject_cast( QgsProject::instance()->mapLayer( draft.layerId ) ); } -bool FeatureDraftController::isDraftValid( const QJsonObject &draft, QgsVectorLayer *layer ) const +bool FeatureDraftController::isDraftValid( const FeatureDraft &draft, QgsVectorLayer *layer ) { - const QDateTime timestamp = QDateTime::fromString( draft.value( QStringLiteral( "timestamp" ) ).toString(), Qt::ISODate ); - - if ( !timestamp.isValid() || timestamp.secsTo( QDateTime::currentDateTimeUtc() ) > MAX_DRAFT_AGE_SECS ) + if ( !draft.timestamp.isValid() || draft.timestamp.secsTo( QDateTime::currentDateTimeUtc() ) > MAX_DRAFT_AGE_SECS ) { return false; } const QgsFields fields = layer->fields(); - const QJsonArray attributes = draft.value( QStringLiteral( "attributes" ) ).toArray(); - for ( const QJsonValue &attributeValue : attributes ) + for ( const FeatureDraftAttribute &attribute : draft.attributes ) { - const QJsonObject attribute = attributeValue.toObject(); - const int fieldIndex = fields.indexOf( attribute.value( QStringLiteral( "name" ) ).toString() ); + const int fieldIndex = fields.indexOf( attribute.name ); if ( fieldIndex < 0 ) { return false; // field removed or renamed since the draft was written } - if ( fields.at( fieldIndex ).typeName() != attribute.value( QStringLiteral( "type" ) ).toString() ) + if ( fields.at( fieldIndex ).typeName() != attribute.typeName ) { return false; // field type changed since the draft was written } } - if ( draft.contains( QStringLiteral( "featureId" ) ) ) + if ( draft.isExistingFeature() && !layer->getFeature( draft.featureId ).isValid() ) { - const QgsFeatureId featureId = draft.value( QStringLiteral( "featureId" ) ).toVariant().toLongLong(); - if ( !layer->getFeature( featureId ).isValid() ) - { - return false; // the feature this draft was editing no longer exists - } + return false; // the feature this draft was editing no longer exists } return true; } -void FeatureDraftController::setDraft( bool hasDraft, QgsVectorLayer *layer, const QString &stage, bool isEdit, const QString &featureTitle ) +FeatureDraftController::DraftStage FeatureDraftController::toQmlStage( FeatureDraft::Stage stage ) +{ + return stage == FeatureDraft::GeometryCapture ? GeometryCapture : AttributeForm; +} + +void FeatureDraftController::setDraft( bool hasDraft, QgsVectorLayer *layer, DraftStage stage, bool isExistingFeature, const QString &featureTitle ) { const QString layerName = layer ? layer->name() : QString(); - if ( mHasDraft != hasDraft || mDraftLayer != layer || mDraftStage != stage || mDraftIsEdit != isEdit || mDraftFeatureTitle != featureTitle ) + if ( mHasDraft != hasDraft || mDraftLayer != layer || mDraftStage != stage || mDraftIsExistingFeature != isExistingFeature || mDraftFeatureTitle != featureTitle ) { mHasDraft = hasDraft; mDraftLayer = layer; mDraftLayerName = layerName; mDraftStage = stage; - mDraftIsEdit = isEdit; + mDraftIsExistingFeature = isExistingFeature; mDraftFeatureTitle = featureTitle; emit hasDraftChanged(); } diff --git a/app/drafts/featuredraftcontroller.h b/app/drafts/featuredraftcontroller.h index 771958bc6..7632ba9b8 100644 --- a/app/drafts/featuredraftcontroller.h +++ b/app/drafts/featuredraftcontroller.h @@ -12,10 +12,9 @@ #include #include -#include #include "featurelayerpair.h" -#include "qgsgeometry.h" +#include "featuredraft.h" class QgsVectorLayer; @@ -39,34 +38,36 @@ class FeatureDraftController : public QObject //! The layer the pending draft belongs to (only meaningful when hasDraft is true) Q_PROPERTY( QgsVectorLayer *draftLayer READ draftLayer NOTIFY hasDraftChanged ) - //! Which stage the draft was interrupted at: "geometryCapture" or "attributeForm" - Q_PROPERTY( QString draftStage READ draftStage NOTIFY hasDraftChanged ) + //! Which stage the draft was interrupted at + Q_PROPERTY( DraftStage draftStage READ draftStage NOTIFY hasDraftChanged ) //! Whether the draft belongs to an existing feature being edited, rather than a new one being added - Q_PROPERTY( bool draftIsEdit READ draftIsEdit NOTIFY hasDraftChanged ) + Q_PROPERTY( bool draftIsExistingFeature READ draftIsExistingFeature NOTIFY hasDraftChanged ) //! Display title of the draft's feature (via the layer's display expression), empty for a new (add-mode) draft Q_PROPERTY( QString draftFeatureTitle READ draftFeatureTitle NOTIFY hasDraftChanged ) public: + enum DraftStage + { + GeometryCapture, + AttributeForm + }; + Q_ENUM( DraftStage ) + explicit FeatureDraftController( QObject *parent = nullptr ); ~FeatureDraftController() override = default; bool hasDraft() const; QString draftLayerName() const; QgsVectorLayer *draftLayer() const; - QString draftStage() const; - bool draftIsEdit() const; + DraftStage draftStage() const; + bool draftIsExistingFeature() const; QString draftFeatureTitle() const; - // Rebuilds the draft as a FeatureLayerPair, geometry/attributes overlaid. Draft - // stays in storage - the resumed form clears or updates it as usual. + //! Rebuilds the draft as a FeatureLayerPair, geometry/attributes overlaid Q_INVOKABLE FeatureLayerPair resumeDraft(); - // For a new-feature geometry-capture draft: returns just the geometry, to feed - // into RecordingMapTool::resumeCapture() instead of opening the form. - Q_INVOKABLE QgsGeometry resumeGeometryDraft(); - //! Permanently discards the pending draft for the currently active project Q_INVOKABLE void discardDraft(); @@ -79,20 +80,24 @@ class FeatureDraftController : public QObject private: //! Resolves the layer the given draft belongs to, or nullptr if it no longer exists - QgsVectorLayer *resolveDraftLayer( const QJsonObject &draft ) const; + static QgsVectorLayer *resolveDraftLayer( const FeatureDraft &draft ); // Guards: not older than 10 days, referenced fields still match the layer's // schema, and (for an edit-mode draft) the feature still exists. - bool isDraftValid( const QJsonObject &draft, QgsVectorLayer *layer ) const; + static bool isDraftValid( const FeatureDraft &draft, QgsVectorLayer *layer ); - void setDraft( bool hasDraft, QgsVectorLayer *layer = nullptr, const QString &stage = QString(), bool isEdit = false, const QString &featureTitle = QString() ); + static DraftStage toQmlStage( FeatureDraft::Stage stage ); + + void setDraft( bool hasDraft, QgsVectorLayer *layer = nullptr, DraftStage stage = AttributeForm, bool isExistingFeature = false, const QString &featureTitle = QString() ); bool mHasDraft = false; QString mDraftLayerName; QgsVectorLayer *mDraftLayer = nullptr; // not owned - QString mDraftStage; - bool mDraftIsEdit = false; + DraftStage mDraftStage = AttributeForm; + bool mDraftIsExistingFeature = false; QString mDraftFeatureTitle; + + FeatureDraft mCachedDraft; // loaded once in checkForDraft(), reused by resumeDraft()/discardDraft() }; #endif // FEATUREDRAFTCONTROLLER_H diff --git a/app/drafts/featuredraftstorage.cpp b/app/drafts/featuredraftstorage.cpp index 73da9b71c..93026e662 100644 --- a/app/drafts/featuredraftstorage.cpp +++ b/app/drafts/featuredraftstorage.cpp @@ -9,48 +9,142 @@ #include "featuredraftstorage.h" #include "coreutils.h" +#include "merginprojectmetadata.h" #include #include +#include -const QString FeatureDraftStorage::QSETTINGS_DRAFTS_GROUP_NAME = QStringLiteral( "featureDrafts" ); +const QString QSETTINGS_DRAFTS_GROUP_NAME = QStringLiteral( "featureDrafts" ); -void FeatureDraftStorage::saveDraft( const QString &projectId, const QJsonObject &draft ) +QHash FeatureDraftStorage::sProjectKeyCache; + +void FeatureDraftStorage::saveDraft( const QString &projectDir, const FeatureDraft &draft ) { QSettings settings; settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); - settings.setValue( settingsKey( projectId ), QJsonDocument( draft ).toJson( QJsonDocument::Compact ) ); + settings.setValue( settingsKey( projectDir ), QJsonDocument( toJson( draft ) ).toJson( QJsonDocument::Compact ) ); settings.endGroup(); // explicit flush - a draft must survive a crash, not just a normal exit settings.sync(); } -QJsonObject FeatureDraftStorage::loadDraft( const QString &projectId ) +FeatureDraft FeatureDraftStorage::loadDraft( const QString &projectDir ) { QSettings settings; settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); - const QByteArray raw = settings.value( settingsKey( projectId ) ).toByteArray(); + const QByteArray raw = settings.value( settingsKey( projectDir ) ).toByteArray(); settings.endGroup(); if ( raw.isEmpty() ) { - return QJsonObject(); + return {}; } - return QJsonDocument::fromJson( raw ).object(); + return fromJson( QJsonDocument::fromJson( raw ).object() ); } -void FeatureDraftStorage::clearDraft( const QString &projectId ) +void FeatureDraftStorage::clearDraft( const QString &projectDir ) { QSettings settings; settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); - settings.remove( settingsKey( projectId ) ); + settings.remove( settingsKey( projectDir ) ); settings.endGroup(); settings.sync(); } -QString FeatureDraftStorage::settingsKey( const QString &projectId ) +void FeatureDraftStorage::clearCache() +{ + sProjectKeyCache.clear(); +} + +QString FeatureDraftStorage::projectKey( const QString &projectDir ) +{ + const auto cached = sProjectKeyCache.constFind( projectDir ); + if ( cached != sProjectKeyCache.constEnd() ) + { + return cached.value(); + } + + const QString merginId = MerginProjectMetadata::fromCachedJson( CoreUtils::getProjectMetadataPath( projectDir ) ).projectId; + const QString key = merginId.isEmpty() ? projectDir : merginId; + + sProjectKeyCache.insert( projectDir, key ); + return key; +} + +QString FeatureDraftStorage::settingsKey( const QString &projectDir ) +{ + return QSETTINGS_DRAFTS_GROUP_NAME + "/" + projectKey( projectDir ); +} + +QJsonObject FeatureDraftStorage::toJson( const FeatureDraft &draft ) +{ + QJsonObject json; + json[ QStringLiteral( "layerId" ) ] = draft.layerId; + json[ QStringLiteral( "stage" ) ] = draft.stage == FeatureDraft::GeometryCapture + ? QStringLiteral( "geometryCapture" ) : QStringLiteral( "attributeForm" ); + json[ QStringLiteral( "timestamp" ) ] = draft.timestamp.toString( Qt::ISODate ); + + if ( !draft.geometry.isNull() ) + { + json[ QStringLiteral( "geometry" ) ] = draft.geometry.asWkt(); + } + + if ( draft.isExistingFeature() ) + { + json[ QStringLiteral( "featureId" ) ] = QJsonValue( draft.featureId ); + } + + QJsonArray attributes; + for ( const FeatureDraftAttribute &attribute : draft.attributes ) + { + QJsonObject attributeJson; + attributeJson[ QStringLiteral( "name" ) ] = attribute.name; + attributeJson[ QStringLiteral( "type" ) ] = attribute.typeName; + attributeJson[ QStringLiteral( "value" ) ] = QJsonValue::fromVariant( attribute.value ); + attributes.append( attributeJson ); + } + json[ QStringLiteral( "attributes" ) ] = attributes; + + return json; +} + +FeatureDraft FeatureDraftStorage::fromJson( const QJsonObject &json ) { - return QSETTINGS_DRAFTS_GROUP_NAME + "/" + projectId; + if ( json.isEmpty() ) + { + return {}; + } + + FeatureDraft draft; + draft.layerId = json.value( QStringLiteral( "layerId" ) ).toString(); + draft.stage = json.value( QStringLiteral( "stage" ) ).toString() == QLatin1String( "geometryCapture" ) + ? FeatureDraft::GeometryCapture : FeatureDraft::AttributeForm; + draft.timestamp = QDateTime::fromString( json.value( QStringLiteral( "timestamp" ) ).toString(), Qt::ISODate ); + + const QString wkt = json.value( QStringLiteral( "geometry" ) ).toString(); + if ( !wkt.isEmpty() ) + { + draft.geometry = QgsGeometry::fromWkt( wkt ); + } + + if ( json.contains( QStringLiteral( "featureId" ) ) ) + { + draft.featureId = json.value( QStringLiteral( "featureId" ) ).toVariant().toLongLong(); + } + + const QJsonArray attributes = json.value( QStringLiteral( "attributes" ) ).toArray(); + for ( const auto &attributeValue : attributes ) + { + const QJsonObject attributeJson = attributeValue.toObject(); + draft.attributes.append( { + attributeJson.value( QStringLiteral( "name" ) ).toString(), + attributeJson.value( QStringLiteral( "type" ) ).toString(), + attributeJson.value( QStringLiteral( "value" ) ).toVariant() + } ); + } + + return draft; } diff --git a/app/drafts/featuredraftstorage.h b/app/drafts/featuredraftstorage.h index 67f4f04c8..c845d0bd8 100644 --- a/app/drafts/featuredraftstorage.h +++ b/app/drafts/featuredraftstorage.h @@ -10,30 +10,43 @@ #ifndef FEATUREDRAFTSTORAGE_H #define FEATUREDRAFTSTORAGE_H -#include +#include #include -// Saves/loads/clears an in-progress feature edit ("draft") per project. Stores -// a JSON blob - knows nothing about what's inside it or when it's valid. +#include "featuredraft.h" + +// Saves/loads/clears an in-progress feature edit ("draft") per project. The only +// class that knows a draft is stored as a QSettings-held JSON blob - everyone +// else works with the plain FeatureDraft struct. class FeatureDraftStorage { public: explicit FeatureDraftStorage() = default; ~FeatureDraftStorage() = default; - // Persists the draft payload for the given project, replacing any previous draft for it. - static void saveDraft( const QString &projectId, const QJsonObject &draft ); + // Persists the draft for the given project, replacing any previous draft for it. + static void saveDraft( const QString &projectDir, const FeatureDraft &draft ); - // Returns the stored draft payload for the given project, or an empty object if none exists. - static QJsonObject loadDraft( const QString &projectId ); + // Returns the stored draft for the given project, or a default (empty) FeatureDraft if none exists. + static FeatureDraft loadDraft( const QString &projectDir ); // Removes the stored draft for the given project, if any. - static void clearDraft( const QString &projectId ); + static void clearDraft( const QString &projectDir ); + + // Forgets cached projectDir -> storage key lookups. Call whenever the active project changes. + static void clearCache(); private: - static QString settingsKey( const QString &projectId ); + static QJsonObject toJson( const FeatureDraft &draft ); + static FeatureDraft fromJson( const QJsonObject &json ); + + // Mergin project id when the project is server-linked, otherwise projectDir itself. + // Resolving this reads a file from disk, so results are cached by projectDir. + static QString projectKey( const QString &projectDir ); + + static QString settingsKey( const QString &projectDir ); - static const QString QSETTINGS_DRAFTS_GROUP_NAME; + static QHash sProjectKeyCache; }; #endif // FEATUREDRAFTSTORAGE_H diff --git a/app/main.cpp b/app/main.cpp index 54bae9f61..4b66ab166 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -72,6 +72,7 @@ #include "qgsunittypes.h" #include "mmstyle.h" #include "notificationmodel.h" +#include "drafts/featuredraftcontroller.h" #include "rememberattributescontroller.h" #include "attributecontroller.h" @@ -284,6 +285,7 @@ void initDeclarative() qRegisterMetaType< FeatureLayerPair * >( "FeatureLayerPair*" ); qRegisterMetaType< AttributeController * >( "AttributeController*" ); qmlRegisterUncreatableType( "mm", 1, 0, "NotificationType", "NotificationType Enum" ); + qmlRegisterUncreatableType( "mm", 1, 0, "FeatureDraftController", "FeatureDraftController Enum" ); qRegisterMetaType< QList >( "QList" ); qRegisterMetaType< QgsAttributes > ( "QgsAttributes" ); diff --git a/app/maptools/recordingmaptool.cpp b/app/maptools/recordingmaptool.cpp index 628d625e5..0c0395086 100644 --- a/app/maptools/recordingmaptool.cpp +++ b/app/maptools/recordingmaptool.cpp @@ -27,13 +27,11 @@ #include #include #include -#include #include "qgsproject.h" RecordingMapTool::RecordingMapTool( QObject *parent ) : AbstractMapTool{parent} - , mDraftSaveTimer( new QTimer( this ) ) { connect( this, &RecordingMapTool::activeFeatureChanged, this, &RecordingMapTool::prepareEditing ); connect( this, &RecordingMapTool::recordedGeometryChanged, this, &RecordingMapTool::completeEditOperation ); @@ -42,12 +40,12 @@ RecordingMapTool::RecordingMapTool( QObject *parent ) connect( this, &RecordingMapTool::activeVertexChanged, this, &RecordingMapTool::updateActiveVertexGeometry ); connect( this, &RecordingMapTool::stateChanged, this, &RecordingMapTool::updateVisibleItems ); - mDraftSaveTimer->setSingleShot( true ); - mDraftSaveTimer->setInterval( 1000 ); - connect( mDraftSaveTimer, &QTimer::timeout, this, &RecordingMapTool::saveDraft ); + mDraftSaveTimer.setSingleShot( true ); + mDraftSaveTimer.setInterval( 1000 ); + connect( &mDraftSaveTimer, &QTimer::timeout, this, &RecordingMapTool::saveDraft ); connect( this, &RecordingMapTool::recordedGeometryChanged, this, [ this ]() { - mDraftSaveTimer->start(); + mDraftSaveTimer.start(); } ); } @@ -1097,7 +1095,7 @@ void RecordingMapTool::releaseVertex( const QgsPoint &point ) FeatureLayerPair RecordingMapTool::getFeatureLayerPair() { - mDraftSaveTimer->stop(); + mDraftSaveTimer.stop(); saveDraft(); bool featureIsValid = FID_IS_NEW( mActiveFeature.id() ) || mActiveFeature.isValid(); @@ -1172,16 +1170,16 @@ void RecordingMapTool::saveDraft() return; } - QJsonObject draft; - draft[ QStringLiteral( "layerId" ) ] = mActiveLayer->id(); - draft[ QStringLiteral( "stage" ) ] = QStringLiteral( "geometryCapture" ); - draft[ QStringLiteral( "timestamp" ) ] = QDateTime::currentDateTimeUtc().toString( Qt::ISODate ); - draft[ QStringLiteral( "geometry" ) ] = mRecordedGeometry.asWkt(); + FeatureDraft draft; + draft.layerId = mActiveLayer->id(); + draft.stage = FeatureDraft::GeometryCapture; + draft.timestamp = QDateTime::currentDateTimeUtc(); + draft.geometry = mRecordedGeometry; if ( isExistingFeature ) { // editing the geometry of an already-existing feature - draft[ QStringLiteral( "featureId" ) ] = QJsonValue( static_cast( mActiveFeature.id() ) ); + draft.featureId = mActiveFeature.id(); } FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); @@ -1189,7 +1187,7 @@ void RecordingMapTool::saveDraft() void RecordingMapTool::clearDraft() { - mDraftSaveTimer->stop(); + mDraftSaveTimer.stop(); FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); } diff --git a/app/maptools/recordingmaptool.h b/app/maptools/recordingmaptool.h index 559e50d60..6e3a46a30 100644 --- a/app/maptools/recordingmaptool.h +++ b/app/maptools/recordingmaptool.h @@ -13,6 +13,7 @@ #include "abstractmaptool.h" #include +#include #include #include "qgsvertexid.h" @@ -24,7 +25,6 @@ class PositionKit; class QgsVectorLayer; -class QTimer; class Vertex { @@ -388,7 +388,7 @@ class RecordingMapTool : public AbstractMapTool int mMinUndoStackIndex = 0; // We can not undo more than this index - QTimer *mDraftSaveTimer = nullptr; // owned by this, debounces saveDraft() + QTimer mDraftSaveTimer; // debounces saveDraft() }; #endif // RECORDINGMAPTOOL_H diff --git a/app/notificationmodel.cpp b/app/notificationmodel.cpp index 6c43b0577..0d4e14fb9 100644 --- a/app/notificationmodel.cpp +++ b/app/notificationmodel.cpp @@ -161,10 +161,10 @@ void NotificationModel::onNotificationClicked( uint id ) emit showSyncFailedDialogClicked(); break; } - case NotificationType::ActionType::OpenDraftAction: + case NotificationType::ActionType::ShowDraftAction: { remove( id ); - emit openDraftActionClicked(); + emit showDraftActionClicked(); break; } default: break; diff --git a/app/notificationmodel.h b/app/notificationmodel.h index 51d8efca3..e0a91d456 100644 --- a/app/notificationmodel.h +++ b/app/notificationmodel.h @@ -44,7 +44,7 @@ class NotificationType ShowProjectIssuesAction, ShowSwitchWorkspaceAction, ShowSyncFailedDialog, - OpenDraftAction + ShowDraftAction }; Q_ENUM( ActionType ) @@ -114,7 +114,7 @@ class NotificationModel : public QAbstractListModel void showProjectIssuesActionClicked(); void showSwitchWorkspaceActionClicked(); void showSyncFailedDialogClicked(); - void openDraftActionClicked(); + void showDraftActionClicked(); private: void add( const QString &message, uint interval, NotificationType::MessageType type = NotificationType::Information, NotificationType::IconType icon = NotificationType::NoneIcon, NotificationType::ActionType action = NotificationType::ActionType::NoAction ); diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index 4335dab7f..8c502ed44 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -29,6 +29,7 @@ set(MM_QML components/MMIconCheckBoxHorizontal.qml components/MMInfoBox.qml components/MMLine.qml + components/MMListBanner.qml components/MMListDelegate.qml components/MMListDrawer.qml components/MMListFooterSpacer.qml @@ -65,7 +66,6 @@ set(MM_QML components/private/MMToolbarLongButton.qml components/private/MMToolbarShortButton.qml filters/MMFiltersDrawer.qml - filters/components/MMFilterBanner.qml filters/components/MMFilterBoolInput.qml filters/components/MMFilterDateInput.qml filters/components/MMFilterDropdownValueMapInput.qml diff --git a/app/qml/filters/components/MMFilterBanner.qml b/app/qml/components/MMListBanner.qml similarity index 72% rename from app/qml/filters/components/MMFilterBanner.qml rename to app/qml/components/MMListBanner.qml index a549fca0d..320a80d70 100644 --- a/app/qml/filters/components/MMFilterBanner.qml +++ b/app/qml/components/MMListBanner.qml @@ -10,19 +10,21 @@ import QtQuick import QtQuick.Layouts -import "../../components" as MMComponents +import "." Rectangle { id: root + enum Variant { Informative, Warning } + + property int variant: MMListBanner.Informative + property string text property string actionText: "" - property alias actionButton: actionButton - signal actionClicked() - color: __style.informativeColor + color: variant === MMListBanner.Warning ? __style.warningColor : __style.informativeColor radius: __style.radius8 implicitHeight: row.implicitHeight + 2 * __style.margin8 @@ -39,7 +41,7 @@ Rectangle { spacing: __style.spacing10 - MMComponents.MMText { + MMText { Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter @@ -50,19 +52,19 @@ Rectangle { elide: Text.ElideNone } - MMComponents.MMButton { + MMButton { id: actionButton visible: root.actionText !== "" Layout.alignment: Qt.AlignVCenter - type: MMComponents.MMButton.Types.Tertiary - size: MMComponents.MMButton.Sizes.ExtraSmall + type: MMButton.Types.Tertiary + size: MMButton.Sizes.ExtraSmall text: root.actionText - fontColor: __style.skyColor - bgndColor: __style.deepOceanColor - bgndColorHover: __style.deepOceanColor - fontColorHover: __style.skyColor + fontColor: root.variant === MMListBanner.Warning ? __style.polarColor : __style.skyColor + bgndColor: root.variant === MMListBanner.Warning ? __style.earthColor : __style.deepOceanColor + bgndColorHover: bgndColor + fontColorHover: fontColor onClicked: root.actionClicked() } diff --git a/app/qml/dialogs/MMDiscardDraftDialog.qml b/app/qml/dialogs/MMDiscardDraftDialog.qml index 60934fae7..3f7810944 100644 --- a/app/qml/dialogs/MMDiscardDraftDialog.qml +++ b/app/qml/dialogs/MMDiscardDraftDialog.qml @@ -30,12 +30,12 @@ MMDrawerDialog { secondaryButton.text: qsTr( "Do not discard" ) - onPrimaryButtonClicked: { + onPrimaryButtonClicked: () => { root.discardDraft() close() } - onSecondaryButtonClicked: { + onSecondaryButtonClicked: () => { close() } } diff --git a/app/qml/dialogs/MMResumeDraftDialog.qml b/app/qml/dialogs/MMResumeDraftDialog.qml index 7bbee582f..c35742dda 100644 --- a/app/qml/dialogs/MMResumeDraftDialog.qml +++ b/app/qml/dialogs/MMResumeDraftDialog.qml @@ -21,7 +21,7 @@ MMDrawerDialog { signal discardClicked() imageSource: __style.neutralMMSymbolImage - title: featureTitle !== "" + title: featureTitle ? qsTr( "You have unsaved changes on feature %1" ).arg( featureTitle ) : qsTr( "You have unsaved changes on a new feature" ) description: qsTr( "The app closed before saving your changes on %1, click resume to start editing them again. If not, click discard." ).arg( layerName ) @@ -38,12 +38,12 @@ MMDrawerDialog { secondaryButton.fontColor: __style.earthColor secondaryButton.fontColorHover: __style.earthColor - onPrimaryButtonClicked: { + onPrimaryButtonClicked: () => { root.resumeClicked() close() } - onSecondaryButtonClicked: { + onSecondaryButtonClicked: () => { root.discardClicked() close() } diff --git a/app/qml/form/MMFormController.qml b/app/qml/form/MMFormController.qml index 10285b31e..4f8391b28 100644 --- a/app/qml/form/MMFormController.qml +++ b/app/qml/form/MMFormController.qml @@ -174,10 +174,8 @@ Item { onOpenFormClicked: root.panelState = "form" - onEditClicked: { + onEditClicked: () => { if ( __activeProject.featureDraftController.hasDraft ) { - // only one drawer should ever be active at a time - root.closeDrawer() resumeDraftDialog.open() } else { @@ -264,8 +262,8 @@ Item { featureTitle: __activeProject.featureDraftController.draftFeatureTitle layerName: __activeProject.featureDraftController.draftLayerName - onResumeClicked: root.resumeDraft() - onDiscardClicked: discardDraftDialog.open() + onResumeClicked: () => root.resumeDraft() + onDiscardClicked: () => discardDraftDialog.open() } MMDiscardDraftDialog { @@ -273,7 +271,7 @@ Item { layerName: __activeProject.featureDraftController.draftLayerName - onDiscardDraft: { + onDiscardDraft: () => { __activeProject.featureDraftController.discardDraft() } } diff --git a/app/qml/form/MMFormStackController.qml b/app/qml/form/MMFormStackController.qml index d0bc7bc83..e924120b5 100644 --- a/app/qml/form/MMFormStackController.qml +++ b/app/qml/form/MMFormStackController.qml @@ -316,7 +316,7 @@ Item { onEditGeometry: function( pair ) { root.editGeometryRequested( pair ) } - onResumeDraft: { + onResumeDraft: () => { root.resumeDraftRequested() } onOpenLinkedFeature: function( linkedFeature ) { diff --git a/app/qml/form/components/MMFeaturesListPageDrawer.qml b/app/qml/form/components/MMFeaturesListPageDrawer.qml index b463a687f..5f2d8d474 100644 --- a/app/qml/form/components/MMFeaturesListPageDrawer.qml +++ b/app/qml/form/components/MMFeaturesListPageDrawer.qml @@ -13,7 +13,6 @@ import QtQuick.Layouts import "../../inputs" as MMInputs import "../../components" as MMComponents -import "../../filters/components" as MMFilters // // Special type of drawer/page -> looks like page, but it is actually Drawer @@ -82,7 +81,7 @@ Drawer { MMComponents.MMListSpacer { Layout.preferredHeight: __style.spacing20 } - MMFilters.MMFilterBanner { + MMComponents.MMListBanner { id: filterBanner visible: __activeProject.filterController?.hasActiveFilterOnLayer( listView.model?.layer?.id ) diff --git a/app/qml/layers/MMFeaturesListPage.qml b/app/qml/layers/MMFeaturesListPage.qml index cfad43880..2dd35c30b 100644 --- a/app/qml/layers/MMFeaturesListPage.qml +++ b/app/qml/layers/MMFeaturesListPage.qml @@ -15,7 +15,6 @@ import mm 1.0 as MM import "../inputs" import "../components" as MMComponents -import "../filters/components" as MMFilterComponents MMComponents.MMPage { id: root @@ -46,80 +45,76 @@ MMComponents.MMPage { onSearchTextChanged: featuresModel.searchExpression = searchBar.text } - MMFilterComponents.MMFilterBanner { - id: draftBanner + ColumnLayout { + id: contentColumn anchors.top: searchBar.bottom anchors.topMargin: __style.spacing20 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom - width: parent.width + spacing: __style.spacing10 - visible: root.selectedLayer && __activeProject.featureDraftController.hasDraft && __activeProject.featureDraftController.draftLayer === root.selectedLayer + MMComponents.MMListBanner { + id: draftBanner - color: __style.warningColor - text: __activeProject.featureDraftController.draftIsEdit - ? qsTr( "Unsaved changes on %1" ).arg( __activeProject.featureDraftController.draftFeatureTitle ) - : qsTr( "There is an unsaved feature" ) - actionText: qsTr( "Resume" ) + Layout.fillWidth: true - actionButton.bgndColor: __style.earthColor - actionButton.bgndColorHover: __style.earthColor - actionButton.fontColor: "white" - actionButton.fontColorHover: "white" + visible: root.selectedLayer && __activeProject.featureDraftController.hasDraft && __activeProject.featureDraftController.draftLayer === root.selectedLayer - onActionClicked: root.resumeDraftClicked() - } + variant: MMComponents.MMListBanner.Warning + text: __activeProject.featureDraftController.draftIsExistingFeature + ? qsTr( "Unsaved changes on %1" ).arg( __activeProject.featureDraftController.draftFeatureTitle ) + : qsTr( "There is an unsaved feature" ) + actionText: qsTr( "Resume" ) - MMFilterComponents.MMFilterBanner { - id: filterBanner + onActionClicked: () => root.resumeDraftClicked() + } - anchors.top: draftBanner.visible ? draftBanner.bottom : searchBar.bottom - anchors.topMargin: draftBanner.visible ? __style.spacing10 : __style.spacing20 + MMComponents.MMListBanner { + id: filterBanner - width: parent.width + Layout.fillWidth: true - visible: root.selectedLayer && __activeProject.filterController?.filteringAvailable && __activeProject.filterController?.hasActiveFilterOnLayer(root.selectedLayer?.id) + visible: root.selectedLayer && __activeProject.filterController?.filteringAvailable && __activeProject.filterController?.hasActiveFilterOnLayer(root.selectedLayer?.id) - text: qsTr("Active filters applied") - actionText: qsTr("Reset") + text: qsTr("Active filters applied") + actionText: qsTr("Reset") - onActionClicked: { - __activeProject.filterController?.clearLayerFilters( root.selectedLayer.id ) - featuresModel.reloadFeatures() - visible = false + onActionClicked: () => { + __activeProject.filterController?.clearLayerFilters( root.selectedLayer.id ) + featuresModel.reloadFeatures() + visible = false + } } - } - MMComponents.MMListView { - id: listView + MMComponents.MMListView { + id: listView - width: parent.width + Layout.fillWidth: true + Layout.fillHeight: true - anchors { - top: filterBanner.visible ? filterBanner.bottom : ( draftBanner.visible ? draftBanner.bottom : searchBar.bottom ) - bottom: parent.bottom - topMargin: ( filterBanner.visible || draftBanner.visible ) ? __style.spacing10 : __style.spacing20 - } + model: MM.LayerFeaturesModel { + id: featuresModel - model: MM.LayerFeaturesModel { - id: featuresModel + useAttributeTableSortOrder: true + layer: root.selectedLayer + attributeList: __inputUtils.referencedAttributeIndexes( layer, layer.displayExpression ) + } - useAttributeTableSortOrder: true - layer: root.selectedLayer - attributeList: __inputUtils.referencedAttributeIndexes( layer, layer.displayExpression ) - } + clip: true - clip: true + delegate: MMComponents.MMListDelegate { + text: model.display?.toString()?.replace(/\n/g, ' ') ?? '' + secondaryText: model.Description + ( model.SearchResult ? ", " + model.SearchResult.replace(/\n/g, ' ') : "" ) - delegate: MMComponents.MMListDelegate { - text: model.display?.toString()?.replace(/\n/g, ' ') ?? '' - secondaryText: model.Description + ( model.SearchResult ? ", " + model.SearchResult.replace(/\n/g, ' ') : "" ) - - onClicked: root.featureClicked( model.FeaturePair ) - } + onClicked: root.featureClicked( model.FeaturePair ) + } - footer: MMComponents.MMListSpacer { - height: __style.margin20 + ( root.hasToolbar ? 0 : __style.safeAreaBottom ) + ( addButton.visible ? addButton.height : 0 ) + footer: MMComponents.MMListSpacer { + height: __style.margin20 + ( root.hasToolbar ? 0 : __style.safeAreaBottom ) + ( addButton.visible ? addButton.height : 0 ) + } } } diff --git a/app/qml/layers/MMLayerDetailPage.qml b/app/qml/layers/MMLayerDetailPage.qml index 34f219191..92ea5ac29 100644 --- a/app/qml/layers/MMLayerDetailPage.qml +++ b/app/qml/layers/MMLayerDetailPage.qml @@ -225,7 +225,7 @@ Page { root.addFeatureClicked( toLayer ) } - onResumeDraftClicked: { + onResumeDraftClicked: () => { root.resumeDraftClicked() } diff --git a/app/qml/layers/MMLayersController.qml b/app/qml/layers/MMLayersController.qml index 83ba67264..16b718f50 100644 --- a/app/qml/layers/MMLayersController.qml +++ b/app/qml/layers/MMLayersController.qml @@ -108,7 +108,7 @@ Item { item.forceActiveFocus() } - onResumeDraftClicked: { + onResumeDraftClicked: () => { root.resumeDraft() } } @@ -135,7 +135,7 @@ Item { root.addFeature( targetLayer ) } - onResumeDraftClicked: { + onResumeDraftClicked: () => { root.resumeDraft() } } diff --git a/app/qml/layers/MMLayersListPage.qml b/app/qml/layers/MMLayersListPage.qml index c638db057..d5688d874 100644 --- a/app/qml/layers/MMLayersListPage.qml +++ b/app/qml/layers/MMLayersListPage.qml @@ -9,10 +9,10 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import "../components" as MMComponents import "../inputs" -import "../filters/components" as MMFilterComponents MMComponents.MMPage { id: root @@ -51,53 +51,53 @@ MMComponents.MMPage { } } - MMFilterComponents.MMFilterBanner { - id: draftBanner + ColumnLayout { + id: contentColumn anchors.top: searchBar.bottom anchors.topMargin: __style.spacing20 - width: parent.width - - visible: __activeProject.featureDraftController.hasDraft + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom - color: __style.warningColor - text: qsTr( "%1 has unsaved changes" ).arg( __activeProject.featureDraftController.draftLayerName ) - actionText: qsTr( "Resume" ) + spacing: __style.spacing10 - actionButton.bgndColor: __style.earthColor - actionButton.bgndColorHover: __style.earthColor - actionButton.fontColor: "white" - actionButton.fontColorHover: "white" + MMComponents.MMListBanner { + id: draftBanner - onActionClicked: root.resumeDraftClicked() - } + Layout.fillWidth: true - MMLayersList { - id: layers + visible: __activeProject.featureDraftController.hasDraft - width: parent.width + variant: MMComponents.MMListBanner.Warning + text: qsTr( "%1 has unsaved changes" ).arg( __activeProject.featureDraftController.draftLayerName ) + actionText: qsTr( "Resume" ) - anchors { - top: draftBanner.visible ? draftBanner.bottom : searchBar.bottom - topMargin: draftBanner.visible ? __style.spacing10 : __style.spacing20 - bottom: parent.bottom + onActionClicked: () => root.resumeDraftClicked() } - clip: true + MMLayersList { + id: layers - basemodel: root.model - parentNodeIndex: root.parentNodeIndex + Layout.fillWidth: true + Layout.fillHeight: true - imageProviderPath: "image://LayerTreeModelPixmapProvider/" + clip: true - footer: MMComponents.MMListFooterSpacer {} + basemodel: root.model + parentNodeIndex: root.parentNodeIndex - onNodeClicked: function( node, nodeType, nodeName ) { - root.nodeClicked( node, nodeType, nodeName ) - } + imageProviderPath: "image://LayerTreeModelPixmapProvider/" + + footer: MMComponents.MMListFooterSpacer {} + + onNodeClicked: function( node, nodeType, nodeName ) { + root.nodeClicked( node, nodeType, nodeName ) + } - onNodeVisibilityClicked: function( node ) { - root.nodeVisibilityClicked( node ) + onNodeVisibilityClicked: function( node ) { + root.nodeVisibilityClicked( node ) + } } } } diff --git a/app/qml/main.qml b/app/qml/main.qml index e76fdd2f3..774ecb98e 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -32,6 +32,9 @@ import "./filters" ApplicationWindow { id: window + // action to run after a pending draft is discarded + property var draftDialogPendingAction: null + visible: true x: __appwindowx y: __appwindowy @@ -315,11 +318,12 @@ ApplicationWindow { text: qsTr("Add") iconSource: __style.addIcon visible: __activeProject.projectRole !== "reader" - onClicked: { + onClicked: () => { if ( __activeProject.projectHasRecordingLayers() ) { stateManager.state = "map" if ( __activeProject.featureDraftController.hasDraft ) { + window.draftDialogPendingAction = function() { map.record() } resumeDraftDialog.open() } else { @@ -519,6 +523,7 @@ ApplicationWindow { } if ( __activeProject.featureDraftController.hasDraft ) { + window.draftDialogPendingAction = startAdding resumeDraftDialog.open() } else { @@ -529,7 +534,7 @@ ApplicationWindow { // make sure to change the root state here to "map" } - onResumeDraft: { + onResumeDraft: () => { mapPanelsStackView.clear( StackView.PopTransition ) stateManager.state = "map" resumeFeatureDraft() @@ -838,7 +843,7 @@ ApplicationWindow { map.edit( pair ) } - onResumeDraftRequested: { + onResumeDraftRequested: () => { stateManager.state = "map" resumeFeatureDraft() } @@ -924,8 +929,14 @@ ApplicationWindow { layerName: __activeProject.featureDraftController.draftLayerName - onDiscardDraft: { + onDiscardDraft: () => { __activeProject.featureDraftController.discardDraft() + + if ( window.draftDialogPendingAction ) { + let action = window.draftDialogPendingAction + window.draftDialogPendingAction = null + action() + } } } @@ -935,8 +946,11 @@ ApplicationWindow { featureTitle: __activeProject.featureDraftController.draftFeatureTitle layerName: __activeProject.featureDraftController.draftLayerName - onResumeClicked: resumeFeatureDraft() - onDiscardClicked: discardDraftDialog.open() + onResumeClicked: () => { + window.draftDialogPendingAction = null + resumeFeatureDraft() + } + onDiscardClicked: () => discardDraftDialog.open() } MMOutOfDateCustomServerDialog{ @@ -1173,20 +1187,21 @@ ApplicationWindow { //! where the user left off - interactive geometry capture, or the form. function resumeFeatureDraft() { const controller = __activeProject.featureDraftController - const isEdit = controller.draftIsEdit + const isExistingFeature = controller.draftIsExistingFeature + const isGeometryCapture = controller.draftStage === MM.FeatureDraftController.GeometryCapture + const layer = controller.draftLayer + + const pair = controller.resumeDraft() // only a brand new feature needs interactive resume - an existing feature // always has a form to reopen, geometry already included - if ( controller.draftStage === "geometryCapture" && !isEdit ) { - const layer = controller.draftLayer - const geometry = controller.resumeGeometryDraft() - map.resumeRecording( layer, geometry ) + if ( isGeometryCapture && !isExistingFeature ) { + map.resumeRecording( layer, __inputUtils.extractGeometry( pair ) ) } else { - const pair = controller.resumeDraft() - formsStackManager.openForm( pair, isEdit ? "edit" : "add", "form" ) + formsStackManager.openForm( pair, isExistingFeature ? "edit" : "add", "form" ) } - __notificationModel.addInfo( qsTr( "This is your unsaved changes, continue editing or discard them by navigating back." ) ) + __notificationModel.addInfo( qsTr( "These are your unsaved changes, continue editing or discard them by navigating back." ) ) } Connections { @@ -1209,7 +1224,7 @@ ApplicationWindow { function onShowSyncFailedDialogClicked() { syncFailedDialog.open() } - function onOpenDraftActionClicked() { + function onShowDraftActionClicked() { resumeFeatureDraft() } } @@ -1219,7 +1234,7 @@ ApplicationWindow { function onHasDraftChanged() { if ( __activeProject.featureDraftController.hasDraft && map.state === "view" ) { - __notificationModel.addDraftNotice( qsTr( "You have unsaved changes. Tap here to open them." ), MM.NotificationType.OpenDraftAction ) + __notificationModel.addDraftNotice( qsTr( "You have unsaved changes. Tap here to open them." ), MM.NotificationType.ShowDraftAction ) } } } diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 7d89fa889..e9bddb92b 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1336,6 +1336,11 @@ Item { __activeProject.setActiveLayer( layer ) state = "record" + if ( !geometry.isNull() ) { + let mapGeometry = __inputUtils.transformGeometryToMapWithLayer( geometry, layer, mapCanvas.mapSettings ) + __inputUtils.setExtentToGeom( mapGeometry, mapCanvas.mapSettings ) + } + // recordingToolsLoader only becomes active once state == "record" takes effect Qt.callLater( function() { if ( recordingToolsLoader.item ) { diff --git a/app/test/inputtests.cpp b/app/test/inputtests.cpp index 5fe25939e..5af8cfa33 100644 --- a/app/test/inputtests.cpp +++ b/app/test/inputtests.cpp @@ -31,6 +31,7 @@ #include "testprojectchecksumcache.h" #include "testmultieditmanager.h" #include "testfiltercontroller.h" +#include "testfeaturedraftcontroller.h" InputTests::InputTests() = default; @@ -211,6 +212,11 @@ int InputTests::runTest() const TestFilterController filterControllerTest; nFailed = QTest::qExec( &filterControllerTest, mTestArgs ); } + else if ( mTestRequested == "--testFeatureDraftController" ) + { + TestFeatureDraftController featureDraftControllerTest; + nFailed = QTest::qExec( &featureDraftControllerTest, mTestArgs ); + } else { qDebug() << "invalid test requested" << mTestRequested; diff --git a/app/test/testfeaturedraftcontroller.cpp b/app/test/testfeaturedraftcontroller.cpp new file mode 100644 index 000000000..847b05530 --- /dev/null +++ b/app/test/testfeaturedraftcontroller.cpp @@ -0,0 +1,218 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "testfeaturedraftcontroller.h" + +#include + +#include "qgsproject.h" +#include "qgsvectorlayer.h" + +#include "featuredraftcontroller.h" +#include "featuredraftstorage.h" +#include "featuredraft.h" + +namespace +{ + QgsVectorLayer *addTestLayer() + { + QgsVectorLayer *layer = new QgsVectorLayer( + QStringLiteral( "Point?field=fldtxt:string&field=fldint:integer" ), + QStringLiteral( "layer" ), + QStringLiteral( "memory" ) + ); + QgsProject::instance()->addMapLayer( layer ); + return layer; + } + + QgsFeatureId addTestFeature( QgsVectorLayer *layer, const QString &txt, int number ) + { + QgsFeature f( layer->fields() ); + f.setAttribute( QStringLiteral( "fldtxt" ), txt ); + f.setAttribute( QStringLiteral( "fldint" ), number ); + + QgsFeatureList features { f }; + layer->dataProvider()->addFeatures( features ); + return features.first().id(); + } +} + +void TestFeatureDraftController::init() +{ + FeatureDraftStorage::clearCache(); + FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); +} + +void TestFeatureDraftController::cleanup() +{ + FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); + QgsProject::instance()->clear(); +} + +void TestFeatureDraftController::validNewFeatureGeometryDraftDetected() +{ + QgsVectorLayer *layer = addTestLayer(); + + FeatureDraft draft; + draft.layerId = layer->id(); + draft.stage = FeatureDraft::GeometryCapture; + draft.timestamp = QDateTime::currentDateTimeUtc(); + draft.geometry = QgsGeometry::fromWkt( QStringLiteral( "Point (1 2)" ) ); + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); + + FeatureDraftController controller; + controller.checkForDraft(); + + QVERIFY( controller.hasDraft() ); + QCOMPARE( controller.draftStage(), FeatureDraftController::GeometryCapture ); + QVERIFY( !controller.draftIsExistingFeature() ); + QCOMPARE( controller.draftLayer(), layer ); + QVERIFY( controller.draftFeatureTitle().isEmpty() ); +} + +void TestFeatureDraftController::validExistingFeatureAttributeDraftDetected() +{ + QgsVectorLayer *layer = addTestLayer(); + const QgsFeatureId featureId = addTestFeature( layer, QStringLiteral( "one" ), 1 ); + + FeatureDraft draft; + draft.layerId = layer->id(); + draft.stage = FeatureDraft::AttributeForm; + draft.timestamp = QDateTime::currentDateTimeUtc(); + draft.featureId = featureId; + draft.attributes.append( { QStringLiteral( "fldtxt" ), QStringLiteral( "string" ), QStringLiteral( "two" ) } ); + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); + + FeatureDraftController controller; + controller.checkForDraft(); + + QVERIFY( controller.hasDraft() ); + QCOMPARE( controller.draftStage(), FeatureDraftController::AttributeForm ); + QVERIFY( controller.draftIsExistingFeature() ); +} + +void TestFeatureDraftController::expiredDraftIgnoredAndCleared() +{ + QgsVectorLayer *layer = addTestLayer(); + + FeatureDraft draft; + draft.layerId = layer->id(); + draft.stage = FeatureDraft::GeometryCapture; + draft.timestamp = QDateTime::currentDateTimeUtc().addDays( -11 ); // older than the 10-day guard + draft.geometry = QgsGeometry::fromWkt( QStringLiteral( "Point (1 2)" ) ); + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); + + FeatureDraftController controller; + controller.checkForDraft(); + + QVERIFY( !controller.hasDraft() ); + QVERIFY( FeatureDraftStorage::loadDraft( QgsProject::instance()->homePath() ).isEmpty() ); +} + +void TestFeatureDraftController::draftWithRemovedFieldIgnored() +{ + QgsVectorLayer *layer = addTestLayer(); + + FeatureDraft draft; + draft.layerId = layer->id(); + draft.stage = FeatureDraft::AttributeForm; + draft.timestamp = QDateTime::currentDateTimeUtc(); + draft.geometry = QgsGeometry::fromWkt( QStringLiteral( "Point (1 2)" ) ); + draft.attributes.append( { QStringLiteral( "doesNotExist" ), QStringLiteral( "string" ), QStringLiteral( "value" ) } ); + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); + + FeatureDraftController controller; + controller.checkForDraft(); + + QVERIFY( !controller.hasDraft() ); +} + +void TestFeatureDraftController::draftWithChangedFieldTypeIgnored() +{ + QgsVectorLayer *layer = addTestLayer(); + + FeatureDraft draft; + draft.layerId = layer->id(); + draft.stage = FeatureDraft::AttributeForm; + draft.timestamp = QDateTime::currentDateTimeUtc(); + draft.geometry = QgsGeometry::fromWkt( QStringLiteral( "Point (1 2)" ) ); + // fldint is actually an "integer" field - a mismatched recorded type means the schema changed since + draft.attributes.append( { QStringLiteral( "fldint" ), QStringLiteral( "string" ), QStringLiteral( "1" ) } ); + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); + + FeatureDraftController controller; + controller.checkForDraft(); + + QVERIFY( !controller.hasDraft() ); +} + +void TestFeatureDraftController::existingFeatureDraftRequiresLiveFeature() +{ + QgsVectorLayer *layer = addTestLayer(); + + FeatureDraft draft; + draft.layerId = layer->id(); + draft.stage = FeatureDraft::AttributeForm; + draft.timestamp = QDateTime::currentDateTimeUtc(); + draft.featureId = 999999; // no such feature was ever added to the layer + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); + + FeatureDraftController controller; + controller.checkForDraft(); + + QVERIFY( !controller.hasDraft() ); +} + +void TestFeatureDraftController::resumeDraftAppliesGeometryAndAttributesButKeepsStorage() +{ + QgsVectorLayer *layer = addTestLayer(); + + FeatureDraft draft; + draft.layerId = layer->id(); + draft.stage = FeatureDraft::AttributeForm; + draft.timestamp = QDateTime::currentDateTimeUtc(); + draft.geometry = QgsGeometry::fromWkt( QStringLiteral( "Point (1 2)" ) ); + draft.attributes.append( { QStringLiteral( "fldtxt" ), QStringLiteral( "string" ), QStringLiteral( "resumed" ) } ); + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); + + FeatureDraftController controller; + controller.checkForDraft(); + QVERIFY( controller.hasDraft() ); + + const FeatureLayerPair pair = controller.resumeDraft(); + + QCOMPARE( pair.layer(), layer ); + QCOMPARE( pair.feature().attribute( QStringLiteral( "fldtxt" ) ).toString(), QStringLiteral( "resumed" ) ); + QVERIFY( pair.feature().geometry().asWkt().contains( QStringLiteral( "1 2" ) ) ); + + // pending state is cleared, but the draft itself stays in storage until save/rollback clears it + QVERIFY( !controller.hasDraft() ); + QVERIFY( !FeatureDraftStorage::loadDraft( QgsProject::instance()->homePath() ).isEmpty() ); +} + +void TestFeatureDraftController::discardDraftClearsStorage() +{ + QgsVectorLayer *layer = addTestLayer(); + + FeatureDraft draft; + draft.layerId = layer->id(); + draft.stage = FeatureDraft::GeometryCapture; + draft.timestamp = QDateTime::currentDateTimeUtc(); + draft.geometry = QgsGeometry::fromWkt( QStringLiteral( "Point (1 2)" ) ); + FeatureDraftStorage::saveDraft( QgsProject::instance()->homePath(), draft ); + + FeatureDraftController controller; + controller.checkForDraft(); + QVERIFY( controller.hasDraft() ); + + controller.discardDraft(); + + QVERIFY( !controller.hasDraft() ); + QVERIFY( FeatureDraftStorage::loadDraft( QgsProject::instance()->homePath() ).isEmpty() ); +} diff --git a/app/test/testfeaturedraftcontroller.h b/app/test/testfeaturedraftcontroller.h new file mode 100644 index 000000000..2d8d44d26 --- /dev/null +++ b/app/test/testfeaturedraftcontroller.h @@ -0,0 +1,33 @@ +/*************************************************************************** + * * + * This program 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 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef TESTFEATUREDRAFTCONTROLLER_H +#define TESTFEATUREDRAFTCONTROLLER_H + +#include + +class TestFeatureDraftController : public QObject +{ + Q_OBJECT + + private slots: + void init(); + void cleanup(); + + void validNewFeatureGeometryDraftDetected(); + void validExistingFeatureAttributeDraftDetected(); + void expiredDraftIgnoredAndCleared(); + void draftWithRemovedFieldIgnored(); + void draftWithChangedFieldTypeIgnored(); + void existingFeatureDraftRequiresLiveFeature(); + void resumeDraftAppliesGeometryAndAttributesButKeepsStorage(); + void discardDraftClearsStorage(); +}; + +#endif // TESTFEATUREDRAFTCONTROLLER_H diff --git a/gallery/qml.qrc b/gallery/qml.qrc index ece26deed..b9cb6c38d 100644 --- a/gallery/qml.qrc +++ b/gallery/qml.qrc @@ -157,6 +157,8 @@ ../app/qml/dialogs/MMProjectLoadErrorDialog.qml ../app/qml/dialogs/MMDiscardGeometryChangesDialog.qml ../app/qml/dialogs/MMProviderRemoveReceiverDialog.qml + ../app/qml/dialogs/MMDiscardDraftDialog.qml + ../app/qml/dialogs/MMResumeDraftDialog.qml ../app/qml/dialogs/components/MMDialogAdditionalText.qml \ No newline at end of file diff --git a/gallery/qml/pages/DrawerPage.qml b/gallery/qml/pages/DrawerPage.qml index 47a0aab1f..d9ea47973 100644 --- a/gallery/qml/pages/DrawerPage.qml +++ b/gallery/qml/pages/DrawerPage.qml @@ -194,6 +194,22 @@ Page { welcomeToNewDesignDialog.open() } } + + Button { + text: "discardDraftDialog" + + onClicked: { + discardDraftDialog.open() + } + } + + Button { + text: "resumeDraftDialog" + + onClicked: { + resumeDraftDialog.open() + } + } } } @@ -368,4 +384,22 @@ Page { MMWelcomeToNewDesignDialog { id: welcomeToNewDesignDialog } + + MMDiscardDraftDialog { + id: discardDraftDialog + + layerName: "Trees" + + onDiscardDraft: console.log("on discard draft clicked") + } + + MMResumeDraftDialog { + id: resumeDraftDialog + + featureTitle: "Oak #42" + layerName: "Trees" + + onResumeClicked: console.log("on resume clicked") + onDiscardClicked: console.log("on discard clicked") + } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 19c5cdbf2..ea062e976 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -28,6 +28,7 @@ set(MM_TESTS testSketching testMultiEditManager testFilterController + testFeatureDraftController ) foreach (test ${MM_TESTS}) From 0a15ce85a20ed3d8eed419474ed45d54387d87dc Mon Sep 17 00:00:00 2001 From: Richard Kello Date: Wed, 23 Sep 2026 18:03:56 +0200 Subject: [PATCH 4/4] Update code layout --- app/drafts/featuredraftstorage.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/drafts/featuredraftstorage.cpp b/app/drafts/featuredraftstorage.cpp index 93026e662..d6f97b735 100644 --- a/app/drafts/featuredraftstorage.cpp +++ b/app/drafts/featuredraftstorage.cpp @@ -84,7 +84,7 @@ QJsonObject FeatureDraftStorage::toJson( const FeatureDraft &draft ) QJsonObject json; json[ QStringLiteral( "layerId" ) ] = draft.layerId; json[ QStringLiteral( "stage" ) ] = draft.stage == FeatureDraft::GeometryCapture - ? QStringLiteral( "geometryCapture" ) : QStringLiteral( "attributeForm" ); + ? QStringLiteral( "geometryCapture" ) : QStringLiteral( "attributeForm" ); json[ QStringLiteral( "timestamp" ) ] = draft.timestamp.toString( Qt::ISODate ); if ( !draft.geometry.isNull() ) @@ -139,7 +139,8 @@ FeatureDraft FeatureDraftStorage::fromJson( const QJsonObject &json ) for ( const auto &attributeValue : attributes ) { const QJsonObject attributeJson = attributeValue.toObject(); - draft.attributes.append( { + draft.attributes.append( + { attributeJson.value( QStringLiteral( "name" ) ).toString(), attributeJson.value( QStringLiteral( "type" ) ).toString(), attributeJson.value( QStringLiteral( "value" ) ).toVariant()