diff --git a/images/images.qrc b/images/images.qrc index f3bedccef5..d44bab7425 100644 --- a/images/images.qrc +++ b/images/images.qrc @@ -531,6 +531,8 @@ themes/qfield/nodpi/ic_hide_green_48dp.svg themes/qfield/nodpi/ic_chevron_down.svg themes/qfield/nodpi/ic_chevron_up.svg + themes/qfield/nodpi/ic_undo.svg + themes/qfield/nodpi/ic_redo.svg themes/qfield/nodpi/ic_arrow_left_white_24dp.svg themes/qfield/nodpi/ic_opacity_black_24dp.svg themes/qfield/nodpi/ic_common_angle_white_24dp.svg diff --git a/images/themes/qfield/nodpi/ic_redo.svg b/images/themes/qfield/nodpi/ic_redo.svg new file mode 100644 index 0000000000..b3602b3b50 --- /dev/null +++ b/images/themes/qfield/nodpi/ic_redo.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/images/themes/qfield/nodpi/ic_undo.svg b/images/themes/qfield/nodpi/ic_undo.svg new file mode 100644 index 0000000000..dffb70e59f --- /dev/null +++ b/images/themes/qfield/nodpi/ic_undo.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 95c7b490a7..41d73a0322 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -57,6 +57,7 @@ set(QFIELD_CORE_SRCS geometryeditorsmodel.cpp identifytool.cpp layerobserver.cpp + featurehistory.cpp layerresolver.cpp layertreemapcanvasbridge.cpp layertreemodel.cpp @@ -164,6 +165,7 @@ set(QFIELD_CORE_HDRS geometryeditorsmodel.h identifytool.h layerobserver.h + featurehistory.h layerresolver.h layertreemapcanvasbridge.h layertreemodel.h diff --git a/src/core/featurehistory.cpp b/src/core/featurehistory.cpp new file mode 100644 index 0000000000..610df5caa8 --- /dev/null +++ b/src/core/featurehistory.cpp @@ -0,0 +1,448 @@ +#include "featurehistory.h" + +#include +#include +#include + + +FeatureHistory::FeatureHistory( const QgsProject *project ) + : mProject( project ) +{ + connect( mProject, &QgsProject::homePathChanged, this, &FeatureHistory::onHomePathChanged ); + connect( mProject, &QgsProject::layersAdded, this, &FeatureHistory::onLayersAdded ); + connect( &mTimer, &QTimer::timeout, this, &FeatureHistory::onTimerTimeout ); +} + + +void FeatureHistory::onHomePathChanged() +{ + if ( mProject->homePath().isNull() ) + return; + + mObservedLayerIds.clear(); + + addLayerListeners(); +} + +void FeatureHistory::addLayerListeners() +{ + const QList layers = mProject->mapLayers().values(); + + for ( QgsMapLayer *layer : layers ) + { + QgsVectorLayer *vl = qobject_cast( layer ); + + if ( !vl ) + { + continue; + } + + if ( mObservedLayerIds.contains( vl->id() ) ) + { + continue; + } + + // make sure we disconnect vector layer signal slots for these events if they are already present + disconnect( vl, &QgsVectorLayer::beforeCommitChanges, this, &FeatureHistory::onBeforeCommitChanges ); + disconnect( vl, &QgsVectorLayer::afterCommitChanges, this, &FeatureHistory::onAfterCommitChanges ); + disconnect( vl, &QgsVectorLayer::committedFeaturesAdded, this, &FeatureHistory::onCommittedFeaturesAdded ); + disconnect( vl, &QgsVectorLayer::committedFeaturesRemoved, this, &FeatureHistory::onCommittedFeaturesRemoved ); + + connect( vl, &QgsVectorLayer::beforeCommitChanges, this, &FeatureHistory::onBeforeCommitChanges ); + connect( vl, &QgsVectorLayer::afterCommitChanges, this, &FeatureHistory::onAfterCommitChanges ); + connect( vl, &QgsVectorLayer::committedFeaturesAdded, this, &FeatureHistory::onCommittedFeaturesAdded ); + connect( vl, &QgsVectorLayer::committedFeaturesRemoved, this, &FeatureHistory::onCommittedFeaturesRemoved ); + + mObservedLayerIds.insert( vl->id() ); + } +} + + +void FeatureHistory::onBeforeCommitChanges() +{ + if ( mIsApplyingModifications ) + { + return; + } + + QgsVectorLayer *vl = qobject_cast( sender() ); + + if ( !vl ) + { + return; + } + + QgsVectorLayerEditBuffer *eb = vl->editBuffer(); + + if ( !eb ) + { + return; + } + + const QgsFeatureIds deletedFids = eb->deletedFeatureIds(); + const QgsFeatureIds changedGeometriesFids = qgis::listToSet( eb->changedGeometries().keys() ); + const QgsFeatureIds changedAttributesFids = qgis::listToSet( eb->changedAttributeValues().keys() ); + // NOTE QgsFeatureIds underlying implementation is QSet, so no need to check if the QgsFeatureId already exists + QgsFeatureIds changedFids; + + for ( const QgsFeatureId fid : deletedFids ) + changedFids.insert( fid ); + + for ( const QgsFeatureId fid : changedGeometriesFids ) + changedFids.insert( fid ); + + for ( const QgsFeatureId fid : changedAttributesFids ) + changedFids.insert( fid ); + + // NOTE we read the features from the dataProvider directly as we want to access the old values. + // If we use the layer, we get the values from the edit buffer. + QgsFeatureIterator featuresIt = vl->dataProvider()->getFeatures( QgsFeatureRequest( changedFids ) ); + QMap modifiedFeatures; + QgsFeature f; + + // ? is it possible to use the iterator in a less ugly way? something like normal `for ( QgsFeature &f : it ) {}` + while ( featuresIt.nextFeature( f ) ) + modifiedFeatures.insert( f.id(), f ); + + qInfo() << "FeatureHistory::onBeforeCommitChanges: vl->id()=" << vl->id() << "sourcePkAttrPair=" << changedFids; + + // NOTE no need to keep track of added features, as they are always present in the layer after commit + mTempModifiedFeaturesByLayerId.insert( vl->id(), modifiedFeatures ); +} + + +void FeatureHistory::onCommittedFeaturesAdded( const QString &localLayerId, const QgsFeatureList &addedFeatures ) +{ + if ( mIsApplyingModifications ) + { + return; + } + + const QgsVectorLayer *vl = qobject_cast( sender() ); + + if ( !vl ) + { + return; + } + + qInfo() << "FeatureHistory::onCommittedFeaturesAdded: adding create committed features"; + + FeatureModifications modifications = mTempHistoryStep.take( vl->id() ); + + for ( const QgsFeature &f : addedFeatures ) + { + modifications.createdFeatures.append( OldNewFeaturePair( QgsFeature(), f ) ); + } + + mTempHistoryStep.insert( vl->id(), modifications ); +} + +void FeatureHistory::onCommittedFeaturesRemoved( const QString &layerId, const QgsFeatureIds &deletedFeatureIds ) +{ + if ( mIsApplyingModifications ) + { + return; + } + + mTempDeletedFeatureIdsByLayerId.insert( layerId, deletedFeatureIds ); +} + +void FeatureHistory::onAfterCommitChanges() +{ + if ( mIsApplyingModifications ) + { + return; + } + + QgsVectorLayer *vl = qobject_cast( sender() ); + + if ( !vl ) + { + return; + } + + const QString layerId = vl->id(); + FeatureModifications modifications = mTempHistoryStep.take( layerId ); + QMap modifiedFeaturesOld = mTempModifiedFeaturesByLayerId.take( layerId ); + const QgsFeatureIds deletedFids = mTempDeletedFeatureIdsByLayerId.take( layerId ); + + for ( const QgsFeatureId &deletedFid : deletedFids ) + { + OldNewFeaturePair oldNewFeaturePair( modifiedFeaturesOld.take( deletedFid ), QgsFeature() ); + modifications.deletedFeatures.append( oldNewFeaturePair ); + } + + const QgsFeatureIds modifiedFids = qgis::listToSet( modifiedFeaturesOld.keys() ); + QgsFeatureIterator featuresIt = vl->getFeatures( QgsFeatureRequest( modifiedFids ) ); + QgsFeature f; + + while ( featuresIt.nextFeature( f ) ) + { + OldNewFeaturePair oldNewFeaturePair( modifiedFeaturesOld.take( f.id() ), f ); + modifications.updatedFeatures.append( oldNewFeaturePair ); + } + + if ( !modifications.createdFeatures.isEmpty() || !modifications.updatedFeatures.isEmpty() || !modifications.deletedFeatures.isEmpty() ) + { + mTempHistoryStep.insert( vl->id(), modifications ); + mTimer.start( sTimeoutMs ); + } +} + +void FeatureHistory::onLayersAdded( const QList &layers ) +{ + Q_UNUSED( layers ); + addLayerListeners(); +} + + +void FeatureHistory::onTimerTimeout() +{ + mTimer.stop(); + mUndoHistory.append( mTempHistoryStep ); + mTempHistoryStep.clear(); + mRedoHistory.clear(); + + emit isUndoEnabledChanged(); + emit isRedoEnabledChanged(); +} + +QMap FeatureHistory::reverseModifications( QMap &modificationsByLayerId ) +{ + QMap reversedModificationsByLayerId; + + const QStringList layerIds = modificationsByLayerId.keys(); + for ( const QString &layerId : layerIds ) + { + QgsVectorLayer *vl = qobject_cast( mProject->mapLayer( layerId ) ); + + if ( !vl ) + { + continue; + } + + QString displayString; + FeatureModifications modifications = modificationsByLayerId.value( layerId ); + FeatureModifications reversedModifications; + + for ( const OldNewFeaturePair &pair : modifications.deletedFeatures ) + { + reversedModifications.createdFeatures.append( OldNewFeaturePair( pair.second, pair.first ) ); + } + + for ( const OldNewFeaturePair &pair : modifications.createdFeatures ) + { + reversedModifications.deletedFeatures.append( OldNewFeaturePair( pair.second, pair.first ) ); + } + + for ( const OldNewFeaturePair &pair : modifications.updatedFeatures ) + { + reversedModifications.updatedFeatures.append( OldNewFeaturePair( pair.second, pair.first ) ); + } + + reversedModificationsByLayerId.insert( layerId, reversedModifications ); + } + + return reversedModificationsByLayerId; +} + + +bool FeatureHistory::applyModifications( QMap &modificationsByLayerId ) +{ + mIsApplyingModifications = true; + + const QStringList layerIds = modificationsByLayerId.keys(); + + for ( const QString &layerId : layerIds ) + { + QgsVectorLayer *vl = qobject_cast( mProject->mapLayer( layerId ) ); + + if ( !vl ) + { + continue; + } + + FeatureModifications undoFeatureModifications = modificationsByLayerId.value( layerId ); + + if ( !vl->startEditing() ) + { + return false; + } + + // created features + QgsFeatureIds fidsToDelete; + for ( const OldNewFeaturePair &pair : undoFeatureModifications.createdFeatures ) + { + fidsToDelete << pair.second.id(); + } + + if ( !undoFeatureModifications.createdFeatures.isEmpty() && !vl->deleteFeatures( fidsToDelete ) ) + { + QgsMessageLog::logMessage( tr( "Failed to undo created features in layer \"%1\"" ).arg( vl->name() ) ); + return false; + } + + // deleted features + QgsFeatureList featuresToAdd; + for ( const OldNewFeaturePair &pair : undoFeatureModifications.deletedFeatures ) + { + featuresToAdd.append( pair.first ); + } + + if ( !undoFeatureModifications.deletedFeatures.isEmpty() && !vl->addFeatures( featuresToAdd ) ) + { + QgsMessageLog::logMessage( tr( "Failed to undo deleted features in layer \"%1\"" ).arg( vl->name() ) ); + return false; + } + + // update features + QgsFeatureIds featureIds; + for ( const OldNewFeaturePair &pair : undoFeatureModifications.updatedFeatures ) + { + featureIds.insert( pair.first.id() ); + } + + for ( OldNewFeaturePair &pair : undoFeatureModifications.updatedFeatures ) + { + if ( !vl->updateFeature( pair.first, true ) ) + { + QgsMessageLog::logMessage( tr( "Failed to undo update features in layer \"%1\"" ).arg( vl->name() ) ); + return false; + } + } + + if ( !vl->commitChanges() ) + { + QgsMessageLog::logMessage( tr( "Failed to commit undo feature modification in layer \"%1\"" ).arg( vl->name() ) ); + + if ( !vl->rollBack() ) + { + QgsMessageLog::logMessage( tr( "Failed to rollback undo featurue modifications in layer \"%1\"" ).arg( vl->name() ) ); + } + + // OGR error creating feature -4: failed to execute insert : UNIQUE constraint failed: area.fid + + return false; + } + } + + mIsApplyingModifications = false; + + return true; +} + +bool FeatureHistory::undo() +{ + if ( mUndoHistory.isEmpty() ) + { + return false; + } + + QMap modifications = mUndoHistory.takeLast(); + QMap reversedModifications = reverseModifications( modifications ); + + if ( !applyModifications( modifications ) ) + { + return false; + } + + mRedoHistory.append( reversedModifications ); + + emit isUndoEnabledChanged(); + emit isRedoEnabledChanged(); + + return true; +} + + +bool FeatureHistory::redo() +{ + if ( mRedoHistory.isEmpty() ) + { + return false; + } + + QMap modifications = mRedoHistory.takeLast(); + QMap reversedModifications = reverseModifications( modifications ); + + if ( !applyModifications( modifications ) ) + { + return false; + } + + mUndoHistory.append( reversedModifications ); + + emit isUndoEnabledChanged(); + emit isRedoEnabledChanged(); + + return true; +} + + +bool FeatureHistory::isUndoEnabled() +{ + return !mUndoHistory.isEmpty(); +} + + +bool FeatureHistory::isRedoEnabled() +{ + return !mRedoHistory.isEmpty(); +} + +const QString FeatureHistory::undoMessage() +{ + if ( mUndoHistory.isEmpty() ) + { + return QString(); + } + + int totalChanges = 0; + QMap modifiedFeaturesByLayerId = mUndoHistory.last(); + + for ( const FeatureModifications &modifiedFeatures : modifiedFeaturesByLayerId.values() ) + { + totalChanges += modifiedFeatures.createdFeatures.count() + + modifiedFeatures.updatedFeatures.count() + + modifiedFeatures.deletedFeatures.count(); + } + + if ( totalChanges == 0 ) + { + return QString(); + } + else + { + // TODO show the display string of the first feature and say something like "Changed and N more" + return QStringLiteral( "Undo modifications on %1 feature(s)." ).arg( totalChanges ); + } +} + + +const QString FeatureHistory::redoMessage() +{ + if ( mRedoHistory.isEmpty() ) + { + return QString(); + } + + int totalChanges = 0; + QMap modifiedFeaturesByLayerId = mRedoHistory.last(); + + for ( const FeatureModifications &modifiedFeatures : modifiedFeaturesByLayerId.values() ) + { + totalChanges += modifiedFeatures.createdFeatures.count() + + modifiedFeatures.updatedFeatures.count() + + modifiedFeatures.deletedFeatures.count(); + } + + if ( totalChanges == 0 ) + { + return QString(); + } + else + { + // TODO show the display string of the first feature and say something like "Changed and N more" + return QStringLiteral( "Redo modifications on %1 feature(s)." ).arg( totalChanges ); + } +} diff --git a/src/core/featurehistory.h b/src/core/featurehistory.h new file mode 100644 index 0000000000..2ec28eefb3 --- /dev/null +++ b/src/core/featurehistory.h @@ -0,0 +1,124 @@ +#ifndef FEATUREHISTORY_H +#define FEATUREHISTORY_H + +#include +#include +#include + + +typedef QPair OldNewFeaturePair; + +class FeatureHistory : public QObject +{ + Q_OBJECT + + Q_PROPERTY( bool isUndoEnabled READ isUndoEnabled NOTIFY isUndoEnabledChanged ) + Q_PROPERTY( bool isRedoEnabled READ isRedoEnabled NOTIFY isRedoEnabledChanged ) + + public: + /** + * Stores the created, updated and deleted features on each undo/redo step. + */ + struct FeatureModifications + { + FeatureModifications() + {} + + QList createdFeatures; + QList updatedFeatures; + QList deletedFeatures; + }; + + /** + * Construct a new Feature history object + * + * @param project + */ + explicit FeatureHistory( const QgsProject *project ); + + //! Perform undo of the most recent modification step + Q_INVOKABLE bool undo(); + + //! Perform redo of the most recent modification step + Q_INVOKABLE bool redo(); + + //! Get the undo message to be show in the UI. NOTE should be called before calling \a undo. + Q_INVOKABLE const QString undoMessage(); + + //! Get the redo message to be show in the UI. NOTE should be called before calling \a redo. + Q_INVOKABLE const QString redoMessage(); + + bool isUndoEnabled(); + bool isRedoEnabled(); + + private: + static const int sTimeoutMs = 50; + + //! Add the needed event listeners to monitor for changes. + void addLayerListeners(); + + //! Apply given modifications on all layers in the current project. Used both by undo and redo operations. + bool applyModifications( QMap &modificationsByLayerId ); + + //! Reverse the modification. Used to make undo modifications into redo modifications. + QMap reverseModifications( QMap &modificationsByLayerId ); + + //! The current project instance. + const QgsProject *mProject = nullptr; + + //! If currently applying undo or redo feature modifications. + bool mIsApplyingModifications = false; + + //! Timer to wait for short time before all features (and child features) are saved. When timeouts, all temporary modifications are added as a new undo step. + QTimer mTimer; + + //! Temporary storage of all modifications before creating a new undo step. + QMap mTempHistoryStep; + + //! Temporary storage of All features that have been modified before creating a new undo step. + QMap> mTempModifiedFeaturesByLayerId; + + //! Temporary storage of the deleted feature ids before creating a new undo step. + QMap mTempDeletedFeatureIdsByLayerId; + + //! Undo history records + QList> mUndoHistory; + + //! Redo history records + QList> mRedoHistory; + + //! Layer ids being observed for changes. Should reset when the project is changed. Used to prevent double event listeners. + QSet mObservedLayerIds; + + signals: + void isUndoEnabledChanged(); + void isRedoEnabledChanged(); + + private slots: + /** + * Monitors the current project for new layers. + * + * @param layers layers added + */ + void onLayersAdded( const QList &layers ); + + //! The project file has been changed + void onHomePathChanged(); + + //! Called when features are added on the layer + void onCommittedFeaturesAdded( const QString &localLayerId, const QgsFeatureList &addedFeatures ); + + //! Called after features are committed for deletion. + void onCommittedFeaturesRemoved( const QString &layerId, const QgsFeatureIds &deletedFeatureIds ); + + //! Called before features are committed. Used to prepare the old state of the features and store it in \a mHistry. + void onBeforeCommitChanges(); + + //! Called after features are committed. Used because the added features do not have FID before they are committed. + void onAfterCommitChanges(); + + //! Timer's timeout slot. Used to collect multiple feature changes (calls of \a onBeforeCommitChanges and \a onAfterCommitChanges) into one undo step. + void onTimerTimeout(); +}; + +#endif // FEATUREHISTORY_H diff --git a/src/core/qgismobileapp.cpp b/src/core/qgismobileapp.cpp index b18cf577d8..f021c66215 100644 --- a/src/core/qgismobileapp.cpp +++ b/src/core/qgismobileapp.cpp @@ -53,6 +53,7 @@ #include "expressionevaluator.h" #include "expressionvariablemodel.h" #include "featurechecklistmodel.h" +#include "featurehistory.h" #include "featurelistextentcontroller.h" #include "featurelistmodel.h" #include "featurelistmodelselection.h" @@ -268,6 +269,7 @@ QgisMobileapp::QgisMobileapp( QgsApplication *app, QObject *parent ) mTrackingModel = new TrackingModel(); mGpkgFlusher = std::make_unique( mProject ); mLayerObserver = std::make_unique( mProject ); + mFeatureHistory = std::make_unique( mProject ); mFlatLayerTree = new FlatLayerTreeModel( mProject->layerTreeRoot(), mProject, this ); mLegendImageProvider = new LegendImageProvider( mFlatLayerTree->layerTreeModel() ); mLocalFilesImageProvider = new LocalFilesImageProvider(); @@ -545,6 +547,7 @@ void QgisMobileapp::initDeclarative() rootContext()->setContextProperty( "LocatorModelNoGroup", QgsLocatorModel::NoGroup ); rootContext()->setContextProperty( "gpkgFlusher", mGpkgFlusher.get() ); rootContext()->setContextProperty( "layerObserver", mLayerObserver.get() ); + rootContext()->setContextProperty( "featureHistory", mFeatureHistory.get() ); rootContext()->setContextProperty( "messageLogModel", mMessageLogModel.get() ); rootContext()->setContextProperty( "qfieldAuthRequestHandler", mAuthRequestHandler ); diff --git a/src/core/qgismobileapp.h b/src/core/qgismobileapp.h index e8a0530a44..e904b66022 100644 --- a/src/core/qgismobileapp.h +++ b/src/core/qgismobileapp.h @@ -51,6 +51,7 @@ class TrackingModel; class LocatorFiltersModel; class QgsProject; class LayerObserver; +class FeatureHistory; class MessageLogModel; class QgsPrintLayout; @@ -208,6 +209,7 @@ class QFIELD_CORE_EXPORT QgisMobileapp : public QQmlApplicationEngine std::unique_ptr mGpkgFlusher; std::unique_ptr mLayerObserver; + std::unique_ptr mFeatureHistory; QFieldAppAuthRequestHandler *mAuthRequestHandler = nullptr; std::unique_ptr mBookmarkModel; diff --git a/src/qml/qgismobileapp.qml b/src/qml/qgismobileapp.qml index b7aba02da6..1f974fdb01 100644 --- a/src/qml/qgismobileapp.qml +++ b/src/qml/qgismobileapp.qml @@ -1249,6 +1249,7 @@ ApplicationWindow { spacing: 4 QfToolButtonDrawer { + id: mainToolbarDrawer name: "digitizingDrawer" size: 48 round: true @@ -2116,6 +2117,48 @@ ApplicationWindow { return result + padding * 2; } + Row { + bottomPadding: parent.topMargin + + QfToolButton { + icon.source: Theme.getThemeVectorIcon( "ic_undo" ) + height: 48 + width: 48 + enabled: featureHistory.isUndoEnabled + opacity: enabled ? 1 : 0.5 + + onClicked: { + const msg = featureHistory.undoMessage(); + + if ( featureHistory.undo() ) { + displayToast( msg ); + } + + dashBoard.close(); + mainMenu.close(); + } + } + + QfToolButton { + icon.source: Theme.getThemeVectorIcon( "ic_redo" ) + height: 48 + width: 48 + enabled: featureHistory.isRedoEnabled + opacity: enabled ? 1 : 0.5 + + onClicked: { + const msg = featureHistory.redoMessage(); + + if ( featureHistory.redo() ) { + displayToast( msg ); + } + + dashBoard.close(); + mainMenu.close(); + } + } + } + MenuItem { text: qsTr( 'Measure Tool' )