diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index ed910b431..4add3edf0 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,9 @@ set(MM_HDRS androidutils.h mapsketchingcontroller.h appsettings.h + drafts/featuredraft.h + drafts/featuredraftstorage.h + drafts/featuredraftcontroller.h autosynccontroller.h bluetoothdiscoverymodel.h qrcodedecoder.h @@ -255,6 +260,7 @@ if (ENABLE_TESTS) test/testprojectchecksumcache.cpp test/testmultieditmanager.cpp test/testfiltercontroller.cpp + test/testfeaturedraftcontroller.cpp ) set(MM_HDRS @@ -282,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) @@ -361,6 +368,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..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" @@ -78,6 +79,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; @@ -163,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 ) ); @@ -683,3 +690,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..5b115fdc7 100644 --- a/app/attributes/attributecontroller.cpp +++ b/app/attributes/attributecontroller.cpp @@ -20,6 +20,10 @@ #include #include +#include +#include + +#include "featuredraftstorage.h" #include "qgis.h" #include "qgsproject.h" @@ -42,6 +46,9 @@ AttributeController::AttributeController( QObject *parent ) : QObject( parent ) , mAttributeTabProxyModel( new AttributeTabProxyModel() ) { + mDraftSaveTimer.setSingleShot( true ); + mDraftSaveTimer.setInterval( 1000 ); + connect( &mDraftSaveTimer, &QTimer::timeout, this, &AttributeController::saveDraft ); } void AttributeController::reset() @@ -67,8 +74,23 @@ 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(); + + // draft immediately so a crash before the first keystroke still resumes into the form + if ( pair.layer() && isNewFeature() ) + { + saveDraft(); + } + } if ( hasLayerChanged ) { // layer changed! @@ -646,6 +668,57 @@ bool AttributeController::isNewFeature() const return FID_IS_NEW( id ) || FID_IS_NULL( id ); } +FeatureDraftAttribute AttributeController::toDraftAttribute( const QgsFields &fields, const QgsFeature &feature, int fieldIndex ) const +{ + return { fields.at( fieldIndex ).name(), fields.at( fieldIndex ).typeName(), feature.attribute( fieldIndex ) }; +} + +void AttributeController::saveDraft() +{ + if ( !mFeatureLayerPair.layer() ) + return; + + const QgsFeature feature = mFeatureLayerPair.feature(); + const QgsFields fields = feature.fields(); + const bool featureIsNew = isNewFeature(); + + FeatureDraft draft; + draft.layerId = mFeatureLayerPair.layer()->id(); + draft.stage = FeatureDraft::AttributeForm; + draft.timestamp = QDateTime::currentDateTimeUtc(); + + // 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 ) + { + if ( fieldIndex >= 0 && fieldIndex < feature.attributeCount() ) + { + draft.attributes.append( toDraftAttribute( fields, feature, fieldIndex ) ); + } + } + + if ( featureIsNew ) + { + // existing-feature geometry edits are drafted separately, by RecordingMapTool + draft.geometry = feature.geometry(); + } + else + { + draft.featureId = 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 +1276,7 @@ bool AttributeController::deleteFeature() { mFeatureLayerPair = FeatureLayerPair(); emit featureLayerPairChanged(); + clearDraft(); emit changesCommited(); } @@ -1214,6 +1288,8 @@ bool AttributeController::rollback() if ( !mFeatureLayerPair.layer() ) return false; + clearDraft(); + if ( !mFeatureLayerPair.layer()->isEditable() ) { return false; @@ -1281,6 +1357,7 @@ bool AttributeController::save() if ( rv ) { + clearDraft(); emit changesCommited(); } else @@ -1509,6 +1586,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..438dca2b2 100644 --- a/app/attributes/attributecontroller.h +++ b/app/attributes/attributecontroller.h @@ -21,14 +21,17 @@ #include #include #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" @@ -188,6 +191,15 @@ class AttributeController : public QObject bool isNewFeature() const; + // Persists touched attributes as a draft, debounced. + void saveDraft(); + + //! Removes any persisted draft for the current project + void clearDraft(); + + //! 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 * Note that reevaluate default values is needed only when an attribnute has changed. @@ -249,5 +261,10 @@ class AttributeController : public QObject AttributeController *mParentController = nullptr; // not owned QgsRelation mLinkedRelation; + + QTimer mDraftSaveTimer; // debounces saveDraft() + + //! 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 new file mode 100644 index 000000000..a89bb7fb2 --- /dev/null +++ b/app/drafts/featuredraftcontroller.cpp @@ -0,0 +1,219 @@ +/*************************************************************************** + * * + * 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 "qgsproject.h" +#include "qgsvectorlayer.h" + +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; +} + +FeatureDraftController::DraftStage FeatureDraftController::draftStage() const +{ + return mDraftStage; +} + +bool FeatureDraftController::draftIsExistingFeature() const +{ + return mDraftIsExistingFeature; +} + +QString FeatureDraftController::draftFeatureTitle() const +{ + return mDraftFeatureTitle; +} + +void FeatureDraftController::checkForDraft() +{ + const QString projectId = QgsProject::instance()->homePath(); + const FeatureDraft draft = FeatureDraftStorage::loadDraft( projectId ); + + if ( draft.isEmpty() ) + { + mCachedDraft = FeatureDraft(); + setDraft( false ); + return; + } + + QgsVectorLayer *layer = resolveDraftLayer( draft ); + + if ( !layer || !isDraftValid( draft, layer ) ) + { + FeatureDraftStorage::clearDraft( projectId ); + mCachedDraft = FeatureDraft(); + setDraft( false ); + return; + } + + mCachedDraft = draft; + + QString featureTitle; + + if ( draft.isExistingFeature() ) + { + const QgsFeature feature = layer->getFeature( draft.featureId ); + featureTitle = InputUtils::featureTitle( FeatureLayerPair( feature, layer ), QgsProject::instance() ); + } + + setDraft( true, layer, toQmlStage( draft.stage ), draft.isExistingFeature(), featureTitle ); +} + +FeatureLayerPair FeatureDraftController::resumeDraft() +{ + if ( !mHasDraft ) + return {}; + + QgsVectorLayer *layer = mDraftLayer; + + // re-validated against the cached draft - state may have changed since checkForDraft() + if ( !layer || !isDraftValid( mCachedDraft, layer ) ) + { + FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); + mCachedDraft = FeatureDraft(); + setDraft( false ); + return {}; + } + + const FeatureDraft &draft = mCachedDraft; + + FeatureLayerPair pair; + + if ( draft.isExistingFeature() ) + { + // existing feature: start from the live one, then overlay the draft on top + pair = FeatureLayerPair( layer->getFeature( draft.featureId ), layer ); + + if ( !draft.geometry.isNull() ) + { + 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( draft.featureId, geometry ); + layer->triggerRepaint(); + } + } + else + { + const QgsGeometry geometry = draft.geometry.isNull() ? InputUtils::emptyGeometry() : draft.geometry; + pair = InputUtils::createFeatureLayerPair( layer, geometry, nullptr ); + } + + const QgsFields fields = layer->fields(); + + for ( const FeatureDraftAttribute &attribute : draft.attributes ) + { + const int fieldIndex = fields.indexOf( attribute.name ); + + if ( fieldIndex >= 0 ) + { + 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; +} + +void FeatureDraftController::discardDraft() +{ + if ( !mHasDraft ) + return; + + FeatureDraftStorage::clearDraft( QgsProject::instance()->homePath() ); + mCachedDraft = FeatureDraft(); + setDraft( false ); +} + +QgsVectorLayer *FeatureDraftController::resolveDraftLayer( const FeatureDraft &draft ) +{ + return qobject_cast( QgsProject::instance()->mapLayer( draft.layerId ) ); +} + +bool FeatureDraftController::isDraftValid( const FeatureDraft &draft, QgsVectorLayer *layer ) +{ + if ( !draft.timestamp.isValid() || draft.timestamp.secsTo( QDateTime::currentDateTimeUtc() ) > MAX_DRAFT_AGE_SECS ) + { + return false; + } + + const QgsFields fields = layer->fields(); + + for ( const FeatureDraftAttribute &attribute : draft.attributes ) + { + 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.typeName ) + { + return false; // field type changed since the draft was written + } + } + + if ( draft.isExistingFeature() && !layer->getFeature( draft.featureId ).isValid() ) + { + return false; // the feature this draft was editing no longer exists + } + + return true; +} + +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 || mDraftIsExistingFeature != isExistingFeature || mDraftFeatureTitle != featureTitle ) + { + mHasDraft = hasDraft; + mDraftLayer = layer; + mDraftLayerName = layerName; + mDraftStage = stage; + mDraftIsExistingFeature = isExistingFeature; + mDraftFeatureTitle = featureTitle; + emit hasDraftChanged(); + } +} diff --git a/app/drafts/featuredraftcontroller.h b/app/drafts/featuredraftcontroller.h new file mode 100644 index 000000000..7632ba9b8 --- /dev/null +++ b/app/drafts/featuredraftcontroller.h @@ -0,0 +1,103 @@ +/*************************************************************************** + * * + * 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 "featurelayerpair.h" +#include "featuredraft.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 + 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 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; + DraftStage draftStage() const; + bool draftIsExistingFeature() const; + QString draftFeatureTitle() const; + + //! Rebuilds the draft as a FeatureLayerPair, geometry/attributes overlaid + Q_INVOKABLE FeatureLayerPair resumeDraft(); + + //! 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 + 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. + static bool isDraftValid( const FeatureDraft &draft, QgsVectorLayer *layer ); + + 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 + 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 new file mode 100644 index 000000000..d6f97b735 --- /dev/null +++ b/app/drafts/featuredraftstorage.cpp @@ -0,0 +1,151 @@ +/*************************************************************************** + * * + * 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 "merginprojectmetadata.h" + +#include +#include +#include + +const QString QSETTINGS_DRAFTS_GROUP_NAME = QStringLiteral( "featureDrafts" ); + +QHash FeatureDraftStorage::sProjectKeyCache; + +void FeatureDraftStorage::saveDraft( const QString &projectDir, const FeatureDraft &draft ) +{ + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + 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(); +} + +FeatureDraft FeatureDraftStorage::loadDraft( const QString &projectDir ) +{ + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + const QByteArray raw = settings.value( settingsKey( projectDir ) ).toByteArray(); + settings.endGroup(); + + if ( raw.isEmpty() ) + { + return {}; + } + + return fromJson( QJsonDocument::fromJson( raw ).object() ); +} + +void FeatureDraftStorage::clearDraft( const QString &projectDir ) +{ + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + settings.remove( settingsKey( projectDir ) ); + settings.endGroup(); + settings.sync(); +} + +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 ) +{ + 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 new file mode 100644 index 000000000..c845d0bd8 --- /dev/null +++ b/app/drafts/featuredraftstorage.h @@ -0,0 +1,52 @@ +/*************************************************************************** + * * + * 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 + +#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 for the given project, replacing any previous draft for it. + static void saveDraft( const QString &projectDir, const FeatureDraft &draft ); + + // 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 &projectDir ); + + // Forgets cached projectDir -> storage key lookups. Call whenever the active project changes. + static void clearCache(); + + private: + 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 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 4c69e06df..0c0395086 100644 --- a/app/maptools/recordingmaptool.cpp +++ b/app/maptools/recordingmaptool.cpp @@ -21,9 +21,14 @@ #include "position/positionkit.h" #include "coreutils.h" +#include "featuredraftstorage.h" #include #include +#include +#include + +#include "qgsproject.h" RecordingMapTool::RecordingMapTool( QObject *parent ) : AbstractMapTool{parent} @@ -34,6 +39,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 +1095,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 +1131,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; + } + + 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.featureId = 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..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" @@ -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; // debounces saveDraft() }; #endif // RECORDINGMAPTOOL_H diff --git a/app/notificationmodel.cpp b/app/notificationmodel.cpp index d5fc1c74a..0d4e14fb9 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::ShowDraftAction: + { + remove( id ); + emit showDraftActionClicked(); + break; + } default: break; } } diff --git a/app/notificationmodel.h b/app/notificationmodel.h index 91711029c..e0a91d456 100644 --- a/app/notificationmodel.h +++ b/app/notificationmodel.h @@ -43,7 +43,8 @@ class NotificationType NoAction, ShowProjectIssuesAction, ShowSwitchWorkspaceAction, - ShowSyncFailedDialog + ShowSyncFailedDialog, + ShowDraftAction }; 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 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 83ef85a09..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 @@ -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/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 86dd6a6e9..320a80d70 100644 --- a/app/qml/filters/components/MMFilterBanner.qml +++ b/app/qml/components/MMListBanner.qml @@ -10,17 +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: "" signal actionClicked() - color: __style.informativeColor + color: variant === MMListBanner.Warning ? __style.warningColor : __style.informativeColor radius: __style.radius8 implicitHeight: row.implicitHeight + 2 * __style.margin8 @@ -37,7 +41,7 @@ Rectangle { spacing: __style.spacing10 - MMComponents.MMText { + MMText { Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter @@ -48,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 new file mode 100644 index 000000000..3f7810944 --- /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..c35742dda --- /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/form/MMFormController.qml b/app/qml/form/MMFormController.qml index 6b098ac52..4f8391b28 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 ) @@ -172,9 +174,14 @@ Item { onOpenFormClicked: root.panelState = "form" - onEditClicked: { - root.panelState = "form" - featureForm.state = "edit" + onEditClicked: () => { + if ( __activeProject.featureDraftController.hasDraft ) { + resumeDraftDialog.open() + } + else { + root.panelState = "form" + featureForm.state = "edit" + } } onCloseClicked: drawer.close() @@ -248,4 +255,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..e924120b5 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/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 3d8e03970..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 @@ -26,6 +25,7 @@ MMComponents.MMPage { signal featureClicked( var featurePair ) signal addFeatureClicked( var toLayer ) + signal resumeDraftClicked() pageHeader.title: root.selectedLayer ? root.selectedLayer.name + " (" + featuresModel.layerFeaturesCount + ")": "" pageBottomMargin: 0 @@ -45,56 +45,76 @@ MMComponents.MMPage { onSearchTextChanged: featuresModel.searchExpression = searchBar.text } - MMFilterComponents.MMFilterBanner { - id: filterBanner + 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 + + MMComponents.MMListBanner { + id: draftBanner - visible: root.selectedLayer && __activeProject.filterController?.filteringAvailable && __activeProject.filterController?.hasActiveFilterOnLayer(root.selectedLayer?.id) + Layout.fillWidth: true - text: qsTr("Active filters applied") - actionText: qsTr("Reset") + visible: root.selectedLayer && __activeProject.featureDraftController.hasDraft && __activeProject.featureDraftController.draftLayer === root.selectedLayer - onActionClicked: { - __activeProject.filterController?.clearLayerFilters( root.selectedLayer.id ) - featuresModel.reloadFeatures() - visible = false + 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" ) + + onActionClicked: () => root.resumeDraftClicked() } - } - MMComponents.MMListView { - id: listView + MMComponents.MMListBanner { + id: filterBanner - width: parent.width + Layout.fillWidth: true - anchors { - top: filterBanner.visible ? filterBanner.bottom : searchBar.bottom - bottom: parent.bottom - topMargin: filterBanner.visible ? __style.spacing10 : __style.spacing20 - } + visible: root.selectedLayer && __activeProject.filterController?.filteringAvailable && __activeProject.filterController?.hasActiveFilterOnLayer(root.selectedLayer?.id) - model: MM.LayerFeaturesModel { - id: featuresModel + text: qsTr("Active filters applied") + actionText: qsTr("Reset") - useAttributeTableSortOrder: true - layer: root.selectedLayer - attributeList: __inputUtils.referencedAttributeIndexes( layer, layer.displayExpression ) + onActionClicked: () => { + __activeProject.filterController?.clearLayerFilters( root.selectedLayer.id ) + featuresModel.reloadFeatures() + visible = false + } } - clip: true + MMComponents.MMListView { + id: listView - delegate: MMComponents.MMListDelegate { - text: model.display?.toString()?.replace(/\n/g, ' ') ?? '' - secondaryText: model.Description + ( model.SearchResult ? ", " + model.SearchResult.replace(/\n/g, ' ') : "" ) + Layout.fillWidth: true + Layout.fillHeight: true - onClicked: root.featureClicked( model.FeaturePair ) - } + model: MM.LayerFeaturesModel { + id: featuresModel + + useAttributeTableSortOrder: true + layer: root.selectedLayer + attributeList: __inputUtils.referencedAttributeIndexes( layer, layer.displayExpression ) + } + + clip: true + + 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 ) + } - 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 9f29d5dc8..92ea5ac29 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..16b718f50 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..d5688d874 100644 --- a/app/qml/layers/MMLayersListPage.qml +++ b/app/qml/layers/MMLayersListPage.qml @@ -9,6 +9,7 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import "../components" as MMComponents import "../inputs" @@ -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,32 +51,53 @@ MMComponents.MMPage { } } - MMLayersList { - id: layers + ColumnLayout { + id: contentColumn - width: parent.width + anchors.top: searchBar.bottom + anchors.topMargin: __style.spacing20 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom - anchors { - top: searchBar.bottom - topMargin: __style.spacing20 - bottom: parent.bottom - } + spacing: __style.spacing10 - clip: true + MMComponents.MMListBanner { + id: draftBanner - basemodel: root.model - parentNodeIndex: root.parentNodeIndex + Layout.fillWidth: true - imageProviderPath: "image://LayerTreeModelPixmapProvider/" + visible: __activeProject.featureDraftController.hasDraft - footer: MMComponents.MMListFooterSpacer {} + variant: MMComponents.MMListBanner.Warning + text: qsTr( "%1 has unsaved changes" ).arg( __activeProject.featureDraftController.draftLayerName ) + actionText: qsTr( "Resume" ) - onNodeClicked: function( node, nodeType, nodeName ) { - root.nodeClicked( node, nodeType, nodeName ) + onActionClicked: () => root.resumeDraftClicked() } - onNodeVisibilityClicked: function( node ) { - root.nodeVisibilityClicked( node ) + MMLayersList { + id: layers + + Layout.fillWidth: true + Layout.fillHeight: true + + clip: true + + basemodel: root.model + parentNodeIndex: root.parentNodeIndex + + imageProviderPath: "image://LayerTreeModelPixmapProvider/" + + footer: MMComponents.MMListFooterSpacer {} + + onNodeClicked: function( node, nodeType, nodeName ) { + root.nodeClicked( node, nodeType, nodeName ) + } + + onNodeVisibilityClicked: function( node ) { + root.nodeVisibilityClicked( node ) + } } } } diff --git a/app/qml/main.qml b/app/qml/main.qml index 340f88280..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,10 +318,17 @@ ApplicationWindow { text: qsTr("Add") iconSource: __style.addIcon visible: __activeProject.projectRole !== "reader" - onClicked: { + onClicked: () => { if ( __activeProject.projectHasRecordingLayers() ) { stateManager.state = "map" - map.record() + + if ( __activeProject.featureDraftController.hasDraft ) { + window.draftDialogPendingAction = function() { map.record() } + resumeDraftDialog.open() + } + else { + map.record() + } } else { __notificationModel.addInfo( qsTr( "No editable layers found." ) ) @@ -507,12 +517,28 @@ 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 ) { + window.draftDialogPendingAction = startAdding + 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 +843,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 +924,35 @@ ApplicationWindow { id: projDialog } + MMDiscardDraftDialog { + id: discardDraftDialog + + layerName: __activeProject.featureDraftController.draftLayerName + + onDiscardDraft: () => { + __activeProject.featureDraftController.discardDraft() + + if ( window.draftDialogPendingAction ) { + let action = window.draftDialogPendingAction + window.draftDialogPendingAction = null + action() + } + } + } + + MMResumeDraftDialog { + id: resumeDraftDialog + + featureTitle: __activeProject.featureDraftController.draftFeatureTitle + layerName: __activeProject.featureDraftController.draftLayerName + + onResumeClicked: () => { + window.draftDialogPendingAction = null + resumeFeatureDraft() + } + onDiscardClicked: () => discardDraftDialog.open() + } + MMOutOfDateCustomServerDialog{ id: migrationDialog @@ -1123,6 +1183,27 @@ 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 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 ( isGeometryCapture && !isExistingFeature ) { + map.resumeRecording( layer, __inputUtils.extractGeometry( pair ) ) + } else { + formsStackManager.openForm( pair, isExistingFeature ? "edit" : "add", "form" ) + } + + __notificationModel.addInfo( qsTr( "These are your unsaved changes, continue editing or discard them by navigating back." ) ) + } + Connections { target: __inputProjUtils function onProjError( message ) { @@ -1143,6 +1224,19 @@ ApplicationWindow { function onShowSyncFailedDialogClicked() { syncFailedDialog.open() } + function onShowDraftActionClicked() { + 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.ShowDraftAction ) + } + } } Connections { diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 1abe26c70..e9bddb92b 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1331,6 +1331,24 @@ Item { state = "recordInLayer" } + //! Resumes digitizing a new feature that was interrupted mid-capture + function resumeRecording( layer, geometry ) { + __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 ) { + recordingToolsLoader.item.recordingMapTool.resumeCapture( geometry ) + } + } ) + } + function edit( featurepair ) { __activeProject.setActiveLayer( featurepair.layer ) root.centerToPair( featurepair ) @@ -1338,6 +1356,9 @@ Item { internal.featurePairToEdit = featurepair state = "edit" + + // force a redraw - canvas may not have repainted while covered by the form + mapCanvas.refresh() } function toggleRedraw() { 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})