diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index a4ec70413..d6f641144 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 @@ -335,6 +339,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 9915b3e46..8f5e76ad1 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 @@ -185,6 +188,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. @@ -246,5 +260,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 47f16c748..964edb53b 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; @@ -1083,6 +1098,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 ) @@ -1116,6 +1134,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 cffff9b66..76f1c60a6 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -92,6 +92,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 f1f75837a..ba160bdc9 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 2fbfe23c5..79f33a620 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1334,6 +1334,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 ) @@ -1341,6 +1354,9 @@ Item { internal.featurePairToEdit = featurepair state = "edit" + + // force a redraw - canvas may not have repainted while covered by the form + mapCanvas.refresh() } function toggleRedraw() {