diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf3c86c..c731dbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,89 @@ # Changelog +## [2.4.0](https://github.com/PnX-SI/gn_mobile_occtax/releases/tag/2.4.0) (2022-10-02, release) + +### 🚀 Nouveautés + +* Refonte ergonomique des listes de choix des nomenclatures. Cette refonte ne concerne pour l'instant + que l'étape "Informations" lors de la saisie d'un taxon. +* Accélérer la saisie en permettant de mémoriser les dernières nomenclatures saisies (https://github.com/PnX-SI/gn_mobile_occtax/issues/169). + Cette fonctionnalité est accessible via la propriété `nomenclature/save_default_values` dans le + [fichier de paramétrage](https://github.com/PnX-SI/gn_mobile_occtax/blob/develop/README.md#nomenclature-settings). +* Amélioration sur la recherche des taxons, notamment sur la distinction des mots (avec ou sans + majuscules, avec ou sans accents) (https://github.com/PnX-SI/gn_mobile_occtax/issues/91). +* Petites améliorations sur la présentation des jeux de données, aussi bien dans la page de + sélection des jeux de données que dans l'affichage du jeu de données sélectionnée dans la saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/120). +* Petites améliorations sur la page de sélection des observateurs et sur la fonction de recherche + des observateurs (https://github.com/PnX-SI/gn_mobile_occtax/issues/142). +* Petites améliorations sur les messages d'information lors de la synchronisation des données (https://github.com/PnX-SI/gn_mobile_occtax/issues/143). +* Affichage du nom vernaculaire du taxon dans le bilan de la saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/153). +* Ajout d'une fonction de filtre sur les rangs taxonomique des taxons dans la page du bilan de la + saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/166). +* Affichage du nombre de taxon en en-tête de page (https://github.com/PnX-SI/gn_mobile_occtax/issues/167). +* Permettre de modifier la date et l'heure de fin des relevés en fin de saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/168). +* Refonte ergonomique sur l'enchaînement des écrans de la saisie. Le bilan de la saisie intervient + notamment après le pointage sur la carte si le relevé contient au moins un taxon (https://github.com/PnX-SI/gn_mobile_occtax/issues/177). + +### 🐛 Corrections + +* Défilement automatique du nom vernaculaire du taxon sélectionné (https://github.com/PnX-SI/gn_mobile_occtax/issues/49). +* Validation sur l'ensemble des taxons ajoutés au relevé (https://github.com/PnX-SI/gn_mobile_occtax/issues/177). +* Correction concernant la mémorisation de la sélection des observateurs lors de la saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/110). +* Validation automatique du compte utilisateur lors de l'authentification (https://github.com/PnX-SI/gn_mobile_occtax/issues/184). + +### ⚠️ Notes de version + +* Code de version : 3090 + +## [2.4.0-rc2](https://github.com/PnX-SI/gn_mobile_occtax/releases/tag/2.4.0-rc2) (2022-09-26, pre-release) + +### 🚀 Nouveautés + +* Refonte ergonomique des listes de choix des nomenclatures. +* Accélérer la saisie en permettant de mémoriser les dernières nomenclatures saisies (https://github.com/PnX-SI/gn_mobile_occtax/issues/169). + +### ⚠️ Notes de version + +* Code de version : 3083 + +## [2.4.0-rc1](https://github.com/PnX-SI/gn_mobile_occtax/releases/tag/2.4.0-rc1) (2022-09-10, pre-release) + +### 🐛 Corrections + +* Défilement automatique du nom vernaculaire du taxon sélectionné (https://github.com/PnX-SI/gn_mobile_occtax/issues/49). +* Validation sur l'ensemble des taxons ajoutés au relevé (https://github.com/PnX-SI/gn_mobile_occtax/issues/177). + +### ⚠️ Notes de version + +* Code de version : 3079 + +## [2.4.0-rc0](https://github.com/PnX-SI/gn_mobile_occtax/releases/tag/2.4.0-rc0) (2022-09-07, pre-release) + +### 🚀 Nouveautés + +* Amélioration sur la recherche des taxons, notamment sur la distinction des mots (avec ou sans + majuscules, avec ou sans accents) (https://github.com/PnX-SI/gn_mobile_occtax/issues/91). +* Petites améliorations sur la présentation des jeux de données, aussi bien dans la page de + sélection des jeux de données que dans l'affichage du jeu de données sélectionnée dans la saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/120). +* Petites améliorations sur la page de sélection des observateurs et sur la fonction de recherche + des observateurs (https://github.com/PnX-SI/gn_mobile_occtax/issues/142). +* Petites améliorations sur les messages d'information lors de la synchronisation des données (https://github.com/PnX-SI/gn_mobile_occtax/issues/143). +* Affichage du nom vernaculaire du taxon dans le bilan de la saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/153). +* Ajout d'une fonction de filtre sur les rangs taxonomique des taxons dans la page du bilan de la + saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/166). +* Affichage du nombre de taxon en en-tête de page (https://github.com/PnX-SI/gn_mobile_occtax/issues/167). +* Permettre de modifier la date et l'heure de fin des relevés en fin de saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/168). +* Refonte ergonomique sur l'enchaînement des écrans de la saisie. Le bilan de la saisie intervient + notamment après le pointage sur la carte si le relevé contient au moins un taxon (https://github.com/PnX-SI/gn_mobile_occtax/issues/177). + +### 🐛 Corrections + +* Correction concernant la mémorisation de la sélection des observateurs lors de la saisie (https://github.com/PnX-SI/gn_mobile_occtax/issues/110). + +### ⚠️ Notes de version + +* Code de version : 3075 + ## [2.3.0](https://github.com/PnX-SI/gn_mobile_occtax/releases/tag/2.3.0) (2022-07-14, release) ### 🚀 Nouveautés diff --git a/README.md b/README.md index 8d0c08d6..52b667d0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Based on [datasync module](https://github.com/PnX-SI/gn_mobile_core) to synchron ## Launcher icons | Name | Flavor | Launcher icon | -| ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +|---------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Default | _generic_ | ![PNX](https://raw.githubusercontent.com/PnX-SI/gn_mobile_occtax/develop/occtax/src/main/res/mipmap-xxxhdpi/ic_launcher.png) ![PNX_debug](https://raw.githubusercontent.com/PnX-SI/gn_mobile_occtax/develop/occtax/src/debug/res/mipmap-xxxhdpi/ic_launcher.png) | ## Settings @@ -81,16 +81,17 @@ Example: ### Parameters description -| Parameter | UI | Description | Default value | -| --------------------------- | ------- | -------------------------------------------------------------------------------------------------- | ------------- | -| `area_observation_duration` | ☐ | Area observation duration period (in days) | 365 | -| `sync` | ☐ | Data synchronization settings (cf. https://github.com/PnX-SI/gn_mobile_core/tree/develop/datasync) | | -| `map` | ☐ | Maps settings (cf. https://github.com/PnX-SI/gn_mobile_maps/tree/develop/maps) | | -| `input` | ☐ | Input form settings | | -| `input/date` | ☐ | Date settings | | -| `nomenclature` | ☐ | Nomenclature settings | | -| `nomenclature/information` | ☐ | Information settings (as array) | | -| `nomenclature/counting` | ☐ | Counting settings (as array) | | +| Parameter | UI | Description | Default value | +|------------------------------------|---------|----------------------------------------------------------------------------------------------------|---------------| +| `area_observation_duration` | ☐ | Area observation duration period (in days) | 365 | +| `sync` | ☐ | Data synchronization settings (cf. https://github.com/PnX-SI/gn_mobile_core/tree/develop/datasync) | | +| `map` | ☐ | Maps settings (cf. https://github.com/PnX-SI/gn_mobile_maps/tree/develop/maps) | | +| `input` | ☐ | Input form settings | | +| `input/date` | ☐ | Date settings | | +| `nomenclature` | ☐ | Nomenclature settings | | +| `nomenclature/save_default_values` | ☐ | Save default nomenclature values | false | +| `nomenclature/information` | ☐ | Information settings (as array) | | +| `nomenclature/counting` | ☐ | Counting settings (as array) | | ### Input settings @@ -101,7 +102,7 @@ Allows to configure settings related to user input. How the user can set the start and end date of the input: | Parameter | Description | Default value | -| ----------------- | ---------------------------------------------------------------------------- | ------------- | +|-------------------|------------------------------------------------------------------------------|---------------| | `enable_end_date` | Whether to edit as well the end date of the input | `false` | | `enable_hours` | Whether to edit as well the hour part of the start and end date (if enabled) | `false` | @@ -116,15 +117,18 @@ If nothing is configured, only the start date without the hour part is editable. ### Nomenclature settings -Allows to define if fields are displayed by default and if they are editable (visible). If a field is not editable (visible), -it will use the default value set in Occtax database. +`save_default_values`: Allows to save locally and only during a session of use selected nomenclature +values as default values (default: `false`). + +Allows to define if fields are displayed by default and if they are editable (visible). +If a field is not editable (visible), it will use the default value set in Occtax database. All these settings may not be defined and the default values will then be used instead: **Information settings** | Nomenclature | Label | Displayed by default | Editable (visible) | -| ---------------- | -------------------- | -------------------- | ------------------ | +|------------------|----------------------|----------------------|--------------------| | METH_OBS | Observation methods | `true` | `true` | | ETA_BIO | Biological state | `true` | `true` | | METH_DETERMIN | Determination method | `false` | `true` | @@ -138,7 +142,7 @@ All these settings may not be defined and the default values will then be used i **Counting settings** | Nomenclature | Label | Displayed by default | Editable (visible) | -| ------------ | -------------------------- | -------------------- | ------------------ | +|--------------|----------------------------|----------------------|--------------------| | STADE_VIE | Life stage | `true` | `true` | | SEXE | Sex | `true` | `true` | | OBJ_DENBR | Purpose of the enumeration | `true` | `true` | @@ -153,6 +157,7 @@ You can override these default settings by adding a property for each nomenclatu ```json { "nomenclature": { + "save_default_values": false, "information": [ "METH_OBS", { @@ -192,7 +197,7 @@ You can override these default settings by adding a property for each nomenclatu Each property may be a simple string representing the nomenclature attribute to show or an object with the following properties: | Property | Description | Mandatory | -| --------- | --------------------------------------------------------------------- | --------- | +|-----------|-----------------------------------------------------------------------|-----------| | `key` | The nomenclature attribute | ☑ | | `visible` | If this attribute is visible (thus editable) or not (default: `true`) | ☐ | | `default` | If this attribute is shown by default (default: `true`) | ☐ | @@ -275,4 +280,4 @@ A full build can be executed with the following command: ## Financial support -This application have been developped with the financial support of the [Office Français de la Biodiversité](https://www.ofb.gouv.fr/) +This application have been developed with the financial support of the [Office Français de la Biodiversité](https://www.ofb.gouv.fr). diff --git a/gn_mobile_core b/gn_mobile_core index cdf89b6d..40f7ddea 160000 --- a/gn_mobile_core +++ b/gn_mobile_core @@ -1 +1 @@ -Subproject commit cdf89b6d9894f2e2c9deb6237ae034bae1ec2593 +Subproject commit 40f7ddeab6a8848ca7e3aed7b85298780dbdb88d diff --git a/gn_mobile_maps b/gn_mobile_maps index fbae4018..b67e4b3d 160000 --- a/gn_mobile_maps +++ b/gn_mobile_maps @@ -1 +1 @@ -Subproject commit fbae40182921bfbfa7e09fd955211fc051f00706 +Subproject commit b67e4b3dbac61878225aec98bda23ec7330a033f diff --git a/occtax/build.gradle b/occtax/build.gradle index b752705a..ee06d46d 100644 --- a/occtax/build.gradle +++ b/occtax/build.gradle @@ -5,8 +5,6 @@ plugins { id 'kotlin-kapt' } -version = "2.2.0" - android { compileSdkVersion 31 @@ -23,8 +21,8 @@ android { applicationId "fr.geonature.occtax2" minSdkVersion 26 targetSdkVersion 31 - versionCode 3070 - versionName "2.3.0" + versionCode 3090 + versionName "2.4.0" buildConfigField "String", "BUILD_DATE", "\"" + new Date().getTime() + "\"" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" archivesBaseName = project.name + "-" + versionName @@ -88,6 +86,11 @@ dependencies { runtimeOnly "org.tinylog:tinylog-impl:$tinylog_version" // Testing dependencies + testImplementation 'androidx.arch.core:core-testing:2.1.0' + testImplementation 'androidx.test.ext:junit-ktx:1.1.3' + testImplementation 'io.mockk:mockk:1.12.3' + testImplementation 'io.mockk:mockk-agent-jvm:1.12.3' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1' testImplementation 'org.robolectric:robolectric:4.8.1' } diff --git a/occtax/src/main/AndroidManifest.xml b/occtax/src/main/AndroidManifest.xml index 77e33c45..8e917ec8 100644 --- a/occtax/src/main/AndroidManifest.xml +++ b/occtax/src/main/AndroidManifest.xml @@ -49,6 +49,7 @@ android:theme="@style/AppTheme.NoActionBar" /> + + /** + * Gets all [Nomenclature] as default nomenclature values. + * + * @return a list of default [Nomenclature] + */ + suspend fun getAllDefaultNomenclatureValues(): List + + /** + * Gets all nomenclature values matching given nomenclature type and an optional taxonomy rank. + * + * @param mnemonic the nomenclature type as main filter + * @param taxonomy the taxonomy rank + * + * @return a list of [Nomenclature] matching given criteria + */ + suspend fun getNomenclatureValuesByTypeAndTaxonomy( + mnemonic: String, + taxonomy: Taxonomy? = null + ): List +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/INomenclatureSettingsLocalDataSource.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/INomenclatureSettingsLocalDataSource.kt new file mode 100644 index 00000000..1ea0ee37 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/INomenclatureSettingsLocalDataSource.kt @@ -0,0 +1,24 @@ +package fr.geonature.occtax.features.nomenclature.data + +import fr.geonature.occtax.features.nomenclature.domain.BaseEditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.domain.EditableNomenclatureType +import fr.geonature.occtax.settings.NomenclatureSettings +import fr.geonature.occtax.settings.PropertySettings + +/** + * Local data source about nomenclature types settings. + * + * @author S. Grimault + */ +interface INomenclatureSettingsLocalDataSource { + + /** + * Gets all [EditableNomenclatureType] matching given nomenclature main type. + * + * @return a list of [EditableNomenclatureType] + */ + suspend fun getNomenclatureTypeSettings( + type: BaseEditableNomenclatureType.Type, + vararg defaultPropertySettings: PropertySettings + ): List +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/IPropertyValueLocalDataSource.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/IPropertyValueLocalDataSource.kt new file mode 100644 index 00000000..118f45f9 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/IPropertyValueLocalDataSource.kt @@ -0,0 +1,57 @@ +package fr.geonature.occtax.features.nomenclature.data + +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.occtax.input.PropertyValue + +/** + * [PropertyValue] local data source. + * + * @author S. Grimault + */ +interface IPropertyValueLocalDataSource { + + /** + * Gets all property values matching given taxonomy rank. + * + * @param taxonomy the taxonomy rank as filter + */ + suspend fun getPropertyValues( + taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ) + ): List + + /** + * Adds or updates given property value for the given given taxonomy rank. + * + * @param taxonomy the taxonomy rank + * @param propertyValue the property value to add or update + */ + suspend fun setPropertyValue( + taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ), + vararg propertyValue: PropertyValue + ) + + /** + * Remove given property value by its code for the given given taxonomy rank. + * + * @param taxonomy the taxonomy rank + * @param code the property value code to remove + */ + suspend fun clearPropertyValue( + taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ), + vararg code: String + ) + + /** + * Clears all saved property values. + */ + suspend fun clearAllPropertyValues() +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/InMemoryPropertyValueLocalDataSourceImpl.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/InMemoryPropertyValueLocalDataSourceImpl.kt new file mode 100644 index 00000000..157bb76c --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/InMemoryPropertyValueLocalDataSourceImpl.kt @@ -0,0 +1,33 @@ +package fr.geonature.occtax.features.nomenclature.data + +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.occtax.input.PropertyValue + +/** + * In memory implementation of [IPropertyValueLocalDataSource]. + * + * @author S. Grimault + */ +class InMemoryPropertyValueLocalDataSourceImpl : IPropertyValueLocalDataSource { + private val propertyValues = mutableMapOf>() + + override suspend fun getPropertyValues(taxonomy: Taxonomy): List { + return propertyValues[taxonomy]?.toList() ?: emptyList() + } + + override suspend fun setPropertyValue(taxonomy: Taxonomy, vararg propertyValue: PropertyValue) { + propertyValues[taxonomy] = + getPropertyValues(taxonomy).filter { existingPropertyValue -> propertyValue.none { it.code == existingPropertyValue.code } } + .toSet() + propertyValue.toSet() + } + + override suspend fun clearPropertyValue(taxonomy: Taxonomy, vararg code: String) { + propertyValues[taxonomy] = + getPropertyValues(taxonomy).filter { propertyValue -> code.none { it == propertyValue.code } } + .toSet() + } + + override suspend fun clearAllPropertyValues() { + propertyValues.clear() + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/NomenclatureLocalDataSourceImpl.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/NomenclatureLocalDataSourceImpl.kt new file mode 100644 index 00000000..9c9b263c --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/NomenclatureLocalDataSourceImpl.kt @@ -0,0 +1,38 @@ +package fr.geonature.occtax.features.nomenclature.data + +import fr.geonature.commons.data.dao.NomenclatureDao +import fr.geonature.commons.data.dao.NomenclatureTypeDao +import fr.geonature.commons.data.entity.Nomenclature +import fr.geonature.commons.data.entity.NomenclatureType +import fr.geonature.commons.data.entity.Taxonomy + +/** + * Default implementation of [INomenclatureLocalDataSource] using local database. + * + * @author S. Grimault + */ +class NomenclatureLocalDataSourceImpl( + private val moduleName: String, + private val nomenclatureTypeDao: NomenclatureTypeDao, + private val nomenclatureDao: NomenclatureDao +) : INomenclatureLocalDataSource { + + override suspend fun getAllNomenclatureTypes(): List { + return nomenclatureTypeDao.findAll() + } + + override suspend fun getAllDefaultNomenclatureValues(): List { + return nomenclatureDao.findAllDefaultNomenclatureValues(moduleName) + } + + override suspend fun getNomenclatureValuesByTypeAndTaxonomy( + mnemonic: String, + taxonomy: Taxonomy? + ): List { + return nomenclatureDao.findAllByNomenclatureTypeAndByTaxonomy( + mnemonic, + taxonomy?.kingdom, + taxonomy?.group + ) + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/NomenclatureSettingsLocalDataSourceImpl.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/NomenclatureSettingsLocalDataSourceImpl.kt new file mode 100644 index 00000000..865a8b8a --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/data/NomenclatureSettingsLocalDataSourceImpl.kt @@ -0,0 +1,125 @@ +package fr.geonature.occtax.features.nomenclature.data + +import fr.geonature.occtax.features.nomenclature.domain.BaseEditableNomenclatureType +import fr.geonature.occtax.settings.PropertySettings + +/** + * Default implementation of [INomenclatureSettingsLocalDataSource]. + * + * @author S. Grimault + */ +class NomenclatureSettingsLocalDataSourceImpl : + INomenclatureSettingsLocalDataSource { + + private val defaultNomenclatureTypes = listOf( + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.DEFAULT, + "TYP_GRP", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "METH_OBS", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "ETA_BIO", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "METH_DETERMIN", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE, + default = false + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "DETERMINER", + BaseEditableNomenclatureType.ViewType.TEXT_SIMPLE, + default = false + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "STATUT_BIO", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE, + default = false + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "OCC_COMPORTEMENT", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE, + default = false + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "NATURALITE", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE, + default = false + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "PREUVE_EXIST", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE, + default = false + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.INFORMATION, + "COMMENT", + BaseEditableNomenclatureType.ViewType.TEXT_MULTIPLE, + default = false + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.COUNTING, + "STADE_VIE", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.COUNTING, + "SEXE", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.COUNTING, + "OBJ_DENBR", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.COUNTING, + "TYP_DENBR", + BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.COUNTING, + "MIN", + BaseEditableNomenclatureType.ViewType.MIN_MAX + ), + BaseEditableNomenclatureType.from( + BaseEditableNomenclatureType.Type.COUNTING, + "MAX", + BaseEditableNomenclatureType.ViewType.MIN_MAX + ) + ) + + override suspend fun getNomenclatureTypeSettings( + type: BaseEditableNomenclatureType.Type, + vararg defaultPropertySettings: PropertySettings + ): List { + if (defaultPropertySettings.isEmpty()) { + return defaultNomenclatureTypes.filter { it.type == type } + } + + return defaultPropertySettings + .mapNotNull { property -> + defaultNomenclatureTypes.find { it.code == property.key }?.let { + BaseEditableNomenclatureType.from( + it.type, + it.code, + it.viewType, + property.visible, + property.default + ) + } + } + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/domain/EditableNomenclatureType.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/domain/EditableNomenclatureType.kt new file mode 100644 index 00000000..f350b2f4 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/domain/EditableNomenclatureType.kt @@ -0,0 +1,225 @@ +package fr.geonature.occtax.features.nomenclature.domain + +import android.os.Parcel +import android.os.Parcelable +import androidx.core.os.ParcelCompat.readBoolean +import androidx.core.os.ParcelCompat.writeBoolean +import fr.geonature.occtax.input.PropertyValue + +/** + * Definition of an editable nomenclature type. + * + * @author S. Grimault + */ +abstract class BaseEditableNomenclatureType { + /** + * Main nomenclature type. + */ + abstract val type: Type + + /** + * Mnemonic code from nomenclature type. + */ + abstract val code: String + + /** + * The corresponding view type. + */ + abstract val viewType: ViewType + + /** + * Whether this property is visible (thus editable directly, default: `true`). + */ + abstract val visible: Boolean + + /** + * Whether this property is shown by default (default: `true`) + */ + abstract val default: Boolean + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BaseEditableNomenclatureType + + if (type != other.type) return false + if (code != other.code) return false + if (viewType != other.viewType) return false + if (visible != other.visible) return false + if (default != other.default) return false + + return true + } + + override fun hashCode(): Int { + var result = type.hashCode() + result = 31 * result + code.hashCode() + result = 31 * result + viewType.hashCode() + result = 31 * result + visible.hashCode() + result = 31 * result + default.hashCode() + + return result + } + + override fun toString(): String { + return "BaseEditableNomenclatureType(type=$type, code='$code', viewType=$viewType, visible=$visible, default=$default)" + } + + companion object { + + /** + * Factory to create [BaseEditableNomenclatureType] from given properties. + */ + fun from( + type: Type, + code: String, + viewType: ViewType, + visible: Boolean = true, + default: Boolean = true + ): BaseEditableNomenclatureType = object : BaseEditableNomenclatureType() { + override val type: Type + get() = type + override val code: String + get() = code + override val viewType: ViewType + get() = viewType + override val visible: Boolean + get() = visible + override val default: Boolean + get() = default + } + } + + /** + * Describes main editable nomenclature type. + */ + enum class Type { + /** + * Default nomenclature types. + */ + DEFAULT, + + /** + * Nomenclature types used for main information. + */ + INFORMATION, + + /** + * Nomenclature types used for describing counting. + */ + COUNTING + } + + /** + * Describes an editable nomenclature type view type. + */ + enum class ViewType { + /** + * No specific view type. + */ + NONE, + + /** + * As dropdown menu items. + */ + NOMENCLATURE_TYPE, + + /** + * As a simple text field. + */ + TEXT_SIMPLE, + + /** + * As multi-lines text field. + */ + TEXT_MULTIPLE, + + /** + * As a bounded numerical value. + */ + MIN_MAX + } +} + +/** + * Describes an editable nomenclature type with value. + * + * @author S. Grimault + */ +data class EditableNomenclatureType( + override val type: Type, + override val code: String, + override val viewType: ViewType, + override val visible: Boolean = true, + override val default: Boolean = true, + + /** + * Nomenclature type's label. + */ + val label: String? = null, + + /** + * The current value for this nomenclature type. + */ + var value: PropertyValue? = null, + + /** + * Whether this property is locked for modification (default: `false`). + */ + var locked: Boolean = false +) : BaseEditableNomenclatureType(), Parcelable { + + private constructor(source: Parcel) : this( + source.readSerializable() as Type, + source.readString()!!, + source.readSerializable() as ViewType, + readBoolean(source), + readBoolean(source), + source.readString(), + source.readParcelable(PropertyValue::class.java.classLoader), + readBoolean(source) + ) + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel( + dest: Parcel?, + flags: Int + ) { + dest?.also { + it.writeSerializable(type) + it.writeString(code) + it.writeSerializable(viewType) + writeBoolean( + it, + visible + ) + writeBoolean( + it, + default + ) + it.writeString(label) + it.writeParcelable( + value, + flags + ) + writeBoolean( + it, + locked + ) + } + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): EditableNomenclatureType { + return EditableNomenclatureType(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/error/NomenclatureFailure.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/error/NomenclatureFailure.kt new file mode 100644 index 00000000..166ac922 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/error/NomenclatureFailure.kt @@ -0,0 +1,19 @@ +package fr.geonature.occtax.features.nomenclature.error + +import fr.geonature.commons.data.entity.Nomenclature +import fr.geonature.commons.data.entity.NomenclatureType +import fr.geonature.commons.error.Failure + +/** + * Failure about no [NomenclatureType] found locally. + * + * @author S. Grimault + */ +object NoNomenclatureTypeFoundLocallyFailure : Failure.FeatureFailure() + +/** + * Failure about no [Nomenclature] found from given mnemonic. + * + * @author S. Grimault + */ +data class NoNomenclatureValuesFoundFailure(val mnemonic: String) : Failure.FeatureFailure() \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/error/PropertyValueFailure.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/error/PropertyValueFailure.kt new file mode 100644 index 00000000..3b6e005e --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/error/PropertyValueFailure.kt @@ -0,0 +1,11 @@ +package fr.geonature.occtax.features.nomenclature.error + +import fr.geonature.commons.error.Failure +import fr.geonature.occtax.input.PropertyValue + +/** + * Failure about [PropertyValue]. + * + * @author S. Grimault + */ +data class PropertyValueFailure(val code: String) : Failure.FeatureFailure() diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/EditableNomenclatureTypeAdapter.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/EditableNomenclatureTypeAdapter.kt new file mode 100644 index 00000000..7c9e0d85 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/EditableNomenclatureTypeAdapter.kt @@ -0,0 +1,444 @@ +package fr.geonature.occtax.features.nomenclature.presentation + +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AutoCompleteTextView +import android.widget.Button +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.textfield.TextInputLayout +import fr.geonature.commons.data.entity.Nomenclature +import fr.geonature.commons.lifecycle.observeOnce +import fr.geonature.commons.util.KeyboardUtils.hideSoftKeyboard +import fr.geonature.occtax.R +import fr.geonature.occtax.features.nomenclature.domain.BaseEditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.domain.EditableNomenclatureType +import fr.geonature.occtax.input.PropertyValue + +/** + * Default RecyclerView Adapter about [EditableNomenclatureType]. + * + * @author S. Grimault + */ +class EditableNomenclatureTypeAdapter(private val listener: OnEditableNomenclatureTypeAdapter) : + RecyclerView.Adapter() { + + private val availableNomenclatureTypes = mutableListOf() + private val selectedNomenclatureTypes = mutableListOf() + private var showAllNomenclatureTypes = false + private var lockDefaultValues = false + + init { + this.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onChanged() { + super.onChanged() + + listener.showEmptyTextView(itemCount == 0) + } + + override fun onItemRangeChanged( + positionStart: Int, + itemCount: Int + ) { + super.onItemRangeChanged( + positionStart, + itemCount + ) + + listener.showEmptyTextView(getItemCount() == 0) + } + + override fun onItemRangeInserted( + positionStart: Int, + itemCount: Int + ) { + super.onItemRangeInserted( + positionStart, + itemCount + ) + + listener.showEmptyTextView(false) + } + + override fun onItemRangeRemoved( + positionStart: Int, + itemCount: Int + ) { + super.onItemRangeRemoved( + positionStart, + itemCount + ) + + listener.showEmptyTextView(getItemCount() == 0) + } + }) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractViewHolder { + return when (viewType) { + BaseEditableNomenclatureType.ViewType.NONE.ordinal -> MoreViewHolder(parent) + BaseEditableNomenclatureType.ViewType.TEXT_SIMPLE.ordinal -> TextSimpleViewHolder(parent) + BaseEditableNomenclatureType.ViewType.TEXT_MULTIPLE.ordinal -> TextMultipleViewHolder(parent) + else -> NomenclatureTypeViewHolder(parent) + } + } + + override fun onBindViewHolder(holder: AbstractViewHolder, position: Int) { + selectedNomenclatureTypes[position].also { + holder.bind(it) + } + } + + override fun getItemCount(): Int { + return selectedNomenclatureTypes.size + } + + override fun getItemViewType(position: Int): Int { + return selectedNomenclatureTypes[position].viewType.ordinal + } + + fun bind( + nomenclatureTypes: List, + vararg propertyValue: PropertyValue + ) { + availableNomenclatureTypes.clear() + availableNomenclatureTypes.addAll( + nomenclatureTypes.filter { it.visible }.map { + it.copy(value = propertyValue.firstOrNull { propertyValue -> propertyValue.code == it.code } + ?: it.value) + } + ) + + if (showAllNomenclatureTypes) showAllNomenclatureTypes(notify = true) else showDefaultNomenclatureTypes(notify = true) + } + + fun showDefaultNomenclatureTypes(notify: Boolean = false) { + showAllNomenclatureTypes = false + + if (availableNomenclatureTypes.isEmpty()) return + + availableNomenclatureTypes.filter { it.default }.run { + if (isEmpty()) { + // nothing to show by default: show everything + showAllNomenclatureTypes(notify) + } else { + setSelectedNomenclatureTypes( + // show 'MORE' button only if we have some other editable nomenclatures to show + this + if (this.size < availableNomenclatureTypes.size) listOf( + EditableNomenclatureType( + BaseEditableNomenclatureType.Type.INFORMATION, + "MORE", + BaseEditableNomenclatureType.ViewType.NONE, + true + ) + ) else emptyList() + ) + } + } + } + + fun showAllNomenclatureTypes(notify: Boolean = false) { + showAllNomenclatureTypes = true + setSelectedNomenclatureTypes( + availableNomenclatureTypes, + notify + ) + } + + fun lockDefaultValues(lock: Boolean = false) { + lockDefaultValues = lock + } + + private fun setSelectedNomenclatureTypes( + nomenclatureTypes: List, + notify: Boolean = false + ) { + val oldKeys = selectedNomenclatureTypes.map { it.code } + val newKeys = nomenclatureTypes.map { it.code } + + if (notify && oldKeys.isEmpty() && newKeys.isEmpty()) { + listener.showEmptyTextView(true) + + return + } + + val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldKeys.size + + override fun getNewListSize(): Int = newKeys.size + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ) = oldKeys.elementAtOrNull(oldItemPosition) == newKeys.elementAtOrNull(newItemPosition) + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ) = + oldKeys.elementAtOrNull(oldItemPosition) + ?.let { code -> selectedNomenclatureTypes.firstOrNull { it.code == code } } + ?.value?.value == newKeys.elementAtOrNull(newItemPosition) + ?.let { code -> nomenclatureTypes.firstOrNull { it.code == code } } + ?.value?.value + }) + + selectedNomenclatureTypes.clear() + selectedNomenclatureTypes.addAll(nomenclatureTypes) + + diffResult.dispatchUpdatesTo(this) + } + + abstract inner class AbstractViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + internal var nomenclatureType: EditableNomenclatureType? = null + + fun bind(nomenclatureType: EditableNomenclatureType) { + this.nomenclatureType = nomenclatureType + + onBind(nomenclatureType) + } + + abstract fun onBind(nomenclatureType: EditableNomenclatureType) + + /** + * Build the default label for given editable nomenclature type as fallback. + */ + fun getNomenclatureTypeLabel(mnemonic: String): String { + return itemView.resources.getIdentifier( + "nomenclature_${mnemonic.lowercase()}", + "string", + itemView.context.packageName + ).takeIf { it > 0 }?.let { itemView.context.getString(it) } ?: mnemonic + } + } + + inner class NomenclatureTypeViewHolder(parent: ViewGroup) : AbstractViewHolder( + LayoutInflater.from(parent.context).inflate( + R.layout.view_action_nomenclature_type_select, + parent, + false + ) + ) { + private var edit: TextInputLayout = itemView.findViewById(android.R.id.edit) + private var nomenclatureAdapter = NomenclatureValueAdapter(parent.context) + private var showDropdown = false + + init { + (edit.editText as? AutoCompleteTextView)?.also { + it.setAdapter(nomenclatureAdapter) + it.setOnItemClickListener { _, _, position, _ -> + showDropdown = false + nomenclatureType?.run { + value = PropertyValue.fromNomenclature( + code, + nomenclatureAdapter.getNomenclatureValue(position) + ) + listener.onUpdate(this) + } + } + } + } + + override fun onBind(nomenclatureType: EditableNomenclatureType) { + if (!lockDefaultValues) { + nomenclatureType.locked = false + } + + with(edit) { + startIconDrawable = if (lockDefaultValues) ResourcesCompat.getDrawable( + itemView.resources, + if (nomenclatureType.locked) R.drawable.ic_lock else R.drawable.ic_lock_open, + itemView.context.theme + ) else null + setStartIconOnClickListener { + if (!lockDefaultValues) return@setStartIconOnClickListener + + nomenclatureType.locked = !nomenclatureType.locked + startIconDrawable = ResourcesCompat.getDrawable( + itemView.resources, + if (nomenclatureType.locked) R.drawable.ic_lock else R.drawable.ic_lock_open, + itemView.context.theme + ) + listener.onUpdate(nomenclatureType) + } + hint = nomenclatureType.label ?: getNomenclatureTypeLabel(nomenclatureType.code) + setEndIconOnClickListener { setNomenclatureValues(nomenclatureType) } + (editText as? AutoCompleteTextView)?.apply { + setOnClickListener { setNomenclatureValues(nomenclatureType) } + text = nomenclatureType.value?.let { + Editable.Factory + .getInstance() + .newEditable(it.label ?: it.code) + } + } + } + } + + private fun setNomenclatureValues(nomenclatureType: EditableNomenclatureType) { + if (showDropdown) { + showDropdown = false + (edit.editText as? AutoCompleteTextView)?.dismissDropDown() + return + } + + listener.getNomenclatureValues(nomenclatureType.code) + .observeOnce(listener.getLifecycleOwner()) { + showDropdown = true + nomenclatureAdapter.setNomenclatureValues(it ?: listOf()) + (edit.editText as? AutoCompleteTextView)?.showDropDown() + } + } + } + + inner class MoreViewHolder(parent: ViewGroup) : AbstractViewHolder( + LayoutInflater.from(parent.context).inflate( + R.layout.view_action_more, + parent, + false + ) + ) { + private var button1: Button = itemView.findViewById(android.R.id.button1) + + override fun onBind(nomenclatureType: EditableNomenclatureType) { + with(button1) { + text = getNomenclatureTypeLabel(nomenclatureType.code) + setOnClickListener { + showAllNomenclatureTypes() + listener.showMore() + } + } + } + } + + open inner class TextSimpleViewHolder(parent: ViewGroup) : AbstractViewHolder( + LayoutInflater.from(parent.context).inflate( + R.layout.view_action_edit_text, + parent, + false + ) + ) { + internal var edit: TextInputLayout = itemView.findViewById(android.R.id.edit) + private val textWatcher = object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int + ) { + } + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int + ) { + } + + override fun afterTextChanged(s: Editable?) { + nomenclatureType?.run { + value = PropertyValue.fromValue( + code, + s?.toString()?.ifEmpty { null }?.ifBlank { null } + ) + listener.onUpdate(this) + } + } + } + + init { + with(edit) { + editText?.addTextChangedListener(textWatcher) + setOnFocusChangeListener { v, hasFocus -> + if (!hasFocus) { + // workaround to force hide the soft keyboard + hideSoftKeyboard(v) + } + } + } + } + + override fun onBind(nomenclatureType: EditableNomenclatureType) { + if (!lockDefaultValues) { + nomenclatureType.locked = false + } + + with(edit) { + startIconDrawable = if (lockDefaultValues) ResourcesCompat.getDrawable( + itemView.resources, + if (nomenclatureType.locked) R.drawable.ic_lock else R.drawable.ic_lock_open, + itemView.context.theme + ) else null + setStartIconOnClickListener { + if (!lockDefaultValues) return@setStartIconOnClickListener + + nomenclatureType.locked = !nomenclatureType.locked + startIconDrawable = ResourcesCompat.getDrawable( + itemView.resources, + if (nomenclatureType.locked) R.drawable.ic_lock else R.drawable.ic_lock_open, + itemView.context.theme + ) + listener.onUpdate(nomenclatureType) + } + hint = getNomenclatureTypeLabel(nomenclatureType.code) + } + + if (nomenclatureType.value?.value is String? && !(nomenclatureType.value?.value as String?).isNullOrEmpty()) { + edit.editText?.removeTextChangedListener(textWatcher) + edit.editText?.text = (nomenclatureType.value?.value as String?)?.let { + Editable.Factory.getInstance().newEditable(it) + } + edit.editText?.addTextChangedListener(textWatcher) + } + } + } + + inner class TextMultipleViewHolder(parent: ViewGroup) : TextSimpleViewHolder(parent) { + init { + edit.isCounterEnabled = true + edit.editText?.apply { + isSingleLine = false + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE + minLines = 2 + maxLines = 4 + } + } + } + + /** + * Callback used by [EditableNomenclatureTypeAdapter]. + */ + interface OnEditableNomenclatureTypeAdapter { + + fun getLifecycleOwner(): LifecycleOwner + + /** + * Whether to show an empty text view when data changed. + */ + fun showEmptyTextView(show: Boolean) + + /** + * Called when the 'more' action button has been clicked. + */ + fun showMore() + + /** + * Requests showing all available nomenclature values from given nomenclature type. + */ + fun getNomenclatureValues(nomenclatureTypeMnemonic: String): LiveData> + + /** + * Called when an [EditableNomenclatureType] has been updated. + * + * @param editableNomenclatureType the [EditableNomenclatureType] updated + */ + fun onUpdate(editableNomenclatureType: EditableNomenclatureType) + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/NomenclatureValueAdapter.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/NomenclatureValueAdapter.kt new file mode 100644 index 00000000..ae8ebaf4 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/NomenclatureValueAdapter.kt @@ -0,0 +1,91 @@ +package fr.geonature.occtax.features.nomenclature.presentation + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.Filter +import android.widget.Filterable +import android.widget.TextView +import fr.geonature.commons.data.entity.Nomenclature + +/** + * Default Adapter about [Nomenclature] values. + * + * @author S. Grimault + */ +class NomenclatureValueAdapter(context: Context) : BaseAdapter(), Filterable { + private val nomenclatureValues = mutableListOf() + private val filteredNomenclatureValues = mutableListOf() + private val layoutInflater: LayoutInflater = LayoutInflater.from(context) + private val defaultFilter = DefaultFilter() + + override fun getCount(): Int { + return filteredNomenclatureValues.size + } + + override fun getItem(position: Int): String { + return filteredNomenclatureValues[position].defaultLabel + } + + override fun getItemId(position: Int): Long { + return filteredNomenclatureValues[position].id + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { + val view = convertView + ?: layoutInflater.inflate( + android.R.layout.simple_list_item_1, + parent, + false + ) + view.findViewById(android.R.id.text1).text = + filteredNomenclatureValues[position].defaultLabel + + return view + } + + override fun getFilter(): Filter { + return defaultFilter + } + + fun getNomenclatureValue(position: Int): Nomenclature { + return filteredNomenclatureValues[position] + } + + fun setNomenclatureValues(nomenclatureValues: List) { + with(this.nomenclatureValues) { + clear() + addAll(nomenclatureValues) + } + with(this.filteredNomenclatureValues) { + clear() + addAll(nomenclatureValues) + } + + notifyDataSetChanged() + } + + inner class DefaultFilter : Filter() { + override fun performFiltering(constraint: CharSequence?): FilterResults { + return filteredNomenclatureValues.filter { if (constraint == null) false else it.defaultLabel.contains(constraint) } + .let { + FilterResults().apply { + values = it + count = it.size + } + } + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults?) { + with(filteredNomenclatureValues) { + clear() + @Suppress("UNCHECKED_CAST") + addAll((results?.values as List?) ?: emptyList()) + } + + if (results?.count == 0) notifyDataSetInvalidated() else notifyDataSetChanged() + } + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/NomenclatureViewModel.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/NomenclatureViewModel.kt new file mode 100644 index 00000000..84dbe356 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/NomenclatureViewModel.kt @@ -0,0 +1,86 @@ +package fr.geonature.occtax.features.nomenclature.presentation + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import fr.geonature.commons.data.entity.Nomenclature +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.lifecycle.BaseViewModel +import fr.geonature.occtax.features.nomenclature.domain.BaseEditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.domain.EditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.usecase.GetEditableNomenclaturesUseCase +import fr.geonature.occtax.features.nomenclature.usecase.GetNomenclatureValuesByTypeAndTaxonomyUseCase +import fr.geonature.occtax.settings.PropertySettings +import javax.inject.Inject + +/** + * Nomenclature view model. + * + * @author S. Grimault + * + * @see GetEditableNomenclaturesUseCase + * @see GetNomenclatureValuesByTypeAndTaxonomyUseCase + */ +@HiltViewModel +class NomenclatureViewModel @Inject constructor( + private val getEditableNomenclaturesUseCase: GetEditableNomenclaturesUseCase, + private val getNomenclatureValuesByTypeAndTaxonomyUseCase: GetNomenclatureValuesByTypeAndTaxonomyUseCase +) : + BaseViewModel() { + + private val _editableNomenclatures = MutableLiveData>() + val editableNomenclatures: LiveData> = _editableNomenclatures + + /** + * Gets all editable nomenclatures from given type with default values. + * + * @param type the main editable nomenclature type + * @param defaultPropertySettings the default nomenclature settings + */ + fun getEditableNomenclatures( + type: BaseEditableNomenclatureType.Type, + defaultPropertySettings: List = listOf(), + taxonomy: Taxonomy? = null + ) { + getEditableNomenclaturesUseCase( + GetEditableNomenclaturesUseCase.Params( + type, + defaultPropertySettings, + taxonomy + ), + viewModelScope + ) { + it.fold(::handleFailure) { editableNomenclatures -> + _editableNomenclatures.value = editableNomenclatures + } + } + } + + /** + * Gets all nomenclature values matching given nomenclature type and an optional taxonomy rank. + * + * @param mnemonic the nomenclature type as main filter + * @param taxonomy the taxonomy rank + */ + fun getNomenclatureValuesByTypeAndTaxonomy( + mnemonic: String, + taxonomy: Taxonomy? = null + ): LiveData> { + val nomenclatureValuesByTypeAndTaxonomy = MutableLiveData>() + + getNomenclatureValuesByTypeAndTaxonomyUseCase( + GetNomenclatureValuesByTypeAndTaxonomyUseCase.Params( + mnemonic, + taxonomy + ), + viewModelScope + ) { + it.fold(::handleFailure) { nomenclatureValues -> + nomenclatureValuesByTypeAndTaxonomy.value = nomenclatureValues + } + } + + return nomenclatureValuesByTypeAndTaxonomy + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/PropertyValueModel.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/PropertyValueModel.kt new file mode 100644 index 00000000..dc8646a5 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/presentation/PropertyValueModel.kt @@ -0,0 +1,85 @@ +package fr.geonature.occtax.features.nomenclature.presentation + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.lifecycle.BaseViewModel +import fr.geonature.occtax.features.nomenclature.usecase.ClearDefaultPropertyValueUseCase +import fr.geonature.occtax.features.nomenclature.usecase.SetDefaultPropertyValueUseCase +import fr.geonature.occtax.input.PropertyValue +import javax.inject.Inject + +/** + * [PropertyValue] view model. + * + * @author S. Grimault + * + * @see SetDefaultPropertyValueUseCase + */ +@HiltViewModel +class PropertyValueModel @Inject constructor( + private val setDefaultPropertyValueUseCase: SetDefaultPropertyValueUseCase, + private val clearDefaultPropertyValueUseCase: ClearDefaultPropertyValueUseCase +) : + BaseViewModel() { + + /** + * Adds or updates given property value for the given given taxonomy rank. + * + * @param taxonomy the taxonomy rank + * @param propertyValue the property value to add or update + */ + fun setPropertyValue( + taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ), + propertyValue: PropertyValue + ) { + setDefaultPropertyValueUseCase( + SetDefaultPropertyValueUseCase.Params( + taxonomy, + propertyValue + ), + viewModelScope + ) { + it.fold(::handleFailure) {} + } + } + + /** + * Remove given property value by its code for the given given taxonomy rank. + * + * @param taxonomy the taxonomy rank + * @param code the property value code to remove + */ + fun clearPropertyValue( + taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ), + code: String + ) { + clearDefaultPropertyValueUseCase( + ClearDefaultPropertyValueUseCase.Params.Params( + taxonomy, + code + ), + viewModelScope + ) { + it.fold(::handleFailure) {} + } + } + + /** + * Clears all saved property values. + */ + fun clearAllPropertyValues() { + clearDefaultPropertyValueUseCase( + ClearDefaultPropertyValueUseCase.Params.None, + viewModelScope + ) { + it.fold(::handleFailure) {} + } + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/DefaultPropertyValueRepositoryImpl.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/DefaultPropertyValueRepositoryImpl.kt new file mode 100644 index 00000000..8c553ef3 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/DefaultPropertyValueRepositoryImpl.kt @@ -0,0 +1,84 @@ +package fr.geonature.occtax.features.nomenclature.repository + +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.error.Failure +import fr.geonature.commons.fp.Either +import fr.geonature.commons.fp.Either.Left +import fr.geonature.commons.fp.Either.Right +import fr.geonature.occtax.features.nomenclature.data.INomenclatureLocalDataSource +import fr.geonature.occtax.features.nomenclature.data.IPropertyValueLocalDataSource +import fr.geonature.occtax.features.nomenclature.error.PropertyValueFailure +import fr.geonature.occtax.input.PropertyValue +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.toList + +/** + * Default implementation of [IDefaultPropertyValueRepository]. + * + * @author S. Grimault + */ +class DefaultPropertyValueRepositoryImpl( + private val propertyValueLocalDataSource: IPropertyValueLocalDataSource, + private val nomenclatureLocalDataSource: INomenclatureLocalDataSource +) : + IDefaultPropertyValueRepository { + + override suspend fun getPropertyValues(taxonomy: Taxonomy?): Either> { + return Right(runCatching { + propertyValueLocalDataSource.getPropertyValues( + taxonomy ?: Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ) + ) + }.getOrElse { emptyList() } + .asFlow() + .filter { propertyValue -> + runCatching { + nomenclatureLocalDataSource.getNomenclatureValuesByTypeAndTaxonomy( + propertyValue.code, + taxonomy + ) + }.getOrElse { emptyList() }.takeIf { it.isNotEmpty() } + ?.any { it.id == propertyValue.value } ?: true + } + .toList()) + } + + override suspend fun setPropertyValue( + taxonomy: Taxonomy, + propertyValue: PropertyValue + ): Either { + return runCatching { + propertyValueLocalDataSource.setPropertyValue( + taxonomy, + propertyValue + ) + }.fold( + onSuccess = { Right(Unit) }, + onFailure = { Left(PropertyValueFailure(propertyValue.code)) } + ) + } + + override suspend fun clearPropertyValue( + taxonomy: Taxonomy, + code: String + ): Either { + return runCatching { + propertyValueLocalDataSource.clearPropertyValue( + taxonomy, + code + ) + }.fold( + onSuccess = { Right(Unit) }, + onFailure = { Left(PropertyValueFailure(code)) } + ) + } + + override suspend fun clearAllPropertyValues(): Either { + propertyValueLocalDataSource.clearAllPropertyValues() + + return Right(Unit) + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/IDefaultPropertyValueRepository.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/IDefaultPropertyValueRepository.kt new file mode 100644 index 00000000..13e3b0f3 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/IDefaultPropertyValueRepository.kt @@ -0,0 +1,54 @@ +package fr.geonature.occtax.features.nomenclature.repository + +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.error.Failure +import fr.geonature.commons.fp.Either +import fr.geonature.occtax.input.PropertyValue + +/** + * Default [PropertyValue] repository. + * + * @author S. Grimault + */ +interface IDefaultPropertyValueRepository { + + /** + * Gets all defined property values. + * + * @param taxonomy the taxonomy rank as filter + */ + suspend fun getPropertyValues(taxonomy: Taxonomy? = null): Either> + + /** + * Adds or updates given property value for the given given taxonomy rank. + * + * @param taxonomy the taxonomy rank + * @param propertyValue the property value to add or update + */ + suspend fun setPropertyValue( + taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ), + propertyValue: PropertyValue + ): Either + + /** + * Remove given property value by its code for the given given taxonomy rank. + * + * @param taxonomy the taxonomy rank + * @param code the property value code to remove + */ + suspend fun clearPropertyValue( + taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ), + code: String + ): Either + + /** + * Clears all saved property values. + */ + suspend fun clearAllPropertyValues(): Either +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/INomenclatureRepository.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/INomenclatureRepository.kt new file mode 100644 index 00000000..5febca0f --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/INomenclatureRepository.kt @@ -0,0 +1,43 @@ +package fr.geonature.occtax.features.nomenclature.repository + +import fr.geonature.commons.data.entity.Nomenclature +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.error.Failure +import fr.geonature.commons.fp.Either +import fr.geonature.occtax.features.nomenclature.domain.BaseEditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.domain.EditableNomenclatureType +import fr.geonature.occtax.settings.PropertySettings + +/** + * Editable nomenclature types repository. + * + * @author S. Grimault + */ +interface INomenclatureRepository { + + /** + * Gets all editable nomenclatures from given type with default values from nomenclature. + * + * @param type the main editable nomenclature type + * @param defaultPropertySettings the default nomenclature settings + * + * @return a list of [EditableNomenclatureType] or [Failure] if none was configured + */ + suspend fun getEditableNomenclatures( + type: BaseEditableNomenclatureType.Type, + vararg defaultPropertySettings: PropertySettings + ): Either> + + /** + * Gets all nomenclature values matching given nomenclature type and an optional taxonomy rank. + * + * @param mnemonic the nomenclature type as main filter + * @param taxonomy the taxonomy rank + * + * @return a list of [Nomenclature] matching given criteria or [Failure] if something goes wrong + */ + suspend fun getNomenclatureValuesByTypeAndTaxonomy( + mnemonic: String, + taxonomy: Taxonomy? = null + ): Either> +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/NomenclatureRepositoryImpl.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/NomenclatureRepositoryImpl.kt new file mode 100644 index 00000000..36da699f --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/repository/NomenclatureRepositoryImpl.kt @@ -0,0 +1,107 @@ +package fr.geonature.occtax.features.nomenclature.repository + +import fr.geonature.commons.data.entity.Nomenclature +import fr.geonature.commons.data.entity.NomenclatureWithType +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.error.Failure +import fr.geonature.commons.fp.Either +import fr.geonature.commons.fp.Either.Left +import fr.geonature.commons.fp.Either.Right +import fr.geonature.occtax.features.nomenclature.data.INomenclatureLocalDataSource +import fr.geonature.occtax.features.nomenclature.data.INomenclatureSettingsLocalDataSource +import fr.geonature.occtax.features.nomenclature.domain.BaseEditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.domain.EditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.error.NoNomenclatureTypeFoundLocallyFailure +import fr.geonature.occtax.features.nomenclature.error.NoNomenclatureValuesFoundFailure +import fr.geonature.occtax.input.PropertyValue +import fr.geonature.occtax.settings.PropertySettings +import org.tinylog.Logger + +/** + * Default implementation of [INomenclatureRepository]. + * + * @author S. Grimault + */ +class NomenclatureRepositoryImpl( + private val nomenclatureLocalDataSource: INomenclatureLocalDataSource, + private val nomenclatureSettingsLocalDataSource: INomenclatureSettingsLocalDataSource +) : INomenclatureRepository { + + override suspend fun getEditableNomenclatures( + type: BaseEditableNomenclatureType.Type, + vararg defaultPropertySettings: PropertySettings + ): Either> { + return runCatching { + val nomenclatureTypes = + nomenclatureLocalDataSource.getAllNomenclatureTypes().associateBy { it.mnemonic } + + val defaultNomenclatureValues = + nomenclatureLocalDataSource.getAllDefaultNomenclatureValues().map { nomenclature -> + NomenclatureWithType(nomenclature).apply { + this.type = + nomenclatureTypes.entries.firstOrNull { it.value.id == typeId }?.value + } + }.filter { it.type != null } + + nomenclatureSettingsLocalDataSource.getNomenclatureTypeSettings( + type, + *defaultPropertySettings + ).mapNotNull { + if (it.viewType == BaseEditableNomenclatureType.ViewType.NOMENCLATURE_TYPE) nomenclatureTypes[it.code]?.let { nomenclatureType -> + EditableNomenclatureType( + it.type, + it.code, + it.viewType, + it.visible, + it.default, + nomenclatureType.defaultLabel.takeIf { label -> label.isNotEmpty() } + ?: run { + Logger.warn { "no label found for nomenclature type '${nomenclatureType.mnemonic}', use default…" } + null + } + ) + } else EditableNomenclatureType( + it.type, + it.code, + it.viewType, + it.visible, + it.default + ) + }.map { editableNomenclature -> + editableNomenclature.copy(value = defaultNomenclatureValues.firstOrNull { it.type?.mnemonic == editableNomenclature.code } + ?.let { + PropertyValue.fromNomenclature( + editableNomenclature.code, + it + ) + }) + } + }.fold( + onSuccess = { + if (it.isEmpty()) Left(NoNomenclatureTypeFoundLocallyFailure) else Right(it) + }, + onFailure = { + Left(Failure.DbFailure(it)) + } + ) + } + + override suspend fun getNomenclatureValuesByTypeAndTaxonomy( + mnemonic: String, + taxonomy: Taxonomy? + ): Either> { + return runCatching { + nomenclatureLocalDataSource.getNomenclatureValuesByTypeAndTaxonomy( + mnemonic, + taxonomy + ) + }.fold( + onSuccess = { + if (it.isEmpty()) Left(NoNomenclatureValuesFoundFailure(mnemonic)) else Right(it) + }, + onFailure = { + Left(Failure.DbFailure(it)) + } + ) + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/ClearDefaultPropertyValueUseCase.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/ClearDefaultPropertyValueUseCase.kt new file mode 100644 index 00000000..00fc2260 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/ClearDefaultPropertyValueUseCase.kt @@ -0,0 +1,41 @@ +package fr.geonature.occtax.features.nomenclature.usecase + +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.error.Failure +import fr.geonature.commons.fp.Either +import fr.geonature.commons.interactor.BaseUseCase +import fr.geonature.occtax.features.nomenclature.repository.IDefaultPropertyValueRepository +import javax.inject.Inject + +/** + * Remove given property value by its code for the given given taxonomy rank. + * If no property value code is given, clears all saved property values. + * + * @author S. Grimault + */ +class ClearDefaultPropertyValueUseCase @Inject constructor( + private val defaultPropertyValueRepository: IDefaultPropertyValueRepository +) : + BaseUseCase() { + override suspend fun run(params: Params): Either { + return when (params) { + Params.None -> defaultPropertyValueRepository.clearAllPropertyValues() + is Params.Params -> defaultPropertyValueRepository.clearPropertyValue( + params.taxonomy, + params.code + ) + } + } + + sealed class Params { + data class Params( + val taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ), + val code: String + ) : ClearDefaultPropertyValueUseCase.Params() + + object None : ClearDefaultPropertyValueUseCase.Params() + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/GetEditableNomenclaturesUseCase.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/GetEditableNomenclaturesUseCase.kt new file mode 100644 index 00000000..3d38832c --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/GetEditableNomenclaturesUseCase.kt @@ -0,0 +1,62 @@ +package fr.geonature.occtax.features.nomenclature.usecase + +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.error.Failure +import fr.geonature.commons.fp.Either +import fr.geonature.commons.fp.Either.Left +import fr.geonature.commons.fp.Either.Right +import fr.geonature.commons.fp.getOrElse +import fr.geonature.commons.interactor.BaseUseCase +import fr.geonature.occtax.features.nomenclature.domain.BaseEditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.domain.EditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.error.NoNomenclatureTypeFoundLocallyFailure +import fr.geonature.occtax.features.nomenclature.repository.IDefaultPropertyValueRepository +import fr.geonature.occtax.features.nomenclature.repository.INomenclatureRepository +import fr.geonature.occtax.settings.PropertySettings +import javax.inject.Inject + +/** + * Gets all editable nomenclatures from given type with default values. + * + * @author S. Grimault + */ +class GetEditableNomenclaturesUseCase @Inject constructor( + private val nomenclatureRepository: INomenclatureRepository, + private val defaultPropertyValueRepository: IDefaultPropertyValueRepository +) : + BaseUseCase, GetEditableNomenclaturesUseCase.Params>() { + override suspend fun run(params: Params): Either> { + val editableNomenclaturesResult = nomenclatureRepository.getEditableNomenclatures( + params.type, + *params.settings.toTypedArray() + ) + + if (editableNomenclaturesResult.isLeft) { + return editableNomenclaturesResult + } + + val editableNomenclatures = editableNomenclaturesResult.getOrElse { emptyList() } + + if (editableNomenclatures.isEmpty()) { + return Left(NoNomenclatureTypeFoundLocallyFailure) + } + + val defaultPropertyValues = + defaultPropertyValueRepository.getPropertyValues(params.taxonomy) + .getOrElse { emptyList() } + + return Right(editableNomenclatures.map { editableNomenclature -> + editableNomenclature.copy( + value = defaultPropertyValues.firstOrNull { it.code == editableNomenclature.code } + ?: editableNomenclature.value, + locked = defaultPropertyValues.any { it.code == editableNomenclature.code } + ) + }) + } + + data class Params( + val type: BaseEditableNomenclatureType.Type, + val settings: List = listOf(), + val taxonomy: Taxonomy? = null + ) +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/GetNomenclatureValuesByTypeAndTaxonomyUseCase.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/GetNomenclatureValuesByTypeAndTaxonomyUseCase.kt new file mode 100644 index 00000000..ca55fe03 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/GetNomenclatureValuesByTypeAndTaxonomyUseCase.kt @@ -0,0 +1,29 @@ +package fr.geonature.occtax.features.nomenclature.usecase + +import fr.geonature.commons.data.entity.Nomenclature +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.error.Failure +import fr.geonature.commons.fp.Either +import fr.geonature.commons.interactor.BaseUseCase +import fr.geonature.occtax.features.nomenclature.repository.INomenclatureRepository +import javax.inject.Inject + +/** + * Gets all nomenclature values matching given nomenclature type and an optional taxonomy rank. + * + * @author S. Grimault + */ +class GetNomenclatureValuesByTypeAndTaxonomyUseCase @Inject constructor(private val nomenclatureRepository: INomenclatureRepository) : + BaseUseCase, GetNomenclatureValuesByTypeAndTaxonomyUseCase.Params>() { + override suspend fun run(params: Params): Either> { + return nomenclatureRepository.getNomenclatureValuesByTypeAndTaxonomy( + params.mnemonic, + params.taxonomy + ) + } + + data class Params( + val mnemonic: String, + val taxonomy: Taxonomy? = null + ) +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/SetDefaultPropertyValueUseCase.kt b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/SetDefaultPropertyValueUseCase.kt new file mode 100644 index 00000000..bb184355 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/features/nomenclature/usecase/SetDefaultPropertyValueUseCase.kt @@ -0,0 +1,34 @@ +package fr.geonature.occtax.features.nomenclature.usecase + +import fr.geonature.commons.data.entity.Taxonomy +import fr.geonature.commons.error.Failure +import fr.geonature.commons.fp.Either +import fr.geonature.commons.interactor.BaseUseCase +import fr.geonature.occtax.features.nomenclature.repository.IDefaultPropertyValueRepository +import fr.geonature.occtax.input.PropertyValue +import javax.inject.Inject + +/** + * Adds or updates given property value for the given given taxonomy rank. + * + * @author S. Grimault + */ +class SetDefaultPropertyValueUseCase @Inject constructor( + private val defaultPropertyValueRepository: IDefaultPropertyValueRepository +) : + BaseUseCase() { + override suspend fun run(params: Params): Either { + return defaultPropertyValueRepository.setPropertyValue( + params.taxonomy, + params.propertyValue + ) + } + + data class Params( + val taxonomy: Taxonomy = Taxonomy( + kingdom = Taxonomy.ANY, + group = Taxonomy.ANY + ), + val propertyValue: PropertyValue + ) +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/input/CountingMetadata.kt b/occtax/src/main/java/fr/geonature/occtax/input/CountingMetadata.kt index 60b00980..7ac0e936 100644 --- a/occtax/src/main/java/fr/geonature/occtax/input/CountingMetadata.kt +++ b/occtax/src/main/java/fr/geonature/occtax/input/CountingMetadata.kt @@ -16,7 +16,7 @@ class CountingMetadata() : Parcelable { internal set val properties: SortedMap = - TreeMap { o1, o2 -> + TreeMap { o1, o2 -> val i1 = defaultMnemonic.indexOfFirst { it.first == o1 } val i2 = defaultMnemonic.indexOfFirst { it.first == o2 } @@ -100,6 +100,7 @@ class CountingMetadata() : Parcelable { * * first value: mnemonic code from nomenclature type * * second value: the corresponding view type */ + @Deprecated("see: INomenclatureSettingsLocalDataSource") val defaultMnemonic = arrayOf( Pair( "STADE_VIE", diff --git a/occtax/src/main/java/fr/geonature/occtax/input/Input.kt b/occtax/src/main/java/fr/geonature/occtax/input/Input.kt index 89e27de0..213e832f 100644 --- a/occtax/src/main/java/fr/geonature/occtax/input/Input.kt +++ b/occtax/src/main/java/fr/geonature/occtax/input/Input.kt @@ -85,6 +85,7 @@ class Input : AbstractInput { companion object { + @Deprecated("see: INomenclatureSettingsLocalDataSource") val defaultPropertiesMnemonic = arrayOf( Pair( "TYP_GRP", diff --git a/occtax/src/main/java/fr/geonature/occtax/input/InputTaxon.kt b/occtax/src/main/java/fr/geonature/occtax/input/InputTaxon.kt index 88ed345e..f7b339d6 100644 --- a/occtax/src/main/java/fr/geonature/occtax/input/InputTaxon.kt +++ b/occtax/src/main/java/fr/geonature/occtax/input/InputTaxon.kt @@ -15,16 +15,7 @@ import java.util.TreeMap class InputTaxon : AbstractInputTaxon { val properties: SortedMap = - TreeMap { o1, o2 -> - val i1 = defaultPropertiesMnemonic.indexOfFirst { it.first == o1 } - val i2 = defaultPropertiesMnemonic.indexOfFirst { it.first == o2 } - - when { - i1 == -1 -> 1 - i2 == -1 -> -1 - else -> i1 - i2 - } - } + TreeMap { o1, o2 -> o1.compareTo(o2) } private val counting: SortedMap = TreeMap() constructor(taxon: AbstractTaxon) : super(taxon) @@ -96,61 +87,6 @@ class InputTaxon : AbstractInputTaxon { companion object { - /** - * default properties as triple: - * - * * first value: mnemonic code from nomenclature type - * * second value: the corresponding view type - * * third value: if this property is visible by default - */ - val defaultPropertiesMnemonic = arrayOf( - Triple( - "METH_OBS", - NomenclatureTypeViewType.NOMENCLATURE_TYPE, - true - ), - Triple( - "ETA_BIO", - NomenclatureTypeViewType.NOMENCLATURE_TYPE, - true - ), - Triple( - "METH_DETERMIN", - NomenclatureTypeViewType.NOMENCLATURE_TYPE, - false - ), - Triple( - "DETERMINER", - NomenclatureTypeViewType.TEXT_SIMPLE, - false - ), - Triple( - "STATUT_BIO", - NomenclatureTypeViewType.NOMENCLATURE_TYPE, - false - ), - Triple( - "OCC_COMPORTEMENT", - NomenclatureTypeViewType.NOMENCLATURE_TYPE, - false - ), - Triple( - "NATURALITE", - NomenclatureTypeViewType.NOMENCLATURE_TYPE, - false - ), - Triple( - "PREUVE_EXIST", - NomenclatureTypeViewType.NOMENCLATURE_TYPE, - false - ), - Triple( - "COMMENT", - NomenclatureTypeViewType.TEXT_MULTIPLE, - false - ) - ) - @JvmField val CREATOR: Parcelable.Creator = object : Parcelable.Creator { diff --git a/occtax/src/main/java/fr/geonature/occtax/input/NomenclatureTypeViewType.kt b/occtax/src/main/java/fr/geonature/occtax/input/NomenclatureTypeViewType.kt index 5057021d..127a0272 100644 --- a/occtax/src/main/java/fr/geonature/occtax/input/NomenclatureTypeViewType.kt +++ b/occtax/src/main/java/fr/geonature/occtax/input/NomenclatureTypeViewType.kt @@ -5,6 +5,7 @@ package fr.geonature.occtax.input * * @author [S. Grimault](mailto:sebastien.grimault@gmail.com) */ +@Deprecated(message = "see EditableNomenclatureType") enum class NomenclatureTypeViewType { NOMENCLATURE_TYPE, MORE, diff --git a/occtax/src/main/java/fr/geonature/occtax/input/PropertyValue.kt b/occtax/src/main/java/fr/geonature/occtax/input/PropertyValue.kt index ea782abc..3fde6215 100644 --- a/occtax/src/main/java/fr/geonature/occtax/input/PropertyValue.kt +++ b/occtax/src/main/java/fr/geonature/occtax/input/PropertyValue.kt @@ -4,6 +4,7 @@ import android.os.Parcel import android.os.Parcelable import fr.geonature.commons.data.entity.Nomenclature import fr.geonature.commons.input.AbstractInputTaxon +import org.tinylog.Logger import java.io.Serializable /** @@ -17,7 +18,7 @@ data class PropertyValue( val value: Serializable? ) : Parcelable { - internal constructor(source: Parcel) : this( + private constructor(source: Parcel) : this( source.readString()!!, source.readString(), source.readSerializable() @@ -51,6 +52,10 @@ data class PropertyValue( code: String, nomenclature: Nomenclature? ): PropertyValue { + if (nomenclature?.defaultLabel.isNullOrEmpty()) { + Logger.warn { "no label found for nomenclature '$code:${nomenclature?.code}'" } + } + return PropertyValue( code, nomenclature?.defaultLabel, diff --git a/occtax/src/main/java/fr/geonature/occtax/settings/NomenclatureSettings.kt b/occtax/src/main/java/fr/geonature/occtax/settings/NomenclatureSettings.kt index bc934544..8bae16f2 100644 --- a/occtax/src/main/java/fr/geonature/occtax/settings/NomenclatureSettings.kt +++ b/occtax/src/main/java/fr/geonature/occtax/settings/NomenclatureSettings.kt @@ -2,17 +2,21 @@ package fr.geonature.occtax.settings import android.os.Parcel import android.os.Parcelable +import androidx.core.os.ParcelCompat +import androidx.core.os.ParcelCompat.readBoolean /** * Nomenclature settings. * - * @author [S. Grimault](mailto:sebastien.grimault@gmail.com) + * @author S. Grimault */ data class NomenclatureSettings( + val saveDefaultValues: Boolean = false, val information: List, val counting: List ) : Parcelable { private constructor(source: Parcel) : this( + readBoolean(source), mutableListOf(), mutableListOf() ) { @@ -35,6 +39,10 @@ data class NomenclatureSettings( flags: Int ) { dest?.also { + ParcelCompat.writeBoolean( + it, + saveDefaultValues + ) it.writeTypedList(information) it.writeTypedList(counting) } diff --git a/occtax/src/main/java/fr/geonature/occtax/settings/io/OnAppSettingsJsonReaderListenerImpl.kt b/occtax/src/main/java/fr/geonature/occtax/settings/io/OnAppSettingsJsonReaderListenerImpl.kt index 35eb8274..c8bc2838 100644 --- a/occtax/src/main/java/fr/geonature/occtax/settings/io/OnAppSettingsJsonReaderListenerImpl.kt +++ b/occtax/src/main/java/fr/geonature/occtax/settings/io/OnAppSettingsJsonReaderListenerImpl.kt @@ -125,6 +125,7 @@ class OnAppSettingsJsonReaderListenerImpl : return null } + var saveDefaultValues = false val information = mutableListOf() val counting = mutableListOf() @@ -132,6 +133,7 @@ class OnAppSettingsJsonReaderListenerImpl : while (reader.hasNext()) { when (reader.nextName()) { + "save_default_values" -> saveDefaultValues = reader.nextBooleanOrElse { false } "information" -> information.addAll(readPropertySettingsAsList(reader)) "counting" -> counting.addAll(readPropertySettingsAsList(reader)) } @@ -140,8 +142,9 @@ class OnAppSettingsJsonReaderListenerImpl : reader.endObject() return NomenclatureSettings( - information, - counting + saveDefaultValues = saveDefaultValues, + information = information, + counting = counting ) } diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/dataset/DatasetRecyclerViewAdapter.kt b/occtax/src/main/java/fr/geonature/occtax/ui/dataset/DatasetRecyclerViewAdapter.kt index 12fcf2a8..01893ca6 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/dataset/DatasetRecyclerViewAdapter.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/dataset/DatasetRecyclerViewAdapter.kt @@ -5,7 +5,6 @@ import android.text.format.DateFormat import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.CheckBox import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.l4digital.fastscroll.FastScroller @@ -36,15 +35,9 @@ class DatasetRecyclerViewAdapter(private val listener: OnDatasetRecyclerViewAdap val text1: TextView = v.findViewById(android.R.id.text1) text1.isSelected = true - val checkbox: CheckBox = v.findViewById(android.R.id.checkbox) - checkbox.isChecked = !checkbox.isChecked - val dataset = v.tag as Dataset - - if (checkbox.isChecked) { - selectedDataset = dataset - listener.onSelectedDataset(dataset) - } + selectedDataset = dataset + listener.onSelectedDataset(dataset) if (previousSelectedItemPosition >= 0) { notifyItemChanged(previousSelectedItemPosition) @@ -133,7 +126,7 @@ class DatasetRecyclerViewAdapter(private val listener: OnDatasetRecyclerViewAdap inner class ViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( LayoutInflater.from(parent.context).inflate( - R.layout.list_selectable_item_3, + R.layout.list_item_dataset, parent, false ) @@ -142,7 +135,6 @@ class DatasetRecyclerViewAdapter(private val listener: OnDatasetRecyclerViewAdap private val title: TextView = itemView.findViewById(android.R.id.title) private val text1: TextView = itemView.findViewById(android.R.id.text1) private val text2: TextView = itemView.findViewById(android.R.id.text2) - private val checkbox: CheckBox = itemView.findViewById(android.R.id.checkbox) fun bind(position: Int) { val cursor = cursor ?: return @@ -154,6 +146,7 @@ class DatasetRecyclerViewAdapter(private val listener: OnDatasetRecyclerViewAdap if (dataset != null) { title.text = dataset.name title.isSelected = selectedDataset?.id == dataset.id + title.isSelected = true text1.text = dataset.description text1.isSelected = selectedDataset?.id == dataset.id text2.text = itemView.context.getString( @@ -163,9 +156,9 @@ class DatasetRecyclerViewAdapter(private val listener: OnDatasetRecyclerViewAdap dataset.createdAt ) ) - checkbox.isChecked = selectedDataset?.id == dataset.id with(itemView) { + isPressed = selectedDataset?.id == dataset.id tag = dataset setOnClickListener(onClickListener) } diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/home/HomeActivity.kt b/occtax/src/main/java/fr/geonature/occtax/ui/home/HomeActivity.kt index f9ffe538..d7e038ee 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/home/HomeActivity.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/home/HomeActivity.kt @@ -421,6 +421,7 @@ class HomeActivity : AppCompatActivity() { vm.isSyncRunning.observe( this@HomeActivity ) { + appSyncView?.enableActionButton(!it) invalidateOptionsMenu() } vm @@ -429,7 +430,7 @@ class HomeActivity : AppCompatActivity() { this@HomeActivity ) { if (it == null) { - appSyncView?.setDataSyncStatus(it) + appSyncView?.setDataSyncStatus(null) } it?.run { diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/AbstractInputFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/AbstractInputFragment.kt new file mode 100644 index 00000000..dafc4c4b --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/AbstractInputFragment.kt @@ -0,0 +1,59 @@ +package fr.geonature.occtax.ui.input + +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import fr.geonature.commons.input.AbstractInput +import fr.geonature.commons.lifecycle.observeUntil +import fr.geonature.occtax.input.Input +import fr.geonature.occtax.input.InputViewModel +import fr.geonature.viewpager.model.IPageWithValidationFragment + +/** + * `Fragment` using [AbstractInput] as page. + * + * @author S. Grimault + */ +abstract class AbstractInputFragment : Fragment(), IPageWithValidationFragment, IInputFragment { + + lateinit var listener: OnInputPageFragmentListener + + private val inputViewModel: InputViewModel by activityViewModels() + var input: Input? = null + private set + + override fun onAttach(context: Context) { + super.onAttach(context) + + if (context is OnInputPageFragmentListener) { + listener = context + } else { + throw RuntimeException("$context must implement ${OnInputPageFragmentListener::class.simpleName}") + } + } + + override fun onResume() { + super.onResume() + + // give a chance to this page to refresh correctly both its title and subtitle + Handler(Looper.getMainLooper()).post { + (activity as AppCompatActivity?)?.apply { + setTitle(getResourceTitle()) + supportActionBar?.subtitle = getSubtitle() + } + } + + inputViewModel.input.observeUntil( + viewLifecycleOwner, + { it != null }) { + if (it == null) return@observeUntil + + input = it + listener.validateCurrentPage() + refreshView() + } + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/IInputFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/IInputFragment.kt index c7469dd0..2fded1f3 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/IInputFragment.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/IInputFragment.kt @@ -10,9 +10,7 @@ import fr.geonature.commons.input.AbstractInput interface IInputFragment { /** - * Sets the current [AbstractInput] to update. - * - * @param input the current [AbstractInput] to update + * Updates the current view. */ - fun setInput(input: AbstractInput) + fun refreshView() } diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/InputPagerFragmentActivity.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/InputPagerFragmentActivity.kt index c381c026..82add334 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/InputPagerFragmentActivity.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/InputPagerFragmentActivity.kt @@ -3,13 +3,17 @@ package fr.geonature.occtax.ui.input import android.Manifest import android.content.Context import android.content.Intent +import android.content.res.ColorStateList import android.os.Build import android.os.Bundle import androidx.activity.viewModels +import androidx.core.graphics.ColorUtils +import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope -import androidx.viewpager.widget.ViewPager import dagger.hilt.android.AndroidEntryPoint import fr.geonature.commons.input.AbstractInput +import fr.geonature.commons.util.KeyboardUtils.hideKeyboard +import fr.geonature.commons.util.ThemeUtils import fr.geonature.maps.settings.MapSettings import fr.geonature.maps.ui.MapFragment import fr.geonature.maps.util.CheckPermissionLifecycleObserver @@ -27,23 +31,21 @@ import fr.geonature.occtax.ui.input.map.InputMapFragment import fr.geonature.occtax.ui.input.observers.ObserversAndDateInputFragment import fr.geonature.occtax.ui.input.summary.InputTaxaSummaryFragment import fr.geonature.occtax.ui.input.taxa.TaxaFragment -import fr.geonature.viewpager.ui.AbstractNavigationHistoryPagerFragmentActivity +import fr.geonature.viewpager.model.IPageWithValidationFragment import fr.geonature.viewpager.ui.AbstractPagerFragmentActivity -import fr.geonature.viewpager.ui.IValidateFragment -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import org.tinylog.kotlin.Logger import kotlin.coroutines.resume /** - * [ViewPager] implementation as [AbstractPagerFragmentActivity] with navigation history support. + * `ViewPager2` implementation through [AbstractPagerFragmentActivity]. * * @author S. Grimault */ @AndroidEntryPoint -class InputPagerFragmentActivity : AbstractNavigationHistoryPagerFragmentActivity(), +class InputPagerFragmentActivity : AbstractPagerFragmentActivity(), + OnInputPageFragmentListener, MapFragment.OnMapFragmentPermissionsListener { private val inputViewModel: InputViewModel by viewModels() @@ -60,6 +62,10 @@ class InputPagerFragmentActivity : AbstractNavigationHistoryPagerFragmentActivit override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // FIXME: this is a workaround to keep MapView alive from InputMapFragment… + // see: https://github.com/osmdroid/osmdroid/issues/1581 + viewPager.offscreenPageLimit = 6 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { manageExternalStoragePermissionLifecycleObserver = ManageExternalStoragePermissionLifecycleObserver(this) @@ -84,74 +90,110 @@ class InputPagerFragmentActivity : AbstractNavigationHistoryPagerFragmentActivit } Logger.info { "loading input: ${input.id}" } - - CoroutineScope(Dispatchers.Main).launch { - pagerManager.load(input.id) - } + inputViewModel.editInput(input) + + pageFragmentViewModel.set( + R.string.pager_fragment_observers_and_date_input_title to ObserversAndDateInputFragment.newInstance( + dateSettings = appSettings.inputSettings.dateSettings, + saveDefaultValues = appSettings.nomenclatureSettings?.saveDefaultValues ?: false + ), + R.string.pager_fragment_map_title to InputMapFragment.newInstance( + MapSettings.Builder.newInstance() + .from(appSettings.mapSettings!!) + .showCompass(showCompass(this)) + .showScale(showScale(this)) + .showZoom(showZoom(this)) + .build() + ), + R.string.pager_fragment_summary_title to InputTaxaSummaryFragment.newInstance(appSettings.inputSettings.dateSettings) + ) } override fun onPause() { super.onPause() - if (input.status == AbstractInput.Status.DRAFT) { - inputViewModel.saveInput(input) + inputViewModel.input.value?.takeIf { it.status == AbstractInput.Status.DRAFT }?.also { + inputViewModel.saveInput(it) } } - override val pagerFragments: Map - get() = LinkedHashMap().apply { - put( - R.string.pager_fragment_observers_and_date_input_title, - ObserversAndDateInputFragment.newInstance(appSettings.inputSettings.dateSettings) - ) - put( - R.string.pager_fragment_map_title, - InputMapFragment.newInstance(getMapSettings()) - ) - put( - R.string.pager_fragment_taxa_title, - TaxaFragment.newInstance(appSettings.areaObservationDuration) - ) - put( - R.string.pager_fragment_information_title, - InformationFragment.newInstance( - *appSettings.nomenclatureSettings?.information?.toTypedArray() ?: emptyArray() - ) - ) - put( - R.string.pager_fragment_counting_title, - CountingFragment.newInstance( - *appSettings.nomenclatureSettings?.counting?.toTypedArray() ?: emptyArray() - ) - ) - put( - R.string.pager_fragment_summary_title, - InputTaxaSummaryFragment.newInstance() - ) - } + override fun getDefaultTitle(): CharSequence { + return getString(R.string.activity_input_title) + } + + override fun onNextAction(): Boolean { + return false + } override fun performFinishAction() { - inputViewModel.exportInput( - input, - appSettings - ) { - finish() + inputViewModel.input.value?.also { + inputViewModel.exportInput( + it, + appSettings + ) { + finish() + } } } override fun onPageSelected(position: Int) { super.onPageSelected(position) - val pageFragment = getCurrentPageFragment() + getCurrentPageFragment()?.also { page -> + if (page is IPageWithValidationFragment) { + // override the default next button color for the last page + nextButton.backgroundTintList = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + IntArray(0) + ), + intArrayOf( + ColorUtils.setAlphaComponent( + ThemeUtils.getColor( + this, + R.attr.colorOnSurface + ), + 32 + ), + if (position < ((viewPager.adapter?.itemCount + ?: 0) - 1) + ) ThemeUtils.getAccentColor(this) + else ThemeUtils.getPrimaryColor(this) + ) + ) - if (pageFragment is IInputFragment && ::input.isInitialized) { - pageFragment.setInput(input) - pageFragment.refreshView() - validateCurrentPage() - inputViewModel.saveInput(input) + hideKeyboard(page as Fragment) + } } } + override fun startEditTaxon() { + pageFragmentViewModel.add( + R.string.pager_fragment_taxa_title to TaxaFragment.newInstance(appSettings.areaObservationDuration), + R.string.pager_fragment_information_title to InformationFragment.newInstance( + saveDefaultValues = appSettings.nomenclatureSettings?.saveDefaultValues ?: false, + *appSettings.nomenclatureSettings?.information?.toTypedArray() + ?: emptyArray() + ), + R.string.pager_fragment_counting_title to CountingFragment.newInstance( + *appSettings.nomenclatureSettings?.counting?.toTypedArray() + ?: emptyArray() + ), + R.string.pager_fragment_taxa_added_title to InputTaxaSummaryFragment.newInstance(appSettings.inputSettings.dateSettings) + ) + goToNextPage() + } + + override fun finishEditTaxon() { + input.clearCurrentSelectedInputTaxon() + removePage( + R.string.pager_fragment_taxa_title, + R.string.pager_fragment_information_title, + R.string.pager_fragment_counting_title, + R.string.pager_fragment_taxa_added_title + ) + } + override suspend fun onStoragePermissionsGranted() = suspendCancellableCoroutine { continuation -> lifecycleScope.launch { @@ -175,15 +217,6 @@ class InputPagerFragmentActivity : AbstractNavigationHistoryPagerFragmentActivit } } - private fun getMapSettings(): MapSettings { - return MapSettings.Builder.newInstance() - .from(appSettings.mapSettings!!) - .showCompass(showCompass(this)) - .showScale(showScale(this)) - .showZoom(showZoom(this)) - .build() - } - companion object { private const val EXTRA_APP_SETTINGS = "extra_app_settings" diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/OnInputPageFragmentListener.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/OnInputPageFragmentListener.kt new file mode 100644 index 00000000..b1a7c2b0 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/OnInputPageFragmentListener.kt @@ -0,0 +1,21 @@ +package fr.geonature.occtax.ui.input + +import fr.geonature.viewpager.ui.OnPageFragmentListener + +/** + * Callback used within pages to control [InputPagerFragmentActivity] view pager. + * + * @author S. Grimault + */ +interface OnInputPageFragmentListener : OnPageFragmentListener { + + /** + * Start taxon editing workflow. + */ + fun startEditTaxon() + + /** + * Finish taxon editing workflow. + */ + fun finishEditTaxon() +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/counting/CountingFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/counting/CountingFragment.kt index 191954e3..66836622 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/counting/CountingFragment.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/counting/CountingFragment.kt @@ -21,29 +21,23 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton import fr.geonature.commons.data.entity.Taxonomy -import fr.geonature.commons.input.AbstractInput import fr.geonature.commons.ui.adapter.AbstractListItemRecyclerViewAdapter import fr.geonature.occtax.R import fr.geonature.occtax.input.CountingMetadata import fr.geonature.occtax.input.Input import fr.geonature.occtax.input.InputTaxon import fr.geonature.occtax.settings.PropertySettings -import fr.geonature.occtax.ui.input.IInputFragment -import fr.geonature.viewpager.ui.AbstractPagerFragmentActivity -import fr.geonature.viewpager.ui.IValidateFragment +import fr.geonature.occtax.ui.input.AbstractInputFragment /** * [Fragment] to let the user to add additional counting information for the given [Input]. * * @author S. Grimault */ -class CountingFragment : Fragment(), - IValidateFragment, - IInputFragment { +class CountingFragment : AbstractInputFragment() { private lateinit var editCountingResultLauncher: ActivityResultLauncher - private var input: Input? = null private var adapter: CountingRecyclerViewAdapter? = null private var recyclerView: RecyclerView? = null private var emptyTextView: TextView? = null @@ -125,7 +119,7 @@ class CountingFragment : Fragment(), ) { dialog, _ -> adapter?.remove(item) (input?.getCurrentSelectedInputTaxon() as InputTaxon?)?.deleteCountingMetadata(item.index) - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() dialog.dismiss() } @@ -205,10 +199,6 @@ class CountingFragment : Fragment(), } } - override fun setInput(input: AbstractInput) { - this.input = input as Input - } - private fun launchEditCountingMetadataActivity(countingMetadata: CountingMetadata? = null) { val context = context ?: return diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/counting/NomenclatureTypesRecyclerViewAdapter.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/counting/NomenclatureTypesRecyclerViewAdapter.kt index adcb7952..caf3ea2c 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/counting/NomenclatureTypesRecyclerViewAdapter.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/counting/NomenclatureTypesRecyclerViewAdapter.kt @@ -28,6 +28,7 @@ import kotlin.math.ceil * * @author S. Grimault */ +@Deprecated("use EditableNomenclatureTypeAdapter") class NomenclatureTypesRecyclerViewAdapter(private val listener: OnNomenclatureTypesRecyclerViewAdapterListener) : RecyclerView.Adapter() { diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/informations/InformationFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/informations/InformationFragment.kt index 19389919..559e080d 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/informations/InformationFragment.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/informations/InformationFragment.kt @@ -1,34 +1,33 @@ package fr.geonature.occtax.ui.input.informations -import android.database.Cursor import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.setPadding +import android.view.animation.AnimationUtils +import android.widget.ProgressBar +import android.widget.TextView import androidx.fragment.app.Fragment -import androidx.loader.app.LoaderManager -import androidx.loader.content.CursorLoader -import androidx.loader.content.Loader +import androidx.fragment.app.viewModels +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dagger.hilt.android.AndroidEntryPoint import fr.geonature.commons.data.ContentProviderAuthority -import fr.geonature.commons.data.entity.DefaultNomenclatureWithType import fr.geonature.commons.data.entity.Nomenclature -import fr.geonature.commons.data.entity.NomenclatureType import fr.geonature.commons.data.entity.Taxonomy -import fr.geonature.commons.data.helper.ProviderHelper.buildUri -import fr.geonature.commons.input.AbstractInput +import fr.geonature.commons.lifecycle.observe import fr.geonature.occtax.R +import fr.geonature.occtax.features.nomenclature.domain.BaseEditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.domain.EditableNomenclatureType +import fr.geonature.occtax.features.nomenclature.presentation.EditableNomenclatureTypeAdapter +import fr.geonature.occtax.features.nomenclature.presentation.NomenclatureViewModel +import fr.geonature.occtax.features.nomenclature.presentation.PropertyValueModel import fr.geonature.occtax.input.Input import fr.geonature.occtax.input.InputTaxon -import fr.geonature.occtax.input.PropertyValue import fr.geonature.occtax.settings.PropertySettings -import fr.geonature.occtax.ui.input.IInputFragment -import fr.geonature.occtax.ui.input.dialog.ChooseNomenclatureDialogFragment -import fr.geonature.viewpager.ui.IValidateFragment -import org.tinylog.kotlin.Logger +import fr.geonature.occtax.ui.input.AbstractInputFragment import javax.inject.Inject /** @@ -37,109 +36,30 @@ import javax.inject.Inject * @author S. Grimault */ @AndroidEntryPoint -class InformationFragment : Fragment(), - IValidateFragment, - IInputFragment, - ChooseNomenclatureDialogFragment.OnChooseNomenclatureDialogFragmentListener { +class InformationFragment : AbstractInputFragment() { @ContentProviderAuthority @Inject lateinit var authority: String - private var input: Input? = null - private var adapter: NomenclatureTypesRecyclerViewAdapter? = null - private lateinit var savedState: Bundle - - private val loaderCallbacks = object : LoaderManager.LoaderCallbacks { - override fun onCreateLoader( - id: Int, - args: Bundle? - ): Loader { - return when (id) { - LOADER_NOMENCLATURE_TYPES -> CursorLoader( - requireContext(), - buildUri( - authority, - NomenclatureType.TABLE_NAME - ), - null, - null, - null, - null - ) - LOADER_DEFAULT_NOMENCLATURE_VALUES -> CursorLoader( - requireContext(), - buildUri( - authority, - NomenclatureType.TABLE_NAME, - "occtax", - "default" - ), - null, - null, - null, - null - ) - else -> throw IllegalArgumentException() - } - } - - override fun onLoadFinished( - loader: Loader, - data: Cursor? - ) { - if (data == null) { - Logger.warn { "failed to load data from '${(loader as CursorLoader).uri}'" } - - return - } + private val nomenclatureViewModel: NomenclatureViewModel by viewModels() + private val propertyValueModel: PropertyValueModel by viewModels() - when (loader.id) { - LOADER_NOMENCLATURE_TYPES -> { - val defaultProperties = arguments?.getParcelableArray(ARG_PROPERTIES) - ?.map { it as PropertySettings } - ?.toTypedArray() ?: emptyArray() - - adapter?.bind( - data, - *defaultProperties - ) - loadDefaultNomenclatureValues() - } - LOADER_DEFAULT_NOMENCLATURE_VALUES -> { - val defaultMnemonicFilter = adapter?.defaultMnemonicFilter() ?: emptyList() - val defaultNomenclatureValues = mutableListOf() - data.moveToFirst() - - while (!data.isAfterLast) { - val defaultNomenclatureValue = DefaultNomenclatureWithType.fromCursor(data) - - if (defaultNomenclatureValue != null && defaultMnemonicFilter.contains( - defaultNomenclatureValue.nomenclatureWithType?.type?.mnemonic - ) - ) { - defaultNomenclatureValues.add(defaultNomenclatureValue) - } - - data.moveToNext() - } - - setPropertyValues(defaultNomenclatureValues) - } - } - } + private lateinit var savedState: Bundle - override fun onLoaderReset(loader: Loader) { - when (loader.id) { - LOADER_NOMENCLATURE_TYPES -> adapter?.bind(null) - } - } - } + private var adapter: EditableNomenclatureTypeAdapter? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) savedState = savedInstanceState ?: Bundle() + + with(nomenclatureViewModel) { + observe( + editableNomenclatures, + ::handleEditableNomenclatureTypes + ) + } } override fun onCreateView( @@ -147,68 +67,113 @@ class InformationFragment : Fragment(), container: ViewGroup?, savedInstanceState: Bundle? ): View { - val recyclerView = inflater.inflate( - R.layout.recycler_view, + return inflater.inflate( + R.layout.fragment_recycler_view_loader, container, false ) - recyclerView.setPadding(resources.getDimensionPixelOffset(R.dimen.padding_default)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated( + view, + savedInstanceState + ) + + val recyclerView = view.findViewById(android.R.id.list) + val emptyTextView = view.findViewById(android.R.id.empty).apply { + text = getString(R.string.information_no_data) + } + val progressBar = view.findViewById(android.R.id.progress) + .apply { visibility = View.VISIBLE } + + adapter = EditableNomenclatureTypeAdapter(object : + EditableNomenclatureTypeAdapter.OnEditableNomenclatureTypeAdapter { + override fun getLifecycleOwner(): LifecycleOwner { + return this@InformationFragment + } + + override fun showEmptyTextView(show: Boolean) { + progressBar?.visibility = View.GONE + + if (emptyTextView?.visibility == View.VISIBLE == show) { + return + } + + if (show) { + emptyTextView?.startAnimation( + AnimationUtils.loadAnimation( + context, + android.R.anim.fade_in + ) + ) + emptyTextView?.visibility = View.VISIBLE + } else { + emptyTextView?.startAnimation( + AnimationUtils.loadAnimation( + context, + android.R.anim.fade_out + ) + ) + emptyTextView?.visibility = View.GONE + } + } - // Set the adapter - adapter = NomenclatureTypesRecyclerViewAdapter(object : - NomenclatureTypesRecyclerViewAdapter.OnNomenclatureTypesRecyclerViewAdapterListener { override fun showMore() { savedState.putBoolean( KEY_SHOW_ALL_NOMENCLATURE_TYPES, true ) - setPropertyValues() } - override fun onAction(nomenclatureTypeMnemonic: String) { - - val taxonomy = input?.getCurrentSelectedInputTaxon()?.taxon?.taxonomy - ?: Taxonomy( - Taxonomy.ANY, - Taxonomy.ANY - ) - - val chooseNomenclatureDialogFragment = ChooseNomenclatureDialogFragment.newInstance( + override fun getNomenclatureValues(nomenclatureTypeMnemonic: String): LiveData> { + return nomenclatureViewModel.getNomenclatureValuesByTypeAndTaxonomy( nomenclatureTypeMnemonic, - taxonomy - ) - chooseNomenclatureDialogFragment.show( - childFragmentManager, - CHOOSE_NOMENCLATURE_DIALOG_FRAGMENT + input?.getCurrentSelectedInputTaxon()?.taxon?.taxonomy + ?: Taxonomy( + Taxonomy.ANY, + Taxonomy.ANY + ) ) } - override fun onEdit( - nomenclatureTypeMnemonic: String, - value: String? - ) { + override fun onUpdate(editableNomenclatureType: EditableNomenclatureType) { (input?.getCurrentSelectedInputTaxon() as InputTaxon?)?.properties?.set( - nomenclatureTypeMnemonic, - PropertyValue.fromValue( - nomenclatureTypeMnemonic, - value - ) + editableNomenclatureType.code, + editableNomenclatureType.value + ) + + val propertyValue = editableNomenclatureType.value + + if (propertyValue !== null && editableNomenclatureType.locked) propertyValueModel.setPropertyValue( + input?.getCurrentSelectedInputTaxon()?.taxon?.taxonomy + ?: Taxonomy( + Taxonomy.ANY, + Taxonomy.ANY + ), + propertyValue + ) else propertyValueModel.clearPropertyValue( + input?.getCurrentSelectedInputTaxon()?.taxon?.taxonomy + ?: Taxonomy( + Taxonomy.ANY, + Taxonomy.ANY + ), + editableNomenclatureType.code ) } }) - adapter?.showAllNomenclatureTypes( - savedState.getBoolean( + + if (savedState.getBoolean( KEY_SHOW_ALL_NOMENCLATURE_TYPES, false ) - ) + ) adapter?.showAllNomenclatureTypes() else adapter?.showDefaultNomenclatureTypes() + adapter?.lockDefaultValues(arguments?.getBoolean(ARG_SAVE_DEFAULT_VALUES) == true) - with(recyclerView as RecyclerView) { + with(recyclerView) { layoutManager = LinearLayoutManager(context) adapter = this@InformationFragment.adapter } - - return recyclerView } override fun onSaveInstanceState(outState: Bundle) { @@ -232,76 +197,37 @@ class InformationFragment : Fragment(), } override fun refreshView() { - LoaderManager.getInstance(this) - .restartLoader( - LOADER_NOMENCLATURE_TYPES, - null, - loaderCallbacks - ) - } - - override fun setInput(input: AbstractInput) { - this.input = input as Input - } - - override fun onSelectedNomenclature( - nomenclatureType: String, - nomenclature: Nomenclature - ) { - - (input?.getCurrentSelectedInputTaxon() as InputTaxon?)?.properties?.set( - nomenclatureType, - PropertyValue.fromNomenclature( - nomenclatureType, - nomenclature - ) + nomenclatureViewModel.getEditableNomenclatures( + BaseEditableNomenclatureType.Type.INFORMATION, + (arguments?.getParcelableArray(ARG_PROPERTIES) + ?.map { it as PropertySettings } + ?.toList() ?: emptyList()), + input?.getCurrentSelectedInputTaxon()?.taxon?.taxonomy ) - setPropertyValues() } - private fun loadDefaultNomenclatureValues() { - LoaderManager.getInstance(this) - .restartLoader( - LOADER_DEFAULT_NOMENCLATURE_VALUES, - null, - loaderCallbacks - ) - } - - private fun setPropertyValues(defaultNomenclatureValues: List = emptyList()) { - defaultNomenclatureValues.forEach { - val nomenclatureType = it.nomenclatureWithType?.type?.mnemonic ?: return@forEach - - if ((input?.getCurrentSelectedInputTaxon() as InputTaxon?)?.properties?.containsKey( - nomenclatureType - ) == true - ) { - return@forEach - } + private fun handleEditableNomenclatureTypes(editableNomenclatureTypes: List) { + editableNomenclatureTypes.filter { it.value != null }.forEach { + if ((input?.getCurrentSelectedInputTaxon() as InputTaxon?)?.properties?.containsKey(it.code) == true) return@forEach (input?.getCurrentSelectedInputTaxon() as InputTaxon?)?.properties?.set( - nomenclatureType, - PropertyValue.fromNomenclature( - nomenclatureType, - it.nomenclatureWithType - ) + it.code, + it.value ) } - adapter?.setPropertyValues( - (input?.getCurrentSelectedInputTaxon() as InputTaxon?)?.properties?.values?.toList() - ?: emptyList() + adapter?.bind( + editableNomenclatureTypes, + *((input?.getCurrentSelectedInputTaxon() as InputTaxon?)?.properties?.values?.filterNotNull() + ?.toTypedArray() + ?: emptyArray()) ) } companion object { + private const val ARG_SAVE_DEFAULT_VALUES = "arg_save_default_values" private const val ARG_PROPERTIES = "arg_properties" - private const val LOADER_NOMENCLATURE_TYPES = 1 - private const val LOADER_DEFAULT_NOMENCLATURE_VALUES = 2 - private const val CHOOSE_NOMENCLATURE_DIALOG_FRAGMENT = - "choose_nomenclature_dialog_fragment" - private const val KEY_SHOW_ALL_NOMENCLATURE_TYPES = "show_all_nomenclature_types" /** @@ -310,8 +236,15 @@ class InformationFragment : Fragment(), * @return A new instance of [InformationFragment] */ @JvmStatic - fun newInstance(vararg propertySettings: PropertySettings) = InformationFragment().apply { + fun newInstance( + saveDefaultValues: Boolean = false, + vararg propertySettings: PropertySettings + ) = InformationFragment().apply { arguments = Bundle().apply { + putBoolean( + ARG_SAVE_DEFAULT_VALUES, + saveDefaultValues + ) putParcelableArray( ARG_PROPERTIES, propertySettings diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/informations/NomenclatureTypesRecyclerViewAdapter.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/informations/NomenclatureTypesRecyclerViewAdapter.kt deleted file mode 100644 index 68206833..00000000 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/informations/NomenclatureTypesRecyclerViewAdapter.kt +++ /dev/null @@ -1,461 +0,0 @@ -package fr.geonature.occtax.ui.input.informations - -import android.database.Cursor -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.AutoCompleteTextView -import android.widget.Button -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.textfield.TextInputLayout -import fr.geonature.commons.data.entity.Nomenclature -import fr.geonature.commons.data.entity.NomenclatureType -import fr.geonature.commons.util.KeyboardUtils.hideSoftKeyboard -import fr.geonature.occtax.R -import fr.geonature.occtax.input.InputTaxon -import fr.geonature.occtax.input.NomenclatureTypeViewType -import fr.geonature.occtax.input.PropertyValue -import fr.geonature.occtax.settings.PropertySettings -import java.util.Locale - -/** - * Default RecyclerView Adapter used by [InformationFragment]. - * - * @author S. Grimault - */ -class NomenclatureTypesRecyclerViewAdapter(private val listener: OnNomenclatureTypesRecyclerViewAdapterListener) : - RecyclerView.Adapter() { - - private val mnemonicFilter = InputTaxon.defaultPropertiesMnemonic - private val moreViewType = Triple( - "MORE", - NomenclatureTypeViewType.MORE, - true - ) - - private val availableNomenclatureTypes = - mutableListOf>() - private val properties = mutableListOf() - private var showAllNomenclatureTypes = false - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): AbstractViewHolder { - return when (NomenclatureTypeViewType.values()[viewType]) { - NomenclatureTypeViewType.MORE -> MoreViewHolder(parent) - NomenclatureTypeViewType.TEXT_SIMPLE -> TextSimpleViewHolder(parent) - NomenclatureTypeViewType.TEXT_MULTIPLE -> TextMultipleViewHolder(parent) - else -> NomenclatureTypeViewHolder(parent) - } - } - - override fun getItemCount(): Int { - return properties.size - } - - override fun onBindViewHolder( - holder: AbstractViewHolder, - position: Int - ) { - holder.bind(properties[position]) - } - - override fun getItemViewType(position: Int): Int { - val property = properties[position] - - return if (property.code == moreViewType.first) moreViewType.second.ordinal - else mnemonicFilter.first { it.first == properties[position].code } - .second.ordinal - } - - fun defaultMnemonicFilter(): List { - return mnemonicFilter.asSequence() - .filter { it.second == NomenclatureTypeViewType.NOMENCLATURE_TYPE } - .map { it.first } - .toList() - } - - fun bind(cursor: Cursor?, vararg defaultPropertySettings: PropertySettings) { - availableNomenclatureTypes.clear() - - cursor?.run { - if (this.isClosed) return@run - - this.moveToFirst() - - while (!this.isAfterLast) { - NomenclatureType.fromCursor(this) - ?.run { - (if (defaultPropertySettings.isEmpty()) { - mnemonicFilter.find { it.first == mnemonic } - } else { - defaultPropertySettings.find { it.key == mnemonic && it.visible } - ?.let { property -> mnemonicFilter.find { it.first == property.key } } - })?.also { - availableNomenclatureTypes.add(it) - } - } - cursor.moveToNext() - } - - // add default mnemonic filters - availableNomenclatureTypes.addAll(mnemonicFilter.filter { - it.second != NomenclatureTypeViewType.NOMENCLATURE_TYPE && - (defaultPropertySettings.isEmpty() || defaultPropertySettings.any { p -> p.key == it.first }) - }) - } - - availableNomenclatureTypes.sortWith { o1, o2 -> - val i1 = mnemonicFilter.indexOfFirst { it.first == o1.first } - val i2 = mnemonicFilter.indexOfFirst { it.first == o2.first } - - when { - i1 == -1 -> 1 - i2 == -1 -> -1 - else -> i1 - i2 - } - } - - val nomenclatureTypes = if (showAllNomenclatureTypes) { - availableNomenclatureTypes - } else { - val defaultNomenclatureTypes = - availableNomenclatureTypes.filter { availableNomenclatureType -> - if (defaultPropertySettings.isEmpty()) availableNomenclatureType.third - else defaultPropertySettings.any { it.key == availableNomenclatureType.first && it.default } - } - - // add MORE ViewType if default nomenclature types are presents - if (defaultNomenclatureTypes.size < availableNomenclatureTypes.size) { - listOf( - *defaultNomenclatureTypes.toTypedArray(), - moreViewType - ) - } else { - availableNomenclatureTypes - } - } - - setNomenclatureTypes(nomenclatureTypes) - } - - fun setPropertyValues(selectedProperties: List) { - val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { - override fun getOldListSize(): Int { - return this@NomenclatureTypesRecyclerViewAdapter.properties.size - } - - override fun getNewListSize(): Int { - return this@NomenclatureTypesRecyclerViewAdapter.properties.size - } - - override fun areItemsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ): Boolean { - return true - } - - override fun areContentsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ): Boolean { - val oldProperty = - this@NomenclatureTypesRecyclerViewAdapter.properties[oldItemPosition] - val newProperty = selectedProperties.firstOrNull { it.code == oldProperty.code } - - return oldProperty == newProperty - } - }) - - val newProperties = this.properties.map { p -> - selectedProperties.firstOrNull { it.code == p.code } ?: p - } - this.properties.clear() - this.properties.addAll(newProperties) - - diffResult.dispatchUpdatesTo(this) - } - - fun showAllNomenclatureTypes(showAllNomenclatureTypes: Boolean = false) { - this.showAllNomenclatureTypes = showAllNomenclatureTypes - setNomenclatureTypes(availableNomenclatureTypes) - } - - private fun setNomenclatureTypes(nomenclatureTypes: List>) { - if (this.properties.isEmpty()) { - this.properties.addAll(nomenclatureTypes.map { - when (it.second) { - NomenclatureTypeViewType.NOMENCLATURE_TYPE -> PropertyValue.fromNomenclature( - it.first, - null - ) - else -> PropertyValue.fromValue( - it.first, - null - ) - } - }) - - if (this.properties.isNotEmpty()) { - notifyItemRangeInserted( - 0, - this.properties.size - ) - } - - return - } - - if (nomenclatureTypes.isEmpty()) { - val numberOfProperties = this.properties.size - this.properties.clear() - notifyItemRangeRemoved( - 0, - numberOfProperties - ) - - return - } - - val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { - - override fun getOldListSize(): Int { - return this@NomenclatureTypesRecyclerViewAdapter.properties.size - } - - override fun getNewListSize(): Int { - return nomenclatureTypes.size - } - - override fun areItemsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ): Boolean { - return this@NomenclatureTypesRecyclerViewAdapter.properties[oldItemPosition].code == nomenclatureTypes[newItemPosition].first - } - - override fun areContentsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ): Boolean { - return this@NomenclatureTypesRecyclerViewAdapter.properties[oldItemPosition].code == nomenclatureTypes[newItemPosition].first - } - }) - - val newProperties = nomenclatureTypes.map { pair -> - properties.firstOrNull { - it.code == pair.first - } - ?: when (pair.second) { - NomenclatureTypeViewType.NOMENCLATURE_TYPE -> PropertyValue.fromNomenclature( - pair.first, - null - ) - else -> PropertyValue.fromValue( - pair.first, - null - ) - } - } - - this.properties.clear() - this.properties.addAll(newProperties) - - diffResult.dispatchUpdatesTo(this) - } - - abstract inner class AbstractViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - internal var property: PropertyValue? = null - - fun bind(property: PropertyValue) { - this.property = property - - onBind(property) - } - - abstract fun onBind(property: PropertyValue) - - fun getNomenclatureTypeLabel(mnemonic: String): String { - val resourceId = itemView.resources.getIdentifier( - "nomenclature_${mnemonic.lowercase(Locale.getDefault())}", - "string", - itemView.context.packageName - ) - - return if (resourceId == 0) mnemonic else itemView.context.getString(resourceId) - } - } - - inner class NomenclatureTypeViewHolder(parent: ViewGroup) : AbstractViewHolder( - LayoutInflater.from(parent.context).inflate( - R.layout.view_action_nomenclature_type_select, - parent, - false - ) - ) { - private var edit: TextInputLayout = itemView.findViewById(android.R.id.edit) - - init { - (edit.editText as? AutoCompleteTextView)?.setAdapter( - ArrayAdapter( - parent.context, - R.layout.list_item_2 - ) - ) - } - - override fun onBind(property: PropertyValue) { - with(edit) { - hint = getNomenclatureTypeLabel(property.code) - setEndIconOnClickListener { - listener.onAction(property.code) - } - - editText?.apply { - setOnClickListener { - listener.onAction(property.code) - } - text = property.label?.let { - Editable.Factory - .getInstance() - .newEditable(it) - } - } - } - } - } - - inner class MoreViewHolder(parent: ViewGroup) : AbstractViewHolder( - LayoutInflater.from(parent.context).inflate( - R.layout.view_action_more, - parent, - false - ) - ) { - private var button1: Button = itemView.findViewById(android.R.id.button1) - - override fun onBind(property: PropertyValue) { - with(button1) { - text = getNomenclatureTypeLabel(property.code) - setOnClickListener { - showAllNomenclatureTypes(true) - listener.showMore() - } - } - } - } - - open inner class TextSimpleViewHolder(parent: ViewGroup) : AbstractViewHolder( - LayoutInflater.from(parent.context).inflate( - R.layout.view_action_edit_text, - parent, - false - ) - ) { - internal var edit: TextInputLayout = itemView.findViewById(android.R.id.edit) - private val textWatcher = object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int - ) { - } - - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int - ) { - } - - override fun afterTextChanged(s: Editable?) { - val property = property ?: return - - listener.onEdit(property.code, - s?.toString()?.ifEmpty { null }?.ifBlank { null }) - } - } - - init { - with(edit) { - editText?.addTextChangedListener(textWatcher) - setOnFocusChangeListener { v, hasFocus -> - if (!hasFocus) { - // workaround to force hide the soft keyboard - hideSoftKeyboard(v) - } - } - } - } - - override fun onBind(property: PropertyValue) { - edit.hint = getEditTextHint(property.code) - - if (property.value is String? && !property.value.isNullOrEmpty()) { - edit.editText?.removeTextChangedListener(textWatcher) - edit.editText?.text = - property.value?.let { Editable.Factory.getInstance().newEditable(it) } - edit.editText?.addTextChangedListener(textWatcher) - } - } - - private fun getEditTextHint(mnemonic: String): String { - val resourceId = itemView.resources.getIdentifier( - "information_${mnemonic.lowercase(Locale.getDefault())}_hint", - "string", - itemView.context.packageName - ) - return if (resourceId == 0) "" else itemView.context.getString(resourceId) - } - } - - inner class TextMultipleViewHolder(parent: ViewGroup) : TextSimpleViewHolder(parent) { - init { - edit.isCounterEnabled = true - edit.editText?.apply { - isSingleLine = false - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE - minLines = 2 - maxLines = 4 - } - } - } - - /** - * Callback used by [NomenclatureTypesRecyclerViewAdapter]. - */ - interface OnNomenclatureTypesRecyclerViewAdapterListener { - - /** - * Called when the 'more' action button has been clicked. - */ - fun showMore() - - /** - * Called when the action button has been clicked for a given nomenclature type. - * - * @param nomenclatureTypeMnemonic the selected nomenclature type - */ - fun onAction(nomenclatureTypeMnemonic: String) - - /** - * Called when a value has been directly edited for a given nomenclature type. - * - * @param nomenclatureTypeMnemonic the selected nomenclature type - * @param value the corresponding value (may be `null`) - */ - fun onEdit( - nomenclatureTypeMnemonic: String, - value: String? - ) - } -} diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/map/InputMapFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/map/InputMapFragment.kt index d5ec9fc2..8765a5a5 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/map/InputMapFragment.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/map/InputMapFragment.kt @@ -1,8 +1,10 @@ package fr.geonature.occtax.ui.input.map +import android.content.Context import android.os.Bundle import androidx.fragment.app.Fragment -import fr.geonature.commons.input.AbstractInput +import androidx.fragment.app.activityViewModels +import fr.geonature.commons.lifecycle.observeUntil import fr.geonature.commons.util.ThemeUtils import fr.geonature.maps.jts.geojson.GeometryUtils.fromPoint import fr.geonature.maps.jts.geojson.GeometryUtils.toPoint @@ -14,9 +16,10 @@ import fr.geonature.maps.ui.overlay.feature.filter.ContainsFeaturesFilter import fr.geonature.maps.ui.widget.EditFeatureButton import fr.geonature.occtax.R import fr.geonature.occtax.input.Input +import fr.geonature.occtax.input.InputViewModel import fr.geonature.occtax.ui.input.IInputFragment -import fr.geonature.viewpager.ui.AbstractPagerFragmentActivity -import fr.geonature.viewpager.ui.IValidateFragment +import fr.geonature.viewpager.model.IPageWithValidationFragment +import fr.geonature.viewpager.ui.OnPageFragmentListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.launch @@ -30,9 +33,13 @@ import org.osmdroid.views.MapView * @author S. Grimault */ class InputMapFragment : MapFragment(), - IValidateFragment, + IPageWithValidationFragment, IInputFragment { + private val inputViewModel: InputViewModel by activityViewModels() + + private lateinit var listener: OnPageFragmentListener + private var input: Input? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -56,6 +63,29 @@ class InputMapFragment : MapFragment(), } } + override fun onAttach(context: Context) { + super.onAttach(context) + + if (context is OnPageFragmentListener) { + listener = context + } else { + throw RuntimeException("$context must implement ${OnPageFragmentListener::class.simpleName}") + } + } + + override fun onResume() { + super.onResume() + + inputViewModel.input.observeUntil( + viewLifecycleOwner, + { it != null }) { + if (it == null) return@observeUntil + + input = it + refreshView() + } + } + override fun onPause() { super.onPause() @@ -86,14 +116,10 @@ class InputMapFragment : MapFragment(), } } - override fun setInput(input: AbstractInput) { - this.input = input as Input - } - private fun clearInputSelection() { input?.geometry = null - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() CoroutineScope(Main).launch { getOverlays { overlay -> overlay is FeatureCollectionOverlay } @@ -131,7 +157,7 @@ class InputMapFragment : MapFragment(), .map { it.id } .firstOrNull() - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() } } diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/observers/ObserversAndDateInputFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/observers/ObserversAndDateInputFragment.kt index 553d5d47..25baf20c 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/observers/ObserversAndDateInputFragment.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/observers/ObserversAndDateInputFragment.kt @@ -5,29 +5,22 @@ import android.content.Intent import android.database.Cursor import android.os.Bundle import android.text.Editable -import android.text.format.DateFormat -import android.text.format.DateFormat.is24HourFormat import android.util.Pair import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.EditText import android.widget.ListView +import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels import androidx.loader.app.LoaderManager import androidx.loader.content.CursorLoader import androidx.loader.content.Loader -import com.google.android.material.datepicker.CalendarConstraints -import com.google.android.material.datepicker.DateValidatorPointBackward -import com.google.android.material.datepicker.DateValidatorPointForward -import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.textfield.TextInputLayout -import com.google.android.material.timepicker.MaterialTimePicker -import com.google.android.material.timepicker.TimeFormat import dagger.hilt.android.AndroidEntryPoint import fr.geonature.commons.data.ContentProviderAuthority import fr.geonature.commons.data.GeoNatureModuleName @@ -37,44 +30,34 @@ import fr.geonature.commons.data.entity.DefaultNomenclatureWithType import fr.geonature.commons.data.entity.InputObserver import fr.geonature.commons.data.entity.NomenclatureType import fr.geonature.commons.data.helper.ProviderHelper.buildUri -import fr.geonature.commons.input.AbstractInput import fr.geonature.commons.util.afterTextChanged -import fr.geonature.commons.util.get -import fr.geonature.commons.util.set import fr.geonature.occtax.R +import fr.geonature.occtax.features.nomenclature.presentation.PropertyValueModel import fr.geonature.occtax.input.Input import fr.geonature.occtax.input.NomenclatureTypeViewType import fr.geonature.occtax.input.PropertyValue import fr.geonature.occtax.settings.InputDateSettings import fr.geonature.occtax.ui.dataset.DatasetListActivity -import fr.geonature.occtax.ui.input.IInputFragment +import fr.geonature.occtax.ui.input.AbstractInputFragment import fr.geonature.occtax.ui.input.InputPagerFragmentActivity import fr.geonature.occtax.ui.observers.InputObserverListActivity +import fr.geonature.occtax.ui.shared.view.ActionView +import fr.geonature.occtax.ui.shared.view.InputDateView import fr.geonature.occtax.ui.shared.view.ListItemActionView import fr.geonature.occtax.util.SettingsUtils.getDefaultDatasetId import fr.geonature.occtax.util.SettingsUtils.getDefaultObserversId -import fr.geonature.viewpager.ui.AbstractPagerFragmentActivity -import fr.geonature.viewpager.ui.IValidateFragment -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import org.tinylog.kotlin.Logger -import java.util.Calendar import java.util.Date import java.util.Locale import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine /** - * Selected observer and current date as first {@code Fragment} used by [InputPagerFragmentActivity]. + * Selected observer and current date as first page used by [InputPagerFragmentActivity]. * * @author S. Grimault */ @AndroidEntryPoint -class ObserversAndDateInputFragment : Fragment(), - IValidateFragment, - IInputFragment { +class ObserversAndDateInputFragment : AbstractInputFragment() { @ContentProviderAuthority @Inject @@ -84,19 +67,19 @@ class ObserversAndDateInputFragment : Fragment(), @Inject lateinit var moduleName: String + private val propertyValueModel: PropertyValueModel by viewModels() + private lateinit var observersResultLauncher: ActivityResultLauncher private lateinit var datasetResultLauncher: ActivityResultLauncher private lateinit var dateSettings: InputDateSettings - private var input: Input? = null private val defaultInputObservers: MutableList = mutableListOf() private val selectedInputObservers: MutableList = mutableListOf() private var selectedDataset: Dataset? = null private var selectedInputObserversActionView: ListItemActionView? = null - private var selectedDatasetActionView: ListItemActionView? = null - private var dateStartTextInputLayout: TextInputLayout? = null - private var dateEndTextInputLayout: TextInputLayout? = null + private var selectedDatasetActionView: ActionView? = null + private var inputDateView: InputDateView? = null private var commentTextInputLayout: TextInputLayout? = null private val loaderCallbacks = object : LoaderManager.LoaderCallbacks { @@ -197,7 +180,7 @@ class ObserversAndDateInputFragment : Fragment(), if (data.count == 0) { selectedDataset = null input?.datasetId = null - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() } if (data.moveToFirst()) { @@ -235,7 +218,7 @@ class ObserversAndDateInputFragment : Fragment(), data.moveToNext() } - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() if (input?.properties?.isNotEmpty() == false) { val context = context ?: return @@ -322,8 +305,8 @@ class ObserversAndDateInputFragment : Fragment(), } selectedDatasetActionView = - view.findViewById(R.id.selected_dataset_action_view)?.apply { - setListener(object : ListItemActionView.OnListItemActionViewListener { + view.findViewById(R.id.selected_dataset_action_view)?.apply { + setListener(object : ActionView.OnActionViewListener { override fun onAction() { val context = context ?: return @@ -337,105 +320,24 @@ class ObserversAndDateInputFragment : Fragment(), }) } - dateStartTextInputLayout = view.findViewById(R.id.dateStart)?.apply { - hint = getString( - if (dateSettings.endDateSettings == null) R.string.observers_and_date_date_hint - else R.string.observers_and_date_date_start_hint - ) - editText?.afterTextChanged { - error = checkStartDateConstraints() - dateEndTextInputLayout?.error = checkEndDateConstraints() - } - editText?.setOnClickListener { - CoroutineScope(Dispatchers.Main).launch { - val startDate = selectDateTime( - CalendarConstraints - .Builder() - .setValidator(DateValidatorPointBackward.now()) - .build(), - dateSettings.startDateSettings == InputDateSettings.DateSettings.DATETIME, - input?.startDate ?: Date() - ) + inputDateView = view.findViewById(R.id.input_date)?.apply { + setInputDateSettings(dateSettings) + setListener(object : InputDateView.OnInputDateViewListener { + override fun fragmentManager(): FragmentManager? { + return activity?.supportFragmentManager + } + override fun onDatesChanged(startDate: Date, endDate: Date) { input?.startDate = startDate + input?.endDate = endDate - if (dateSettings.endDateSettings == null) { - input?.endDate = startDate - } - - dateStartTextInputLayout?.editText?.apply { - updateDateEditText( - this, - dateSettings.startDateSettings ?: InputDateSettings.DateSettings.DATE, - startDate - ) - } - dateEndTextInputLayout?.editText?.apply { - updateDateEditText( - this, - dateSettings.endDateSettings ?: InputDateSettings.DateSettings.DATE, - input?.endDate - ) - } - - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() } - } - } - - dateEndTextInputLayout = view.findViewById(R.id.dateEnd)?.apply { - visibility = if (dateSettings.endDateSettings == null) View.GONE else View.VISIBLE - editText?.afterTextChanged { - error = checkEndDateConstraints() - dateStartTextInputLayout?.error = checkStartDateConstraints() - } - editText?.setOnClickListener { - CoroutineScope(Dispatchers.Main).launch { - val endDate = selectDateTime( - CalendarConstraints - .Builder() - .setValidator( - DateValidatorPointForward.from( - (input?.startDate ?: Date()) - .set( - Calendar.HOUR_OF_DAY, - 0 - ).set( - Calendar.MINUTE, - 0 - ).set( - Calendar.SECOND, - 0 - ).set( - Calendar.MILLISECOND, - 0 - ).time - ) - ) - .build(), - dateSettings.endDateSettings == InputDateSettings.DateSettings.DATETIME, - input?.endDate ?: input?.startDate ?: Date() - ) - - input?.endDate = endDate - dateStartTextInputLayout?.editText?.apply { - updateDateEditText( - this, - dateSettings.startDateSettings ?: InputDateSettings.DateSettings.DATE, - input?.startDate - ) - } - dateEndTextInputLayout?.editText?.apply { - updateDateEditText( - this, - dateSettings.endDateSettings ?: InputDateSettings.DateSettings.DATE, - endDate - ) - } - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + override fun hasError(message: CharSequence) { + listener.validateCurrentPage() } - } + }) } commentTextInputLayout = view.findViewById(android.R.id.edit)?.apply { @@ -464,31 +366,37 @@ class ObserversAndDateInputFragment : Fragment(), ?.isNotEmpty() ?: false && this.input?.datasetId != null && this.input?.properties?.isNotEmpty() == true && - checkStartDateConstraints() == null && - checkEndDateConstraints() == null + inputDateView?.hasErrors() == false } override fun refreshView() { + // clear any existing local property default values + if (arguments?.getBoolean(ARG_SAVE_DEFAULT_VALUES) == false) { + propertyValueModel.clearAllPropertyValues() + } + setDefaultDatasetFromSettings() - val selectedInputObserverIds = - input?.getAllInputObserverIds() ?: context?.let { getDefaultObserversId(it) } - ?: emptyList() + input?.getAllInputObserverIds()?.also { selectedInputObserverIdsFromInput -> + val selectedInputObserverIds = selectedInputObserverIdsFromInput.ifEmpty { + context?.let { getDefaultObserversId(it) } ?: emptyList() + } - if (selectedInputObserverIds.isNotEmpty()) { - LoaderManager.getInstance(this) - .initLoader( - LOADER_OBSERVERS_IDS, - bundleOf( - kotlin.Pair( - KEY_SELECTED_INPUT_OBSERVER_IDS, - selectedInputObserverIds.toTypedArray().toLongArray() - ) - ), - loaderCallbacks - ) - } else { - updateSelectedObserversActionView(emptyList()) + if (selectedInputObserverIds.isNotEmpty()) { + LoaderManager.getInstance(this) + .initLoader( + LOADER_OBSERVERS_IDS, + bundleOf( + kotlin.Pair( + KEY_SELECTED_INPUT_OBSERVER_IDS, + selectedInputObserverIds.toTypedArray().toLongArray() + ) + ), + loaderCallbacks + ) + } else { + updateSelectedObserversActionView(emptyList()) + } } val selectedDatasetId = input?.datasetId @@ -525,32 +433,21 @@ class ObserversAndDateInputFragment : Fragment(), loaderCallbacks ) - dateStartTextInputLayout?.editText?.apply { - updateDateEditText( - this, - dateSettings.startDateSettings ?: InputDateSettings.DateSettings.DATE, - input?.startDate ?: Date() - ) - } - dateEndTextInputLayout?.editText?.apply { - updateDateEditText( - this, - dateSettings.endDateSettings ?: InputDateSettings.DateSettings.DATE, - input?.endDate - ) - } - commentTextInputLayout?.hint = - getString( - if (input?.comment.isNullOrBlank()) R.string.observers_and_date_comment_add_hint - else R.string.observers_and_date_comment_edit_hint - ) - commentTextInputLayout?.editText?.apply { - text = input?.comment?.let { Editable.Factory.getInstance().newEditable(it) } - } - } + inputDateView?.setDates( + startDate = input?.startDate ?: Date(), + endDate = input?.endDate + ) - override fun setInput(input: AbstractInput) { - this.input = input as Input + commentTextInputLayout?.apply { + hint = + getString( + if (input?.comment.isNullOrBlank()) R.string.input_comment_add_hint + else R.string.input_comment_edit_hint + ) + editText?.apply { + text = input?.comment?.let { Editable.Factory.getInstance().newEditable(it) } + } + } } private fun updateSelectedObservers(selectedInputObservers: List) { @@ -564,7 +461,7 @@ class ObserversAndDateInputFragment : Fragment(), updateSelectedObserversActionView(selectedInputObservers) - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() } private fun updateSelectedDataset(selectedDataset: Dataset?) { @@ -574,7 +471,7 @@ class ObserversAndDateInputFragment : Fragment(), it.datasetId = selectedDataset?.id } - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() updateSelectedDatasetActionView(selectedDataset) } @@ -596,15 +493,13 @@ class ObserversAndDateInputFragment : Fragment(), } private fun updateSelectedDatasetActionView(selectedDataset: Dataset?) { - selectedDatasetActionView?.setItems( - if (selectedDataset == null) emptyList() - else listOf( - Pair.create( - selectedDataset.name, - selectedDataset.description - ) - ) - ) + selectedDatasetActionView?.getContentView()?.also { contentView -> + contentView.isSelected = true + contentView.findViewById(R.id.dataset_name)?.text = selectedDataset?.name + contentView.findViewById(R.id.dataset_description)?.text = + selectedDataset?.description + } + selectedDatasetActionView?.setContentViewVisibility(if (selectedDataset == null) View.GONE else View.VISIBLE) } private fun setDefaultDatasetFromSettings() { @@ -618,171 +513,11 @@ class ObserversAndDateInputFragment : Fragment(), } } - /** - * Select a new date from given optional date through date/time pickers. - * If no date was given, use the current date. - */ - private suspend fun selectDateTime( - bounds: CalendarConstraints, - withTime: Boolean = false, - from: Date = Date() - ): Date = - suspendCoroutine { continuation -> - val supportFragmentManager = - activity?.supportFragmentManager - - if (supportFragmentManager == null) { - continuation.resume(from) - - return@suspendCoroutine - } - - val context = context - - if (context == null) { - continuation.resume(from) - - return@suspendCoroutine - } - - with( - MaterialDatePicker.Builder - .datePicker() - .setSelection(from.time) - .setCalendarConstraints(bounds) - .build() - ) { - addOnPositiveButtonClickListener { - val selectedDate = Date(it).set( - Calendar.HOUR_OF_DAY, - from.get(Calendar.HOUR_OF_DAY) - ).set( - Calendar.MINUTE, - from.get(Calendar.MINUTE) - ) - - if (!withTime) { - continuation.resume(selectedDate) - - return@addOnPositiveButtonClickListener - } - - with( - MaterialTimePicker.Builder() - .setTimeFormat(if (is24HourFormat(context)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H) - .setHour(selectedDate.get(if (is24HourFormat(context)) Calendar.HOUR_OF_DAY else Calendar.HOUR)) - .setMinute(selectedDate.get(Calendar.MINUTE)) - .build() - ) { - addOnPositiveButtonClickListener { - continuation.resume( - selectedDate.set( - if (is24HourFormat(context)) Calendar.HOUR_OF_DAY else Calendar.HOUR, - hour - ).set( - Calendar.MINUTE, - minute - ) - ) - } - addOnNegativeButtonClickListener { - continuation.resume(selectedDate) - } - addOnCancelListener { - continuation.resume(selectedDate) - } - show( - supportFragmentManager, - TIME_PICKER_DIALOG_FRAGMENT - ) - } - } - addOnNegativeButtonClickListener { - continuation.resume(from) - } - addOnCancelListener { - continuation.resume(from) - } - show( - supportFragmentManager, - DATE_PICKER_DIALOG_FRAGMENT - ) - } - } - - private fun updateDateEditText( - editText: EditText, - dateSettings: InputDateSettings.DateSettings, - date: Date? - ) { - editText.text = date?.let { - Editable.Factory - .getInstance() - .newEditable( - DateFormat.format( - getString( - if (dateSettings == InputDateSettings.DateSettings.DATETIME) R.string.observers_and_date_datetime_format - else R.string.observers_and_date_date_format - ), - it - ).toString() - ) - } - } - - /** - * Checks start date constraints from current [AbstractInput]. - * - * @return `null` if all constraints are valid, or an error message - */ - private fun checkStartDateConstraints(): CharSequence? { - if (input == null) { - return null - } - - val startDate = input?.startDate - ?: return getString(R.string.observers_and_date_error_date_start_not_set) - - if (startDate.after(Date())) { - return getString(R.string.observers_and_date_error_date_start_after_now) - } - - return null - } - - /** - * Checks end date constraints from current [AbstractInput]. - * - * @return `null` if all constraints are valid, or an error message - */ - private fun checkEndDateConstraints(): CharSequence? { - if (input == null) { - return null - } - - val endDate = input?.endDate - - if (dateSettings.endDateSettings == null) { - return null - } - - if (endDate == null) { - return getString(R.string.observers_and_date_error_date_end_not_set) - } - - if ((input?.startDate ?: Date()).after(endDate)) { - return getString(R.string.observers_and_date_error_date_end_before_start_date) - } - - return null - } - companion object { private const val ARG_DATE_SETTINGS = "arg_date_settings" + private const val ARG_SAVE_DEFAULT_VALUES = "arg_save_default_values" - private const val DATE_PICKER_DIALOG_FRAGMENT = "date_picker_dialog_fragment" - private const val TIME_PICKER_DIALOG_FRAGMENT = "time_picker_dialog_fragment" private const val LOADER_OBSERVERS_IDS = 1 private const val LOADER_DATASET_ID = 2 private const val LOADER_DEFAULT_NOMENCLATURE_VALUES = 3 @@ -794,13 +529,18 @@ class ObserversAndDateInputFragment : Fragment(), * @return A new instance of [ObserversAndDateInputFragment] */ @JvmStatic - fun newInstance(dateSettings: InputDateSettings) = ObserversAndDateInputFragment().apply { - arguments = Bundle().apply { - putParcelable( - ARG_DATE_SETTINGS, - dateSettings - ) + fun newInstance(dateSettings: InputDateSettings, saveDefaultValues: Boolean = false) = + ObserversAndDateInputFragment().apply { + arguments = Bundle().apply { + putParcelable( + ARG_DATE_SETTINGS, + dateSettings + ) + putBoolean( + ARG_SAVE_DEFAULT_VALUES, + saveDefaultValues + ) + } } - } } } diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/summary/InputTaxaSummaryFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/summary/InputTaxaSummaryFragment.kt index 9512d9ce..ca8810dc 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/summary/InputTaxaSummaryFragment.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/summary/InputTaxaSummaryFragment.kt @@ -1,9 +1,12 @@ package fr.geonature.occtax.ui.input.summary +import android.app.Activity +import android.content.Intent import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.os.VibrationEffect import android.os.Vibrator -import android.text.TextUtils import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -11,56 +14,81 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.animation.AnimationUtils -import android.widget.TextView +import android.widget.ProgressBar +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat.getSystemService -import androidx.fragment.app.Fragment +import androidx.core.view.get import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton -import fr.geonature.commons.input.AbstractInput +import fr.geonature.commons.data.entity.Taxonomy import fr.geonature.commons.input.AbstractInputTaxon import fr.geonature.commons.ui.adapter.AbstractListItemRecyclerViewAdapter import fr.geonature.occtax.R -import fr.geonature.occtax.input.Input -import fr.geonature.occtax.ui.input.IInputFragment -import fr.geonature.occtax.ui.shared.dialog.CommentDialogFragment -import fr.geonature.viewpager.ui.AbstractPagerFragmentActivity -import fr.geonature.viewpager.ui.IValidateFragment +import fr.geonature.occtax.input.InputTaxon +import fr.geonature.occtax.settings.InputDateSettings +import fr.geonature.occtax.ui.input.AbstractInputFragment +import fr.geonature.occtax.ui.input.taxa.Filter +import fr.geonature.occtax.ui.input.taxa.FilterTaxonomy +import fr.geonature.occtax.ui.input.taxa.TaxaFilterActivity +import fr.geonature.occtax.ui.shared.dialog.InputDateDialogFragment +import java.util.Date /** * Summary of all edited taxa. * * @author S. Grimault */ -class InputTaxaSummaryFragment : Fragment(), - IValidateFragment, - IInputFragment { +class InputTaxaSummaryFragment : AbstractInputFragment() { + + private lateinit var savedState: Bundle + private lateinit var dateSettings: InputDateSettings + private lateinit var taxaFilterResultLauncher: ActivityResultLauncher - private var input: Input? = null private var adapter: InputTaxaSummaryRecyclerViewAdapter? = null - private var recyclerView: RecyclerView? = null - private var emptyTextView: TextView? = null - private var fab: ExtendedFloatingActionButton? = null - - private val onCommentDialogFragmentListener = - object : CommentDialogFragment.OnCommentDialogFragmentListener { - override fun onChanged(comment: String?) { - input?.comment = comment - activity?.invalidateOptionsMenu() + private var progressBar: ProgressBar? = null + private var emptyTextView: View? = null + private var filterChipGroup: ChipGroup? = null + private var startEditTaxon = false + + private val onInputDateDialogFragmentListener = + object : InputDateDialogFragment.OnInputDateDialogFragmentListener { + override fun onDatesChanged(startDate: Date, endDate: Date) { + input?.apply { + this.startDate = startDate + this.endDate = endDate + } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + savedState = savedInstanceState ?: Bundle() + dateSettings = arguments?.getParcelable(ARG_DATE_SETTINGS) ?: InputDateSettings.DEFAULT + val supportFragmentManager = activity?.supportFragmentManager ?: return - (supportFragmentManager.findFragmentByTag(COMMENT_DIALOG_FRAGMENT) as CommentDialogFragment?)?.also { - it.setOnCommentDialogFragmentListener(onCommentDialogFragmentListener) + (supportFragmentManager.findFragmentByTag(INPUT_DATE_DIALOG_FRAGMENT) as InputDateDialogFragment?)?.also { + it.setOnInputDateDialogFragmentListenerListener(onInputDateDialogFragmentListener) } + + taxaFilterResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult -> + if ((activityResult.resultCode != Activity.RESULT_OK) || (activityResult.data == null)) { + return@registerForActivityResult + } + + val selectedFilters = + activityResult.data?.getParcelableArrayExtra(TaxaFilterActivity.EXTRA_SELECTED_FILTERS) + ?.map { it as Filter<*> }?.toTypedArray() ?: emptyArray() + applyFilters(*selectedFilters) + } } override fun onCreateView( @@ -69,7 +97,7 @@ class InputTaxaSummaryFragment : Fragment(), savedInstanceState: Bundle? ): View? { return inflater.inflate( - R.layout.fragment_recycler_view_fab, + R.layout.fragment_input_summary, container, false ) @@ -87,29 +115,25 @@ class InputTaxaSummaryFragment : Fragment(), // we have a menu item to show in action bar setHasOptionsMenu(true) - recyclerView = view.findViewById(android.R.id.list) - fab = view.findViewById(R.id.fab) - + val recyclerView = view.findViewById(android.R.id.list) + progressBar = view.findViewById(android.R.id.progress) emptyTextView = view.findViewById(android.R.id.empty) - emptyTextView?.text = getString(R.string.summary_no_data) + filterChipGroup = view.findViewById(R.id.chip_group_filter) - fab?.apply { - setText(R.string.action_add_taxon) - extend() + view.findViewById(R.id.fab).apply { setOnClickListener { - ((activity as AbstractPagerFragmentActivity?))?.also { - input?.clearCurrentSelectedInputTaxon() - it.goToPreviousPage() - it.goToNextPage() - } + startEditTaxon = true + input?.clearCurrentSelectedInputTaxon() + listener.startEditTaxon() } } adapter = InputTaxaSummaryRecyclerViewAdapter(object : AbstractListItemRecyclerViewAdapter.OnListItemRecyclerViewAdapterListener { override fun onClick(item: AbstractInputTaxon) { + startEditTaxon = true input?.setCurrentSelectedInputTaxonId(item.taxon.id) - (activity as AbstractPagerFragmentActivity?)?.goToPageByKey(R.string.pager_fragment_information_title) + listener.startEditTaxon() } override fun onLongClicked( @@ -134,7 +158,7 @@ class InputTaxaSummaryFragment : Fragment(), ) { dialog, _ -> adapter?.remove(item) input?.removeInputTaxon(item.taxon.id) - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() dialog.dismiss() } @@ -182,6 +206,34 @@ class InputTaxaSummaryFragment : Fragment(), } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(savedState.apply { putAll(outState) }) + } + + override fun onResume() { + super.onResume() + + Handler(Looper.getMainLooper()).post { + // bypass this page and redirect to the previous one if we have started editing the first taxon + if (startEditTaxon && input?.getInputTaxa()?.isEmpty() == true) { + startEditTaxon = false + listener.goToPreviousPage() + return@post + } + + // no taxon added yet: redirect to the edit taxon pages + if (input?.getInputTaxa()?.isEmpty() == true) { + startEditTaxon = true + listener.startEditTaxon() + return@post + } + + // finish taxon editing workflow + startEditTaxon = false + listener.finishEditTaxon() + } + } + override fun onCreateOptionsMenu( menu: Menu, inflater: MenuInflater @@ -192,38 +244,57 @@ class InputTaxaSummaryFragment : Fragment(), inflater ) - inflater.inflate( - R.menu.comment, - menu - ) + with(inflater) { + inflate( + R.menu.date, + menu + ) + inflate( + R.menu.filter, + menu + ) + } } override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) - val commentItem = menu.findItem(R.id.menu_comment) - commentItem.title = - if (TextUtils.isEmpty(input?.comment)) getString(R.string.action_comment_add) else getString( - R.string.action_comment_edit - ) + val dateMenuItem = menu.findItem(R.id.menu_date) + dateMenuItem.isVisible = dateSettings.endDateSettings != null } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { - R.id.menu_comment -> { + R.id.menu_date -> { val supportFragmentManager = activity?.supportFragmentManager ?: return true - CommentDialogFragment.newInstance(input?.comment) + InputDateDialogFragment.newInstance( + InputDateSettings(endDateSettings = dateSettings.endDateSettings), + input?.startDate ?: Date(), + input?.endDate + ) .apply { - setOnCommentDialogFragmentListener(onCommentDialogFragmentListener) + setOnInputDateDialogFragmentListenerListener(onInputDateDialogFragmentListener) show( supportFragmentManager, - COMMENT_DIALOG_FRAGMENT + INPUT_DATE_DIALOG_FRAGMENT ) } return true } + R.id.menu_filter -> { + val context = context ?: return true + + taxaFilterResultLauncher.launch( + TaxaFilterActivity.newIntent( + context, + filter = getSelectedFilters().toTypedArray() + ) + ) + + true + } else -> super.onOptionsItemSelected(item) } } @@ -233,7 +304,15 @@ class InputTaxaSummaryFragment : Fragment(), } override fun getSubtitle(): CharSequence? { - return input?.getCurrentSelectedInputTaxon()?.taxon?.name + val context = context ?: return null + + return input?.getInputTaxa()?.size?.let { + context.resources.getQuantityString( + R.plurals.summary_taxa_subtitle, + it, + it + ) + } } override fun pagingEnabled(): Boolean { @@ -241,22 +320,127 @@ class InputTaxaSummaryFragment : Fragment(), } override fun validate(): Boolean { - return this.input?.getCurrentSelectedInputTaxon() != null + return startEditTaxon || (this.input?.getInputTaxa() ?: emptyList()).all { + it is InputTaxon && it.properties.isNotEmpty() && it.getCounting().isNotEmpty() + } } override fun refreshView() { // FIXME: this is a workaround to refresh adapter's list as getInputTaxa() items are not immutable... if ((adapter?.itemCount ?: 0) > 0) adapter?.clear() - adapter?.setItems(input?.getInputTaxa() ?: emptyList()) + + val selectedFilters = + savedState.getParcelableArray(KEY_SELECTED_FILTERS)?.map { it as Filter<*> } + ?.toList() ?: emptyList() + val filterByTaxonomy = + selectedFilters.find { filter -> filter.type == Filter.FilterType.TAXONOMY }?.value as Taxonomy? + + adapter?.setItems((input?.getInputTaxa() ?: emptyList()).filter { + val taxonomy = filterByTaxonomy ?: return@filter true + + // filter by kingdom only + if (taxonomy.group == Taxonomy.ANY) { + return@filter it.taxon.taxonomy.kingdom == taxonomy.kingdom + } + + it.taxon.taxonomy == taxonomy + }) + } + + private fun applyFilters(vararg filter: Filter<*>) { + savedState.putParcelableArray( + KEY_SELECTED_FILTERS, + filter + ) + + val selectedTaxonomy = + filter.find { it.type == Filter.FilterType.TAXONOMY }?.value as Taxonomy? + + filterByTaxonomy(selectedTaxonomy) + refreshView() + } + + private fun filterByTaxonomy(selectedTaxonomy: Taxonomy?) { + val filterChipGroup = filterChipGroup ?: return + val context = context ?: return + + val taxonomyChipsToDelete = arrayListOf() + + for (i in 0 until filterChipGroup.childCount) { + with(filterChipGroup[i]) { + if (this is Chip && tag is Taxonomy) { + taxonomyChipsToDelete.add(this) + } + } + } + + taxonomyChipsToDelete.forEach { + filterChipGroup.removeView(it) + } + + filterChipGroup.visibility = if (filterChipGroup.childCount > 0) View.VISIBLE else View.GONE + + if (selectedTaxonomy != null) { + filterChipGroup.visibility = View.VISIBLE + + // build kingdom taxonomy filter chip + with( + LayoutInflater.from(context).inflate( + R.layout.chip, + filterChipGroup, + false + ) as Chip + ) { + tag = Taxonomy(selectedTaxonomy.kingdom) + text = selectedTaxonomy.kingdom + setOnClickListener { + applyFilters(*getSelectedFilters().filter { it.type != Filter.FilterType.TAXONOMY } + .toTypedArray()) + } + setOnCloseIconClickListener { + applyFilters(*getSelectedFilters().filter { it.type != Filter.FilterType.TAXONOMY } + .toTypedArray()) + } + + filterChipGroup.addView(this) + } + + // build group taxonomy filter chip + if (selectedTaxonomy.group != Taxonomy.ANY) { + with( + LayoutInflater.from(context).inflate( + R.layout.chip, + filterChipGroup, + false + ) as Chip + ) { + tag = selectedTaxonomy + text = selectedTaxonomy.group + setOnClickListener { + applyFilters(*(getSelectedFilters().filter { filter -> filter.type != Filter.FilterType.TAXONOMY } + .toTypedArray() + mutableListOf(FilterTaxonomy(Taxonomy((it.tag as Taxonomy).kingdom))))) + } + setOnCloseIconClickListener { + applyFilters(*(getSelectedFilters().filter { filter -> filter.type != Filter.FilterType.TAXONOMY } + .toTypedArray() + mutableListOf(FilterTaxonomy(Taxonomy((it.tag as Taxonomy).kingdom))))) + } + + filterChipGroup.addView(this) + } + } + } } - override fun setInput(input: AbstractInput) { - this.input = input as Input + private fun getSelectedFilters(): List> { + return savedState.getParcelableArray(KEY_SELECTED_FILTERS)?.map { it as Filter<*> } + ?.toList() ?: emptyList() } companion object { - private const val COMMENT_DIALOG_FRAGMENT = "comment_dialog_fragment" + private const val INPUT_DATE_DIALOG_FRAGMENT = "input_date_dialog_fragment" + private const val ARG_DATE_SETTINGS = "arg_date_settings" + private const val KEY_SELECTED_FILTERS = "key_selected_filters" /** * Use this factory method to create a new instance of [InputTaxaSummaryFragment]. @@ -264,6 +448,13 @@ class InputTaxaSummaryFragment : Fragment(), * @return A new instance of [InputTaxaSummaryFragment] */ @JvmStatic - fun newInstance() = InputTaxaSummaryFragment() + fun newInstance(dateSettings: InputDateSettings) = InputTaxaSummaryFragment().apply { + arguments = Bundle().apply { + putParcelable( + ARG_DATE_SETTINGS, + dateSettings + ) + } + } } } diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/summary/InputTaxaSummaryRecyclerViewAdapter.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/summary/InputTaxaSummaryRecyclerViewAdapter.kt index b89db9f5..053cbdf0 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/summary/InputTaxaSummaryRecyclerViewAdapter.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/summary/InputTaxaSummaryRecyclerViewAdapter.kt @@ -1,13 +1,10 @@ package fr.geonature.occtax.ui.input.summary import android.text.Spanned -import android.view.LayoutInflater +import android.text.SpannedString import android.view.View import android.widget.TextView import androidx.core.text.HtmlCompat -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup -import fr.geonature.commons.data.entity.Taxonomy import fr.geonature.commons.input.AbstractInputTaxon import fr.geonature.commons.ui.adapter.AbstractListItemRecyclerViewAdapter import fr.geonature.occtax.R @@ -58,61 +55,28 @@ class InputTaxaSummaryRecyclerViewAdapter(listener: OnListItemRecyclerViewAdapte inner class ViewHolder(itemView: View) : AbstractListItemRecyclerViewAdapter.AbstractViewHolder(itemView) { private val title: TextView = itemView.findViewById(android.R.id.title) - private val filterChipGroup: ChipGroup = itemView.findViewById(R.id.chip_group_filter) private val text1: TextView = itemView.findViewById(android.R.id.text1) + private val summary: TextView = itemView.findViewById(android.R.id.summary) private val text2: TextView = itemView.findViewById(android.R.id.text2) override fun onBind(item: AbstractInputTaxon) { title.text = item.taxon.name - buildTaxonomyChips(item.taxon.taxonomy) - text1.text = buildInformation(*(item as InputTaxon).properties.values.toTypedArray()) - text1.isSelected = true + text1.text = item.taxon.commonName + summary.text = buildInformation(*(item as InputTaxon).properties.values.toTypedArray()) + summary.isSelected = true text2.text = buildCounting(item.getCounting().size) } - private fun buildTaxonomyChips(taxonomy: Taxonomy) { - filterChipGroup.removeAllViews() - - // build kingdom taxonomy filter chip - with( - LayoutInflater.from(itemView.context).inflate( - R.layout.chip, - filterChipGroup, - false - ) as Chip - ) { - text = taxonomy.kingdom - filterChipGroup.addView(this) - isCloseIconVisible = false - isEnabled = false - } - - // build group taxonomy filter chip - if (taxonomy.group != Taxonomy.ANY) { - with( - LayoutInflater.from(itemView.context).inflate( - R.layout.chip, - filterChipGroup, - false - ) as Chip - ) { - text = taxonomy.group - filterChipGroup.addView(this) - isCloseIconVisible = false - isEnabled = false - } - } - } - private fun buildInformation(vararg propertyValue: PropertyValue): Spanned { - return HtmlCompat.fromHtml(propertyValue + return if (propertyValue.isEmpty()) SpannedString(itemView.context.getString(R.string.summary_taxon_information_empty)) + else HtmlCompat.fromHtml(propertyValue .asSequence() .filterNot { it.isEmpty() } .map { itemView.context.getString( R.string.summary_taxon_information, getNomenclatureTypeLabel(it.code), - it.label + it.label ?: it.value ) } .joinToString(", "), diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/taxa/TaxaFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/taxa/TaxaFragment.kt index 1733b765..94e9913b 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/taxa/TaxaFragment.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/taxa/TaxaFragment.kt @@ -15,6 +15,7 @@ import android.view.animation.AnimationUtils import android.widget.ProgressBar import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.view.get import androidx.fragment.app.Fragment @@ -33,15 +34,11 @@ import fr.geonature.commons.data.entity.Taxon import fr.geonature.commons.data.entity.TaxonWithArea import fr.geonature.commons.data.entity.Taxonomy import fr.geonature.commons.data.helper.ProviderHelper.buildUri -import fr.geonature.commons.input.AbstractInput import fr.geonature.commons.util.ThemeUtils import fr.geonature.occtax.R -import fr.geonature.occtax.input.Input import fr.geonature.occtax.input.InputTaxon import fr.geonature.occtax.settings.AppSettings -import fr.geonature.occtax.ui.input.IInputFragment -import fr.geonature.viewpager.ui.AbstractPagerFragmentActivity -import fr.geonature.viewpager.ui.IValidateFragment +import fr.geonature.occtax.ui.input.AbstractInputFragment import org.tinylog.Logger import java.util.Locale import javax.inject.Inject @@ -52,9 +49,7 @@ import javax.inject.Inject * @author S. Grimault */ @AndroidEntryPoint -class TaxaFragment : Fragment(), - IValidateFragment, - IInputFragment { +class TaxaFragment : AbstractInputFragment() { @ContentProviderAuthority @Inject @@ -63,7 +58,6 @@ class TaxaFragment : Fragment(), private lateinit var savedState: Bundle private lateinit var taxaFilterResultLauncher: ActivityResultLauncher - private var input: Input? = null private var adapter: TaxaRecyclerViewAdapter? = null private var progressBar: ProgressBar? = null private var emptyTextView: View? = null @@ -126,7 +120,7 @@ class TaxaFragment : Fragment(), null, taxonFilter.first, taxonFilter.second.map { it.toString() }.toTypedArray(), - TaxonWithArea.OrderBy().by(AbstractTaxon.COLUMN_NAME).build() + TaxonWithArea.OrderBy().byName(args?.getString(KEY_FILTER_BY_NAME)).build() ) } LOADER_TAXON -> { @@ -167,7 +161,8 @@ class TaxaFragment : Fragment(), when (loader.id) { LOADER_TAXA -> { adapter?.bind(data) - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() + (activity as AppCompatActivity?)?.supportActionBar?.subtitle = getSubtitle() } LOADER_TAXON -> { if (data.moveToFirst()) { @@ -235,14 +230,14 @@ class TaxaFragment : Fragment(), Logger.info { "selected taxon (id: ${taxon.id}, name: '${taxon.name}', taxonomy: (kingdom='${taxon.taxonomy.kingdom}', group='${taxon.taxonomy.group}'))" } - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() } override fun onNoTaxonSelected() { input?.getCurrentSelectedInputTaxon() ?.also { input?.removeInputTaxon(it.taxon.id) } - (activity as AbstractPagerFragmentActivity?)?.validateCurrentPage() + listener.validateCurrentPage() } override fun scrollToFirstSelectedItemPosition(position: Int) { @@ -381,13 +376,15 @@ class TaxaFragment : Fragment(), } override fun getSubtitle(): CharSequence? { + val context = context ?: return null + if (progressBar?.visibility == View.VISIBLE && adapter?.itemCount == 0) { return null } val taxaFound = adapter?.itemCount ?: return null - return resources.getQuantityString( + return context.resources.getQuantityString( R.plurals.taxa_found, taxaFound, taxaFound @@ -403,6 +400,12 @@ class TaxaFragment : Fragment(), } override fun refreshView() { + if (input?.selectedFeatureId.isNullOrEmpty()) savedState.remove(KEY_SELECTED_FEATURE_ID) + else savedState.putString( + KEY_SELECTED_FEATURE_ID, + input?.selectedFeatureId + ) + loadTaxa() val selectedInputTaxon = this.input?.getCurrentSelectedInputTaxon() @@ -424,19 +427,6 @@ class TaxaFragment : Fragment(), } } - override fun setInput(input: AbstractInput) { - this.input = input as Input - - savedState.putString( - KEY_SELECTED_FEATURE_ID, - input.selectedFeatureId - ) - - if (input.selectedFeatureId.isNullOrEmpty()) { - savedState.remove(KEY_SELECTED_FEATURE_ID) - } - } - private fun loadTaxa() { progressBar?.visibility = View.VISIBLE diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/input/taxa/TaxaRecyclerViewAdapter.kt b/occtax/src/main/java/fr/geonature/occtax/ui/input/taxa/TaxaRecyclerViewAdapter.kt index b8445e07..7169590b 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/input/taxa/TaxaRecyclerViewAdapter.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/input/taxa/TaxaRecyclerViewAdapter.kt @@ -83,8 +83,7 @@ class TaxaRecyclerViewAdapter(private val listener: OnTaxaRecyclerViewAdapterLis val checkbox: CheckBox = v.findViewById(android.R.id.checkbox) checkbox.isChecked = !checkbox.isChecked - val summary: TextView = v.findViewById(android.R.id.summary) - summary.isSelected = true + v.isSelected = true val taxon = v.tag as AbstractTaxon @@ -227,8 +226,7 @@ class TaxaRecyclerViewAdapter(private val listener: OnTaxaRecyclerViewAdapterLis "${if (taxon.description.isNullOrBlank()) "" else "${taxon.description ?: ""}"}${if (taxon.rank.isNullOrBlank()) "" else "${if (taxon.description.isNullOrBlank()) "" else " - [${taxon.rank}]"} "}", HtmlCompat.FROM_HTML_MODE_COMPACT ) - summary.isSelected = selectedTaxon?.id == taxon.id - + itemView.isSelected = selectedTaxon?.id == taxon.id checkbox.isChecked = selectedTaxon?.id == taxon.id with(taxon.taxonArea) { diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/observers/InputObserverListFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/observers/InputObserverListFragment.kt index 62aaa830..0ce1c839 100644 --- a/occtax/src/main/java/fr/geonature/occtax/ui/observers/InputObserverListFragment.kt +++ b/occtax/src/main/java/fr/geonature/occtax/ui/observers/InputObserverListFragment.kt @@ -41,6 +41,7 @@ class InputObserverListFragment : Fragment() { @Inject lateinit var authority: String + private lateinit var savedState: Bundle private var listener: OnInputObserverListFragmentListener? = null private var adapter: InputObserverRecyclerViewAdapter? = null @@ -52,7 +53,8 @@ class InputObserverListFragment : Fragment() { return when (id) { LOADER_OBSERVERS -> { - val selections = InputObserver.filter(args?.getString(KEY_FILTER)) + val observersFilter = + InputObserver.Filter().byName(args?.getString(KEY_FILTER)).build() CursorLoader( requireContext(), @@ -61,9 +63,9 @@ class InputObserverListFragment : Fragment() { InputObserver.TABLE_NAME ), null, - selections.first, - selections.second, - null + observersFilter.first, + observersFilter.second.map { it.toString() }.toTypedArray(), + InputObserver.OrderBy().byName(args?.getString(KEY_FILTER)).build() ) } @@ -96,6 +98,15 @@ class InputObserverListFragment : Fragment() { mode: ActionMode?, menu: Menu? ): Boolean { + mode?.menuInflater?.inflate( + R.menu.search, + menu + ) + + (menu?.findItem(R.id.action_search)?.actionView as SearchView?)?.apply { + configureSearchView(this) + } + return true } @@ -103,7 +114,17 @@ class InputObserverListFragment : Fragment() { mode: ActionMode?, menu: Menu? ): Boolean { - return false + val searchCriterion = savedState.getString(KEY_FILTER) + + (menu?.findItem(R.id.action_search)?.actionView as SearchView?)?.apply { + isIconified = searchCriterion.isNullOrEmpty() + setQuery( + searchCriterion, + false + ) + } + + return !searchCriterion.isNullOrEmpty() } override fun onActionItemClicked( @@ -119,6 +140,12 @@ class InputObserverListFragment : Fragment() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + savedState = savedInstanceState ?: Bundle() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -192,6 +219,10 @@ class InputObserverListFragment : Fragment() { setHasOptionsMenu(true) } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(savedState.apply { putAll(outState) }) + } + override fun onCreateOptionsMenu( menu: Menu, inflater: MenuInflater @@ -207,29 +238,9 @@ class InputObserverListFragment : Fragment() { menu ) - val searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - return true - } - - override fun onQueryTextChange(newText: String): Boolean { - LoaderManager.getInstance(this@InputObserverListFragment) - .restartLoader( - LOADER_OBSERVERS, - bundleOf( - Pair( - KEY_FILTER, - newText - ) - ), - loaderCallbacks - ) - - return true - } - }) + (menu.findItem(R.id.action_search)?.actionView as SearchView?)?.apply { + configureSearchView(this) + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -259,6 +270,35 @@ class InputObserverListFragment : Fragment() { listener = null } + private fun configureSearchView(searchView: SearchView) { + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + savedState.putString( + KEY_FILTER, + newText + ) + + LoaderManager.getInstance(this@InputObserverListFragment) + .restartLoader( + LOADER_OBSERVERS, + bundleOf( + Pair( + KEY_FILTER, + newText + ) + ), + loaderCallbacks + ) + + return true + } + }) + } + private fun updateActionMode(inputObservers: List) { if (inputObservers.isEmpty()) { actionMode?.finish() diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/shared/dialog/CommentDialogFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/shared/dialog/CommentDialogFragment.kt deleted file mode 100644 index e18030b5..00000000 --- a/occtax/src/main/java/fr/geonature/occtax/ui/shared/dialog/CommentDialogFragment.kt +++ /dev/null @@ -1,154 +0,0 @@ -package fr.geonature.occtax.ui.shared.dialog - -import android.app.Dialog -import android.os.Bundle -import android.text.Editable -import android.text.TextUtils -import android.text.TextWatcher -import android.view.View -import android.view.ViewGroup -import android.widget.EditText -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import fr.geonature.commons.util.KeyboardUtils.hideSoftKeyboard -import fr.geonature.commons.util.KeyboardUtils.showSoftKeyboard -import fr.geonature.occtax.R - -/** - * Custom [Dialog] used to add comment. - * - * @author S. Grimault - */ -class CommentDialogFragment : DialogFragment() { - - private var comment: String? = null - private var onCommentDialogFragmentListener: OnCommentDialogFragmentListener? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = requireContext() - - val view = View.inflate( - context, - R.layout.dialog_comment, - null - ) - val editText = view.findViewById(android.R.id.edit) - .also { - it.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int - ) { - } - - override fun afterTextChanged(s: Editable?) { - } - - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int - ) { - comment = s?.toString() - } - }) - } - - arguments?.getString(KEY_COMMENT) - ?.also { - comment = it - editText.text = Editable.Factory.getInstance() - .newEditable(it) - } - - // restore the previous state if any - savedInstanceState?.getString(KEY_COMMENT) - ?.also { - comment = it - editText.text = Editable.Factory.getInstance() - .newEditable(it) - } - - // show automatically the soft keyboard for the EditText - editText.post { - showSoftKeyboard(editText) - } - - return AlertDialog.Builder(context) - .setTitle(if (TextUtils.isEmpty(comment)) R.string.alert_dialog_add_comment_title else R.string.alert_dialog_edit_comment_title) - .setView(view) - .setPositiveButton(R.string.alert_dialog_ok) { _, _ -> - hideSoftKeyboard(editText) - onCommentDialogFragmentListener?.onChanged(comment) - } - .setNegativeButton( - R.string.alert_dialog_cancel, - null - ) - .create() - } - - override fun onStart() { - super.onStart() - - // resize the dialog width to match parent - dialog?.also { - it.window?.setLayout( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - } - } - - override fun onSaveInstanceState(outState: Bundle) { - outState.putSerializable( - KEY_COMMENT, - comment - ) - - super.onSaveInstanceState(outState) - } - - fun setOnCommentDialogFragmentListener(onCommentDialogFragmentListener: OnCommentDialogFragmentListener) { - - this.onCommentDialogFragmentListener = onCommentDialogFragmentListener - } - - companion object { - - const val KEY_COMMENT = "comment" - - /** - * Use this factory method to create a new instance of [CommentDialogFragment]. - * - * @return A new instance of [CommentDialogFragment] - */ - @JvmStatic - fun newInstance(comment: String?) = CommentDialogFragment().apply { - arguments = Bundle().apply { - putString( - KEY_COMMENT, - comment - ) - } - } - } - - /** - * The callback used by [CommentDialogFragment]. - * - * @author S. Grimault - */ - interface OnCommentDialogFragmentListener { - - /** - * Invoked when the positive button of the dialog is pressed. - * - * @param comment the string comment edited from this dialog - */ - fun onChanged(comment: String?) - } -} diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/shared/dialog/InputDateDialogFragment.kt b/occtax/src/main/java/fr/geonature/occtax/ui/shared/dialog/InputDateDialogFragment.kt new file mode 100644 index 00000000..2c4c686e --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/ui/shared/dialog/InputDateDialogFragment.kt @@ -0,0 +1,169 @@ +package fr.geonature.occtax.ui.shared.dialog + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import android.widget.Button +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import fr.geonature.occtax.R +import fr.geonature.occtax.settings.InputDateSettings +import fr.geonature.occtax.ui.shared.view.InputDateView +import java.util.Date + +/** + * Custom [Dialog] used to edit input date. + * + * @author S. Grimault + */ +class InputDateDialogFragment : DialogFragment() { + + private var onInputDateDialogFragmentListener: OnInputDateDialogFragmentListener? = null + + private var dateSettings: InputDateSettings = + InputDateSettings(endDateSettings = InputDateSettings.DateSettings.DATE) + private var startDate: Date = Date() + private var endDate: Date = startDate + private var buttonValidate: Button? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + + val view = View.inflate( + context, + R.layout.dialog_date, + null + ) + + // restore the previous state if any + dateSettings = (savedInstanceState?.getParcelable(KEY_DATE_SETTINGS) + ?: arguments?.getParcelable(KEY_DATE_SETTINGS) + ?: InputDateSettings(endDateSettings = InputDateSettings.DateSettings.DATE)) + startDate = (savedInstanceState?.getSerializable(KEY_DATE_START) as Date?) + ?: (arguments?.getSerializable(KEY_DATE_START) as Date?) ?: Date() + endDate = (savedInstanceState?.getSerializable(KEY_DATE_END) as Date?) + ?: (arguments?.getSerializable(KEY_DATE_END) as Date?) ?: startDate + + view.findViewById(R.id.input_date)?.also { + it.setInputDateSettings(dateSettings) + it.setDates( + startDate, + endDate + ) + it.setListener(object : InputDateView.OnInputDateViewListener { + override fun fragmentManager(): FragmentManager? { + return activity?.supportFragmentManager + } + + override fun onDatesChanged(startDate: Date, endDate: Date) { + this@InputDateDialogFragment.startDate = startDate + this@InputDateDialogFragment.endDate = endDate + + buttonValidate?.isEnabled = true + } + + override fun hasError(message: CharSequence) { + // disable validate button unless start and end date are valid + buttonValidate?.isEnabled = false + } + }) + } + + val alertDialog = AlertDialog.Builder(context) + .setTitle( + if (dateSettings.startDateSettings != null && dateSettings.endDateSettings == null) R.string.input_date_start_hint + else if (dateSettings.startDateSettings == null && dateSettings.endDateSettings != null) R.string.input_date_end_hint + else R.string.input_date_hint + ) + .setView(view) + .setPositiveButton(R.string.alert_dialog_ok) { _, _ -> + onInputDateDialogFragmentListener?.onDatesChanged( + startDate, + endDate + ) + } + .setNegativeButton( + R.string.alert_dialog_cancel, + null + ) + .create() + + alertDialog.setOnShowListener { + buttonValidate = (it as AlertDialog).getButton(DialogInterface.BUTTON_POSITIVE) + } + + return alertDialog + } + + override fun onSaveInstanceState(outState: Bundle) { + with(outState) { + putParcelable( + KEY_DATE_SETTINGS, + dateSettings + ) + putSerializable( + KEY_DATE_START, + startDate + ) + putSerializable( + KEY_DATE_END, + endDate + ) + } + + super.onSaveInstanceState(outState) + } + + fun setOnInputDateDialogFragmentListenerListener(onInputDateDialogFragmentListener: OnInputDateDialogFragmentListener) { + this.onInputDateDialogFragmentListener = onInputDateDialogFragmentListener + } + + companion object { + + const val KEY_DATE_SETTINGS = "key_settings" + const val KEY_DATE_START = "key_date_start" + const val KEY_DATE_END = "key_date_end" + + /** + * Use this factory method to create a new instance of [InputDateDialogFragment]. + * + * @return A new instance of [InputDateDialogFragment] + */ + @JvmStatic + fun newInstance(dateSettings: InputDateSettings, startDate: Date, endDate: Date?) = + InputDateDialogFragment().apply { + arguments = Bundle().apply { + putParcelable( + KEY_DATE_SETTINGS, + dateSettings + ) + putSerializable( + KEY_DATE_START, + startDate + ) + putSerializable( + KEY_DATE_END, + endDate ?: startDate + ) + } + } + } + + /** + * The callback used by [InputDateDialogFragment]. + * + * @author S. Grimault + */ + interface OnInputDateDialogFragmentListener { + + /** + * Invoked when the positive button of the dialog is pressed. + * + * @param startDate the start date edited from this dialog + * @param endDate the end date edited from this dialog + */ + fun onDatesChanged(startDate: Date, endDate: Date) + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/shared/view/ActionView.kt b/occtax/src/main/java/fr/geonature/occtax/ui/shared/view/ActionView.kt new file mode 100644 index 00000000..a2ae664b --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/ui/shared/view/ActionView.kt @@ -0,0 +1,225 @@ +package fr.geonature.occtax.ui.shared.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import fr.geonature.occtax.R + +/** + * Generic [View] about adding custom view with an action button. + * + * @author S. Grimault + */ +class ActionView : ConstraintLayout { + + private var contentView: FrameLayout? = null + private lateinit var titleTextView: TextView + private lateinit var actionButton: Button + private lateinit var emptyTextView: TextView + private var listener: OnActionViewListener? = null + + private var contentViewVisibility: Int = View.GONE + + @StringRes + private var actionText: Int = 0 + + @StringRes + private var actionEmptyText: Int = 0 + + constructor(context: Context) : super(context) { + init( + null, + 0 + ) + } + + constructor( + context: Context, + attrs: AttributeSet + ) : super( + context, + attrs + ) { + init( + attrs, + 0 + ) + } + + constructor( + context: Context, + attrs: AttributeSet, + defStyleAttr: Int + ) : super( + context, + attrs, + defStyleAttr + ) { + init( + attrs, + defStyleAttr + ) + } + + override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) { + if (contentView == null) { + super.addView( + child, + index, + params + ) + } else { + contentView?.children?.asSequence()?.filter { it.id != emptyTextView.id }?.forEach { + contentView?.removeView(it) + } + contentView?.addView( + child, + index, + params + ) + setContentViewVisibility(contentViewVisibility) + } + } + + fun getContentView(): View? { + return contentView?.children?.asSequence()?.firstOrNull { it.id != emptyTextView.id } + } + + fun setListener(listener: OnActionViewListener) { + this.listener = listener + } + + fun setTitle(@StringRes titleResourceId: Int) { + setTitle(if (titleResourceId == 0) null else context.getString(titleResourceId)) + } + + fun setTitle(title: String?) { + titleTextView.text = title + titleTextView.visibility = if (title.isNullOrBlank()) GONE else VISIBLE + } + + fun setEmptyText(@StringRes emptyTextResourceId: Int) { + emptyTextView.setText(if (emptyTextResourceId == 0) R.string.no_data else emptyTextResourceId) + } + + fun enableActionButton(enabled: Boolean = true) { + actionButton.isEnabled = enabled + } + + fun setActionText(@StringRes actionResourceId: Int) { + if (actionResourceId == 0) { + return + } + + actionText = actionResourceId + actionEmptyText = actionResourceId + } + + fun setActionEmptyText(@StringRes actionResourceId: Int) { + if (actionResourceId == 0) { + return + } + + actionEmptyText = actionResourceId + } + + fun setContentViewVisibility(visibility: Int) { + contentViewVisibility = visibility + actionButton.setText(if (visibility == View.VISIBLE) actionText else actionEmptyText.takeIf { it > 0 } + ?: actionText) + contentView?.children?.asSequence()?.forEach { + if (it.id == emptyTextView.id) it.visibility = + if (visibility == View.VISIBLE) View.GONE else View.VISIBLE + else it.visibility = if (visibility == View.VISIBLE) View.VISIBLE else View.GONE + } + } + + private fun init( + attrs: AttributeSet?, + defStyle: Int + ) { + View.inflate( + context, + R.layout.view_action, + this + ) + + titleTextView = findViewById(android.R.id.title) + actionButton = findViewById(android.R.id.button1) + actionButton.setOnClickListener { listener?.onAction() } + contentView = findViewById(android.R.id.content) + emptyTextView = findViewById(android.R.id.empty) + + // load attributes + val ta = context.obtainStyledAttributes( + attrs, + R.styleable.ActionView, + defStyle, + 0 + ) + + ta.getString(R.styleable.ActionView_title)?.also { + setTitle(it) + } + setTitle( + ta.getResourceId( + R.styleable.ActionView_title, + 0 + ) + ) + + setEmptyText( + ta.getResourceId( + R.styleable.ActionView_no_data, + R.string.no_data + ) + ) + + enableActionButton( + ta.getBoolean( + R.styleable.ActionView_action_enabled, + true + ) + ) + + setActionText( + ta.getResourceId( + R.styleable.ActionView_action, + 0 + ) + ) + setActionEmptyText( + ta.getResourceId( + R.styleable.ActionView_action_empty, + 0 + ) + ) + + setContentViewVisibility( + ta.getInt( + R.styleable.ActionView_content_visibility, + View.GONE + ) + ) + + ta.recycle() + } + + /** + * Callback used by [ActionView]. + */ + interface OnActionViewListener { + + /** + * Called when the action button has been clicked. + */ + fun onAction() + } +} \ No newline at end of file diff --git a/occtax/src/main/java/fr/geonature/occtax/ui/shared/view/InputDateView.kt b/occtax/src/main/java/fr/geonature/occtax/ui/shared/view/InputDateView.kt new file mode 100644 index 00000000..aa873084 --- /dev/null +++ b/occtax/src/main/java/fr/geonature/occtax/ui/shared/view/InputDateView.kt @@ -0,0 +1,447 @@ +package fr.geonature.occtax.ui.shared.view + +import android.content.Context +import android.text.Editable +import android.text.format.DateFormat +import android.util.AttributeSet +import android.view.View +import android.widget.EditText +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.fragment.app.FragmentManager +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.DateValidatorPointBackward +import com.google.android.material.datepicker.DateValidatorPointForward +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.textfield.TextInputLayout +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import fr.geonature.commons.input.AbstractInput +import fr.geonature.commons.util.afterTextChanged +import fr.geonature.commons.util.get +import fr.geonature.commons.util.set +import fr.geonature.occtax.R +import fr.geonature.occtax.settings.InputDateSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Calendar +import java.util.Date +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Generic [View] about [AbstractInput] start and end date. + * + * @author S. Grimault + */ +class InputDateView : ConstraintLayout { + + private lateinit var titleTextView: TextView + private lateinit var dateStartTextInputLayout: TextInputLayout + private lateinit var dateEndTextInputLayout: TextInputLayout + + private var dateSettings: InputDateSettings = InputDateSettings.DEFAULT + private var startDate: Date = Date() + private var endDate: Date = startDate + + private var listener: OnInputDateViewListener? = null + + constructor(context: Context) : super(context) { + init( + null, + 0 + ) + } + + constructor(context: Context, attrs: AttributeSet) : super( + context, + attrs + ) { + init( + attrs, + 0 + ) + } + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super( + context, + attrs, + defStyle + ) { + init( + attrs, + defStyle + ) + } + + fun setListener(listener: OnInputDateViewListener) { + this.listener = listener + } + + fun setTitle(@StringRes titleResourceId: Int) { + setTitle(if (titleResourceId == 0) null else context.getString(titleResourceId)) + } + + fun setTitle(title: String?) { + titleTextView.text = title + titleTextView.visibility = if (title.isNullOrBlank()) GONE else VISIBLE + } + + fun setInputDateSettings(dateSettings: InputDateSettings) { + this.dateSettings = dateSettings + + with(dateStartTextInputLayout) { + visibility = if (dateSettings.startDateSettings == null) View.GONE else View.VISIBLE + hint = context.getString( + if (dateSettings.endDateSettings == null) R.string.input_date_hint + else R.string.input_date_start_hint + ) + } + dateEndTextInputLayout.visibility = + if (dateSettings.endDateSettings == null) View.GONE else View.VISIBLE + } + + fun setDates(startDate: Date, endDate: Date?) { + this.startDate = startDate + this.endDate = endDate ?: startDate + + dateStartTextInputLayout.editText?.apply { + updateDateEditText( + this, + dateSettings.startDateSettings ?: InputDateSettings.DateSettings.DATE, + startDate + ) + } + dateEndTextInputLayout.editText?.apply { + updateDateEditText( + this, + dateSettings.endDateSettings ?: InputDateSettings.DateSettings.DATE, + endDate + ) + } + } + + fun hasErrors(): Boolean { + return checkStartDateConstraints() != null || + checkEndDateConstraints() != null + } + + private fun init(attrs: AttributeSet?, defStyle: Int) { + View.inflate( + context, + R.layout.view_input_date, + this + ) + + titleTextView = findViewById(android.R.id.title) + + dateStartTextInputLayout = findViewById(R.id.dateStart).apply { + visibility = if (dateSettings.startDateSettings == null) View.GONE else View.VISIBLE + hint = context.getString( + if (dateSettings.endDateSettings == null) R.string.input_date_hint + else R.string.input_date_start_hint + ) + editText?.afterTextChanged { + error = checkStartDateConstraints() + dateEndTextInputLayout.error = checkEndDateConstraints() + + error?.also { + listener?.hasError(it) + } + } + editText?.setOnClickListener { + CoroutineScope(Dispatchers.Main).launch { + val startDate = selectDateTime( + CalendarConstraints + .Builder() + .setValidator(DateValidatorPointBackward.now()) + .build(), + dateSettings.startDateSettings == InputDateSettings.DateSettings.DATETIME, + startDate + ) + + this@InputDateView.startDate = startDate + + if (dateSettings.endDateSettings == null) { + this@InputDateView.endDate = startDate + } + + dateStartTextInputLayout.editText?.apply { + updateDateEditText( + this, + dateSettings.startDateSettings ?: InputDateSettings.DateSettings.DATE, + startDate + ) + } + dateEndTextInputLayout.editText?.apply { + updateDateEditText( + this, + dateSettings.endDateSettings ?: InputDateSettings.DateSettings.DATE, + endDate + ) + } + + if (error == null && dateEndTextInputLayout.editText?.error == null) { + listener?.onDatesChanged( + startDate, + endDate + ) + } + } + } + } + + dateEndTextInputLayout = findViewById(R.id.dateEnd).apply { + visibility = if (dateSettings.endDateSettings == null) View.GONE else View.VISIBLE + editText?.afterTextChanged { + error = checkEndDateConstraints() + dateStartTextInputLayout.error = checkStartDateConstraints() + + error?.also { + listener?.hasError(it) + } + } + editText?.setOnClickListener { + CoroutineScope(Dispatchers.Main).launch { + val endDate = selectDateTime( + CalendarConstraints + .Builder() + .setValidator( + DateValidatorPointForward.from( + startDate + .set( + Calendar.HOUR_OF_DAY, + 0 + ).set( + Calendar.MINUTE, + 0 + ).set( + Calendar.SECOND, + 0 + ).set( + Calendar.MILLISECOND, + 0 + ).time + ) + ) + .build(), + dateSettings.endDateSettings == InputDateSettings.DateSettings.DATETIME, + endDate + ) + + this@InputDateView.endDate = endDate + dateStartTextInputLayout.editText?.apply { + updateDateEditText( + this, + dateSettings.startDateSettings ?: InputDateSettings.DateSettings.DATE, + startDate + ) + } + dateEndTextInputLayout.editText?.apply { + updateDateEditText( + this, + dateSettings.endDateSettings ?: InputDateSettings.DateSettings.DATE, + endDate + ) + } + + if (error == null && dateStartTextInputLayout.editText?.error == null) { + listener?.onDatesChanged( + startDate, + endDate + ) + } + } + } + } + + // Load attributes + val ta = context.obtainStyledAttributes( + attrs, + R.styleable.InputDateView, + defStyle, + 0 + ) + + ta.getString(R.styleable.InputDateView_title)?.also { + setTitle(it) + } + setTitle( + ta.getResourceId( + R.styleable.InputDateView_title, + 0 + ) + ) + + ta.recycle() + } + + /** + * Select a new date from given optional date through date/time pickers. + * If no date was given, use the current date. + */ + private suspend fun selectDateTime( + bounds: CalendarConstraints, + withTime: Boolean = false, + from: Date = Date() + ): Date = + suspendCoroutine { continuation -> + val fragmentManager = listener?.fragmentManager() + + if (fragmentManager == null) { + continuation.resume(from) + + return@suspendCoroutine + } + + val context = context + + if (context == null) { + continuation.resume(from) + + return@suspendCoroutine + } + + with( + MaterialDatePicker.Builder + .datePicker() + .setSelection(from.time) + .setCalendarConstraints(bounds) + .build() + ) { + addOnPositiveButtonClickListener { + val selectedDate = Date(it).set( + Calendar.HOUR_OF_DAY, + from.get(Calendar.HOUR_OF_DAY) + ).set( + Calendar.MINUTE, + from.get(Calendar.MINUTE) + ) + + if (!withTime) { + continuation.resume(selectedDate) + + return@addOnPositiveButtonClickListener + } + + with( + MaterialTimePicker.Builder() + .setTimeFormat(if (DateFormat.is24HourFormat(context)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H) + .setHour(selectedDate.get(if (DateFormat.is24HourFormat(context)) Calendar.HOUR_OF_DAY else Calendar.HOUR)) + .setMinute(selectedDate.get(Calendar.MINUTE)) + .build() + ) { + addOnPositiveButtonClickListener { + continuation.resume( + selectedDate.set( + if (DateFormat.is24HourFormat(context)) Calendar.HOUR_OF_DAY else Calendar.HOUR, + hour + ).set( + Calendar.MINUTE, + minute + ) + ) + } + addOnNegativeButtonClickListener { + continuation.resume(selectedDate) + } + addOnCancelListener { + continuation.resume(selectedDate) + } + show( + fragmentManager, + TIME_PICKER_DIALOG_FRAGMENT + ) + } + } + addOnNegativeButtonClickListener { + continuation.resume(from) + } + addOnCancelListener { + continuation.resume(from) + } + show( + fragmentManager, + DATE_PICKER_DIALOG_FRAGMENT + ) + } + } + + private fun updateDateEditText( + editText: EditText, + dateSettings: InputDateSettings.DateSettings, + date: Date? + ) { + editText.text = date?.let { + Editable.Factory + .getInstance() + .newEditable( + DateFormat.format( + context.getString( + if (dateSettings == InputDateSettings.DateSettings.DATETIME) R.string.input_datetime_format + else R.string.input_date_format + ), + it + ).toString() + ) + } + } + + /** + * Checks start date constraints from current [AbstractInput]. + * + * @return `null` if all constraints are valid, or an error message + */ + private fun checkStartDateConstraints(): CharSequence? { + if (startDate.after(Date())) { + return context.getString(R.string.input_error_date_start_after_now) + } + + return null + } + + /** + * Checks end date constraints from current [AbstractInput]. + * + * @return `null` if all constraints are valid, or an error message + */ + private fun checkEndDateConstraints(): CharSequence? { + if (dateSettings.endDateSettings == null) { + return null + } + + if (startDate.after(endDate)) { + return context.getString(R.string.input_error_date_end_before_start_date) + } + + return null + } + + /** + * Callback used by [InputDateView]. + */ + interface OnInputDateViewListener { + + /** + * Return the FragmentManager for interacting with fragments associated with this view. + */ + fun fragmentManager(): FragmentManager? + + /** + * Called when the start and end dates have been changed. + */ + fun onDatesChanged(startDate: Date, endDate: Date) + + /** + * Called when the current start or end dates is not valid. + */ + fun hasError(message: CharSequence) + } + + companion object { + private const val DATE_PICKER_DIALOG_FRAGMENT = "date_picker_dialog_fragment" + private const val TIME_PICKER_DIALOG_FRAGMENT = "time_picker_dialog_fragment" + } +} \ No newline at end of file diff --git a/occtax/src/main/res/drawable/ic_action_comment.xml b/occtax/src/main/res/drawable/ic_action_comment.xml deleted file mode 100644 index 4aa3ca17..00000000 --- a/occtax/src/main/res/drawable/ic_action_comment.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/occtax/src/main/res/drawable/ic_action_edit_calendar.xml b/occtax/src/main/res/drawable/ic_action_edit_calendar.xml index 0a97af78..9f50ba54 100644 --- a/occtax/src/main/res/drawable/ic_action_edit_calendar.xml +++ b/occtax/src/main/res/drawable/ic_action_edit_calendar.xml @@ -2,6 +2,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" + android:tint="@color/actionbar_icon_tint" android:viewportWidth="24" android:viewportHeight="24"> + + diff --git a/occtax/src/main/res/drawable/ic_lock.xml b/occtax/src/main/res/drawable/ic_lock.xml new file mode 100644 index 00000000..36cc0ed3 --- /dev/null +++ b/occtax/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,5 @@ + + + diff --git a/occtax/src/main/res/drawable/ic_lock_open.xml b/occtax/src/main/res/drawable/ic_lock_open.xml new file mode 100644 index 00000000..89c86aca --- /dev/null +++ b/occtax/src/main/res/drawable/ic_lock_open.xml @@ -0,0 +1,5 @@ + + + diff --git a/occtax/src/main/res/layout/dialog_comment.xml b/occtax/src/main/res/layout/dialog_comment.xml deleted file mode 100644 index dba0daf2..00000000 --- a/occtax/src/main/res/layout/dialog_comment.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/occtax/src/main/res/layout/dialog_date.xml b/occtax/src/main/res/layout/dialog_date.xml new file mode 100644 index 00000000..95f34940 --- /dev/null +++ b/occtax/src/main/res/layout/dialog_date.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/occtax/src/main/res/layout/fragment_input_summary.xml b/occtax/src/main/res/layout/fragment_input_summary.xml new file mode 100644 index 00000000..8806b88b --- /dev/null +++ b/occtax/src/main/res/layout/fragment_input_summary.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/occtax/src/main/res/layout/fragment_observers_and_date_input.xml b/occtax/src/main/res/layout/fragment_observers_and_date_input.xml index 98ae1ca8..2dae7249 100644 --- a/occtax/src/main/res/layout/fragment_observers_and_date_input.xml +++ b/occtax/src/main/res/layout/fragment_observers_and_date_input.xml @@ -42,95 +42,71 @@ app:cardElevation="@dimen/cardview_elevation" app:contentPadding="@dimen/padding_default"> - - - - - + app:content_visibility="gone" + app:title="@string/observers_and_date_dataset"> - - - - - + android:background="?attr/selectableItemBackground" + android:orientation="vertical" + android:paddingHorizontal="@dimen/padding_default" + android:paddingTop="@dimen/padding_default"> - + android:textAppearance="?attr/textAppearanceListItem" + android:textStyle="bold" + tools:text="@tools:sample/last_names" /> - - - - - + android:duplicateParentState="true" + android:ellipsize="marquee" + android:lines="3" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:textAppearance="?attr/textAppearanceListItemSecondary" + tools:text="@tools:sample/first_names" /> - + + + + + - + + + @@ -153,7 +129,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingBottom="@dimen/padding_default" - android:text="@string/observers_and_date_comment" + android:text="@string/input_comment" android:textAllCaps="false" android:textAppearance="@style/TextAppearance.AppCompat.Large" android:textStyle="bold" /> @@ -163,13 +139,12 @@ style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" - android:padding="@dimen/padding_default" app:endIconMode="clear_text"> + + + + + + + + + \ No newline at end of file diff --git a/occtax/src/main/res/layout/list_selectable_item_3.xml b/occtax/src/main/res/layout/list_item_dataset.xml similarity index 84% rename from occtax/src/main/res/layout/list_selectable_item_3.xml rename to occtax/src/main/res/layout/list_item_dataset.xml index aaa44a44..21d03682 100644 --- a/occtax/src/main/res/layout/list_selectable_item_3.xml +++ b/occtax/src/main/res/layout/list_item_dataset.xml @@ -10,19 +10,11 @@ android:paddingStart="?attr/listPreferredItemPaddingStart" android:paddingEnd="?attr/listPreferredItemPaddingEnd"> - - @@ -43,8 +34,8 @@ android:layout_marginStart="?attr/listPreferredItemPaddingStart" android:ellipsize="marquee" android:marqueeRepeatLimit="marquee_forever" + android:lines="3" android:scrollHorizontally="true" - android:singleLine="true" android:textAppearance="?attr/textAppearanceListItemSecondary" app:layout_constraintEnd_toEndOf="@android:id/title" app:layout_constraintStart_toStartOf="@android:id/title" diff --git a/occtax/src/main/res/layout/list_item_taxon.xml b/occtax/src/main/res/layout/list_item_taxon.xml index 553e58e7..a8611c0e 100644 --- a/occtax/src/main/res/layout/list_item_taxon.xml +++ b/occtax/src/main/res/layout/list_item_taxon.xml @@ -39,10 +39,13 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="?attr/listPreferredItemHeight" - android:layout_marginEnd="?attr/listPreferredItemPaddingEnd" android:layout_marginTop="?attr/listPreferredItemPaddingStart" - android:ellipsize="end" - android:maxLines="1" + android:layout_marginEnd="?attr/listPreferredItemPaddingEnd" + android:duplicateParentState="true" + android:ellipsize="marquee" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:singleLine="true" android:textAppearance="?attr/textAppearanceListItem" android:textStyle="bold" app:layout_constraintEnd_toStartOf="@+id/taxon_observers_image_view" @@ -56,12 +59,15 @@ android:layout_height="wrap_content" android:layout_marginStart="?attr/listPreferredItemHeight" android:layout_marginEnd="?attr/listPreferredItemPaddingEnd" - android:ellipsize="end" - android:maxLines="1" + android:duplicateParentState="true" + android:ellipsize="marquee" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:singleLine="true" android:textAppearance="?attr/textAppearanceListItemSecondary" - app:layout_constraintTop_toBottomOf="@android:id/text1" app:layout_constraintEnd_toStartOf="@+id/taxon_observers_image_view" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@android:id/text1" tools:text="@tools:sample/first_names" /> + android:paddingHorizontal="@dimen/padding_default"> - + tools:text="@tools:sample/first_names" /> \ No newline at end of file diff --git a/occtax/src/main/res/layout/view_action.xml b/occtax/src/main/res/layout/view_action.xml new file mode 100644 index 00000000..2502f501 --- /dev/null +++ b/occtax/src/main/res/layout/view_action.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + +