Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -255,6 +260,7 @@ if (ENABLE_TESTS)
test/testprojectchecksumcache.cpp
test/testmultieditmanager.cpp
test/testfiltercontroller.cpp
test/testfeaturedraftcontroller.cpp
)

set(MM_HDRS
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions app/activeproject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

#include "activeproject.h"
#include "coreutils.h"
#include "drafts/featuredraftstorage.h"

#ifdef ANDROID
#include "position/tracking/androidtrackingbroadcast.h"
Expand Down Expand Up @@ -78,6 +79,9 @@ ActiveProject::ActiveProject( AppSettings &appSettings

mFilterController = std::make_unique<FilterController>();
connect( this, &ActiveProject::projectReloaded, mFilterController.get(), &FilterController::loadFilterConfig );

mFeatureDraftController = std::make_unique<FeatureDraftController>();
connect( this, &ActiveProject::loadingFinished, mFeatureDraftController.get(), &FeatureDraftController::checkForDraft );
}

ActiveProject::~ActiveProject() = default;
Expand Down Expand Up @@ -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 ) );
Expand Down Expand Up @@ -683,3 +690,8 @@ FilterController *ActiveProject::filterController() const
{
return mFilterController.get();
}

FeatureDraftController *ActiveProject::featureDraftController() const
{
return mFeatureDraftController.get();
}
10 changes: 10 additions & 0 deletions app/activeproject.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 )

Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -235,6 +244,7 @@ class ActiveProject: public QObject
InputMapSettings *mMapSettings = nullptr;
std::unique_ptr<AutosyncController> mAutosyncController;
std::unique_ptr<FilterController> mFilterController;
std::unique_ptr<FeatureDraftController> mFeatureDraftController;

QString mProjectLoadingLog;
QString mProjectRole;
Expand Down
79 changes: 79 additions & 0 deletions app/attributes/attributecontroller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

#include <QDebug>
#include <QSet>
#include <QTimer>
#include <QDateTime>

#include "featuredraftstorage.h"

#include "qgis.h"
#include "qgsproject.h"
Expand All @@ -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()
Expand All @@ -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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not? Can't we simply store draft immediately? What is the problem with it?


// Set new active pair
mFeatureLayerPair = pair;
if ( !isSameFeature )
{
mTouchedFieldIndices.clear();

// draft immediately so a crash before the first keystroke still resumes into the form

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually do that here :)

if ( pair.layer() && isNewFeature() )
{
saveDraft();
}
}
if ( hasLayerChanged )
{
// layer changed!
Expand Down Expand Up @@ -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
Comment on lines +690 to +691

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer if we stored everything, not just selected fields. Imagine there are automatic field values that are changed when a different field changes. They would not get stored. Let's instead try storing everything and when opening the feature again, do not evaluate anything, just present what we stored.

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 );
Comment thread
xkello marked this conversation as resolved.
}

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() )
Expand Down Expand Up @@ -1203,6 +1276,7 @@ bool AttributeController::deleteFeature()
{
mFeatureLayerPair = FeatureLayerPair();
emit featureLayerPairChanged();
clearDraft();
emit changesCommited();
}

Expand All @@ -1214,6 +1288,8 @@ bool AttributeController::rollback()
if ( !mFeatureLayerPair.layer() )
return false;

clearDraft();

if ( !mFeatureLayerPair.layer()->isEditable() )
{
return false;
Expand Down Expand Up @@ -1281,6 +1357,7 @@ bool AttributeController::save()

if ( rv )
{
clearDraft();
emit changesCommited();
}
else
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions app/attributes/attributecontroller.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@
#include <QVariant>
#include <memory>
#include <QMap>
#include <QSet>
#include <QVector>
#include <QUuid>
#include <QTimer>

#include "featurelayerpair.h"
#include "attributedata.h"
#include "attributeformproxymodel.h"
#include "attributetabproxymodel.h"
#include "rememberattributescontroller.h"
#include "featuredraft.h"

#include "qgsfeature.h"
#include "qgsproject.h"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<int> mTouchedFieldIndices;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not need this. Simply store everything whenever an attribute is changed

};
#endif // ATTRIBUTECONTROLLER_H
53 changes: 53 additions & 0 deletions app/drafts/featuredraft.h
Original file line number Diff line number Diff line change
@@ -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 <QString>
#include <QDateTime>
#include <QVariant>
#include <QVector>

#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<FeatureDraftAttribute> attributes;
QgsFeatureId featureId = FID_NULL; // FID_NULL means a new (not-yet-existing) feature
};

#endif // FEATUREDRAFT_H
Loading
Loading