diff --git a/qfieldsync/core/layer.py b/qfieldsync/core/layer.py index 4b4e3cea..a52b7b3b 100644 --- a/qfieldsync/core/layer.py +++ b/qfieldsync/core/layer.py @@ -71,16 +71,24 @@ def __init__(self, layer): self.layer = layer self._action = None self._photo_naming = {} + self._is_geometry_locked = None self.read_layer() def read_layer(self): self._action = self.layer.customProperty('QFieldSync/action') self._photo_naming = json.loads(self.layer.customProperty('QFieldSync/photo_naming') or '{}') + self._is_geometry_locked = self.layer.customProperty('QFieldSync/is_geometry_locked', False) def apply(self): self.layer.setCustomProperty('QFieldSync/action', self.action) self.layer.setCustomProperty('QFieldSync/photo_naming', json.dumps(self._photo_naming)) + # custom properties does not store the data type, so it is safer to remove boolean custom properties, rather than setting them to the string 'false' (which is boolean `True`) + if self.is_geometry_locked: + self.layer.setCustomProperty('QFieldSync/is_geometry_locked', True) + else: + self.layer.removeCustomProperty('QFieldSync/is_geometry_locked') + @property def action(self): if self._action is None: @@ -151,6 +159,18 @@ def is_supported(self): else: return True + @property + def can_lock_geometry(self): + return self.layer.type() == QgsMapLayer.VectorLayer + + @property + def is_geometry_locked(self): + return bool(self._is_geometry_locked) + + @is_geometry_locked.setter + def is_geometry_locked(self, is_geometry_locked): + self._is_geometry_locked = is_geometry_locked + @property def warning(self): if self.layer.source().endswith(('jp2', 'jpx')): diff --git a/qfieldsync/gui/map_layer_config_widget.py b/qfieldsync/gui/map_layer_config_widget.py new file mode 100644 index 00000000..f375805c --- /dev/null +++ b/qfieldsync/gui/map_layer_config_widget.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +""" +/*************************************************************************** + QFieldSyncDialog + A QGIS plugin + Sync your projects to QField on android + ------------------- + begin : 2020-06-15 + git sha : $Format:%H$ + copyright : (C) 2020 by OPENGIS.ch + email : info@opengis.ch + ***************************************************************************/ + +/*************************************************************************** + * * + * 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 os + +from qgis.core import Qgis, QgsProject, QgsMapLayer +from qgis.gui import QgsMapLayerConfigWidget, QgsMapLayerConfigWidgetFactory + +from qgis.PyQt.uic import loadUiType + +from qfieldsync.core.layer import LayerSource +from qfieldsync.gui.photo_naming_widget import PhotoNamingTableWidget +from qfieldsync.gui.utils import set_available_actions + +WidgetUi, _ = loadUiType(os.path.join(os.path.dirname(__file__), '../ui/map_layer_config_widget.ui')) + + +class MapLayerConfigWidgetFactory(QgsMapLayerConfigWidgetFactory): + def __init__(self, title, icon): + super(MapLayerConfigWidgetFactory, self).__init__(title, icon) + + + def createWidget(self, layer, canvas, dock_widget, parent): + return MapLayerConfigWidget(layer, canvas, parent) + + + def supportsLayer(self, layer): + return LayerSource(layer).is_supported + + + def supportLayerPropertiesDialog(self): + return True + + +class MapLayerConfigWidget(QgsMapLayerConfigWidget, WidgetUi): + def __init__(self, layer, canvas, parent): + super(MapLayerConfigWidget, self).__init__(layer, canvas, parent) + self.setupUi(self) + self.layer_source = LayerSource(layer) + self.project = QgsProject.instance() + + set_available_actions(self.layerActionComboBox, self.layer_source) + + self.isGeometryLockedCheckBox.setEnabled(self.layer_source.can_lock_geometry) + self.isGeometryLockedCheckBox.setChecked(self.layer_source.is_geometry_locked) + self.photoNamingTable = PhotoNamingTableWidget() + self.photoNamingTable.addLayerFields(self.layer_source) + self.photoNamingTable.setLayerColumnHidden(True) + + # insert the table as a second row only for vector layers + if Qgis.QGIS_VERSION_INT >= 31300 and layer.type() == QgsMapLayer.VectorLayer: + self.layout().insertRow(1, self.tr('Photo Naming'), self.photoNamingTable) + self.photoNamingTable.setEnabled(self.photoNamingTable.rowCount() > 0) + + + def apply(self): + old_layer_action = self.layer_source.action + old_is_geometry_locked = self.layer_source.is_geometry_locked + + self.layer_source.action = self.layerActionComboBox.itemData(self.layerActionComboBox.currentIndex()) + self.layer_source.is_geometry_locked = self.isGeometryLockedCheckBox.isChecked() + self.photoNamingTable.syncLayerSourceValues() + + # apply always the photo_namings (to store default values on first apply as well) + if (self.layer_source.action != old_layer_action or + self.layer_source.is_geometry_locked != old_is_geometry_locked or + self.photoNamingTable.rowCount() > 0 + ): + self.layer_source.apply() + self.project.setDirty(True) diff --git a/qfieldsync/gui/photo_naming_widget.py b/qfieldsync/gui/photo_naming_widget.py new file mode 100644 index 00000000..68402a41 --- /dev/null +++ b/qfieldsync/gui/photo_naming_widget.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" +/*************************************************************************** + QFieldSyncDialog + A QGIS plugin + Sync your projects to QField on android + ------------------- + begin : 2020-06-15 + git sha : $Format:%H$ + copyright : (C) 2020 by OPENGIS.ch + email : info@opengis.ch + ***************************************************************************/ + +/*************************************************************************** + * * + * 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. * + * * + ***************************************************************************/ +""" + +from qgis.core import QgsMapLayer +from qgis.gui import QgsFieldExpressionWidget + +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import QTableWidget, QTableWidgetItem, QAbstractScrollArea + + +class PhotoNamingTableWidget(QTableWidget): + def __init__(self): + super(PhotoNamingTableWidget, self).__init__() + + self.setColumnCount(3) + self.setHorizontalHeaderLabels([self.tr('Layer'), self.tr('Field'), self.tr('Naming Expression')]) + self.horizontalHeaderItem(2).setToolTip(self.tr('Enter expression for a file path with the extension .jpg')) + self.horizontalHeader().setStretchLastSection(True) + self.setRowCount(0) + self.resizeColumnsToContents() + self.setMinimumHeight(100) + self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + + + def addLayerFields(self, layer_source): + layer = layer_source.layer + + if layer.type() != QgsMapLayer.VectorLayer: + return + + for i, field in enumerate(layer.fields()): + row = self.rowCount() + ews = layer.editorWidgetSetup(i) + + if ews.type() == 'ExternalResource': + # for later: if ews.config().get('DocumentViewer', QgsExternalResourceWidget.NoContent) == QgsExternalResourceWidget.Image: + self.insertRow(row) + item = QTableWidgetItem(layer.name()) + item.setData(Qt.UserRole, layer_source) + self.setItem(row, 0, item) + item = QTableWidgetItem(field.name()) + self.setItem(row, 1, item) + ew = QgsFieldExpressionWidget() + ew.setLayer(layer) + expression = layer_source.photo_naming(field.name()) + ew.setExpression(expression) + self.setCellWidget(row, 2, ew) + + self.resizeColumnsToContents() + + + def setLayerColumnHidden(self, is_hidden): + self.setColumnHidden(0, is_hidden) + + + def syncLayerSourceValues(self, should_apply=False): + for i in range(self.rowCount()): + layer_source = self.item(i, 0).data(Qt.UserRole) + field_name = self.item(i, 1).text() + new_expression = self.cellWidget(i, 2).currentText() + layer_source.set_photo_naming(field_name, new_expression) + + if should_apply: + layer_source.apply() diff --git a/qfieldsync/gui/project_configuration_dialog.py b/qfieldsync/gui/project_configuration_dialog.py index aa062be7..5ecd094d 100644 --- a/qfieldsync/gui/project_configuration_dialog.py +++ b/qfieldsync/gui/project_configuration_dialog.py @@ -21,15 +21,16 @@ from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QDialog, QTableWidgetItem, QToolButton, QComboBox, QMenu, QAction +from qgis.PyQt.QtWidgets import QDialog, QTableWidgetItem, QToolButton, QComboBox, QCheckBox, QMenu, QAction, QWidget, QHBoxLayout from qgis.PyQt.uic import loadUiType -from qgis.core import QgsProject, QgsMapLayerProxyModel, QgsMapLayer, Qgis -from qgis.gui import QgsFieldExpressionWidget +from qgis.core import QgsProject, QgsMapLayerProxyModel, Qgis from qfieldsync.core import ProjectConfiguration from qfieldsync.core.layer import LayerSource, SyncAction from qfieldsync.core.project import ProjectProperties +from qfieldsync.gui.photo_naming_widget import PhotoNamingTableWidget +from qfieldsync.gui.utils import set_available_actions DialogUi, _ = loadUiType( @@ -83,6 +84,10 @@ def reloadProject(self): Load all layers from the map layer registry into the table. """ self.unsupportedLayersList = list() + + self.photoNamingTable = PhotoNamingTableWidget() + self.photoNamingTab.layout().addWidget(self.photoNamingTable) + self.layersTable.setRowCount(0) self.layersTable.setSortingEnabled(False) for layer in self.project.mapLayers().values(): @@ -96,48 +101,32 @@ def reloadProject(self): item.setData(Qt.EditRole, layer.name()) self.layersTable.setItem(count, 0, item) - cbx = QComboBox() - for action, description in layer_source.available_actions: - cbx.addItem(description) - cbx.setItemData(cbx.count() - 1, action) - if layer_source.action == action: - cbx.setCurrentIndex(cbx.count() - 1) - - self.layersTable.setCellWidget(count, 1, cbx) + cmb = QComboBox() + set_available_actions(cmb, layer_source) + + cbx = QCheckBox() + cbx.setEnabled(layer_source.can_lock_geometry) + cbx.setChecked(layer_source.is_geometry_locked) + # it's more UI friendly when the checkbox is centered, an ugly workaround to achieve it + cbx_widget = QWidget() + cbx_layout = QHBoxLayout() + cbx_layout.setAlignment(Qt.AlignCenter) + cbx_layout.setContentsMargins(0, 0, 0, 0) + cbx_layout.addWidget(cbx) + cbx_widget.setLayout(cbx_layout) + # NOTE the margin is not updated when the table column is resized, so better rely on the code above + # cbx.setStyleSheet("margin-left:50%; margin-right:50%;") + + self.layersTable.setCellWidget(count, 1, cbx_widget) + self.layersTable.setCellWidget(count, 2, cmb) + + # make sure layer_source is the same instance everywhere + self.photoNamingTable.addLayerFields(layer_source) self.layersTable.resizeColumnsToContents() self.layersTable.sortByColumn(0, Qt.AscendingOrder) self.layersTable.setSortingEnabled(True) - self.photoResourceTable.setColumnCount(3) - self.photoResourceTable.setHorizontalHeaderLabels([self.tr('Layer'), self.tr('Field'), self.tr('Naming Expression')]) - self.photoResourceTable.horizontalHeaderItem(2).setToolTip(self.tr('Enter expression for a file path with the extension .jpg')) - self.photoResourceTable.horizontalHeader().setStretchLastSection(True) - self.photoResourceTable.setRowCount(0) - row = 0 - for layer in self.project.instance().mapLayers().values(): - if layer.type() == QgsMapLayer.VectorLayer: - layer_source = LayerSource(layer) - i = 0 - for field in layer.fields(): - ews = layer.editorWidgetSetup(i) - i += 1 - if ews.type() == 'ExternalResource': - # for later: if ews.config().get('DocumentViewer', QgsExternalResourceWidget.NoContent) == QgsExternalResourceWidget.Image: - self.photoResourceTable.insertRow(row) - item = QTableWidgetItem(layer.name()) - item.setData(Qt.UserRole, layer_source) - self.photoResourceTable.setItem(row, 0, item) - item = QTableWidgetItem(field.name()) - self.photoResourceTable.setItem(row, 1, item) - ew = QgsFieldExpressionWidget() - ew.setLayer(layer) - expression = layer_source.photo_naming(field.name()) - ew.setExpression(expression) - self.photoResourceTable.setCellWidget(row, 2, ew) - row += 1 - self.photoResourceTable.resizeColumnsToContents() - # Remove the tab when not yet suported in QGIS if Qgis.QGIS_VERSION_INT < 31300: self.tabWidget.removeTab(self.tabWidget.count() - 1) @@ -184,22 +173,23 @@ def onAccepted(self): for i in range(self.layersTable.rowCount()): item = self.layersTable.item(i, 0) layer_source = item.data(Qt.UserRole) - cbx = self.layersTable.cellWidget(i, 1) + cbx = self.layersTable.cellWidget(i, 1).layout().itemAt(0).widget() + cmb = self.layersTable.cellWidget(i, 2) old_action = layer_source.action - layer_source.action = cbx.itemData(cbx.currentIndex()) - if layer_source.action != old_action: + old_is_geometry_locked = layer_source.can_lock_geometry and layer_source.is_geometry_locked + + layer_source.action = cmb.itemData(cmb.currentIndex()) + layer_source.is_geometry_locked = cbx.isChecked() + + if layer_source.action != old_action or layer_source.is_geometry_locked != old_is_geometry_locked: self.project.setDirty(True) layer_source.apply() - for i in range(self.photoResourceTable.rowCount()): - layer_source: LayerSource = self.photoResourceTable.item(i, 0).data(Qt.UserRole) - field_name = self.photoResourceTable.item(i, 1).text() - old_expression = layer_source.photo_naming(field_name) - new_expression = self.photoResourceTable.cellWidget(i, 2).currentText() - layer_source.set_photo_naming(field_name, new_expression) + # apply always the photo_namings (to store default values on first apply as well) + self.photoNamingTable.syncLayerSourceValues(should_apply=True) + if self.photoNamingTable.rowCount() > 0: self.project.setDirty(True) - layer_source.apply() self.__project_configuration.create_base_map = self.createBaseMapGroupBox.isChecked() self.__project_configuration.base_map_theme = self.mapThemeComboBox.currentText() diff --git a/qfieldsync/gui/utils.py b/qfieldsync/gui/utils.py new file mode 100644 index 00000000..68987a0a --- /dev/null +++ b/qfieldsync/gui/utils.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" +/*************************************************************************** + QFieldSyncDialog + A QGIS plugin + Sync your projects to QField on android + ------------------- + begin : 2020-06-15 + git sha : $Format:%H$ + copyright : (C) 2020 by OPENGIS.ch + email : info@opengis.ch + ***************************************************************************/ + +/*************************************************************************** + * * + * 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. * + * * + ***************************************************************************/ +""" + +def set_available_actions(combobox, layer_source): + """Sets available actions on a checkbox and selects the current one. + + Args: + combobox (QComboBox): target combobox + layer_source (LayerSource): target layer + """ + for action, description in layer_source.available_actions: + combobox.addItem(description) + combobox.setItemData(combobox.count() - 1, action) + + if layer_source.action == action: + combobox.setCurrentIndex(combobox.count() - 1) diff --git a/qfieldsync/qfield_sync.py b/qfieldsync/qfield_sync.py index dc7665e5..6344787e 100644 --- a/qfieldsync/qfield_sync.py +++ b/qfieldsync/qfield_sync.py @@ -40,6 +40,7 @@ from qfieldsync.gui.preferences_dialog import PreferencesDialog from qfieldsync.gui.synchronize_dialog import SynchronizeDialog from qfieldsync.gui.project_configuration_dialog import ProjectConfigurationDialog +from qfieldsync.gui.map_layer_config_widget import MapLayerConfigWidgetFactory class QFieldSync(object): @@ -80,6 +81,9 @@ def __init__(self, iface): self.toolbar = self.iface.addToolBar(u'QFieldSync') self.toolbar.setObjectName(u'QFieldSync') + # instance of the map config widget factory, shown in layer properties + self.mapLayerConfigWidgetFactory = MapLayerConfigWidgetFactory('QField', QIcon(os.path.join(os.path.dirname(__file__), 'resources/icon.png'))) + # instance of the QgsOfflineEditing self.offline_editing = QgsOfflineEditing() self.preferences = Preferences() @@ -193,7 +197,7 @@ def initGui(self): parent=self.iface.mainWindow()) self.add_action( - './resources/icon.png', + os.path.join(os.path.dirname(__file__), './resources/icon.png'), text=self.tr(u'Project Configuration'), callback=self.show_project_configuration_dialog, parent=self.iface.mainWindow(), @@ -201,12 +205,14 @@ def initGui(self): ) self.add_action( - './resources/icon.png', + os.path.join(os.path.dirname(__file__), './resources/icon.png' ), text=self.tr(u'Preferences'), callback=self.show_preferences_dialog, parent=self.iface.mainWindow(), add_to_toolbar=False) + self.iface.registerMapLayerConfigWidgetFactory(self.mapLayerConfigWidgetFactory) + self.update_button_enabled_status() def unload(self): @@ -219,6 +225,8 @@ def unload(self): # remove the toolbar del self.toolbar + self.iface.unregisterMapLayerConfigWidgetFactory(self.mapLayerConfigWidgetFactory) + def show_preferences_dialog(self): dlg = PreferencesDialog(self.iface.mainWindow()) dlg.exec_() diff --git a/qfieldsync/ui/map_layer_config_widget.ui b/qfieldsync/ui/map_layer_config_widget.ui new file mode 100644 index 00000000..6e675dc4 --- /dev/null +++ b/qfieldsync/ui/map_layer_config_widget.ui @@ -0,0 +1,48 @@ + + + QFieldLayerSettingsPage + + + + 0 + 0 + 500 + 250 + + + + Form + + + + + + Layer Action + + + + + + + + 0 + 0 + + + + + + + + When enabled, this option disables adding and deleting features, as well as modifying the geometries of existing features. + + + Lock Geometries + + + + + + + + diff --git a/qfieldsync/ui/project_configuration_dialog.ui b/qfieldsync/ui/project_configuration_dialog.ui index 077c47a8..d5195856 100644 --- a/qfieldsync/ui/project_configuration_dialog.ui +++ b/qfieldsync/ui/project_configuration_dialog.ui @@ -75,6 +75,14 @@ Layer + + + Lock Geometries + + + When enabled, this option disables adding and deleting features, as well as modifying the geometries of existing features. + + Action @@ -264,15 +272,11 @@ - + Photo Naming - - - - - +