diff --git a/app/build.gradle b/app/build.gradle index f65f2b563..f8b312572 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -226,7 +226,10 @@ dependencies { implementation "androidx.exifinterface:exifinterface:1.3.6" //camerax - def camerax_version = "1.3.3" + def camerax_version = "1.4.1" + + implementation "androidx.camera:camera-effects:$camerax_version" + implementation "androidx.camera:camera-camera2:$camerax_version" implementation "androidx.camera:camera-lifecycle:$camerax_version" implementation "androidx.camera:camera-view:$camerax_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c94b9a1c6..1122bae4e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -206,6 +206,10 @@ android:scheme="fieldbook" /> + = listOf() + private var traitId: String? = null + companion object { val TAG = CameraActivity::class.simpleName const val EXTRA_TITLE = "title" - + const val EXTRA_TRAIT_ID = "trait_id" } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + //get trait id from extras + traitId = intent.getStringExtra(EXTRA_TRAIT_ID) + setContentView(R.layout.activity_camera) previewView = findViewById(R.id.act_camera_pv) @@ -117,7 +124,9 @@ class CameraActivity : ThemedActivity() { val resolution = getSupportedResolutionByPreferences() cameraXFacade.bindPreview( - previewView, resolution + previewView, resolution, + traitId, + Handler(Looper.getMainLooper()) ) { camera, executor, capture -> setupCaptureUi(camera, executor, capture) @@ -141,8 +150,10 @@ class CameraActivity : ThemedActivity() { object : ImageCapture.OnImageSavedCallback { override fun onError(error: ImageCaptureException) {} override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { - setResult(RESULT_OK) - finish() + runOnUiThread { + setResult(RESULT_OK) + finish() + } } }) } diff --git a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java index 4ffa4fe27..345029ba4 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java @@ -55,6 +55,7 @@ import com.fieldbook.tracker.dialogs.GeoNavCollectDialog; import com.fieldbook.tracker.dialogs.ObservationMetadataFragment; import com.fieldbook.tracker.dialogs.SearchDialog; +import com.fieldbook.tracker.fragments.CropImageFragment; import com.fieldbook.tracker.interfaces.FieldSwitcher; import com.fieldbook.tracker.location.GPSTracker; import com.fieldbook.tracker.objects.FieldObject; @@ -117,6 +118,7 @@ import org.phenoapps.utils.TextToSpeechHelper; import org.threeten.bp.OffsetDateTime; +import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; @@ -153,6 +155,7 @@ public class CollectActivity extends ThemedActivity SensorHelper.RelativeRotationListener { public static final int REQUEST_FILE_EXPLORER_CODE = 1; + public static final int REQUEST_CROP_IMAGE_CODE = 101; public static final int BARCODE_COLLECT_CODE = 99; public static final int BARCODE_SEARCH_CODE = 98; @@ -483,7 +486,7 @@ public String getStudyId() { } public String getObservationUnit() { - return getCRange().plot_id; + return getCRange().uniqueId; } public String getPerson() { @@ -842,11 +845,11 @@ public void initWidgets(final boolean rangeSuppress) { traitBox.initTraitDetails(); + String currentSortOrder = preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position"); // trait is unique, format is not - String[] traits = database.getVisibleTrait(); + String[] traits = database.getVisibleTrait(currentSortOrder); if (traits != null) { traitBox.initTraitType(traits, rangeSuppress); - } } @@ -881,7 +884,7 @@ public boolean moveToSearch( RangeObject ro = rangeBox.getCRange(); //issue #634 fix for now to check the search query by plot_id which should be the unique id - if (Objects.equals(ro.plot_id, searchUnique)) { + if (Objects.equals(ro.uniqueId, searchUnique)) { moveToResultCore(j); return true; } @@ -893,7 +896,7 @@ public boolean moveToSearch( for (int j = 1; j <= plotIndices.length; j++) { rangeBox.setRangeByIndex(j - 1); - if (rangeBox.getCRange().range.equals(range) & rangeBox.getCRange().plot.equals(plot)) { + if (rangeBox.getCRange().primaryId.equals(range) & rangeBox.getCRange().secondaryId.equals(plot)) { moveToResultCore(j); return true; } @@ -905,7 +908,7 @@ public boolean moveToSearch( for (int j = 1; j <= plotIndices.length; j++) { rangeBox.setRangeByIndex(j - 1); - if (rangeBox.getCRange().plot.equals(data)) { + if (rangeBox.getCRange().secondaryId.equals(data)) { moveToResultCore(j); return true; } @@ -917,7 +920,7 @@ public boolean moveToSearch( for (int j = 1; j <= plotIndices.length; j++) { rangeBox.setRangeByIndex(j - 1); - if (rangeBox.getCRange().range.equals(data)) { + if (rangeBox.getCRange().primaryId.equals(data)) { moveToResultCore(j); return true; } @@ -930,7 +933,7 @@ public boolean moveToSearch( for (int j = 1; j <= rangeSize; j++) { rangeBox.setRangeByIndex(j - 1); - if (rangeBox.getCRange().plot_id.equals(data)) { + if (rangeBox.getCRange().uniqueId.equals(data)) { if (trait == -1) { moveToResultCore(j); @@ -1186,8 +1189,9 @@ public void navigateToTrait(String trait) { if (trait != null) { + String currentSortOrder = preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position"); //get all traits, filter the preference trait and check it's visibility - String[] traits = database.getVisibleTrait(); + String[] traits = database.getVisibleTrait(currentSortOrder); try { @@ -1453,7 +1457,7 @@ public boolean onOptionsItemSelected(MenuItem item) { collectDataTapTargetView(R.id.traitLeft, getString(R.string.tutorial_main_traits_title), getString(R.string.tutorial_main_traits_description), 60), collectDataTapTargetView(R.id.traitTypeTv, getString(R.string.tutorial_main_traitlist_title), getString(R.string.tutorial_main_traitlist_description), 80), collectDataTapTargetView(R.id.rangeLeft, getString(R.string.tutorial_main_entries_title), getString(R.string.tutorial_main_entries_description), 60), - collectDataTapTargetView(R.id.valuesPlotRangeHolder, getString(R.string.tutorial_main_navinfo_title), getString(R.string.tutorial_main_navinfo_description), 60), + collectDataTapTargetView(R.id.namesHolderLayout, getString(R.string.tutorial_main_navinfo_title), getString(R.string.tutorial_main_navinfo_description), 60), collectDataTapTargetView(R.id.traitHolder, getString(R.string.tutorial_main_datacollect_title), getString(R.string.tutorial_main_datacollect_description), 200), collectDataTapTargetView(R.id.missingValue, getString(R.string.tutorial_main_na_title), getString(R.string.tutorial_main_na_description), 60), collectDataTapTargetView(R.id.deleteValue, getString(R.string.tutorial_main_delete_title), getString(R.string.tutorial_main_delete_description), 60) @@ -2115,6 +2119,13 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } else triggerTts(fail); break; + case REQUEST_CROP_IMAGE_CODE: + if (resultCode == RESULT_OK) { + File f = new File(getContext().getCacheDir(), AbstractCameraTrait.TEMPORARY_IMAGE_NAME); + Uri uri = Uri.fromFile(f); + startCropActivity(getCurrentTrait().getId(), uri); + } + break; } } @@ -2281,7 +2292,8 @@ public boolean existsTrait(final int plotId) { */ @Override public int existsAllTraits(final int traitIndex, final int plotId) { - final ArrayList traits = database.getVisibleTraitObjects(); + String sortOrder = preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position"); + final ArrayList traits = database.getVisibleTraitObjects(sortOrder); for (int i = 0; i < traits.size(); i++) { if (i != traitIndex && !database.getTraitExists(plotId, traits.get(i).getId())) return i; @@ -2292,7 +2304,8 @@ public int existsAllTraits(final int traitIndex, final int plotId) { @NonNull @Override public List getNonExistingTraits(final int plotId) { - final ArrayList traits = database.getVisibleTraitObjects(); + String sortOrder = preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position"); + final ArrayList traits = database.getVisibleTraitObjects(sortOrder); final ArrayList indices = new ArrayList<>(); for (int i = 0; i < traits.size(); i++) { if (!database.getTraitExists(plotId, traits.get(i).getId())) @@ -2882,4 +2895,50 @@ private void sendCrashReport(Exception e) { } } + + /** + * a function that starts the crop activity and sends it required intent data + */ + public void startCropActivity(String traitId, Uri uri) { + try { + Intent intent = new Intent(this, CropImageActivity.class); + intent.putExtra(CropImageFragment.EXTRA_TRAIT_ID, Integer.parseInt(traitId)); + intent.putExtra(CropImageFragment.EXTRA_IMAGE_URI, uri.toString()); + cameraXFacade.unbind(); + startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public void requestAndCropImage() { + try { + Intent intent = new Intent(this, CameraActivity.class); + intent.putExtra(CropImageFragment.EXTRA_TRAIT_ID, Integer.parseInt(getCurrentTrait().getId())); + cameraXFacade.unbind(); + startActivityForResult(intent, REQUEST_CROP_IMAGE_CODE); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * shows an alert dialog that asks user to define a crop region + */ + public void showCropDialog(String traitId, Uri uri) { + try { + AlertDialog.Builder builder = new AlertDialog.Builder(this, R.style.AppAlertDialog); + builder.setTitle(R.string.dialog_crop_title); + builder.setMessage(R.string.dialog_crop_message); + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + startCropActivity(traitId, uri); + }); + builder.setNegativeButton(android.R.string.no, (dialog, which) -> { + dialog.dismiss(); + }); + builder.create().show(); + } catch (Exception e) { + e.printStackTrace(); + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/activities/ConfigActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/ConfigActivity.java index 589a3fc23..aefedf869 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/ConfigActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/ConfigActivity.java @@ -378,7 +378,8 @@ private void loadScreen() { */ private int checkTraitsExist() { - String[] traits = database.getVisibleTrait(); + String currentSortOrder = preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position"); + String[] traits = database.getVisibleTrait(currentSortOrder); if (!preferences.getBoolean(GeneralKeys.IMPORT_FIELD_FINISHED, false) || preferences.getInt(GeneralKeys.SELECTED_FIELD_ID, -1) == -1) { diff --git a/app/src/main/java/com/fieldbook/tracker/activities/CropImageActivity.kt b/app/src/main/java/com/fieldbook/tracker/activities/CropImageActivity.kt new file mode 100644 index 000000000..18ef32860 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/activities/CropImageActivity.kt @@ -0,0 +1,34 @@ +package com.fieldbook.tracker.activities + +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.fragment.app.add +import androidx.fragment.app.commit +import com.fieldbook.tracker.R +import com.fieldbook.tracker.fragments.CropImageFragment +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class CropImageActivity: ThemedActivity() { + + companion object { + const val TAG = "CropImageActivity" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_crop_image) + if (savedInstanceState == null) { + + val bundle = bundleOf( + CropImageFragment.EXTRA_TRAIT_ID to intent.getIntExtra(CropImageFragment.EXTRA_TRAIT_ID, -1), + CropImageFragment.EXTRA_IMAGE_URI to intent.getStringExtra(CropImageFragment.EXTRA_IMAGE_URI), + ) + + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.fragment_container_view, args = bundle) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/activities/FieldDetailFragment.kt b/app/src/main/java/com/fieldbook/tracker/activities/FieldDetailFragment.kt index 62c816dc9..b3ce2f907 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/FieldDetailFragment.kt +++ b/app/src/main/java/com/fieldbook/tracker/activities/FieldDetailFragment.kt @@ -456,7 +456,11 @@ class FieldDetailFragment : Fragment(), FieldSyncController { } fun checkTraitsExist(): Int { - val traits = database.getVisibleTrait() + + val currentSortOrder: String = + preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position") ?: "" + + val traits = database.getVisibleTrait(currentSortOrder) return when { traits.isEmpty() -> { diff --git a/app/src/main/java/com/fieldbook/tracker/activities/FieldEditorActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/FieldEditorActivity.java index 2f80770b0..e81621e96 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/FieldEditorActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/FieldEditorActivity.java @@ -109,9 +109,7 @@ public class FieldEditorActivity extends ThemedActivity private static final Handler mHandler = new Handler(); private FieldFileObject.FieldFileBase fieldFile; private final int PERMISSIONS_REQUEST_STORAGE = 998; - Spinner unique; - Spinner primary; - Spinner secondary; + private Spinner unique; private Menu systemMenu; private GPSTracker mGpsTracker; private ActionMode actionMode; @@ -135,9 +133,7 @@ public void run() { new ImportRunnableTask(FieldEditorActivity.this, fieldFile, unique.getSelectedItemPosition(), - unique.getSelectedItem().toString(), - primary.getSelectedItem().toString(), - secondary.getSelectedItem().toString()).execute(0); + unique.getSelectedItem().toString()).execute(0); } }; @@ -941,15 +937,11 @@ private void loadFile(FieldFileObject.FieldFileBase fieldFile) { private void importDialog(String[] columns) { LayoutInflater inflater = this.getLayoutInflater(); - View layout = inflater.inflate(R.layout.dialog_import, null); + View layout = inflater.inflate(R.layout.dialog_field_file_import, null); unique = layout.findViewById(R.id.uniqueSpin); - primary = layout.findViewById(R.id.primarySpin); - secondary = layout.findViewById(R.id.secondarySpin); setSpinner(unique, columns, GeneralKeys.UNIQUE_NAME); - setSpinner(primary, columns, GeneralKeys.PRIMARY_NAME); - setSpinner(secondary, columns, GeneralKeys.SECONDARY_NAME); AlertDialog.Builder builder = new AlertDialog.Builder(this, R.style.AppAlertDialog); builder.setTitle(R.string.fields_new_dialog_title) @@ -957,9 +949,7 @@ private void importDialog(String[] columns) { .setView(layout); builder.setPositiveButton(getString(R.string.dialog_import), (dialogInterface, i) -> { - if (checkImportColumnNames()) { - mHandler.post(importRunnable); - } + mHandler.post(importRunnable); }); builder.show(); @@ -973,20 +963,6 @@ private void setSpinner(Spinner spinner, String[] data, String pref) { spinner.setSelection(spinnerPosition); } - // Validate that column choices are different from one another - private boolean checkImportColumnNames() { - final String uniqueS = unique.getSelectedItem().toString(); - final String primaryS = primary.getSelectedItem().toString(); - final String secondaryS = secondary.getSelectedItem().toString(); - - if (uniqueS.equals(primaryS) || uniqueS.equals(secondaryS) || primaryS.equals(secondaryS)) { - Utils.makeToast(getApplicationContext(), getString(R.string.import_error_column_choice)); - return false; - } - - return true; - } - @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); diff --git a/app/src/main/java/com/fieldbook/tracker/activities/SearchActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/SearchActivity.java index 5d8102230..b9189204f 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/SearchActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/SearchActivity.java @@ -334,8 +334,9 @@ public void addRow(String text) { if (col != null) { rangeUntil = col.length; + String currentSortOrder = preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position"); ArrayAdapter adapter2 = new ArrayAdapter(SearchActivity.this, R.layout.custom_spinner_layout, - concat(col, database.getVisibleTrait())); + concat(col, database.getVisibleTrait(currentSortOrder))); adapter2.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); c.setAdapter(adapter2); diff --git a/app/src/main/java/com/fieldbook/tracker/activities/SummaryFragment.kt b/app/src/main/java/com/fieldbook/tracker/activities/SummaryFragment.kt index 142362215..eef5f52eb 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/SummaryFragment.kt +++ b/app/src/main/java/com/fieldbook/tracker/activities/SummaryFragment.kt @@ -313,7 +313,8 @@ class SummaryFragment : Fragment(), SummaryAdapter.SummaryController { this?.let { collector -> - if (attribute in database.visibleTrait) { + val sortOrder = collector.preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position") + if (attribute in database.getVisibleTrait(sortOrder)) { collector.navigateToTrait(attribute) diff --git a/app/src/main/java/com/fieldbook/tracker/activities/TraitEditorActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/TraitEditorActivity.java index 3bd4400fe..c17bbe1f9 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/TraitEditorActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/TraitEditorActivity.java @@ -120,6 +120,8 @@ public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView. int to = target.getBindingAdapterPosition(); try { + preferences.edit().putString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position").apply(); + queryAndLoadTraits(); adapter.moveItem(from, to); } catch (IndexOutOfBoundsException iobe) { iobe.printStackTrace(); @@ -399,6 +401,9 @@ private void checkShowDeleteDialog() { Toast.makeText(this, R.string.act_trait_editor_no_traits_exist, Toast.LENGTH_SHORT).show(); } else { showDeleteTraitDialog((dialog, which) -> { + for (TraitObject t : traits) { + preferences.edit().remove(GeneralKeys.getCropCoordinatesKey(Integer.parseInt(t.getId()))).apply(); + } database.deleteTraitsTable(); queryAndLoadTraits(); dialog.dismiss(); @@ -586,6 +591,10 @@ private void showFileDialog() { if (!traits.isEmpty()) { + for (TraitObject t : traits) { + preferences.edit().remove(GeneralKeys.getCropCoordinatesKey(Integer.parseInt(t.getId()))).apply(); + } + showDeleteTraitDialog((dialog, which) -> { database.deleteTraitsTable(); @@ -643,9 +652,10 @@ private void checkTraitExportDialog() { private void showTraitSortDialog() { Map sortOptions = new LinkedHashMap<>(); - final String defaultSortOrder = "internal_id_observation_variable"; + final String defaultSortOrder = "position"; String currentSortOrder = preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, defaultSortOrder); + sortOptions.put(getString(R.string.traits_sort_default), "position"); sortOptions.put(getString(R.string.traits_sort_name), "observation_variable_name"); sortOptions.put(getString(R.string.traits_sort_format), "observation_variable_field_book_format"); sortOptions.put(getString(R.string.traits_sort_import_order), "internal_id_observation_variable"); @@ -924,6 +934,8 @@ private void deleteTrait(TraitObject trait) { builder.setPositiveButton(getString(R.string.dialog_yes), new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int which) { + preferences.edit().remove(GeneralKeys.getCropCoordinatesKey(Integer.parseInt(trait.getId()))).apply(); + getDatabase().deleteTrait(trait.getId()); queryAndLoadTraits(); diff --git a/app/src/main/java/com/fieldbook/tracker/activities/brapi/io/BrapiStudyImportActivity.kt b/app/src/main/java/com/fieldbook/tracker/activities/brapi/io/BrapiStudyImportActivity.kt index 9d1123ea8..4771328eb 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/brapi/io/BrapiStudyImportActivity.kt +++ b/app/src/main/java/com/fieldbook/tracker/activities/brapi/io/BrapiStudyImportActivity.kt @@ -105,8 +105,6 @@ class BrapiStudyImportActivity : ThemedActivity(), CoroutineScope by MainScope() private var selectedLevel: Int = -1 private var selectedSort: Int = -1 - private var selectedPrimary: Int = -1 - private var selectedSecondary: Int = -1 private var attributesTable: HashMap>>? = null @@ -208,7 +206,7 @@ class BrapiStudyImportActivity : ThemedActivity(), CoroutineScope by MainScope() } enum class Tab { - LEVELS, PRIMARY_ORDER, SECONDARY_ORDER, SORT + LEVELS, SORT } private fun loadTabLayout(studyDbIds: List) { @@ -220,8 +218,6 @@ class BrapiStudyImportActivity : ThemedActivity(), CoroutineScope by MainScope() when (tab?.position) { Tab.LEVELS.ordinal -> setLevelListOptions() Tab.SORT.ordinal -> setSortListOptions() - Tab.PRIMARY_ORDER.ordinal -> setPrimaryOrderListOptions() - Tab.SECONDARY_ORDER.ordinal -> setSecondaryOrderListOptions() } listView.visibility = View.VISIBLE @@ -314,66 +310,8 @@ class BrapiStudyImportActivity : ThemedActivity(), CoroutineScope by MainScope() } } - private fun setPrimaryOrderListOptions() { - - listView.visibility = View.VISIBLE - - val attributes = getAttributeKeys() - - listView.adapter = ArrayAdapter( - this, - android.R.layout.simple_list_item_single_choice, - attributes - ) - - listView.setItemChecked(selectedPrimary, true) - - listView.smoothScrollToPosition(selectedPrimary) - - listView.setOnItemClickListener { _, _, position, _ -> - - selectedPrimary = if (selectedPrimary == position) { - - listView.setItemChecked(selectedPrimary, false) - - -1 - - } else position - } - } - - private fun setSecondaryOrderListOptions() { - - listView.visibility = View.VISIBLE - - val attributes = getAttributeKeys() - - listView.adapter = ArrayAdapter( - this, - android.R.layout.simple_list_item_single_choice, - attributes - ) - - listView.setItemChecked(selectedSecondary, true) - - listView.smoothScrollToPosition(selectedSecondary) - - listView.setOnItemClickListener { _, _, position, _ -> - - selectedSecondary = if (selectedSecondary == position) { - - listView.setItemChecked(selectedSecondary, false) - - -1 - - } else position - } - } - private fun setDefaultAttributeIdentifiers() { - val attributes = getAttributeKeys() - val levels = existingLevels() if (selectedLevel == -1) { @@ -383,26 +321,6 @@ class BrapiStudyImportActivity : ThemedActivity(), CoroutineScope by MainScope() 0 } } - - if (selectedPrimary == -1) { - selectedPrimary = if (attributes.contains("Row")) { - attributes.indexOf("Row") - } else if (attributes.contains("Block")) { - attributes.indexOf("Block") - } else { - 0 - } - } - - if (selectedSecondary == -1) { - selectedSecondary = if (attributes.contains("Column")) { - attributes.indexOf("Column") - } else if (attributes.contains("Rep")) { - attributes.indexOf("Rep") - } else { - 0 - } - } } private fun getAttributes(studyDbId: String): Map> { @@ -643,16 +561,27 @@ class BrapiStudyImportActivity : ThemedActivity(), CoroutineScope by MainScope() } val allAttributes = getAttributeKeys() - val primaryId = allAttributes[selectedPrimary] - val secondaryId = allAttributes[selectedSecondary] val sortOrder = if (selectedSort == -1) "" else allAttributes[selectedSort] studyDbIds.forEach { id -> - studies.firstOrNull { it.studyDbId == id }?.let { + try { + + studies.firstOrNull { it.studyDbId == id }?.let { - saveStudy(it, level, primaryId, secondaryId, sortOrder) + saveStudy(it, level, sortOrder) + + } + } catch (e: Exception) { + + Log.e(TAG, "Failed to save study", e) + + runOnUiThread { + + Toast.makeText(this@BrapiStudyImportActivity, getString(R.string.failed_to_save_study), Toast.LENGTH_SHORT).show() + + } } } @@ -666,8 +595,6 @@ class BrapiStudyImportActivity : ThemedActivity(), CoroutineScope by MainScope() private fun saveStudy( study: BrAPIStudy, level: BrapiObservationLevel, - primaryId: String, - secondaryId: String, sortId: String ) { @@ -693,58 +620,47 @@ class BrapiStudyImportActivity : ThemedActivity(), CoroutineScope by MainScope() val attributes = (studyAttributes.values.flatMap { it.keys } + geoCoordinateColumnName).distinct() - if (listOf(primaryId, secondaryId, sortId).filter { it.isNotEmpty() }.all { it in attributes }) { + details.attributes = attributes - details.attributes = attributes + val unitAttributes = ArrayList>() + units.forEach { unit -> - val unitAttributes = ArrayList>() - units.forEach { unit -> + val row = ArrayList() - val row = ArrayList() - - attributes.forEach { attr -> - if (attr != geoCoordinateColumnName) { - row.add(studyAttributes[unit.observationUnitDbId]?.get(attr) ?: "") - } + attributes.forEach { attr -> + if (attr != geoCoordinateColumnName) { + row.add(studyAttributes[unit.observationUnitDbId]?.get(attr) ?: "") } + } - //add geo json as json string - if (geoCoordinateColumnName in attributes) { - unit.observationUnitPosition?.geoCoordinates?.let { coordString -> - try { - row.add(JSON().serialize(coordString)) - } catch (e: Exception) { - Log.e(TAG, "Failed to serialize geo coordinates", e) - } + //add geo json as json string + if (geoCoordinateColumnName in attributes) { + unit.observationUnitPosition?.geoCoordinates?.let { coordString -> + try { + row.add(JSON().serialize(coordString)) + } catch (e: Exception) { + Log.e(TAG, "Failed to serialize geo coordinates", e) } } - - unitAttributes.add(row) - } - details.values = mutableListOf() - details.values.addAll(unitAttributes) + unitAttributes.add(row) - brapiService.saveStudyDetails( - details, - level, - primaryId, - secondaryId, - sortId, - ) - } else { - - runOnUiThread { - - Toast.makeText(this, getString(R.string.failed_to_save_study), Toast.LENGTH_SHORT).show() - setResult(Activity.RESULT_CANCELED) - finish() - } } + + details.values = mutableListOf() + details.values.addAll(unitAttributes) + + //primary/secondary no longer required + brapiService.saveStudyDetails( + details, + level, + "", + "", + sortId, + ) } } - } private suspend fun fetchObservationLevels(programDbId: String) = coroutineScope { diff --git a/app/src/main/java/com/fieldbook/tracker/adapters/TraitsStatusAdapter.kt b/app/src/main/java/com/fieldbook/tracker/adapters/TraitsStatusAdapter.kt index 74d66ffa8..1d6919fe3 100644 --- a/app/src/main/java/com/fieldbook/tracker/adapters/TraitsStatusAdapter.kt +++ b/app/src/main/java/com/fieldbook/tracker/adapters/TraitsStatusAdapter.kt @@ -106,7 +106,8 @@ class TraitsStatusAdapter(private val traitBoxView: TraitBoxView) : // Only update if the new selection is different if (currentSelection != newSelection) { currentSelection = newSelection - notifyDataSetChanged() // Notify adapter to refresh all items + notifyItemChanged(currentSelection) + notifyItemChanged(newSelection) } } @@ -118,7 +119,7 @@ class TraitsStatusAdapter(private val traitBoxView: TraitBoxView) : this[currentSelection].hasObservation = value } submitList(updatedList) - notifyDataSetChanged() + notifyItemChanged(currentSelection) } } @@ -128,11 +129,11 @@ class TraitsStatusAdapter(private val traitBoxView: TraitBoxView) : class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: TraitBoxItemModel, newItem: TraitBoxItemModel): Boolean { - return oldItem == newItem + return oldItem.trait == newItem.trait } override fun areContentsTheSame(oldItem: TraitBoxItemModel, newItem: TraitBoxItemModel): Boolean { - return oldItem == newItem + return oldItem.hasObservation == newItem.hasObservation } } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/async/ImportRunnableTask.java b/app/src/main/java/com/fieldbook/tracker/async/ImportRunnableTask.java index 1645a20f4..14000595e 100644 --- a/app/src/main/java/com/fieldbook/tracker/async/ImportRunnableTask.java +++ b/app/src/main/java/com/fieldbook/tracker/async/ImportRunnableTask.java @@ -27,7 +27,7 @@ public class ImportRunnableTask extends AsyncTask { WeakReference mContext; FieldAdapterController controller; FieldFileObject.FieldFileBase mFieldFile; - String unique, primary, secondary; + String unique; int idColPosition; SharedPreferences preferences; @@ -37,7 +37,7 @@ public class ImportRunnableTask extends AsyncTask { boolean containsDuplicates = false; public ImportRunnableTask(Context context, FieldFileObject.FieldFileBase fieldFile, - int idColPosition, String unique, String primary, String secondary) { + int idColPosition, String unique) { mFieldFile = fieldFile; @@ -51,8 +51,6 @@ public ImportRunnableTask(Context context, FieldFileObject.FieldFileBase fieldFi this.idColPosition = idColPosition; this.unique = unique; - this.primary = primary; - this.secondary = secondary; } @Override @@ -116,26 +114,21 @@ protected Integer doInBackground(Integer... params) { if (columns[i].equals(unique)) { uniqueIndex = i; - } else if (columns[i].equals(primary)) { - primaryIndex = i; - } else if (columns[i].equals(secondary)) { - secondaryIndex = i; } + } else containsDuplicates = true; } } FieldObject f = mFieldFile.createFieldObject(); f.setUnique_id(unique); - f.setPrimary_id(primary); - f.setSecondary_id(secondary); controller.getDatabase().beginTransaction(); studyId = controller.getDatabase().createField(f, nonEmptyColumns, false); //start iterating over all the rows of the csv file only if we found the u/p/s indices - if (uniqueIndex > -1 && primaryIndex > -1 && secondaryIndex > -1) { + if (uniqueIndex > -1) { int line = 0; @@ -149,11 +142,10 @@ protected Integer doInBackground(Integer... params) { int rowSize = data.length; //ensure next check won't cause an AIOB - if (rowSize > uniqueIndex && rowSize > primaryIndex && rowSize > secondaryIndex) { + if (rowSize > uniqueIndex) { //check that all u/p/s strings are not empty - if (!data[uniqueIndex].isEmpty() && !data[primaryIndex].isEmpty() - && !data[secondaryIndex].isEmpty()) { + if (!data[uniqueIndex].isEmpty()) { ArrayList nonEmptyData = new ArrayList<>(); for (int j = 0; j < data.length; j++) { @@ -162,7 +154,7 @@ protected Integer doInBackground(Integer... params) { } } - controller.getDatabase().createFieldData(studyId, nonEmptyColumns, nonEmptyData); + controller.getDatabase().createFieldData(studyId, nonEmptyColumns, nonEmptyData, false); } } diff --git a/app/src/main/java/com/fieldbook/tracker/brapi/service/BrAPIServiceV1.java b/app/src/main/java/com/fieldbook/tracker/brapi/service/BrAPIServiceV1.java index 91be0ae79..e0ec34ac1 100644 --- a/app/src/main/java/com/fieldbook/tracker/brapi/service/BrAPIServiceV1.java +++ b/app/src/main/java/com/fieldbook/tracker/brapi/service/BrAPIServiceV1.java @@ -1203,7 +1203,7 @@ public BrapiControllerResponse saveStudyDetails(BrapiStudyDetails studyDetails, System.out.println("Size of study details: "+studyDetails.getValues().size()); for (List dataRow : studyDetails.getValues()) { - dataHelper.createFieldData(expId, studyDetails.getAttributes(), dataRow); + dataHelper.createFieldData(expId, studyDetails.getAttributes(), dataRow, true); } // Insert the traits already associated with this study diff --git a/app/src/main/java/com/fieldbook/tracker/brapi/service/BrAPIServiceV2.java b/app/src/main/java/com/fieldbook/tracker/brapi/service/BrAPIServiceV2.java index f01f973ac..e95c8a4b6 100644 --- a/app/src/main/java/com/fieldbook/tracker/brapi/service/BrAPIServiceV2.java +++ b/app/src/main/java/com/fieldbook/tracker/brapi/service/BrAPIServiceV2.java @@ -1600,7 +1600,7 @@ public BrapiControllerResponse saveStudyDetails(BrapiStudyDetails studyDetails, System.out.println("Size of study details: " + studyDetails.getValues().size()); for (List dataRow : studyDetails.getValues()) { - dataHelper.createFieldData(expId, studyDetails.getAttributes(), dataRow); + dataHelper.createFieldData(expId, studyDetails.getAttributes(), dataRow, true); Log.d("BrAPIServiceV2", "Saving: Attributes: " + studyDetails.getAttributes()); Log.d("BrAPIServiceV2", "Saving: dataRow: " + dataRow); } diff --git a/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java b/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java index 08584549a..dd63d56a0 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java +++ b/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java @@ -1052,11 +1052,11 @@ public Cursor getExportTableData(int fieldId, ArrayList traits) { /** * Used by the application to return all traits which are visible */ - public String[] getVisibleTrait() { + public String[] getVisibleTrait(String sortOrder) { open(); - return VisibleObservationVariableDao.Companion.getVisibleTrait(); + return VisibleObservationVariableDao.Companion.getVisibleTrait(sortOrder); // String[] data = null; // @@ -1083,11 +1083,11 @@ public String[] getVisibleTrait() { // return data; } - public ArrayList getVisibleTraitObjects() { + public ArrayList getVisibleTraitObjects(String sortOrder) { open(); - return VisibleObservationVariableDao.Companion.getVisibleTraitObjects(); + return VisibleObservationVariableDao.Companion.getVisibleTraitObjects(sortOrder); } /** @@ -1314,7 +1314,7 @@ public FieldObject getFieldObject(Integer studyId) { return StudyDao.Companion.getFieldObject( studyId, - preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "internal_id_observation_variable") + preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position") ); // Cursor cursor = db.query(EXP_INDEX, new String[]{"exp_id", "exp_name", "unique_id", "primary_id", @@ -1352,7 +1352,7 @@ public ArrayList getAllTraitObjects() { open(); return ObservationVariableDao.Companion.getAllTraitObjects( - preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "internal_id_observation_variable") + preferences.getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position") ); // ArrayList list = new ArrayList<>(); @@ -2104,12 +2104,13 @@ public void updateTraitPosition(String id, int realPosition) { */ public long editTraits(String traitDbId, String trait, String format, String defaultValue, String minimum, String maximum, String details, String categories, - Boolean closeKeyboardOnOpen) { + Boolean closeKeyboardOnOpen, + Boolean cropImage) { open(); return ObservationVariableDao.Companion.editTraits(traitDbId, trait, format, defaultValue, - minimum, maximum, details, categories, closeKeyboardOnOpen); + minimum, maximum, details, categories, closeKeyboardOnOpen, cropImage); // try { // ContentValues c = new ContentValues(); // c.put("trait", trait); @@ -2147,7 +2148,7 @@ public long updateTrait(TraitObject trait) { return ObservationVariableDao.Companion.editTraits(trait.getId(), trait.getName(), trait.getFormat(), trait.getDefaultValue(), trait.getMinimum(), trait.getMaximum(), - trait.getDetails(), trait.getCategories(), trait.getCloseKeyboardOnOpen()); + trait.getDetails(), trait.getCategories(), trait.getCloseKeyboardOnOpen(), trait.getCropImage()); } public boolean checkUnique(HashMap values) { @@ -2351,11 +2352,11 @@ public int createField(FieldObject e, List columns, Boolean fromBrapi) { // return (int) exp_id; } - public void createFieldData(int studyId, List columns, List data) { + public void createFieldData(int studyId, List columns, List data, Boolean isBrapi) { open(); - StudyDao.Companion.createFieldData(studyId, columns, data); + StudyDao.Companion.createFieldData(studyId, columns, data, isBrapi); // // get unique_id, primary_id, secondary_id names from exp_id // Cursor cursor = db.rawQuery("SELECT exp_id.unique_id, exp_id.primary_id, exp_id.secondary_id from exp_id where exp_id.exp_id = " + exp_id, null); diff --git a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationUnitPropertyDao.kt b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationUnitPropertyDao.kt index 68b7da0b6..7a65c6b83 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationUnitPropertyDao.kt +++ b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationUnitPropertyDao.kt @@ -60,21 +60,20 @@ class ObservationUnitPropertyDao { RangeObject().apply { val model = db.query(sObservationUnitPropertyViewName, - select = arrayOf(firstName, secondName, uniqueName, "id"), where = "id = ?", whereArgs = arrayOf(id.toString())).toFirst() - range = model[firstName].toString() + primaryId = model[firstName].toString() - plot = model[secondName].toString() + secondaryId = model[secondName].toString() - plot_id = model[uniqueName].toString() + uniqueId = model[uniqueName].toString() } } ?: RangeObject().apply { - range = "" - plot = "" - plot_id = "" + primaryId = "" + secondaryId = "" + uniqueId = "" } /** diff --git a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationVariableDao.kt b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationVariableDao.kt index bb7f5afb7..1f2e91abb 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationVariableDao.kt +++ b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationVariableDao.kt @@ -214,15 +214,12 @@ class ObservationVariableDao { ) } - fun getAllTraitObjects(sortOrder: String = "internal_id_observation_variable"): ArrayList = withDatabase { db -> + fun getAllTraitObjects(sortOrder: String = "position"): ArrayList = withDatabase { db -> val traits = ArrayList() - // Sort ascending except for visibility, for visibility sort desc to have visible traits first - val orderDirection = if (sortOrder == "visible") "DESC" else "ASC" - val query = """ SELECT * FROM ${ObservationVariable.tableName} - ORDER BY $sortOrder COLLATE NOCASE $orderDirection + ORDER BY ${if (sortOrder == "visible") "position" else sortOrder} COLLATE NOCASE ASC """ db.rawQuery(query, null).use { cursor -> @@ -243,6 +240,7 @@ class ObservationVariableDao { minimum = "" categories = "" closeKeyboardOnOpen = false + cropImage = false val values = ObservationVariableValueDao.getVariableValues(id.toInt()) values?.forEach { value -> @@ -252,17 +250,27 @@ class ObservationVariableDao { "validValuesMax" -> maximum = value["observation_variable_attribute_value"] as? String ?: "" "category" -> categories = value["observation_variable_attribute_value"] as? String ?: "" "closeKeyboardOnOpen" -> closeKeyboardOnOpen = (value["observation_variable_attribute_value"] as? String ?: "false").toBoolean() + "cropImage" -> cropImage = (value["observation_variable_attribute_value"] as? String ?: "false").toBoolean() } } } traits.add(trait) } } - ArrayList(traits) + + if (sortOrder == "visible") { + val visibleTraits = traits.filter { it.visible } + val invisibleTraits = traits.filter { !it.visible } + + ArrayList(visibleTraits.sortedBy { it.realPosition } + ArrayList(invisibleTraits.sortedBy { it.realPosition })) + + } else { + ArrayList(traits) + } } ?: ArrayList() // Overload for Java compatibility - fun getAllTraitObjects(): ArrayList = getAllTraitObjects("internal_id_observation_variable") + fun getAllTraitObjects(): ArrayList = getAllTraitObjects("position") // fun getAllTraitObjects(sortOrder: String): ArrayList = withDatabase { db -> @@ -350,6 +358,7 @@ class ObservationVariableDao { Log.d("ObservationVariableDao", "maximum: ${t.maximum.orEmpty()}") Log.d("ObservationVariableDao", "categories: ${t.categories.orEmpty()}") Log.d("ObservationVariableDao", "closeKeyboardOnOpen: ${t.closeKeyboardOnOpen ?: "false"}") + Log.d("ObservationVariableDao", "cropImage: ${t.cropImage ?: "false"}") val varRowId = db.insert(ObservationVariable.tableName, null, contentValues) @@ -359,6 +368,7 @@ class ObservationVariableDao { t.maximum.orEmpty(), t.categories.orEmpty(), (t.closeKeyboardOnOpen ?: "false").toString(), + (t.cropImage ?: "false").toString(), varRowId.toString() ) Log.d("ObservationVariableDao", "Trait ${t.name} inserted successfully with row ID: $varRowId") @@ -390,7 +400,8 @@ class ObservationVariableDao { //TODO need to edit min/max/category obs. var. val/attrs fun editTraits(id: String, trait: String, format: String, defaultValue: String, minimum: String, maximum: String, details: String, categories: String, - closeKeyboardOnOpen: Boolean): Long = withDatabase { db -> + closeKeyboardOnOpen: Boolean, + cropImage: Boolean): Long = withDatabase { db -> val rowid = db.update(ObservationVariable.tableName, ContentValues().apply { put("observation_variable_name", trait) @@ -415,7 +426,10 @@ class ObservationVariableDao { ObservationVariableValueDao.update(id, attrId.toString(), categories) } "closeKeyboardOnOpen" -> { - ObservationVariableValueDao.insertCloseKeyboard(closeKeyboardOnOpen.toString(), id) + ObservationVariableValueDao.insertAttributeValue(it, closeKeyboardOnOpen.toString(), id) + } + "cropImage" -> { + ObservationVariableValueDao.insertAttributeValue(it, cropImage.toString(), id) } } } diff --git a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationVariableValueDao.kt b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationVariableValueDao.kt index 21eec24c7..096883c6f 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationVariableValueDao.kt +++ b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationVariableValueDao.kt @@ -27,38 +27,32 @@ class ObservationVariableValueDao { } - fun insertCloseKeyboard(closeKeyboardOnOpen: String, id: String) = withDatabase { db -> + fun insertAttributeValue(attrName: String, value: String, id: String) = withDatabase { db -> - val attrId = ObservationVariableAttributeDao.getAttributeIdByName("closeKeyboardOnOpen") + val attrId = ObservationVariableAttributeDao.getAttributeIdByName(attrName) db.insert(ObservationVariableValue.tableName, null, contentValuesOf( ObservationVariable.FK to id, Migrator.ObservationVariableAttribute.FK to attrId, - "observation_variable_attribute_value" to closeKeyboardOnOpen + "observation_variable_attribute_value" to value )) } - fun insert(min: String, max: String, categories: String, closeKeyboardOnOpen: String, id: String) = withDatabase { db -> + fun insert(min: String, max: String, categories: String, closeKeyboardOnOpen: String, cropImage: String, id: String) = withDatabase { db -> //iterate through mapping of the old columns that are now attr/vals mapOf( "validValuesMin" to min, "validValuesMax" to max, "category" to categories, - "closeKeyboardOnOpen" to closeKeyboardOnOpen + "closeKeyboardOnOpen" to closeKeyboardOnOpen, + "cropImage" to cropImage ).asSequence().forEach { attrValue -> - val attrId = ObservationVariableAttributeDao.getAttributeIdByName(attrValue.key) + insertAttributeValue(attrValue.key, attrValue.value, id) - db.insert(ObservationVariableValue.tableName, null, contentValuesOf( - - ObservationVariable.FK to id, - Migrator.ObservationVariableAttribute.FK to attrId, - "observation_variable_attribute_value" to attrValue.value - - )) } } } diff --git a/app/src/main/java/com/fieldbook/tracker/database/dao/StudyDao.kt b/app/src/main/java/com/fieldbook/tracker/database/dao/StudyDao.kt index 5122c2574..fff87c9a1 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/dao/StudyDao.kt +++ b/app/src/main/java/com/fieldbook/tracker/database/dao/StudyDao.kt @@ -202,7 +202,7 @@ class StudyDao { (SELECT COUNT(DISTINCT observation_variable_name) FROM observations WHERE study_id = Studies.${Study.PK} AND observation_variable_db_id > 0) AS trait_count, (SELECT COUNT(*) FROM observations WHERE study_id = Studies.${Study.PK} AND observation_variable_db_id > 0) AS observation_count FROM ${Study.tableName} AS Studies - ORDER BY $sortOrder COLLATE NOCASE ${if (isDateSort) "DESC" else "ASC"} + ORDER BY ${if (sortOrder == "visible") "position" else sortOrder} COLLATE NOCASE ${if (isDateSort) "DESC" else "ASC"} """ db.rawQuery(query, null).use { cursor -> while (cursor.moveToNext()) { @@ -218,7 +218,7 @@ class StudyDao { } ?: ArrayList() - fun getFieldObject(exp_id: Int, sortOrder: String = "internal_id_observation_variable"): FieldObject? = withDatabase { db -> + fun getFieldObject(exp_id: Int, sortOrder: String = "position"): FieldObject? = withDatabase { db -> val query = """ SELECT ${Study.PK}, @@ -253,7 +253,7 @@ class StudyDao { } map.toFieldObject().apply { // Set the trait details - this.setTraitDetails(getTraitDetailsForStudy(exp_id, sortOrder)) + this.traitDetails = getTraitDetailsForStudy(exp_id, sortOrder) } } else { null @@ -275,13 +275,10 @@ class StudyDao { * ) */ - fun getTraitDetailsForStudy(studyId: Int, sortOrder: String = "internal_id_observation_variable"): List { + fun getTraitDetailsForStudy(studyId: Int, sortOrder: String = "position"): List { return withDatabase { db -> val traitDetails = mutableListOf() - // Sort ascending except for visibility, for visibility sort desc to have visible traits first - val orderDirection = if (sortOrder == "visible") "DESC" else "ASC" - val cursor = db.rawQuery(""" SELECT o.observation_variable_name, o.observation_variable_field_book_format, COUNT(*) as count, GROUP_CONCAT(o.value, '|') as observations, (SELECT COUNT(DISTINCT observation_unit_id) FROM observations WHERE study_id = ? AND observation_variable_name = o.observation_variable_name) AS distinct_obs_units, @@ -294,7 +291,7 @@ class StudyDao { JOIN observation_variables ov ON o.observation_variable_db_id = ov.internal_id_observation_variable WHERE o.study_id = ? AND o.observation_variable_db_id > 0 GROUP BY o.observation_variable_name, o.observation_variable_field_book_format - ORDER BY ov.$sortOrder COLLATE NOCASE $orderDirection + ORDER BY ov.${if (sortOrder == "visible") "position" else sortOrder} COLLATE NOCASE ASC """, arrayOf(studyId.toString(), studyId.toString(), studyId.toString())) if (cursor.moveToFirst()) { @@ -400,17 +397,15 @@ class StudyDao { /** * This function should always be called within a transaction. */ - fun createFieldData(studyId: Int, columns: List, data: List) = withDatabase { db -> + fun createFieldData(studyId: Int, columns: List, data: List, isBrapi: Boolean = false) = withDatabase { db -> val names = getNames(studyId)!! - //TODO: indexOf can return -1 which leads to array out of bounds exception //input data corresponds to original database column names val uniqueIndex = columns.indexOf(names.unique) val primaryIndex = columns.indexOf(names.primary) val secondaryIndex = columns.indexOf(names.secondary) - //TODO remove when we handle primary/secondary ids better //check if data size matches the columns size, on mismatch fill with dummy data //mainly fixes issues with BrAPI when xtype/ytype and row/col values are not given val actualData = if (data.size != columns.size) { @@ -429,12 +424,10 @@ class StudyDao { geoCoordinates = data[geoCoordinatesIndex] } } - + val rowid = db.insert(ObservationUnit.tableName, null, contentValuesOf( Study.FK to studyId, "observation_unit_db_id" to actualData[uniqueIndex], - "primary_id" to if (primaryIndex < 0) "NA" else actualData[primaryIndex], - "secondary_id" to if (secondaryIndex < 0) "NA" else actualData[secondaryIndex], "geo_coordinates" to geoCoordinates )) @@ -452,28 +445,31 @@ class StudyDao { } } - if (primaryIndex < 0) { + if (isBrapi) { - val attrId = ObservationUnitAttributeDao.getIdByName("Row") + if (primaryIndex < 0) { - db.insert(ObservationUnitValue.tableName, null, contentValuesOf( - Study.FK to studyId, - ObservationUnit.FK to rowid, - ObservationUnitAttribute.FK to attrId, - "observation_unit_value_name" to "NA" - )) - } + val attrId = ObservationUnitAttributeDao.getIdByName("Row") + + db.insert(ObservationUnitValue.tableName, null, contentValuesOf( + Study.FK to studyId, + ObservationUnit.FK to rowid, + ObservationUnitAttribute.FK to attrId, + "observation_unit_value_name" to "NA" + )) + } - if (secondaryIndex < 0) { + if (secondaryIndex < 0) { - val attrId = ObservationUnitAttributeDao.getIdByName("Column") + val attrId = ObservationUnitAttributeDao.getIdByName("Column") - db.insert(ObservationUnitValue.tableName, null, contentValuesOf( - Study.FK to studyId, - ObservationUnit.FK to rowid, - ObservationUnitAttribute.FK to attrId, - "observation_unit_value_name" to "NA" - )) + db.insert(ObservationUnitValue.tableName, null, contentValuesOf( + Study.FK to studyId, + ObservationUnit.FK to rowid, + ObservationUnitAttribute.FK to attrId, + "observation_unit_value_name" to "NA" + )) + } } } diff --git a/app/src/main/java/com/fieldbook/tracker/database/dao/VisibleObservationVariableDao.kt b/app/src/main/java/com/fieldbook/tracker/database/dao/VisibleObservationVariableDao.kt index f52101523..e0b937ea5 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/dao/VisibleObservationVariableDao.kt +++ b/app/src/main/java/com/fieldbook/tracker/database/dao/VisibleObservationVariableDao.kt @@ -7,24 +7,27 @@ import com.fieldbook.tracker.database.query import com.fieldbook.tracker.database.toTable import com.fieldbook.tracker.database.withDatabase import com.fieldbook.tracker.objects.TraitObject +import com.fieldbook.tracker.preferences.GeneralKeys class VisibleObservationVariableDao { companion object { - fun getVisibleTrait(): Array = withDatabase { db -> + fun getVisibleTrait(sortOrder: String = "position"): Array = withDatabase { db -> - db.query(sVisibleObservationVariableViewName, - select = arrayOf("observation_variable_name"), - orderBy = "position").toTable().map { it -> - it["observation_variable_name"].toString() - }.toTypedArray() + db.query(sVisibleObservationVariableViewName) + .toTable() + .sortedBy { (it[if (sortOrder == "visible") "position" else sortOrder] as? String ?: "position").lowercase() } + .map { it["observation_variable_name"] as? String ?: ""} + .toTypedArray() } ?: emptyArray() - fun getVisibleTraitObjects(): ArrayList = withDatabase { db -> - val rows = db.query(sVisibleObservationVariableViewName, orderBy = "position").toTable() + fun getVisibleTraitObjects(sortOrder: String = "position"): ArrayList = withDatabase { db -> + val rows = db.query(sVisibleObservationVariableViewName) + .toTable() + .sortedBy { (it[if (sortOrder == "visible") "position" else sortOrder] as? String ?: "position").lowercase() } val variables: ArrayList = ArrayList() @@ -84,8 +87,10 @@ class VisibleObservationVariableDao { "closeKeyboardOnOpen" -> closeKeyboardOnOpen = (it["observation_variable_attribute_value"] as? String ?: "false").toBoolean() - } + "cropImage" -> cropImage = + (it["observation_variable_attribute_value"] as? String ?: "false").toBoolean() + } } } } diff --git a/app/src/main/java/com/fieldbook/tracker/database/models/ObservationUnitModel.kt b/app/src/main/java/com/fieldbook/tracker/database/models/ObservationUnitModel.kt index 8f0b9e9a7..a5f0ae5a5 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/models/ObservationUnitModel.kt +++ b/app/src/main/java/com/fieldbook/tracker/database/models/ObservationUnitModel.kt @@ -13,8 +13,8 @@ data class ObservationUnitModel(val map: Row) { val internal_id_observation_unit: Int by map //comp. pk 1 val study_id: Int by map //fk to studies table val observation_unit_db_id: String by map //unique id - val primary_id: String by map - val secondary_id: String by map + var primary_id: String? = (map["primary_id"] as? String) ?: "" + var secondary_id: String? = (map["secondary_id"] as? String) ?: "" var geo_coordinates: String? = (map["geo_coordinates"] as? String) ?: "" //blob? val additionalInfo: String? by map //blob, can be replaced with value/attr query? val germplasmDbId: String? by map //brapId ? diff --git a/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt b/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt index ccd9c4c03..422fe8f1c 100644 --- a/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt +++ b/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt @@ -433,7 +433,7 @@ class CanonApi @Inject constructor(@ActivityContext private val context: Context saveState = if (offset == 0) AbstractCameraTrait.SaveState.NEW else AbstractCameraTrait.SaveState.SAVING, offset = offset ?: 0) - Log.d(TAG, "Getting offset: ${offset ?: 0} for ${unit.plot_id}") + Log.d(TAG, "Getting offset: ${offset ?: 0} for ${unit.uniqueId}") requestGetImage(session, handle, unit, (offset ?: 0) + length, saveTime) } @@ -517,7 +517,7 @@ class CanonApi @Inject constructor(@ActivityContext private val context: Context lastSavedTime?.let { time -> - log("Found new image $handleInteger for ${unit.plot_id}") + log("Found new image $handleInteger for ${unit.uniqueId}") //requestUiLock(true, session, storageId, unit) diff --git a/app/src/main/java/com/fieldbook/tracker/devices/camera/GoProApi.kt b/app/src/main/java/com/fieldbook/tracker/devices/camera/GoProApi.kt index ab281e4ad..0ed2d4581 100644 --- a/app/src/main/java/com/fieldbook/tracker/devices/camera/GoProApi.kt +++ b/app/src/main/java/com/fieldbook/tracker/devices/camera/GoProApi.kt @@ -2,8 +2,6 @@ package com.fieldbook.tracker.devices.camera import android.bluetooth.BluetoothDevice import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri import android.util.Log import androidx.media3.common.Player @@ -124,7 +122,7 @@ class GoProApi @Inject constructor( } Player.STATE_READY -> { - Log.d(TAG, "Player Ready ${if (range.isNotEmpty()) range[0].range.plot_id else ""}") + Log.d(TAG, "Player Ready ${if (range.isNotEmpty()) range[0].range.uniqueId else ""}") callbacks?.onStreamReady() } @@ -440,7 +438,7 @@ class GoProApi @Inject constructor( */ private fun requestFileUrl(url: String, model: ImageRequestData) { - Log.d(TAG, "Image request: $url for model: ${model.range.plot_id}") + Log.d(TAG, "Image request: $url for model: ${model.range.uniqueId}") //stop stream first, on fail or success start stream again: val requestImage: Request = Request.Builder() diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/AttributeChooserDialog.kt b/app/src/main/java/com/fieldbook/tracker/dialogs/AttributeChooserDialog.kt index 046ed22eb..9e16e4417 100644 --- a/app/src/main/java/com/fieldbook/tracker/dialogs/AttributeChooserDialog.kt +++ b/app/src/main/java/com/fieldbook/tracker/dialogs/AttributeChooserDialog.kt @@ -20,12 +20,32 @@ import com.google.android.material.tabs.TabLayout /** * A tab layout with tabs: attributes, traits, and other. * Each tab will load data into a recycler view that lets user choose infobar prefixes. + * + * Added 'showSystemAttributes' that will toggle the adding of the field name attribute to the list of attributes (and any future system attributes). */ -open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.AttributeAdapterController { +open class AttributeChooserDialog( + private val showTraits: Boolean = true, + private val showOther: Boolean = true, + private val showSystemAttributes: Boolean = true +) : DialogFragment(), AttributeAdapter.AttributeAdapterController { + companion object { const val TAG = "AttributeChooserDialog" + + private const val ARG_TITLE = "title" + + /** Factory method to create a new dialog with an infoBarPosition argument. */ + fun newInstance(titleResId: Int): AttributeChooserDialog { + val args = Bundle().apply { + putInt(ARG_TITLE, titleResId) + } + return AttributeChooserDialog().apply { + arguments = args + } + } } + interface OnAttributeSelectedListener { fun onAttributeSelected(label: String) } @@ -43,6 +63,11 @@ open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.Attribute val inflater = requireActivity().layoutInflater val view = inflater.inflate(R.layout.dialog_collect_att_chooser, null) + var titleResId = arguments?.getInt(ARG_TITLE) ?: R.string.dialog_att_chooser_title_default + if (titleResId == 0) titleResId = R.string.dialog_att_chooser_title_default + + val dialogTitle = context?.getString(titleResId) + // Initialize UI elements tabLayout = view.findViewById(R.id.dialog_collect_att_chooser_tl) recyclerView = view.findViewById(R.id.dialog_collect_att_chooser_lv) @@ -51,11 +76,18 @@ open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.Attribute recyclerView.layoutManager = LinearLayoutManager(requireActivity()) recyclerView.adapter = AttributeAdapter(this, null) - val dialog = AlertDialog.Builder(requireActivity(), R.style.AppAlertDialog) + //toggle view of traits/other based on class param + tabLayout.getTabAt(1)?.view?.visibility = if (showTraits) TabLayout.VISIBLE else TabLayout.GONE + tabLayout.getTabAt(2)?.view?.visibility = if (showOther) TabLayout.VISIBLE else TabLayout.GONE + + val builder = AlertDialog.Builder(requireActivity(), R.style.AppAlertDialog) .setView(view) .setNegativeButton(android.R.string.cancel, null) .setCancelable(true) - .create() + + dialogTitle?.let { builder.setTitle(it) } + + val dialog = builder.create() // Call loadData after dialog is shown dialog.setOnShowListener { @@ -81,7 +113,9 @@ open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.Attribute val activity = requireActivity() as CollectActivity attributes = activity.getDatabase().getAllObservationUnitAttributeNames(activity.studyId.toInt()) val attributesList = attributes.toMutableList() - attributesList.add(0, getString(R.string.field_name_attribute)) + if (showSystemAttributes) { + attributesList.add(0, getString(R.string.field_name_attribute)) + } attributes = attributesList.toTypedArray() traits = activity.getDatabase().allTraitObjects.toTypedArray() other = traits.filter { !it.visible }.toTypedArray() diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/FieldCreatorDialogFragment.kt b/app/src/main/java/com/fieldbook/tracker/dialogs/FieldCreatorDialogFragment.kt index 6736dd6a3..3d2131ad7 100644 --- a/app/src/main/java/com/fieldbook/tracker/dialogs/FieldCreatorDialogFragment.kt +++ b/app/src/main/java/com/fieldbook/tracker/dialogs/FieldCreatorDialogFragment.kt @@ -490,7 +490,7 @@ class FieldCreatorDialogFragment(private val activity: ThemedActivity) : val col = j.toString() val index = k.toString() - helper.createFieldData(studyDbId, fieldColumns, listOf(row, col, index, uuid)) + helper.createFieldData(studyDbId, fieldColumns, listOf(row, col, index, uuid), false) updatePlotInsertText(row, col, index) diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/NewTraitDialog.kt b/app/src/main/java/com/fieldbook/tracker/dialogs/NewTraitDialog.kt index 133d6a467..9232d0711 100644 --- a/app/src/main/java/com/fieldbook/tracker/dialogs/NewTraitDialog.kt +++ b/app/src/main/java/com/fieldbook/tracker/dialogs/NewTraitDialog.kt @@ -493,7 +493,8 @@ class NewTraitDialog( traitObject.maximum, traitObject.details, traitObject.categories, - traitObject.closeKeyboardOnOpen + traitObject.closeKeyboardOnOpen, + traitObject.cropImage ) } diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/QuickGotoDialog.kt b/app/src/main/java/com/fieldbook/tracker/dialogs/QuickGotoDialog.kt new file mode 100644 index 000000000..12c39fef3 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/dialogs/QuickGotoDialog.kt @@ -0,0 +1,88 @@ +package com.fieldbook.tracker.dialogs + +import android.app.AlertDialog +import android.app.Dialog +import android.os.Bundle +import android.text.InputType +import android.widget.EditText +import android.widget.ImageButton +import androidx.fragment.app.DialogFragment +import com.fieldbook.tracker.R +import com.fieldbook.tracker.interfaces.CollectRangeController +import com.fieldbook.tracker.preferences.GeneralKeys +import org.phenoapps.utils.SoftKeyboardUtil + +/** + * Dialog to quickly jump to a specific range of data in the CollectActivity. + */ +class QuickGotoDialog( + private val controller: CollectRangeController, + private val primaryClicked: Boolean = true, + private val callback: (String, String) -> Unit): DialogFragment() { + + companion object { + const val TAG = "QuickGotoDialog" + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val inflater = requireActivity().layoutInflater + val view = inflater.inflate(R.layout.dialog_quick_goto, null) + + val prefs = controller.getPreferences() + + //get primary/secondary from preferences and set hints of the edit texts + val primary = prefs.getString(GeneralKeys.PRIMARY_NAME, "") + val secondary = prefs.getString(GeneralKeys.SECONDARY_NAME, "") + + val primaryEt = view.findViewById(R.id.quick_goto_primary_et) + val secondaryEt = view.findViewById(R.id.quick_goto_secondary_et) + + val swapButton = view.findViewById(R.id.quick_goto_swap_btn) + + primaryEt.hint = primary + secondaryEt.hint = secondary + + val dialog = AlertDialog.Builder(requireContext()) + .setTitle(R.string.dialog_quick_goto_title) + .setView(view) + .setPositiveButton(R.string.dialog_quick_goto_go) { _, _ -> + val primaryId = primaryEt.text.toString() + val secondaryId = secondaryEt.text.toString() + callback(primaryId, secondaryId) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .create() + + dialog.setOnShowListener { + + with(if (primaryClicked) primaryEt else secondaryEt) { + this.requestFocus() + SoftKeyboardUtil.Companion.showKeyboard(context, this) + } + } + + swapButton.setOnClickListener { + + toggleInputType(primaryEt) + toggleInputType(secondaryEt) + + + swapButton.setImageResource(if (secondaryEt.inputType == InputType.TYPE_CLASS_NUMBER) { + R.drawable.ic_trait_text + } else { + R.drawable.ic_trait_numeric + }) + } + + return dialog + } + + private fun toggleInputType(editText: EditText) { + + editText.inputType = if (editText.inputType == InputType.TYPE_CLASS_NUMBER) { + InputType.TYPE_CLASS_TEXT + } else { + InputType.TYPE_CLASS_NUMBER + } + } +} diff --git a/app/src/main/java/com/fieldbook/tracker/fragments/CropImageFragment.kt b/app/src/main/java/com/fieldbook/tracker/fragments/CropImageFragment.kt new file mode 100644 index 000000000..d51d87387 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/fragments/CropImageFragment.kt @@ -0,0 +1,92 @@ +package com.fieldbook.tracker.fragments + +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import com.fieldbook.tracker.R +import com.fieldbook.tracker.preferences.GeneralKeys +import com.fieldbook.tracker.utilities.BitmapLoader +import com.fieldbook.tracker.views.CropImageView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Controller code for handling user input for cropping an image. + */ +@AndroidEntryPoint +class CropImageFragment: Fragment(R.layout.crop_image_fragment), CoroutineScope by MainScope() { + + companion object { + const val TAG = "CropImageFragment" + const val EXTRA_TRAIT_ID = "traitId" + const val EXTRA_IMAGE_URI = "imageUri" + } + + @Inject + lateinit var prefs: SharedPreferences + + private var cropImageView: CropImageView? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val traitId = requireArguments().getInt(EXTRA_TRAIT_ID) + val imageUri = requireArguments().getString(EXTRA_IMAGE_URI) ?: "" + cropImageView = view.findViewById(R.id.crop_image_view) + setupCropImageView(traitId, imageUri) + } + + private fun setupCropImageView(traitId: Int, imageUri: String) { + + cropImageView?.cropImageHandler = object: CropImageView.CropImageHandler { + + override fun onCropImageSaved(rectCoordinates: String) { + + //save the coordinate text to preferences, make the key relative to the input trait id + prefs.edit().putString(GeneralKeys.getCropCoordinatesKey(traitId), rectCoordinates).apply() + + launch(Dispatchers.IO) { + + val uri = Uri.parse(imageUri) + + val croppedBmp = BitmapLoader.cropBitmap(context, uri, rectCoordinates) + + //save cropped bmp to uri + context?.contentResolver?.openOutputStream(uri)?.use { output -> + croppedBmp.compress(Bitmap.CompressFormat.JPEG, 80, output) + } + + withContext(Dispatchers.Main) { + + //finish from the crop activity + activity?.finish() + } + } + } + + override fun getCropCoordinates(): String { + + return prefs.getString(GeneralKeys.getCropCoordinatesKey(traitId), "") ?: "" + } + + override fun getImageUri() = imageUri + } + + cropImageView?.post { + cropImageView?.initialize() + } + } + + override fun onDestroy() { + super.onDestroy() + cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/location/GPSTracker.java b/app/src/main/java/com/fieldbook/tracker/location/GPSTracker.java index 3c6dee685..4cfcfe28f 100644 --- a/app/src/main/java/com/fieldbook/tracker/location/GPSTracker.java +++ b/app/src/main/java/com/fieldbook/tracker/location/GPSTracker.java @@ -70,34 +70,40 @@ private Location getLastLocation(long minDistance, long minTime) { // First get location from Network Provider if (isNetworkEnabled) { - locationManager.requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - minTime, - minDistance, this); - Log.d("Network", "Network"); + if (locationManager != null) { + + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + minTime, + minDistance, this); + location = locationManager .getLastKnownLocation(LocationManager.NETWORK_PROVIDER); if (location != null) { latitude = location.getLatitude(); longitude = location.getLongitude(); + onLocationChanged(location); } } // if GPS Enabled get lat/long using GPS Services if (isGPSEnabled) { if (location == null) { - locationManager.requestLocationUpdates( - LocationManager.GPS_PROVIDER, - minTime, - minDistance, this); - Log.d("GPS Enabled", "GPS Enabled"); if (locationManager != null) { + + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + minTime, + minDistance, this); + location = locationManager .getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (location != null) { latitude = location.getLatitude(); longitude = location.getLongitude(); + onLocationChanged(location); } } } @@ -106,7 +112,9 @@ private Location getLastLocation(long minDistance, long minTime) { } } catch (Exception e) { + e.printStackTrace(); + } return location; diff --git a/app/src/main/java/com/fieldbook/tracker/objects/RangeObject.java b/app/src/main/java/com/fieldbook/tracker/objects/RangeObject.java index c24723b9e..9aa92164c 100644 --- a/app/src/main/java/com/fieldbook/tracker/objects/RangeObject.java +++ b/app/src/main/java/com/fieldbook/tracker/objects/RangeObject.java @@ -4,7 +4,7 @@ * Simple wrapper class for range data */ public class RangeObject { - public String range; - public String plot; - public String plot_id; + public String primaryId; + public String secondaryId; + public String uniqueId; } diff --git a/app/src/main/java/com/fieldbook/tracker/objects/TraitObject.java b/app/src/main/java/com/fieldbook/tracker/objects/TraitObject.java index 8e4dd5f24..16b50730d 100644 --- a/app/src/main/java/com/fieldbook/tracker/objects/TraitObject.java +++ b/app/src/main/java/com/fieldbook/tracker/objects/TraitObject.java @@ -31,6 +31,7 @@ public class TraitObject { private String traitDataSource; private String additionalInfo; private Boolean closeKeyboardOnOpen = false; + private Boolean cropImage = false; /** * This is a BMS specific field. This will be populated when traits are imported from @@ -189,12 +190,12 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; TraitObject that = (TraitObject) o; - return realPosition == that.realPosition && Objects.equals(name, that.name) && Objects.equals(format, that.format) && Objects.equals(defaultValue, that.defaultValue) && Objects.equals(minimum, that.minimum) && Objects.equals(maximum, that.maximum) && Objects.equals(details, that.details) && Objects.equals(categories, that.categories) && Objects.equals(id, that.id) && Objects.equals(visible, that.visible) && Objects.equals(externalDbId, that.externalDbId) && Objects.equals(traitDataSource, that.traitDataSource) && Objects.equals(additionalInfo, that.additionalInfo) && Objects.equals(observationLevelNames, that.observationLevelNames) && Objects.equals(closeKeyboardOnOpen, that.closeKeyboardOnOpen); + return realPosition == that.realPosition && Objects.equals(name, that.name) && Objects.equals(format, that.format) && Objects.equals(defaultValue, that.defaultValue) && Objects.equals(minimum, that.minimum) && Objects.equals(maximum, that.maximum) && Objects.equals(details, that.details) && Objects.equals(categories, that.categories) && Objects.equals(id, that.id) && Objects.equals(visible, that.visible) && Objects.equals(externalDbId, that.externalDbId) && Objects.equals(traitDataSource, that.traitDataSource) && Objects.equals(additionalInfo, that.additionalInfo) && Objects.equals(observationLevelNames, that.observationLevelNames) && Objects.equals(closeKeyboardOnOpen, that.closeKeyboardOnOpen) && Objects.equals(cropImage, that.cropImage); } @Override public int hashCode() { - return Objects.hash(name, format, defaultValue, minimum, maximum, details, categories, realPosition, id, visible, externalDbId, traitDataSource, additionalInfo, observationLevelNames, closeKeyboardOnOpen); + return Objects.hash(name, format, defaultValue, minimum, maximum, details, categories, realPosition, id, visible, externalDbId, traitDataSource, additionalInfo, observationLevelNames, closeKeyboardOnOpen, cropImage); } public TraitObject clone() { @@ -215,6 +216,7 @@ public TraitObject clone() { t.setAdditionalInfo(this.additionalInfo); t.setObservationLevelNames(this.observationLevelNames); t.setCloseKeyboardOnOpen(this.closeKeyboardOnOpen); + t.setCropImage(this.cropImage); return t; } @@ -226,4 +228,12 @@ public Boolean getCloseKeyboardOnOpen() { public void setCloseKeyboardOnOpen(Boolean closeKeyboardOnOpen) { this.closeKeyboardOnOpen = closeKeyboardOnOpen; } + + public Boolean getCropImage() { + return cropImage; + } + + public void setCropImage(Boolean cropImage) { + this.cropImage = cropImage; + } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java b/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java index 84b6149a6..a92b495d2 100644 --- a/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java +++ b/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java @@ -41,7 +41,6 @@ public class GeneralKeys { // General public static final String TUTORIAL_MODE = "Tips"; public static final String NEXT_ENTRY_NO_DATA = "NextEmptyPlot"; - public static final String QUICK_GOTO = "QuickGoTo"; public static final String MOVE_TO_UNIQUE_ID = "com.fieldbook.tracker.MOVE_TO_UNIQUE_ID"; public static final String DATAGRID_SETTING = "DataGrid"; public static final String HIDE_ENTRIES_WITH_DATA = "com.fieldbook.tracker.HIDE_ENTRIES"; @@ -278,6 +277,16 @@ public class GeneralKeys { @NotNull public static final String RESET_PREFERENCES = "RESET_PREFERENCES"; + /** + * Function that returns the key for the crop coordinates of a trait + * @param traitId --the internal db id of the trait + * @return key used in preferences to obtain the (tl, tr, br, bl) coordinates used for cropping images + */ + @NotNull + public static String getCropCoordinatesKey(int traitId) { + return "com.fieldbook.tracker.crop_coordinates." + traitId; + } + private GeneralKeys() { } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt index b28685a1b..b192857ec 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.threeten.bp.OffsetDateTime import java.io.File @@ -164,6 +165,16 @@ abstract class AbstractCameraTrait : } } + protected fun isCropRequired() = (currentTrait?.cropImage ?: false) + + protected fun isCropExist() = (preferences.getString(GeneralKeys.getCropCoordinatesKey(currentTrait?.id?.toInt() ?: -1), "") ?: "").isNotEmpty() + + protected fun requestCropDefinition(traitId: String, imageUri: Uri) { + + (context as CollectActivity).showCropDialog(traitId, imageUri) + + } + protected fun saveJpegToStorage( format: String, data: ByteArray, @@ -262,7 +273,7 @@ abstract class AbstractCameraTrait : (controller.getContext() as CollectActivity).person, timestamp, database.getStudyById(studyId), - database.getObservationUnitById(currentRange.plot_id), + database.getObservationUnitById(currentRange.uniqueId), database.getObservationVariableById(currentTrait.id), file.uri, controller.getRotationRelativeToDevice() @@ -278,7 +289,7 @@ abstract class AbstractCameraTrait : saver: (Uri) -> Unit ) { - val plot = obsUnit.plot_id + val plot = obsUnit.uniqueId val studyId = collectActivity.studyId val person = (activity as? CollectActivity)?.person val location = (activity as? CollectActivity)?.locationByPreferences @@ -318,7 +329,7 @@ abstract class AbstractCameraTrait : writeExif(file, studyId, saveTime) - notifyItemInserted() + notifyItemInserted(file.uri) } } @@ -338,7 +349,7 @@ abstract class AbstractCameraTrait : writeExif(file, studyId, saveTime) - notifyItemInserted() + notifyItemInserted(file.uri) } else { @@ -352,17 +363,52 @@ abstract class AbstractCameraTrait : } } - private fun notifyItemInserted() { + private fun notifyItemInserted(uri: Uri) { ui.launch { - loadAdapterItems() + if (isCropRequired()) { + + if (isCropExist()) { + + //get bitmap from uri, create new bitmap from preference roi and update uri to database + val cropRect = preferences.getString(GeneralKeys.getCropCoordinatesKey(currentTrait.id.toInt()), "") ?: "" + + withContext(Dispatchers.IO) { + + //crop bmp + val croppedBmp = BitmapLoader.cropBitmap(context, uri, cropRect) + + //save cropped bmp to uri + context.contentResolver.openOutputStream(uri)?.use { output -> + croppedBmp.compress(Bitmap.CompressFormat.JPEG, 80, output) + } + + withContext(Dispatchers.Main) { + loadItems() + } + } + + } else { + + requestCropDefinition(currentTrait.id, uri) + } + + } else { - // update trait status as observation was saved - (context as CollectActivity).updateCurrentTraitStatus(true) + loadItems() + } } } + private fun loadItems() { + + loadAdapterItems() + + // update trait status as observation was saved + (context as CollectActivity).updateCurrentTraitStatus(true) + } + private fun getSelectedImage(): ImageAdapter.Model? { //check if its external or no preview @@ -574,7 +620,7 @@ abstract class AbstractCameraTrait : private fun insertNa() { database.insertObservation( - currentRange.plot_id, + currentRange.uniqueId, currentTrait.id, currentTrait.format, "NA", @@ -599,7 +645,7 @@ abstract class AbstractCameraTrait : val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() - val plot = currentRange.plot_id + val plot = currentRange.uniqueId val traitDbId = currentTrait.id diff --git a/app/src/main/java/com/fieldbook/tracker/traits/AudioTraitLayout.java b/app/src/main/java/com/fieldbook/tracker/traits/AudioTraitLayout.java index 900d7ea88..d6201f064 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/AudioTraitLayout.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/AudioTraitLayout.java @@ -2,16 +2,20 @@ import android.app.Activity; import android.content.Context; +import android.media.MediaMetadataRetriever; import android.media.MediaPlayer; -import android.media.MediaRecorder; import android.net.Uri; import android.util.AttributeSet; +import android.util.Log; +import android.view.Gravity; import android.view.View; import android.widget.ImageButton; import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import androidx.cardview.widget.CardView; import androidx.documentfile.provider.DocumentFile; import com.fieldbook.tracker.R; @@ -20,16 +24,24 @@ import com.fieldbook.tracker.utilities.FieldAudioHelper; import com.google.android.material.floatingactionbutton.FloatingActionButton; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + public class AudioTraitLayout extends BaseTraitLayout { static public String type = "audio"; - - private MediaRecorder mediaRecorder; private MediaPlayer mediaPlayer; private Uri recordingLocation; private FloatingActionButton controlButton; private ButtonState buttonState; - private TextView audioRecordingText; + + private CardView audioInfoCard; + private LinearLayout fileMetadataLayout; + private TextView fileNameText; + private TextView fileTimestamp; + private TextView fileDuration; + private TextView fileSize; public AudioTraitLayout(Context context) { super(context); @@ -45,7 +57,13 @@ public AudioTraitLayout(Context context, AttributeSet attrs, int defStyleAttr) { @Override public void setNaTraitsText() { - audioRecordingText.setText("NA"); + // delete previously saved audio if it exists + deleteRecording(); + recordingLocation = null; + mediaPlayer = null; + refreshButtonState(); + + setNAAudioInfoCard(); } @Override @@ -60,17 +78,24 @@ public int layoutId() { @Override public void init(Activity act) { - audioRecordingText = act.findViewById(R.id.audioRecordingText); + audioInfoCard = act.findViewById(R.id.audio_info_card); + fileMetadataLayout = act.findViewById(R.id.file_metadata_layout); + fileNameText = act.findViewById(R.id.file_name_text); + fileTimestamp = act.findViewById(R.id.file_timestamp); + fileDuration = act.findViewById(R.id.file_duration); + fileSize = act.findViewById(R.id.file_size); + buttonState = ButtonState.WAITING_FOR_RECORDING; controlButton = act.findViewById(R.id.record); controlButton.setOnClickListener(new AudioTraitOnClickListener()); controlButton.requestFocus(); - audioRecordingText.setOnLongClickListener( view -> { + audioInfoCard.setOnLongClickListener( view -> { + ((CollectActivity) getContext()).showObservationMetadataDialog(); // handle the long click when some audio was saved - if(audioRecordingText.getText().toString().equals(getContext().getString(R.string.trait_layout_data_stored))){ - ((CollectActivity) getContext()).showObservationMetadataDialog(); - } +// if(fileNameText.getText().toString().equals(getContext().getString(R.string.trait_layout_data_stored))){ +// ((CollectActivity) getContext()).showObservationMetadataDialog(); +// } return true; }); } @@ -81,14 +106,14 @@ public void afterLoadExists(CollectActivity act, String value) { if (value != null && value.equals("NA")) { buttonState = ButtonState.WAITING_FOR_RECORDING; controlButton.setImageResource(buttonState.getImageId()); - audioRecordingText.setText("NA"); + setNAAudioInfoCard(); } else { DocumentFile file = DocumentFile.fromSingleUri(getContext(), Uri.parse(value)); if (file != null && file.exists()) { this.recordingLocation = file.getUri(); buttonState = ButtonState.WAITING_FOR_PLAYBACK; controlButton.setImageResource(buttonState.getImageId()); - audioRecordingText.setText(getContext().getString(R.string.trait_layout_data_stored)); + setAudioInfoCard(file); } else { deleteTraitListener(); } @@ -100,7 +125,7 @@ public void afterLoadNotExists(CollectActivity act) { super.afterLoadNotExists(act); buttonState = ButtonState.WAITING_FOR_RECORDING; controlButton.setImageResource(buttonState.getImageId()); - audioRecordingText.setText(""); + hideAudioInfoCard(); } @Override @@ -122,13 +147,13 @@ public void refreshLayout(Boolean onNew) { private void refreshButtonState() { ObservationModel model = getCurrentObservation(); - if (model != null && !model.getValue().isEmpty()) { + if (model != null && !model.getValue().isEmpty() && !model.getValue().equals("NA")) { buttonState = ButtonState.WAITING_FOR_PLAYBACK; controlButton.setImageResource(buttonState.getImageId()); - } else { + } else { // for NA, change the button state to WAITING_FOR_RECORDING buttonState = ButtonState.WAITING_FOR_RECORDING; controlButton.setImageResource(buttonState.getImageId()); - audioRecordingText.setText(""); + fileNameText.setText(""); getCollectInputView().setText(""); } } @@ -140,8 +165,8 @@ public void deleteTraitListener() { super.deleteTraitListener(); recordingLocation = null; mediaPlayer = null; - mediaRecorder = null; refreshButtonState(); + hideAudioInfoCard(); } // Delete recording @@ -155,6 +180,67 @@ private void deleteRecording() { } } + private void setNAAudioInfoCard() { + audioInfoCard.setVisibility(View.VISIBLE); + fileMetadataLayout.setVisibility(View.GONE); + + fileNameText.setText("NA"); + fileNameText.setGravity(Gravity.CENTER); + } + + private void setAudioInfoCard(DocumentFile file) { + audioInfoCard.setVisibility(View.VISIBLE); + fileMetadataLayout.setVisibility(View.VISIBLE); + + fileNameText.setGravity(Gravity.START); + fileNameText.setText(getContext().getString(R.string.trait_audio_placeholder_filename)); + + fileTimestamp.setText(formatDateTime(file.lastModified())); + fileSize.setText(getFileSize(file.length())); + fileDuration.setText(getAudioDuration(file.getUri())); + } + + private void hideAudioInfoCard() { + audioInfoCard.setVisibility(View.GONE); + } + + private String getAudioDuration(Uri uri) { + try (MediaMetadataRetriever retriever = new MediaMetadataRetriever()) { + retriever.setDataSource(getContext(), uri); + String time = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + + if (time == null) { + Log.w("AudioTraitLayout", "Could not extract file duration"); + return "00:00"; + } + + + long timeInMillis = Long.parseLong(time); + + // ceil any fraction of a second to the next second + long totalSeconds = (long) Math.ceil(timeInMillis / 1000.0); + long minutes = totalSeconds / 60; + long seconds = totalSeconds % 60; + + return String.format("%02d:%02d", minutes, seconds); + } catch (Exception e) { + Log.e("AudioTraitLayout", "Error getting file duration", e); + return "00:00"; + } + } + + private String getFileSize(long bytes) { + if (bytes < 1024) return bytes + " B"; + int exponent = (int) (Math.log(bytes) / Math.log(1024)); + String prefix = "KMGTPE".charAt(exponent-1) + ""; + // bytes / 1024^(exponent) + return String.format("%.2f %sB", bytes / Math.pow(1024, exponent), prefix); + } + + private String formatDateTime(long timestamp) { + return new SimpleDateFormat("MMM d, yyyy | h:mm a", Locale.getDefault()).format(new Date(timestamp)); + } + public boolean isAudioRecording(){ return buttonState == ButtonState.RECORDING; } @@ -188,7 +274,7 @@ public class AudioTraitOnClickListener implements OnClickListener { @Override public void onClick(View view) { - ((CollectActivity) getContext()).setNewTraits(getDatabase().getUserDetail(getCurrentRange().plot_id)); + ((CollectActivity) getContext()).setNewTraits(getDatabase().getUserDetail(getCurrentRange().uniqueId)); boolean enableNavigation = true; switch (buttonState) { @@ -264,7 +350,7 @@ private void stopPlayback() { private void startRecording() { try { removeTrait(getCurrentTrait()); - audioRecordingText.setText(""); + hideAudioInfoCard(); fieldAudioHelper.startRecording(false); } catch (Exception e) { e.printStackTrace(); @@ -274,9 +360,18 @@ private void startRecording() { private void stopRecording() { try { fieldAudioHelper.stopRecording(); - updateObservation(getCurrentTrait(), fieldAudioHelper.getRecordingLocation().toString()); - audioRecordingText.setText(getContext().getString(R.string.trait_layout_data_stored)); - getCollectInputView().setText(fieldAudioHelper.getRecordingLocation().toString()); + Uri audioUri = fieldAudioHelper.getRecordingLocation(); + if (audioUri != null) { + updateObservation(getCurrentTrait(), audioUri.toString()); + getCollectInputView().setText(audioUri.toString()); + + // update recordingLocation + DocumentFile file = DocumentFile.fromSingleUri(getContext(), audioUri); + if (file != null && file.exists()) { + AudioTraitLayout.this.recordingLocation = file.getUri(); + setAudioInfoCard(file); + } + } } catch (Exception e) { e.printStackTrace(); } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/BaseTraitLayout.java b/app/src/main/java/com/fieldbook/tracker/traits/BaseTraitLayout.java index 5bbba7886..1ada0c81d 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/BaseTraitLayout.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/BaseTraitLayout.java @@ -182,6 +182,8 @@ private void checkDefaultValue() { } public void afterLoadExists(CollectActivity act, @Nullable String value) { + getCollectInputView().markObservationSaved(); + getCollectInputView().setTextColor(Color.parseColor(getDisplayColor())); //lock data if frozen or locked state isLocked = act.isFrozen() || act.isLocked(); } @@ -311,6 +313,8 @@ protected void toggleVisibility(int visibility) { */ public void updateObservation(TraitObject trait, String value) { ((CollectActivity) getContext()).updateObservation(trait, value, null); + + setCurrentValueAsEdited(); } public void removeTrait(TraitObject trait) { @@ -338,4 +342,9 @@ protected ObservationModel getCurrentObservation() { } protected DataHelper getDatabase() { return controller.getDatabase(); } + + protected void setCurrentValueAsEdited() { + getCollectInputView().markObservationEdited(); + getCollectInputView().setTextColor(getTextColor()); + } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CategoricalTraitLayout.java b/app/src/main/java/com/fieldbook/tracker/traits/CategoricalTraitLayout.java index c0307a478..45a5429ac 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/CategoricalTraitLayout.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/CategoricalTraitLayout.java @@ -131,8 +131,6 @@ public void afterLoadExists(CollectActivity act, @Nullable String value) { } - getCollectInputView().setTextColor(Color.parseColor(getDisplayColor())); - } } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/DateTraitLayout.java b/app/src/main/java/com/fieldbook/tracker/traits/DateTraitLayout.java index d2a76c1f6..58cdf5b8c 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/DateTraitLayout.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/DateTraitLayout.java @@ -6,6 +6,7 @@ import android.util.AttributeSet; import android.util.Log; import android.widget.ImageButton; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -32,6 +33,7 @@ public class DateTraitLayout extends BaseTraitLayout { FloatingActionButton addDayBtn; FloatingActionButton minusDayBtn; FloatingActionButton saveDayBtn; + private TextView datePreviewText; private String date; private boolean isBlocked = true; //tracks when multi measures can be navigated private boolean isFirstLoad = true; @@ -51,8 +53,6 @@ public DateTraitLayout(Context context, AttributeSet attrs, int defStyleAttr) { @Override public void setNaTraitsText() { getCollectInputView().setText("NA"); - //issue 413 apply the date saved preference color to NA values - forceDataSavedColor(); } @Override @@ -86,6 +86,9 @@ public void init(Activity act) { addDayBtn = act.findViewById(R.id.addDateBtn); minusDayBtn = act.findViewById(R.id.minusDateBtn); saveDayBtn = act.findViewById(R.id.enterBtn); + datePreviewText = act.findViewById(R.id.datePreviewText); + + updatePreviewDate(calendar); ImageButton calendarVisibilityBtn = act.findViewById(R.id.trait_date_calendar_visibility_btn); @@ -102,10 +105,9 @@ public void init(Activity act) { calendar.set(y, m, d); - updateViewDate(calendar); + updatePreviewDate(calendar); - //this saves the date, so update text to display color - forceDataSavedColor(); + updateViewDate(calendar); String rep = ((CollectActivity) getContext()).getRep(); @@ -141,9 +143,9 @@ public void init(Activity act) { // Add day calendar.add(Calendar.DATE, 1); - updateViewDate(calendar); + updatePreviewDate(calendar); - isBlocked = true; + isBlocked = false; }); // Minus day @@ -163,9 +165,9 @@ public void init(Activity act) { //Subtract day, rewrite date calendar.add(Calendar.DATE, -1); - updateViewDate(calendar); + updatePreviewDate(calendar); - isBlocked = true; + isBlocked = false; }); // Saving date data @@ -182,18 +184,14 @@ public void init(Activity act) { e.printStackTrace(); } - if (!getCollectInputView().getText().equals("NA")) { //issue 413, don't update NA when save button is pressed - if (getPrefs().getBoolean(GeneralKeys.USE_DAY_OF_YEAR, false)) { - updateObservation(getCurrentTrait(), String.valueOf(calendar.get(Calendar.DAY_OF_YEAR))); - } else { - updateObservation(getCurrentTrait(), dateFormat.format(calendar.getTime())); - } - } + String previewText = datePreviewText.getText().toString(); + getCollectInputView().setText(previewText); - parseDateAndView(); - - // Change the text color accordingly - forceDataSavedColor(); + if (getPrefs().getBoolean(GeneralKeys.USE_DAY_OF_YEAR, false)) { + updateObservation(getCurrentTrait(), String.valueOf(calendar.get(Calendar.DAY_OF_YEAR))); + } else { + updateObservation(getCurrentTrait(), dateFormat.format(calendar.getTime())); + } isBlocked = false; }); @@ -208,6 +206,13 @@ private String getTtsFromCalendar(Calendar calendar) { return month + " " + day; } + private void updatePreviewDate(Calendar calendar) { + date = dateFormat.format(calendar.getTime()); + log(); + setDatePreviewText(getMonthForInt(calendar.get(Calendar.MONTH)), + String.format(Locale.getDefault(),"%02d", calendar.get(Calendar.DAY_OF_MONTH))); + } + private void updateViewDate(Calendar calendar) { date = dateFormat.format(calendar.getTime()); @@ -221,14 +226,8 @@ private void updateViewDate(Calendar calendar) { .putString(GeneralKeys.CALENDAR_LAST_SAVED_DATE, yearText + "-" + monthText + "-" + dayOfMonth) .apply(); - getCollectInputView().setText(getMonthForInt(calendar.get(Calendar.MONTH)) + " " + dayOfMonth); - - // Change text color - if (getNewTraits().containsKey(getCurrentTrait().getName())) { - getCollectInputView().setTextColor(getValueAlteredColor()); - } else { - getCollectInputView().setTextColor(getTextColor()); - } + setDateText(getMonthForInt(calendar.get(Calendar.MONTH)), + String.format(Locale.getDefault(),"%02d", calendar.get(Calendar.DAY_OF_MONTH))); } private void log() { @@ -353,19 +352,12 @@ public void afterLoadExists(CollectActivity act, @Nullable String value) { public void afterLoadNotExists(CollectActivity act) { super.afterLoadNotExists(act); - getCollectInputView().setTextColor(Color.BLACK); - //if data does not exist, use the current date as a default value final Calendar c = Calendar.getInstance(); - date = dateFormat.format(c.getTime()); - log(); - - parseDateAndView(); + updatePreviewDate(c); isBlocked = true; - - setDateText(getMonthForInt(c.get(Calendar.MONTH)), String.format(Locale.getDefault(), "%02d", c.get(Calendar.DAY_OF_MONTH)) ); } @Override @@ -380,15 +372,8 @@ public void deleteTraitListener() { parseDateAndView(); - } else { - - final Calendar c = Calendar.getInstance(); - date = dateFormat.format(c.getTime()); - - getCollectInputView().setTextColor(getTextColor()); - - //This is used to persist moving between months - setDateText(getMonthForInt(c.get(Calendar.MONTH)), String.format(Locale.getDefault(), "%02d", c.get(Calendar.DAY_OF_MONTH)) ); + } else { // clear the editText + getCollectInputView().setText(""); } } @@ -407,6 +392,10 @@ public String getMonthForInt(int m) { return month; } + private void setDatePreviewText(String month, String day) { + datePreviewText.setText(month + " " + day); + } + private void setDateText(String month, String day) { getCollectInputView().setText(month + " " + day); } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/GNSSTraitLayout.kt b/app/src/main/java/com/fieldbook/tracker/traits/GNSSTraitLayout.kt index a542e6e73..7a606aedf 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/GNSSTraitLayout.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/GNSSTraitLayout.kt @@ -239,7 +239,7 @@ class GNSSTraitLayout : BaseTraitLayout, GPSTracker.GPSTrackerListener { val studyDbId = (context as CollectActivity).studyId val units = database.getAllObservationUnits(studyDbId.toInt()) - .filter { it.observation_unit_db_id == currentRange.plot_id } + .filter { it.observation_unit_db_id == currentRange.uniqueId } if (units.isNotEmpty()) { @@ -374,7 +374,7 @@ class GNSSTraitLayout : BaseTraitLayout, GPSTracker.GPSTrackerListener { val newLng = longitude.toDouble() val units = database.getAllObservationUnits(studyDbId.toInt()) - .filter { it.observation_unit_db_id == currentRange.plot_id } + .filter { it.observation_unit_db_id == currentRange.uniqueId } if (units.isNotEmpty()) { @@ -892,14 +892,14 @@ class GNSSTraitLayout : BaseTraitLayout, GPSTracker.GPSTrackerListener { val studyDbId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() val observation = - database.getObservation(studyDbId, currentRange.plot_id, currentTrait.id, rep) + database.getObservation(studyDbId, currentRange.uniqueId, currentTrait.id, rep) if (observation != null) { - database.deleteTrait(studyDbId, currentRange.plot_id, currentTrait.id, rep) + database.deleteTrait(studyDbId, currentRange.uniqueId, currentTrait.id, rep) val units = controller.getDatabase().getAllObservationUnits(studyDbId.toInt()) - .filter { it.observation_unit_db_id == currentRange.plot_id } + .filter { it.observation_unit_db_id == currentRange.uniqueId } if (units.isNotEmpty()) { units.first().let { unit -> controller.getDatabase().updateObservationUnit(unit, "") diff --git a/app/src/main/java/com/fieldbook/tracker/traits/GoProTraitLayout.kt b/app/src/main/java/com/fieldbook/tracker/traits/GoProTraitLayout.kt index afa3d67e4..a8fd09d89 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/GoProTraitLayout.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/GoProTraitLayout.kt @@ -99,7 +99,7 @@ class GoProTraitLayout : override fun loadLayout() { super.loadLayout() setup() - currentPlotId = currentRange.plot_id + currentPlotId = currentRange.uniqueId } private fun initializeConnectButton() { @@ -223,7 +223,7 @@ class GoProTraitLayout : private fun connect() { - controller.advisor().withNearby { adapter -> + controller.advisor().withNearby { adapter: BluetoothAdapter -> if (!adapter.isEnabled) { diff --git a/app/src/main/java/com/fieldbook/tracker/traits/LabelPrintTraitLayout.java b/app/src/main/java/com/fieldbook/tracker/traits/LabelPrintTraitLayout.java index 2121a35fe..b91f7f5cd 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/LabelPrintTraitLayout.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/LabelPrintTraitLayout.java @@ -515,7 +515,7 @@ public String getValueFromSpinner(Spinner spinner, String[] options) { } else { int pos = spinner.getSelectedItemPosition(); if (pos < options.length) { - String[] v = getDatabase().getDropDownRange(options[pos], getCurrentRange().plot_id); + String[] v = getDatabase().getDropDownRange(options[pos], getCurrentRange().uniqueId); if (v.length != 0) { value = v[0]; } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/LayoutCollections.java b/app/src/main/java/com/fieldbook/tracker/traits/LayoutCollections.java index 72d53dd5b..9675d607d 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/LayoutCollections.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/LayoutCollections.java @@ -1,9 +1,6 @@ package com.fieldbook.tracker.traits; import android.app.Activity; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; import java.util.ArrayList; @@ -61,6 +58,7 @@ public void deleteTraitListener(String format) { public void setNaTraitsText(String format) { getTraitLayout(format).setNaTraitsText(); + getTraitLayout(format).setCurrentValueAsEdited(); } // Deprecated - simpler to disable/enable data collection using lockOverlay instead diff --git a/app/src/main/java/com/fieldbook/tracker/traits/MultiCatTraitLayout.java b/app/src/main/java/com/fieldbook/tracker/traits/MultiCatTraitLayout.java index 49158d725..c37da5287 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/MultiCatTraitLayout.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/MultiCatTraitLayout.java @@ -2,7 +2,6 @@ import android.app.Activity; import android.content.Context; -import android.graphics.Color; import android.util.AttributeSet; import android.view.ViewTreeObserver; import android.widget.Button; @@ -214,7 +213,6 @@ private void loadScale() { refreshList(); refreshCategoryText(); - getCollectInputView().setTextColor(Color.parseColor(getDisplayColor())); } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/PercentTraitLayout.java b/app/src/main/java/com/fieldbook/tracker/traits/PercentTraitLayout.java index dfa657f3f..651dc951d 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/PercentTraitLayout.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/PercentTraitLayout.java @@ -68,7 +68,7 @@ public void init(Activity act) { seekListener = new SeekBar.OnSeekBarChangeListener() { - public void onProgressChanged(SeekBar sb, int progress, boolean arg2) { + public void onProgressChanged(SeekBar sb, int progress, boolean fromUser) { int minimum = 0; if (getCurrentTrait() != null) { try { @@ -80,7 +80,13 @@ public void onProgressChanged(SeekBar sb, int progress, boolean arg2) { if (sb.getProgress() < minimum) sb.setProgress(minimum); - setCurrentValueText(sb.getProgress(), Color.parseColor(getDisplayColor())); + getCollectInputView().setText(sb.getProgress() + "%"); + + // check if the change was from user interaction. + // useful when navigating across repeated values + if (fromUser) { + setCurrentValueAsEdited(); + } } public void onStartTrackingTouch(SeekBar sb) { @@ -156,16 +162,12 @@ public void afterLoadExists(CollectActivity act, String value) { String maxString = getCurrentTrait().getMaximum(); setSeekBarMax(maxString); - int textColor = value.equals(getDefaultValue()) ? Color.BLACK : Color.parseColor(getDisplayColor()); - setCurrentValueText(value, textColor); - seekBar.setOnSeekBarChangeListener(null); setSeekBarProgress(value); seekBar.setOnSeekBarChangeListener(seekListener); } else if (value != null && value.equals("NA")) { getCollectInputView().setText("NA"); - getCollectInputView().setTextColor(Color.parseColor(getDisplayColor())); seekBar.setProgress(0); } } @@ -180,7 +182,8 @@ public void afterLoadNotExists(CollectActivity act) { @Override public void afterLoadDefault(CollectActivity act) { super.afterLoadDefault(act); - updateLoadBarValue(getDefaultValue()); + getCollectInputView().setText(getDefaultValue() + "%"); + updateLoadBar(); } @Override @@ -215,11 +218,6 @@ private void updateLoadBar() { seekBar.setOnSeekBarChangeListener(seekListener); } - private void updateLoadBarValue(String value) { - setCurrentValueText(value, Color.BLACK); - updateLoadBar(); - } - private String getDefaultValue() { String defaultValue = "0"; if (getCurrentTrait().getDefaultValue() != null @@ -229,18 +227,6 @@ private String getDefaultValue() { return defaultValue; } - private void setCurrentValueText(int value, int color) { - setCurrentValueText(String.valueOf(value), color); - } - - private void setCurrentValueText(String value, int color) { - getCollectInputView().setTextColor(color); - if (value.isEmpty()) - getCollectInputView().setText(value); - else - getCollectInputView().setText(value + "%"); - } - @Override public void deleteTraitListener() { removeTrait(getCurrentTrait()); @@ -248,11 +234,15 @@ public void deleteTraitListener() { ObservationModel model = getCurrentObservation(); seekBar.setOnSeekBarChangeListener(null); if (model != null) { - setCurrentValueText(model.getValue(), Color.BLACK); - setSeekBarProgress(model.getValue()); + getCollectInputView().setText(model.getValue() + "%"); + if (model.getValue().equals("NA")) { + seekBar.setProgress(0); + } else { + setSeekBarProgress(model.getValue()); + } } else { String defaultValue = getDefaultValue(); - setCurrentValueText(defaultValue, Color.BLACK); + getCollectInputView().setText(""); setSeekBarProgress(defaultValue); } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/PhotoTraitLayout.kt b/app/src/main/java/com/fieldbook/tracker/traits/PhotoTraitLayout.kt index 1607cb22b..cc3546807 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/PhotoTraitLayout.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/PhotoTraitLayout.kt @@ -146,7 +146,9 @@ class PhotoTraitLayout : CameraTrait { setupCaptureUi(camera, executor, capture) } - } catch (_: IllegalArgumentException) { + } catch (e: IllegalArgumentException) { + + e.printStackTrace() } } @@ -191,14 +193,17 @@ class PhotoTraitLayout : CameraTrait { controller.getCameraXFacade().bindPreview( previewViewHolder?.previewView, - resolution + resolution, + currentTrait.id, + Handler(Looper.getMainLooper()) ) { camera, executor, capture -> setupCaptureUi(camera, executor, capture) } - } catch (_: IllegalArgumentException) { + } catch (e: IllegalArgumentException) { + e.printStackTrace() } } @@ -249,6 +254,8 @@ class PhotoTraitLayout : CameraTrait { } else { + controller.getCameraXFacade().unbind() + previewViewHolder = null loadAdapterItems() @@ -325,7 +332,6 @@ class PhotoTraitLayout : CameraTrait { } } - /** * When button is pressed, create a cached image and switch to the camera intent. * CollectActivity will receive REQUEST_IMAGE_CAPTURE and call this layout's makeImage() method. @@ -354,7 +360,10 @@ class PhotoTraitLayout : CameraTrait { } private fun launchCameraX() { + controller.getCameraXFacade().unbind() val intent = Intent(context, CameraActivity::class.java) + //set current trait id to set crop region + intent.putExtra(CameraActivity.EXTRA_TRAIT_ID, currentTrait.id) activity?.startActivityForResult(intent, PICTURE_REQUEST_CODE) } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/TextTraitLayout.kt b/app/src/main/java/com/fieldbook/tracker/traits/TextTraitLayout.kt index fa6fe2b01..a2ec65c12 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/TextTraitLayout.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/TextTraitLayout.kt @@ -3,6 +3,7 @@ package com.fieldbook.tracker.traits import android.app.Activity import android.content.Context import android.graphics.Color +import android.graphics.Typeface import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet @@ -53,6 +54,8 @@ class TextTraitLayout : BaseTraitLayout { collectInputView.text = value updateObservation(currentTrait, value) + + observationEditedStyle() } } @@ -69,6 +72,7 @@ class TextTraitLayout : BaseTraitLayout { override fun setNaTraitsText() { inputEditText?.setText("NA") + observationEditedStyle() } override fun type(): String { @@ -241,4 +245,10 @@ class TextTraitLayout : BaseTraitLayout { super.refreshLock() loadLayout() } + + private fun observationEditedStyle() { + inputEditText?.setTypeface(null, Typeface.ITALIC) + + inputEditText?.setTextColor(textColor) + } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/traits/formats/BasePhotoFormat.kt b/app/src/main/java/com/fieldbook/tracker/traits/formats/BasePhotoFormat.kt index 110b8aa90..c3f358d1e 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/formats/BasePhotoFormat.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/formats/BasePhotoFormat.kt @@ -3,6 +3,7 @@ package com.fieldbook.tracker.traits.formats import android.content.Context import android.view.View import com.fieldbook.tracker.R +import com.fieldbook.tracker.traits.formats.parameters.CropImageParameter import com.fieldbook.tracker.traits.formats.parameters.DetailsParameter import com.fieldbook.tracker.traits.formats.parameters.NameParameter import com.fieldbook.tracker.traits.formats.presenters.UriPresenter @@ -26,4 +27,5 @@ open class BasePhotoFormat( stringNameAux = stringNameAux, NameParameter(), DetailsParameter(), + CropImageParameter() ), ValuePresenter by UriPresenter() \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/traits/formats/parameters/CropImageParameter.kt b/app/src/main/java/com/fieldbook/tracker/traits/formats/parameters/CropImageParameter.kt new file mode 100644 index 000000000..65c0c2239 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/traits/formats/parameters/CropImageParameter.kt @@ -0,0 +1,55 @@ +package com.fieldbook.tracker.traits.formats.parameters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ToggleButton +import com.fieldbook.tracker.R +import com.fieldbook.tracker.database.DataHelper +import com.fieldbook.tracker.objects.TraitObject +import com.fieldbook.tracker.traits.formats.ValidationResult + +class CropImageParameter(private val initialDefaultValue: Boolean? = null) : + BaseFormatParameter( + nameStringResourceId = R.string.traits_crop_image_parameter, + defaultLayoutId = R.layout.list_item_trait_parameter_default_toggle_value, + parameter = Parameters.CROP_IMAGE + ) { + + override fun createViewHolder( + parent: ViewGroup, + ): BaseFormatParameter.ViewHolder { + val v = LayoutInflater.from(parent.context) + .inflate(R.layout.list_item_trait_parameter_default_toggle_value, parent, false) + return ViewHolder(v) + } + + inner class ViewHolder(itemView: View) : BaseFormatParameter.ViewHolder(itemView) { + + val defaultValueToggle = + itemView.findViewById(R.id.dialog_new_trait_default_toggle_btn).also { + initialDefaultValue?.let { value -> + it.isChecked = value + } + } + + override fun merge(traitObject: TraitObject) = traitObject.apply { + cropImage = defaultValueToggle.isChecked + } + + override fun load(traitObject: TraitObject?): Boolean { + try { + defaultValueToggle.isChecked = traitObject?.cropImage == true + } catch (e: Exception) { + e.printStackTrace() + return false + } + return true + } + + override fun validate( + database: DataHelper, + initialTraitObject: TraitObject? + ) = ValidationResult() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/traits/formats/parameters/Parameters.kt b/app/src/main/java/com/fieldbook/tracker/traits/formats/parameters/Parameters.kt index 727a0ada5..bc06cf704 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/formats/parameters/Parameters.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/formats/parameters/Parameters.kt @@ -2,7 +2,7 @@ package com.fieldbook.tracker.traits.formats.parameters enum class Parameters { - FORMAT, NAME, DEFAULT_VALUE, DETAILS, MAXIMUM, MINIMUM, CATEGORIES, CAMERA, CLOSE_KEYBOARD; + FORMAT, NAME, DEFAULT_VALUE, DETAILS, MAXIMUM, MINIMUM, CATEGORIES, CAMERA, CLOSE_KEYBOARD, CROP_IMAGE; companion object { @@ -10,7 +10,8 @@ enum class Parameters { "validValuesMin", "validValuesMax", "category", - "closeKeyboardOnOpen" + "closeKeyboardOnOpen", + "cropImage", ) } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/BitmapLoader.kt b/app/src/main/java/com/fieldbook/tracker/utilities/BitmapLoader.kt index 33342f84f..099b6670e 100644 --- a/app/src/main/java/com/fieldbook/tracker/utilities/BitmapLoader.kt +++ b/app/src/main/java/com/fieldbook/tracker/utilities/BitmapLoader.kt @@ -8,6 +8,8 @@ import android.graphics.Point import android.net.Uri import android.provider.DocumentsContract import com.fieldbook.tracker.R +import kotlin.math.max +import kotlin.math.min /** * Uses functions from android documentation: @@ -90,5 +92,44 @@ class BitmapLoader { } ?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) } } + + fun cropBitmap(context: Context?, imageUri: Uri, rectCoordinates: String): Bitmap { + + val (tlx, tly, blx, bly) = rectCoordinates.split(",").map { it.toFloat() } + + //load bmp from uri + var bmp = BitmapFactory.decodeStream(context?.contentResolver?.openInputStream(imageUri)) + + val (h, w) = if (bmp.width > bmp.height) (bmp.width to bmp.height) else (bmp.height to bmp.width) + + if (h != bmp.height) { + //rotate the bitmap 90 + val matrix = android.graphics.Matrix() + matrix.postRotate(90f) + bmp = Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, matrix, true) + } + + //convert normalized coordinates to relative image coordinates + val rtlx = min(max((tlx * w).toInt(), 0), w) + val rtly = min(max((tly * h).toInt(), 0), h) + val rblx = min(max((blx * w).toInt(), 0), w) + val rbly = min(max((bly * h).toInt(), 0), h) + + var iw = max(0, min(w, rblx - rtlx)) + var ih = max(0, min(h, rbly - rtly)) + + if (iw + rtlx > w) { + iw = w + } + + if (ih + rtly > h) { + ih = h + } + + //crop bmp + val croppedBmp = Bitmap.createBitmap(bmp, rtlx, rtly, iw, ih) + + return croppedBmp + } } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/CameraXFacade.kt b/app/src/main/java/com/fieldbook/tracker/utilities/CameraXFacade.kt index 60eb9e017..5dd6de031 100644 --- a/app/src/main/java/com/fieldbook/tracker/utilities/CameraXFacade.kt +++ b/app/src/main/java/com/fieldbook/tracker/utilities/CameraXFacade.kt @@ -1,26 +1,43 @@ package com.fieldbook.tracker.utilities import android.content.Context -import android.content.res.Configuration +import android.graphics.Color import android.graphics.ImageFormat +import android.graphics.Matrix +import android.graphics.PorterDuff +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Region import android.hardware.camera2.CameraCharacteristics +import android.os.Build +import android.os.Handler import android.util.Log import android.util.Size +import android.util.TypedValue import androidx.annotation.OptIn import androidx.camera.camera2.interop.Camera2CameraInfo import androidx.camera.camera2.interop.ExperimentalCamera2Interop import androidx.camera.core.AspectRatio import androidx.camera.core.Camera +import androidx.camera.core.CameraEffect.PREVIEW +import androidx.camera.core.CameraEffect.IMAGE_CAPTURE +import androidx.camera.core.CameraEffect.VIDEO_CAPTURE import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.core.Preview +import androidx.camera.core.UseCaseGroup import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.effects.OverlayEffect import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner +import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.ThemedActivity +import com.fieldbook.tracker.preferences.GeneralKeys +import com.fieldbook.tracker.views.CropImageView import dagger.hilt.android.qualifiers.ActivityContext import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -50,6 +67,7 @@ class CameraXFacade @Inject constructor(@ActivityContext private val context: Co } fun await(context: Context, onBindReady: () -> Unit) { + cameraXInstance.get().unbind() cameraXInstance.addListener({ onBindReady() }, ContextCompat.getMainExecutor(context)) @@ -141,9 +159,13 @@ class CameraXFacade @Inject constructor(@ActivityContext private val context: Co } } + private var cropEffect: OverlayEffect? = null + fun bindPreview( previewView: PreviewView?, targetResolution: Size?, + traitId: String? = null, + handler: Handler, onBind: (Camera, ExecutorService, ImageCapture) -> Unit ) { @@ -174,13 +196,98 @@ class CameraXFacade @Inject constructor(@ActivityContext private val context: Co val p = prevBuilder.build() - p.setSurfaceProvider(previewView?.surfaceProvider) + p.surfaceProvider = previewView?.surfaceProvider + + //close previous crop effect or else EGL leak + cropEffect?.close() + + //create an overlay effect to draw a rectangle on the preview + cropEffect = OverlayEffect(VIDEO_CAPTURE or PREVIEW or IMAGE_CAPTURE, 0, handler) { e -> + Log.e(TAG, "OverlayEffect error: $e") + } + + //parse crop from prefs + val cropCoordinates = (context as? ThemedActivity)?.prefs?.getString(GeneralKeys.getCropCoordinatesKey( + traitId?.toInt() ?: -1), "") + + //overlay effect gives a frame that has an 'overlayCanvas' + cropEffect?.setOnDrawListener { frame -> + try { + //check that coordinates are correct, parse and draw the rect + if (!cropCoordinates.isNullOrEmpty() && cropCoordinates != CropImageView.DEFAULT_CROP_COORDINATES) { + //convert the camera sensor coordinates to local preview view coordinates + val sensorToUi = previewView!!.sensorToViewTransform + if (sensorToUi != null) { + val sensorToEffect = frame.sensorToBufferTransform + val uiToSensor = Matrix() + sensorToUi.invert(uiToSensor) + uiToSensor.postConcat(sensorToEffect) + + //get canvas and clear color, apply affine transformation + val canvas = frame.overlayCanvas + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + canvas.setMatrix(uiToSensor) + + val rect = CropImageView.parseRectCoordinates(cropCoordinates) + //check if rect is null or matches the default + if (rect != null) { + //draw a rectangle on the left of the canvas + //converts normalized coordinates to previewView-relative + val left = rect.left * previewView.width + val top = (rect.top * previewView.height) - 8f + val right = rect.right * previewView.width + val bottom = (rect.bottom * previewView.height) + 8f + + //newer APIs allow clipping a rect in two commands, earlier is a little more complex + val clipRect = RectF(left, top, right, bottom) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + canvas.clipOutRect(clipRect) + } else { + canvas.clipRect(clipRect, Region.Op.DIFFERENCE) + } + + val typedValue = TypedValue() + context.theme?.resolveAttribute( + R.attr.fb_inverse_crop_region_color, + typedValue, + true + ) + + canvas.drawARGB( + Color.alpha(typedValue.data), + Color.red(typedValue.data), + Color.green(typedValue.data), + Color.blue(typedValue.data) + ) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + canvas.clipRect( + Rect(0, 0, previewView.width, previewView.height), + Region.Op.REPLACE + ) + } + } + } + } + } catch (e: Exception) { + //skip invalid region exceptions + if ("Region.Op" !in e.message.orEmpty()) + e.printStackTrace() + } + + true + } + + val useCaseGroup = UseCaseGroup.Builder() + .addUseCase(p) + .addUseCase(imageCapture) + .addEffect(cropEffect!!) + .build() val camera = cameraXInstance.get().bindToLifecycle( context as LifecycleOwner, frontSelector, - p, - imageCapture + useCaseGroup ) Log.d(TAG, "Camera lifecycle bound: ${camera.cameraInfo}") diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/FieldAudioHelper.kt b/app/src/main/java/com/fieldbook/tracker/utilities/FieldAudioHelper.kt index 74174ef8b..b0d2c7961 100644 --- a/app/src/main/java/com/fieldbook/tracker/utilities/FieldAudioHelper.kt +++ b/app/src/main/java/com/fieldbook/tracker/utilities/FieldAudioHelper.kt @@ -129,7 +129,7 @@ class FieldAudioHelper @Inject constructor(@ActivityContext private val context: paths.add(fullGeoNavFile) paths.add(traitsDocumentFile) - val mGeneratedName = "field_audio_log" + context.cRange.plot_id + "_" + fieldAlias + " " + timeStamp.format(c.time) + ".zip" + val mGeneratedName = "field_audio_log" + context.cRange.uniqueId + "_" + fieldAlias + " " + timeStamp.format(c.time) + ".zip" val exportDir = getDirectory(context, R.string.dir_field_export) val zipFile = exportDir?.createFile("*/*", mGeneratedName) @@ -207,10 +207,10 @@ class FieldAudioHelper @Inject constructor(@ActivityContext private val context: val mGeneratedName: String val fieldAlias = preferences.getString(GeneralKeys.FIELD_FILE, "") mGeneratedName = try { - if (isFieldAudio) "field_audio_" + (context as CollectActivity).cRange.plot_id + "_" + fieldAlias + " " + timeStamp.format( + if (isFieldAudio) "field_audio_" + (context as CollectActivity).cRange.uniqueId + "_" + fieldAlias + " " + timeStamp.format( c.time ) - else (context as CollectActivity).cRange.plot_id + " " + timeStamp.format(c.time) + else (context as CollectActivity).cRange.uniqueId + " " + timeStamp.format(c.time) } catch (e: Exception) { "error " + timeStamp.format(c.time) } diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/FieldSwitchImpl.kt b/app/src/main/java/com/fieldbook/tracker/utilities/FieldSwitchImpl.kt index 05293d5f1..4472d5bd0 100644 --- a/app/src/main/java/com/fieldbook/tracker/utilities/FieldSwitchImpl.kt +++ b/app/src/main/java/com/fieldbook/tracker/utilities/FieldSwitchImpl.kt @@ -1,6 +1,7 @@ package com.fieldbook.tracker.utilities import android.content.Context +import android.util.Log import androidx.preference.PreferenceManager import com.fieldbook.tracker.database.DataHelper import com.fieldbook.tracker.interfaces.FieldSwitcher @@ -15,6 +16,12 @@ import javax.inject.Inject */ class FieldSwitchImpl @Inject constructor(@ActivityContext private val context: Context): FieldSwitcher { + companion object { + private const val TAG = "FieldSwitchImpl" + private val POSSIBLE_COLUMN_IDS = listOf("col", "column", "column_id", "range") + private val POSSIBLE_ROW_IDS = listOf("row", "row_id") + } + @Inject lateinit var database: DataHelper @@ -36,14 +43,59 @@ class FieldSwitchImpl @Inject constructor(@ActivityContext private val context: database.switchField(field.exp_id) + //get all entry props from field + val entryProps = database.getAllObservationUnitAttributeNames(field.exp_id).toMutableList() + + //remove unique id as a choice for the initial primary/secondary ids + val uniqueId = field.unique_id + entryProps.remove(uniqueId) + + //attempt to automatically select based on previous selections + val primaryName = preferences.getString(GeneralKeys.PRIMARY_NAME, "") + val secondaryName = preferences.getString(GeneralKeys.SECONDARY_NAME, "") + + //add some basic logic to match row/col or block/rep if it exists, otherwise just use the first two + val hasPrimary = entryProps.indexOfFirst { it.equals(primaryName, true) } + val hasRow = entryProps.indexOfFirst { it.lowercase() in POSSIBLE_ROW_IDS } + val hasRange = entryProps.indexOfFirst { it.equals("range", true) } + val hasBlock = entryProps.indexOfFirst { it.equals("block", true) } + + val primary = if (field.primary_id == "null" || field.primary_id == null || field.primary_id.isEmpty()) { + if (hasPrimary != -1) { + entryProps.removeAt(hasPrimary) + } else if (hasRow != -1) { + entryProps.removeAt(hasRow) + } else if (hasRange != -1) { + entryProps.removeAt(hasRange) + } else if (hasBlock != -1) { + entryProps.removeAt(hasBlock) + } else if (entryProps.isNotEmpty()) entryProps.removeAt(0) else "" + } else field.primary_id + + val hasSecondary = entryProps.indexOfFirst { it.equals(secondaryName, true) } + val hasCol = entryProps.indexOfFirst { it.lowercase() in POSSIBLE_COLUMN_IDS } + val hasPlot = entryProps.indexOfFirst { it.equals("plot", true) } + + val secondary = if (field.secondary_id == "null" || field.secondary_id == null || field.secondary_id.isEmpty()) { + if (hasSecondary != -1) { + entryProps.removeAt(hasSecondary) + } else if (hasCol != -1) { + entryProps.removeAt(hasCol) + } else if (hasPlot != -1) { + entryProps.removeAt(hasPlot) + } else if (entryProps.isNotEmpty()) entryProps.removeAt(0) else "" + } else field.secondary_id + + Log.d(TAG, "Field Switched: ${field.exp_id}\tUnique: $uniqueId\tPrimary: $primary\tSecondary: $secondary") + //clear field selection after updates preferences.edit().putInt(GeneralKeys.SELECTED_FIELD_ID, field.exp_id) .putString(GeneralKeys.FIELD_FILE, field.exp_name) .putString(GeneralKeys.FIELD_ALIAS, field.exp_alias) .putString(GeneralKeys.FIELD_OBS_LEVEL, field.observation_level) .putString(GeneralKeys.UNIQUE_NAME, field.unique_id) - .putString(GeneralKeys.PRIMARY_NAME, field.primary_id) - .putString(GeneralKeys.SECONDARY_NAME, field.secondary_id) + .putString(GeneralKeys.PRIMARY_NAME, primary) + .putString(GeneralKeys.SECONDARY_NAME, secondary) .putBoolean(GeneralKeys.IMPORT_FIELD_FINISHED, true) .putString(GeneralKeys.LAST_PLOT, null).apply() diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/GeoNavHelper.kt b/app/src/main/java/com/fieldbook/tracker/utilities/GeoNavHelper.kt index bc472b369..ff753b3f5 100644 --- a/app/src/main/java/com/fieldbook/tracker/utilities/GeoNavHelper.kt +++ b/app/src/main/java/com/fieldbook/tracker/utilities/GeoNavHelper.kt @@ -22,6 +22,7 @@ import android.os.Message import android.util.Log import android.util.TypedValue import android.view.View +import android.widget.FrameLayout import android.widget.ImageButton import android.widget.LinearLayout import android.widget.TextView @@ -526,7 +527,17 @@ class GeoNavHelper @Inject constructor(private val controller: CollectController //add all units that have non null coordinates. for (model in units) { if (model.geo_coordinates != null && model.geo_coordinates!!.isNotEmpty()) { - coordinates.add(model) + val entryKeys = controller.getDatabase().getRange( + preferences.getString(GeneralKeys.PRIMARY_NAME, null), + preferences.getString(GeneralKeys.SECONDARY_NAME, null), + model.observation_unit_db_id, + model.internal_id_observation_unit, + ) + //set currently chosen primary/secondary ids to model + coordinates.add(model.also { + it.primary_id = entryKeys.primaryId + it.secondary_id = entryKeys.secondaryId + }) } } @@ -556,7 +567,7 @@ class GeoNavHelper @Inject constructor(private val controller: CollectController val id = first.observation_unit_db_id with((controller.getContext() as CollectActivity)) { - if (id != getRangeBox().cRange.plot_id && id != lastPlotIdNav) { + if (id != getRangeBox().cRange.uniqueId && id != lastPlotIdNav) { lastPlotIdNav = id runOnUiThread { if (preferences.getBoolean(GeneralKeys.GEONAV_AUTO, false)) { @@ -572,7 +583,6 @@ class GeoNavHelper @Inject constructor(private val controller: CollectController findViewById(R.id.toolbarBottom), id, Snackbar.LENGTH_INDEFINITE ) - val snackLayout = mGeoNavSnackbar?.view as SnackbarLayout val snackView: View = layoutInflater.inflate( R.layout.geonav_snackbar_layout, @@ -609,13 +619,15 @@ class GeoNavHelper @Inject constructor(private val controller: CollectController params.bottomToTop = R.id.toolbarBottom snackView.layoutParams = params - snackLayout.addView(snackView) - snackLayout.setPadding(0, 0, 0, 0) + (mGeoNavSnackbar?.view as? FrameLayout)?.addView(snackView) + (mGeoNavSnackbar?.view as? FrameLayout)?.setPadding(0, 0, 0, 0) + val tv = snackView.findViewById(R.id.geonav_snackbar_tv) - var popupHeader = + val popupHeader = preferences.getString(GeneralKeys.GEONAV_POPUP_DISPLAY, "plot_id") + tv.text = getPopupInfo(id, popupHeader ?: "plot_id") // if the value saved in GEONAV_POPUP_DISPLAY was disabled in traits @@ -627,17 +639,12 @@ class GeoNavHelper @Inject constructor(private val controller: CollectController preferenceChangeListener ) -// if (tv != null) { -// tv.text = id -// } - val btn = snackView.findViewById(R.id.geonav_snackbar_btn) btn?.setOnClickListener { v: View? -> mGeoNavSnackbar?.dismiss() lastPlotIdNav = null - println(snackView.height) //when navigate button is pressed use rangeBox to go to the plot id moveToSearch( "id", diff --git a/app/src/main/java/com/fieldbook/tracker/views/CameraTraitSettingsView.kt b/app/src/main/java/com/fieldbook/tracker/views/CameraTraitSettingsView.kt index 01d64b2d0..a9628c97e 100644 --- a/app/src/main/java/com/fieldbook/tracker/views/CameraTraitSettingsView.kt +++ b/app/src/main/java/com/fieldbook/tracker/views/CameraTraitSettingsView.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.AttributeSet import android.util.Size import android.view.View +import android.widget.Button import android.widget.CheckBox import android.widget.FrameLayout import android.widget.RadioButton @@ -12,6 +13,7 @@ import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.preference.PreferenceManager import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.CollectActivity import com.fieldbook.tracker.preferences.GeneralKeys /** @@ -29,6 +31,7 @@ open class CameraTraitSettingsView: ConstraintLayout { protected val resolutionGroup: RadioGroup protected val resolutionTitle: TextView protected val resolutionFrameLayout: FrameLayout + protected val cropButton: Button private var lastCameraId: Int? = null private var lastPreview: Boolean? = null @@ -42,6 +45,12 @@ open class CameraTraitSettingsView: ConstraintLayout { resolutionGroup = view.findViewById(R.id.view_trait_photo_settings_resolution_rg) resolutionTitle = view.findViewById(R.id.view_trait_photo_settings_resolution_tv) resolutionFrameLayout = view.findViewById(R.id.view_trait_photo_settings_resolution_fl) + + cropButton = view.findViewById(R.id.view_trait_photo_settings_crop_btn) + // Set the visibility of the crop button to GONE if the trait does not support cropping + (context as CollectActivity).currentTrait.cropImage?.let { + cropButton.visibility = if (it) View.VISIBLE else View.GONE + } } constructor(ctx: Context, supportedResolutions: List) : super(ctx) { @@ -107,7 +116,16 @@ open class CameraTraitSettingsView: ConstraintLayout { private fun setup() { setupSystemCheckBox() + setupCropButton() + } + + private fun setupCropButton() { + cropButton.setOnClickListener { + + (context as CollectActivity).requestAndCropImage() + + } } private fun setupSettingsModeBasedOnPreference(checkedRadioButtonId: Int) { diff --git a/app/src/main/java/com/fieldbook/tracker/views/CollectInputView.kt b/app/src/main/java/com/fieldbook/tracker/views/CollectInputView.kt index 863b01aa3..c815936df 100644 --- a/app/src/main/java/com/fieldbook/tracker/views/CollectInputView.kt +++ b/app/src/main/java/com/fieldbook/tracker/views/CollectInputView.kt @@ -1,6 +1,7 @@ package com.fieldbook.tracker.views import android.content.Context +import android.graphics.Typeface import android.util.AttributeSet import android.widget.EditText import android.widget.TextView @@ -22,6 +23,8 @@ class CollectInputView(context: Context, attributeSet: AttributeSet) : Constrain var hasData: Boolean = false + private var isObservationSaved: Boolean = false + private val originalEditText: EditText val repeatView: RepeatedValuesView @@ -51,6 +54,7 @@ class CollectInputView(context: Context, attributeSet: AttributeSet) : Constrain } else { text = "" + markObservationEdited() } } @@ -59,6 +63,8 @@ class CollectInputView(context: Context, attributeSet: AttributeSet) : Constrain initialize(models) repeatView.prepareModeNonEmpty() + + markObservationSaved() } fun initialize(models: List) { @@ -70,6 +76,7 @@ class CollectInputView(context: Context, attributeSet: AttributeSet) : Constrain } else { text = models.minByOrNull { it.rep.toInt() }?.value ?: "" + markObservationEdited() } } @@ -138,4 +145,26 @@ class CollectInputView(context: Context, attributeSet: AttributeSet) : Constrain fun resetInitialIndex() { forceInitialRep = -1 } + + fun markObservationEdited() { + isObservationSaved = false + updateCurrentValueETStyle() + } + + /** + * Mark current input as saved and update styling + */ + fun markObservationSaved() { + isObservationSaved = true + updateCurrentValueETStyle() + } + + /** + * Updates the text style based on saved/edited state + */ + private fun updateCurrentValueETStyle() { + val style = if (isObservationSaved) Typeface.BOLD else Typeface.ITALIC + editText.setTypeface(null, style) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/views/CropImageView.kt b/app/src/main/java/com/fieldbook/tracker/views/CropImageView.kt new file mode 100644 index 000000000..8e45e99a9 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/views/CropImageView.kt @@ -0,0 +1,624 @@ +package com.fieldbook.tracker.views + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.RectF +import android.net.Uri +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.RelativeLayout +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.marginBottom +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.fieldbook.tracker.R +import com.fieldbook.tracker.utilities.VibrateUtil +import com.google.android.material.button.MaterialButton +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale +import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.sqrt + +/** + * Custom view to crop an image using handles. + * The view contains a bitmap image, and five handles to crop the image. + * The handles are draggable, and the image is cropped to the rectangle defined by the handles. + * The fifth handle is a middle handle that adjusts the location of the entire rect. + * The view also contains buttons to save, reset, copy, and expand the crop rectangle. + */ +@AndroidEntryPoint +class CropImageView : ConstraintLayout { + + companion object { + const val TAG = "CropImageView" + const val MIN_DISTANCE_BETWEEN_HANDLES = 32 + const val DEFAULT_CROP_COORDINATES = "0.00, 0.00, 1.00, 1.00" + fun parseRectCoordinates(rectCoordinates: String): RectF? { + + if (rectCoordinates.isNotBlank()) { + val rect = RectF() + val values = rectCoordinates.split(",") + if (values.size == 4) { + try { + rect.left = values[0].toFloat() + rect.top = values[1].toFloat() + rect.right = values[2].toFloat() + rect.bottom = values[3].toFloat() + + return rect + + } catch (e: Exception) { + + e.printStackTrace() + + } + } + } + + return null + } + } + + interface CropImageHandler { + fun onCropImageSaved(rectCoordinates: String) + fun getImageUri(): String + fun getCropCoordinates(): String + } + + @Inject + lateinit var vibrateUtil: VibrateUtil + + //interface reference + var cropImageHandler: CropImageHandler? = null + + private var cropHandleTop: ImageView? = null + private var cropHandleBottom: ImageView? = null + private var cropHandleStart: ImageView? = null + private var cropHandleEnd: ImageView? = null + private var cropHandleMid: ImageView? = null + + //declare buttons + private var saveButton: MaterialButton? = null + private var resetButton: ImageButton? = null + private var copyButton: ImageButton? = null + private var expandButton: ImageButton? = null + + private var relativeLayout: RelativeLayout? = null + + //declare edit text + private var editText: EditText? = null + + private var imageView: OverlayImageView? = null + + //size of the handle image view, set in dimens.xml + private val handleSize: Int + + //global handle that tracks which handle was last clicked + private var handle: ImageView? = null + + //bitmap that holds the image to crop + private var bitmap: Bitmap? = + null // = BitmapFactory.decodeStream(resources.openRawResource(R.raw.chip)) + + init { + + //allows child views to run onDraw function on invalidation + setWillNotDraw(false) + + //half the size of the handle, to center on lines + handleSize = resources.getDimensionPixelSize(R.dimen.crop_handle_size) + + val view = inflate(context, R.layout.view_crop_image, this) + + //get the handles + cropHandleTop = view.findViewById(R.id.crop_top_handle) + cropHandleBottom = view.findViewById(R.id.crop_bottom_handle) + cropHandleStart = view.findViewById(R.id.crop_start_handle) + cropHandleEnd = view.findViewById(R.id.crop_end_handle) + cropHandleMid = view.findViewById(R.id.crop_mid_handle) + + //initialize buttons + saveButton = view.findViewById(R.id.crop_save_btn) + resetButton = view.findViewById(R.id.crop_reset_btn) + copyButton = view.findViewById(R.id.crop_copy_btn) + expandButton = view.findViewById(R.id.crop_expand_btn) + + //initialize edit text + editText = view.findViewById(R.id.crop_image_tv) + + //initialize image view + imageView = view.findViewById(R.id.crop_image_iv) + + //initialize relative layout + relativeLayout = view.findViewById(R.id.crop_image_rl) + + initExpandButton() + initEditText() + initResetButton() + initCopyButton() + initSaveButton() + init() + } + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + private fun distance(x1: Float, y1: Float, x2: Float, y2: Float): Float { + return sqrt((x2 - x1).toDouble() * (x2 - x1) + (y2 - y1).toDouble() * (y2 - y1)).toFloat() + } + + fun initialize() { + + try { + + //set the crop coordinates from the handler + val rectCoordinates = cropImageHandler?.getCropCoordinates() + val imageUri = Uri.parse(cropImageHandler?.getImageUri()) + + editText?.setText(rectCoordinates) + + //load, rotate and scale the image using a background coroutine + findViewTreeLifecycleOwner()?.lifecycleScope?.launch { + + withContext(Dispatchers.IO) { + + //set the image uri from the handler + BitmapFactory.decodeStream(context.contentResolver.openInputStream(imageUri))?.let { bmp -> + + bitmap = bmp + + //rotate bitmap if portrait + if (bmp.width > bmp.height) { + val matrix = android.graphics.Matrix() + matrix.postRotate(90f) + bitmap = Bitmap.createBitmap( + bmp, + 0, + 0, + bmp.width, + bmp.height, + matrix, + true + ) + } + + withContext(Dispatchers.Main) { + + invalidate() + + submitCoordinatesToUi() + } + } + } + } + + } catch (e: Exception) { + + e.printStackTrace() + + } + } + + /** + * Expand rect coordinates to the size of the image + */ + private fun initExpandButton() { + + expandButton?.setOnClickListener { + + VibrateUtil(context).vibrate(1L) + VibrateUtil(context).vibrate(1L) + + imageView?.let { iv -> + + //reset handles to default positions + cropHandleTop?.let { top -> + top.x = iv.x + iv.width / 2 - handleSize / 2 + top.y = iv.y - handleSize / 2 + } + + cropHandleBottom?.let { bot -> + bot.x = iv.x + iv.width / 2 - handleSize / 2 + bot.y = iv.y + iv.height - handleSize / 2 + } + + cropHandleStart?.let { start -> + start.x = iv.x - handleSize / 2 + start.y = iv.y + iv.height / 2 - handleSize / 2 + } + + cropHandleEnd?.let { end -> + end.x = iv.x + iv.width - handleSize / 2 + end.y = iv.y + iv.height / 2 - handleSize / 2 + } + } + + invalidate() + } + } + + private fun submitCoordinatesToUi() { + val input = editText!!.text.toString() + if (input.isNotBlank()) { + val values = input.split(",") + if (values.size == 4) { + try { + val topLeftX = values[0].toFloat() + val topLeftY = values[1].toFloat() + val bottomRightX = values[2].toFloat() + val bottomRightY = values[3].toFloat() + + imageView?.let { iv -> + cropHandleTop?.y = + iv.y + topLeftY * iv.height - handleSize / 2 + cropHandleBottom?.y = + iv.y + bottomRightY * iv.height - handleSize / 2 + cropHandleStart?.x = + iv.x + topLeftX * iv.width - handleSize / 2 + cropHandleEnd?.x = + iv.x + bottomRightX * iv.width - handleSize / 2 + } + + editText?.clearFocus() + + invalidate() + + } catch (e: Exception) { + + e.printStackTrace() + + } + } + } + } + + private fun initEditText() { + + //detect action ime done on keyboard + editText?.setOnEditorActionListener { _, actionId, _ -> + //on action done/ok + if (actionId == EditorInfo.IME_ACTION_DONE) { + submitCoordinatesToUi() + } + true + } + } + + /** + * Save the coordinates to preferences. + */ + private fun initSaveButton() { + + saveButton?.setOnClickListener { + + vibrateUtil.vibrate(1L) + vibrateUtil.vibrate(1L) + + try { + cropImageHandler?.onCropImageSaved(editText?.text.toString()) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + /** + * Copies the coordinates to user clipboard. + */ + private fun initCopyButton() { + + copyButton?.setOnClickListener { + + vibrateUtil.vibrate(1L) + vibrateUtil.vibrate(1L) + + try { + //start a text share chooser + val text = editText?.text.toString() + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = ClipData.newPlainText(context.getString(R.string.crop_image_clip_data_label), text) + clipboard.setPrimaryClip(clip) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + /** + * Resets the rect coordinates to what is saved currently. + */ + private fun initResetButton() { + + resetButton?.setOnClickListener { + + vibrateUtil.vibrate(1L) + vibrateUtil.vibrate(1L) + + //reset handles to default positions in preferences + editText?.setText(cropImageHandler?.getCropCoordinates()) + + submitCoordinatesToUi() + + invalidate() + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun init() { + + /** + * pass touch events to parent, mid has slightly less elevation to give others priority + */ + cropHandleTop?.setOnTouchListener { _, _ -> + handle = cropHandleTop + false + } + + cropHandleBottom?.setOnTouchListener { _, _ -> + handle = cropHandleBottom + false + } + + cropHandleStart?.setOnTouchListener { _, _ -> + handle = cropHandleStart + false + } + + cropHandleEnd?.setOnTouchListener { _, _ -> + handle = cropHandleEnd + false + } + + cropHandleMid?.setOnTouchListener { _, _ -> + handle = cropHandleMid + false + } + + /** + * Handle touch events over the entire view, ignore some touch events that are outside the image + */ + //set touch area to the size of the crop image view + setOnTouchListener { _, event -> + + imageView?.let { iv -> + + //return early if views are null + if (cropHandleBottom == null + || cropHandleTop == null + || cropHandleStart == null + || cropHandleEnd == null + || cropHandleMid == null) return@setOnTouchListener true + + //don't allow touch events below the image + if (event.y > iv.y + iv.height + iv.marginBottom / 2) { + return@setOnTouchListener true + } + + //detect the closest handle to the touch event on action down + if (event.action == MotionEvent.ACTION_DOWN) { + + vibrateUtil.vibrate(1L) + + //find handle that the event is closest to + val topDistance = distance(event.x, event.y, cropHandleTop!!.x, cropHandleTop!!.y) + val bottomDistance = + distance(event.x, event.y, cropHandleBottom!!.x, cropHandleBottom!!.y) + val startDistance = + distance(event.x, event.y, cropHandleStart!!.x, cropHandleStart!!.y) + val endDistance = distance(event.x, event.y, cropHandleEnd!!.x, cropHandleEnd!!.y) + val midDistance = distance(event.x, event.y, cropHandleMid!!.x, cropHandleMid!!.y) + + if (topDistance <= bottomDistance && topDistance <= startDistance && topDistance <= endDistance) { + handle = cropHandleTop + } + + if (bottomDistance <= topDistance && bottomDistance <= startDistance && bottomDistance <= endDistance) { + handle = cropHandleBottom + } + + if (startDistance <= topDistance && startDistance <= bottomDistance && startDistance <= endDistance) { + handle = cropHandleStart + } + + if (endDistance <= topDistance && endDistance <= bottomDistance && endDistance <= startDistance) { + handle = cropHandleEnd + } + + if (midDistance <= topDistance && midDistance <= bottomDistance && midDistance <= startDistance && midDistance <= endDistance) { + handle = cropHandleMid + } + } + + //when touch drags, drag the handle that was closest on touch, but keep handle within bounds of image + //middle handle drags the entire crop rectangle + if (event.action == MotionEvent.ACTION_MOVE) { + + handle?.let { h -> + + if (h == cropHandleTop) { + h.y = event.y + if (h.y + handleSize / 2 < iv.y) { + h.y = iv.y - handleSize / 2 + } + } else if (h == cropHandleBottom) { + h.y = event.y + if (h.y + handleSize / 2 > iv.height + iv.y) { + h.y = iv.height + iv.y - handleSize / 2 + } + } else if (h == cropHandleStart) { + h.x = event.x + if (h.x < iv.x) { + h.x = iv.x - handleSize / 2 + } + } else if (h == cropHandleEnd) { + h.x = event.x + if (h.x + handleSize / 2 > iv.width + iv.x) { + h.x = iv.width + iv.x - handleSize / 2 + } + } else { + //middle handle position is updated, other handles are updated in onDraw + val deltaX = event.x - h.x + val deltaY = event.y - h.y + + for (hand in setOf( + cropHandleTop, + cropHandleBottom, + cropHandleStart, + cropHandleEnd, + cropHandleMid + )) { + hand?.x = hand!!.x + deltaX + hand.y += deltaY + } + } + + invalidate() + } + + } + } + + true + } + } + + private fun updateEditTextValue() { + + try { + val topLeftX = (cropHandleStart!!.x - imageView!!.x + handleSize / 2) / imageView!!.width + val topLeftY = (cropHandleTop!!.y - imageView!!.y + handleSize / 2) / imageView!!.height + val bottomRightX = (cropHandleEnd!!.x + handleSize / 2 - imageView!!.x) / imageView!!.width + val bottomRightY = + (cropHandleBottom!!.y - imageView!!.y + handleSize / 2) / imageView!!.height + + editText?.setText( + String.format( + Locale.getDefault(), + "%.2f, %.2f, %.2f, %.2f", + topLeftX, + topLeftY, + bottomRightX, + bottomRightY + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + try { + + if (bitmap == null) { + return + } + + //update handles to be within the bounds of the image + cropHandleTop?.y = round( + max( + imageView!!.y - handleSize / 2, + min(cropHandleTop!!.y, imageView!!.height.toFloat() + imageView!!.y) + ) + ) + cropHandleBottom?.y = round( + max( + imageView!!.y, + min(cropHandleBottom!!.y, imageView!!.height.toFloat() + imageView!!.y) + ) + ) + cropHandleStart?.x = round( + max( + imageView!!.x - handleSize / 2, + min(cropHandleStart!!.x, imageView!!.width.toFloat() + imageView!!.x) + ) + ) + cropHandleEnd?.x = round( + max( + imageView!!.x, + min(cropHandleEnd!!.x, imageView!!.width.toFloat() + imageView!!.x) + ) + ) + cropHandleMid?.x = round( + max( + imageView!!.x, + min(cropHandleMid!!.x, imageView!!.width.toFloat() + imageView!!.x) + ) + ) + cropHandleMid?.y = round( + max( + imageView!!.y, + min(cropHandleMid!!.y, imageView!!.height.toFloat() + imageView!!.y) + ) + ) + + //update handles to not cross each other + if (cropHandleTop!!.y > cropHandleBottom!!.y - MIN_DISTANCE_BETWEEN_HANDLES) { + cropHandleTop!!.y = round(cropHandleBottom!!.y - MIN_DISTANCE_BETWEEN_HANDLES) + } + + if (cropHandleStart!!.x > cropHandleEnd!!.x - MIN_DISTANCE_BETWEEN_HANDLES) { + cropHandleStart!!.x = round(cropHandleEnd!!.x - MIN_DISTANCE_BETWEEN_HANDLES) + } + + if (cropHandleBottom!!.y < cropHandleTop!!.y + MIN_DISTANCE_BETWEEN_HANDLES) { + cropHandleBottom!!.y = round(cropHandleTop!!.y + MIN_DISTANCE_BETWEEN_HANDLES) + } + + if (cropHandleEnd!!.x < cropHandleStart!!.x + MIN_DISTANCE_BETWEEN_HANDLES) { + cropHandleEnd!!.x = round(cropHandleStart!!.x + MIN_DISTANCE_BETWEEN_HANDLES) + } + + //get midpoints + val midX = round((cropHandleStart!!.x + cropHandleEnd!!.x) / 2) + val midY = round((cropHandleTop!!.y + cropHandleBottom!!.y) / 2) + + //set points to be middle of rect lines + cropHandleTop!!.x = midX + cropHandleBottom!!.x = midX + cropHandleStart!!.y = midY + cropHandleEnd!!.y = midY + + //update mid handle to the mid point + cropHandleMid!!.x = midX + cropHandleMid!!.y = midY + + //draw a rectangle between the two handles, notice relative to the imageView using OverlayImageView + //otherwise canvas draws underneath the image + //also add offset so rectangle intersects middle of handles + imageView?.drawRectangle( + bitmap!!, imageView!!.x, imageView!!.y, imageView!!.width, imageView!!.height, + cropHandleStart!!.x + handleSize / 2, + cropHandleTop!!.y + handleSize / 2, + cropHandleEnd!!.x + handleSize / 2, + cropHandleBottom!!.y + handleSize / 2 + ) + + updateEditTextValue() + + } catch (e: Exception) { + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/views/OverlayImageView.kt b/app/src/main/java/com/fieldbook/tracker/views/OverlayImageView.kt new file mode 100644 index 000000000..706fa886d --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/views/OverlayImageView.kt @@ -0,0 +1,90 @@ +package com.fieldbook.tracker.views + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.os.Build +import android.util.AttributeSet +import androidx.annotation.RequiresApi +import com.fieldbook.tracker.R +import androidx.appcompat.widget.AppCompatImageView + +/** + * An ImageView wrapper class that draws a rectangle around an image, leaving the outside semi-transparent. + */ +class OverlayImageView: AppCompatImageView { + + private var topX: Float = 0f + private var topY: Float = 0f + private var bottomX: Float = 0f + private var bottomY: Float = 0f + private var bitmap: Bitmap? = null + private var parentX: Float = 0f + private var parentY: Float = 0f + private var parentWidth: Int = 0 + private var parentHeight: Int = 0 + private var parentRect: Rect? = null + + private val rectPaint = Paint().also { paint -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + paint.color = context.getColor(R.color.main_primary) + } else { + paint.color = Color.BLACK + } + //create a dashed path + paint.style = Paint.Style.STROKE + paint.strokeWidth = 15f + } + + private val paint = Paint() + private val porterDuffXfermode = PorterDuffXfermode(PorterDuff.Mode.OVERLAY) + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + //draw rect relative to this image view + fun drawRectangle(bitmap: Bitmap, parentX: Float, parentY: Float, parentWidth: Int, parentHeight: Int, + topX: Float, topY: Float, bottomX: Float, bottomY: Float) { + this.topX = topX - x + this.topY = topY - y + this.bottomX = bottomX - x + this.bottomY = bottomY - y + this.bitmap = bitmap + this.parentX = parentX + this.parentY = parentY + this.parentWidth = parentWidth + this.parentHeight = parentHeight + this.parentRect = Rect(0, 0, parentWidth, parentHeight) + + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + bitmap?.let { bmp -> + + parentRect?.let { parentRect -> + + //set alpha to half and draw the full bitmap scaled to the parent + paint.alpha = 128 + canvas.drawBitmap(bmp, null, parentRect, paint) + + //enable porter duff overlay, draw rect with full alpha and then disable + paint.alpha = 255 + paint.xfermode = porterDuffXfermode + canvas.drawRect(topX, topY, bottomX, bottomY, paint) + paint.xfermode = null + + //draw the crop border + canvas.drawRect(topX, topY, bottomX, bottomY, rectPaint) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/views/RangeBoxView.kt b/app/src/main/java/com/fieldbook/tracker/views/RangeBoxView.kt index 4881c4202..70f5eaba4 100644 --- a/app/src/main/java/com/fieldbook/tracker/views/RangeBoxView.kt +++ b/app/src/main/java/com/fieldbook/tracker/views/RangeBoxView.kt @@ -1,6 +1,5 @@ package com.fieldbook.tracker.views -import android.app.Service import android.content.Context import android.content.SharedPreferences import android.database.Cursor @@ -9,19 +8,17 @@ import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet import android.util.Log -import android.view.KeyEvent import android.view.MotionEvent -import android.view.View import android.view.View.OnTouchListener -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.EditText import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import android.widget.Toast import androidx.constraintlayout.widget.ConstraintLayout import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.CollectActivity +import com.fieldbook.tracker.dialogs.AttributeChooserDialog +import com.fieldbook.tracker.dialogs.QuickGotoDialog import com.fieldbook.tracker.interfaces.CollectRangeController import com.fieldbook.tracker.objects.RangeObject import com.fieldbook.tracker.objects.TraitObject @@ -33,6 +30,8 @@ class RangeBoxView : ConstraintLayout { companion object { const val TAG = "RangeBoxView" + //truncate to three characters plus the colon + const val TRUNCATE_LENGTH = 3 + 1 } private var controller: CollectRangeController @@ -43,16 +42,12 @@ class RangeBoxView : ConstraintLayout { var cRange: RangeObject private var lastRange: String - private var rangeName: TextView - private var plotName: TextView + private var primaryNameTv: TextView + private var secondaryNameTv: TextView - //edit text used for quick goto feature range = primary id - private var rangeEt: EditText + var primaryIdTv: TextView + var secondaryIdTv: TextView - //edit text used for quick goto feature plot = secondary id - private var plotEt: EditText - private var tvRange: TextView - private var tvPlot: TextView private var rangeLeft: ImageView private var rangeRight: ImageView @@ -66,14 +61,6 @@ class RangeBoxView : ConstraintLayout { private var rangeEdited = false private var plotEdited = false - /** - * unique plot names used in range queries - * query and save them once during initialization - */ - private var firstName: String - private var secondName: String - private var uniqueName: String - private var delay = 100 private var count = 1 @@ -85,25 +72,20 @@ class RangeBoxView : ConstraintLayout { this.rangeLeft = v.findViewById(R.id.rangeLeft) this.rangeRight = v.findViewById(R.id.rangeRight) - this.tvRange = v.findViewById(R.id.tvRange) - this.tvPlot = v.findViewById(R.id.tvPlot) - this.plotEt = v.findViewById(R.id.plot) - this.rangeEt = v.findViewById(R.id.range) - this.rangeName = v.findViewById(R.id.rangeName) - this.plotName = v.findViewById(R.id.plotName) + this.primaryIdTv = v.findViewById(R.id.primaryIdTv) + this.secondaryIdTv = v.findViewById(R.id.secondaryIdTv) + this.primaryNameTv = v.findViewById(R.id.primaryNameTv) + this.secondaryNameTv = v.findViewById(R.id.secondaryNameTv) this.plotsProgressBar = v.findViewById(R.id.plotsProgressBar) this.controller = context as CollectRangeController rangeID = this.controller.getDatabase().allRangeID cRange = RangeObject() - cRange.plot = "" - cRange.plot_id = "" - cRange.range = "" + cRange.secondaryId = "" + cRange.uniqueId = "" + cRange.primaryId = "" lastRange = "" - firstName = controller.getPreferences().getString(GeneralKeys.PRIMARY_NAME, "") ?: "" - secondName = controller.getPreferences().getString(GeneralKeys.SECONDARY_NAME, "") ?: "" - uniqueName = controller.getPreferences().getString(GeneralKeys.UNIQUE_NAME, "") ?: "" } constructor(ctx: Context) : super(ctx) @@ -123,6 +105,18 @@ class RangeBoxView : ConstraintLayout { defStyleRes ) + private fun getPrimaryName(): String { + return controller.getPreferences().getString(GeneralKeys.PRIMARY_NAME, "") ?: "" + } + + private fun getSecondaryName(): String { + return controller.getPreferences().getString(GeneralKeys.SECONDARY_NAME, "") ?: "" + } + + private fun getUniqueName(): String { + return controller.getPreferences().getString(GeneralKeys.UNIQUE_NAME, "") ?: "" + } + fun toggleNavigation(toggle: Boolean) { rangeLeft.isEnabled = toggle rangeRight.isEnabled = toggle @@ -145,11 +139,11 @@ class RangeBoxView : ConstraintLayout { } fun getPlotID(): String? { - return cRange.plot_id + return cRange.uniqueId } fun isEmpty(): Boolean { - return cRange.plot_id.isEmpty() + return cRange.uniqueId.isEmpty() } fun connectTraitBox(traitBoxView: TraitBoxView) { @@ -166,118 +160,120 @@ class RangeBoxView : ConstraintLayout { // Go to next range rangeRight.setOnClickListener { moveEntryRight() } - rangeEt.setOnEditorActionListener(createOnEditorListener(rangeEt, "range")) - plotEt.setOnEditorActionListener(createOnEditorListener(plotEt, "plot")) - rangeEt.setOnTouchListener { _, _ -> - rangeEt.isCursorVisible = true - false - } + setName() - plotEt.setOnTouchListener { _, _ -> - plotEt.isCursorVisible = true - false - } - setName(10) - rangeName.setOnTouchListener { _, _ -> - Utils.makeToast( - context, - controller.getPreferences().getString( - GeneralKeys.PRIMARY_NAME, - context.getString(R.string.search_results_dialog_range) - ) + val attributeChooserDialog = AttributeChooserDialog( + showTraits = false, + showOther = false, + showSystemAttributes = false + ) + + primaryNameTv.setOnClickListener { + attributeChooserDialog.setOnAttributeSelectedListener(object : + AttributeChooserDialog.OnAttributeSelectedListener { + override fun onAttributeSelected(label: String) { + //update preference primary name + controller.getPreferences().edit().putString(GeneralKeys.PRIMARY_NAME, label).apply() + setName() + refresh() + } + }) + attributeChooserDialog.show( + (controller.getContext() as CollectActivity).supportFragmentManager, + "attributeChooserDialog" ) - false } - //TODO https://stackoverflow.com/questions/47107105/android-button-has-setontouchlistener-called-on-it-but-does-not-override-perform - plotName.setOnTouchListener { v: View, _: MotionEvent? -> - Utils.makeToast( - context, - controller.getPreferences().getString( - GeneralKeys.SECONDARY_NAME, - context.getString(R.string.search_results_dialog_range) - ) + secondaryNameTv.setOnClickListener { + attributeChooserDialog.setOnAttributeSelectedListener(object : + AttributeChooserDialog.OnAttributeSelectedListener { + override fun onAttributeSelected(label: String) { + //update preference primary name + controller.getPreferences().edit().putString(GeneralKeys.SECONDARY_NAME, label).apply() + setName() + refresh() + } + }) + attributeChooserDialog.show( + (controller.getContext() as CollectActivity).supportFragmentManager, + "attributeChooserDialog" ) - v.performClick() + } + + primaryIdTv.setOnClickListener { + showQuickGoToDialog() + } + + secondaryIdTv.setOnClickListener { + showQuickGoToDialog(primaryClicked = false) } } - private fun repeatUpdate() { + /** + * Builds and shows an alert dialog with two edit text fields for the primary/secondary ids + */ + private fun showQuickGoToDialog(primaryClicked: Boolean = true) { - controller.getTraitBox().setNewTraits(getPlotID()) + val dialog = QuickGotoDialog(controller, primaryClicked) { primaryId, secondaryId -> - } + quickGoToNavigateFromDialog(primaryId, secondaryId) + } + + dialog.show((context as CollectActivity).supportFragmentManager, "quickGotoDialog") - private fun truncate(s: String, maxLen: Int): String { - return if (s.length > maxLen) s.substring(0, maxLen - 1) + ":" else s } - /** - * This listener is used in the QuickGoto feature. - * This listens to the primary/secondary edit text's in the rangebox. - * When the soft keyboard enter key action is pressed (IME_ACTION_DONE) - * this will use the moveToSearch function. - * First it will search for both primary/secondary ids if they have both been changed. - * If one has not been changed or a plot is not found for both terms then it defaults to - * a search with whatever was changed last. - * @param edit the edit text to assign this listener to - * @param searchType the type used in moveToSearch, either plot or range - */ - private fun createOnEditorListener( - edit: EditText, - searchType: String - ): TextView.OnEditorActionListener { - return object : TextView.OnEditorActionListener { - override fun onEditorAction(view: TextView, actionId: Int, event: KeyEvent?): Boolean { - // do not do bit check on event, crashes keyboard - if (actionId == EditorInfo.IME_ACTION_DONE) { - try { - - //if both quick goto et's have been changed, attempt a search with them - if (rangeEdited && plotEdited) { - - //if the search fails back-down to the original search - if (!controller.moveToSearch( - "quickgoto", rangeID, - rangeEt.text.toString(), - plotEt.text.toString(), null, -1 - ) - ) { - controller.moveToSearch( - searchType, - rangeID, - null, - null, - view.text.toString(), - -1 - ) - } - } else { //original search if only one has changed - controller.moveToSearch( - searchType, - rangeID, - null, - null, - view.text.toString(), - -1 - ) - } + private fun quickGoToNavigateFromDialog(primaryId: String, secondaryId: String) { - //reset the changed flags - rangeEdited = false - plotEdited = false - val imm: InputMethodManager = - context.getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(edit.windowToken, 0) - } catch (ignore: Exception) { - } - return true + try { + + when { + + primaryId.isNotBlank() && secondaryId.isNotBlank() -> { + controller.moveToSearch( + "quickgoto", rangeID, + primaryId, + secondaryId, null, -1 + ) } - return false + + primaryId.isNotBlank() && secondaryId.isBlank() -> { + controller.moveToSearch( + "range", rangeID, + primaryId, + null, primaryId, -1 + ) + } + + primaryId.isBlank() && secondaryId.isNotBlank() -> { + controller.moveToSearch( + "plot", rangeID, + null, + secondaryId, secondaryId, -1 + ) + } + + else -> return + } + + } catch (e: Exception) { + + Log.e(TAG, "Error in quickGoToNavigateFromDialog: $e") + } } + private fun repeatUpdate() { + + controller.getTraitBox().setNewTraits(getPlotID()) + + } + + private fun truncate(s: String, maxLen: Int): String { + return if (s.length > maxLen) s.substring(0, maxLen - 1) + ":" else s + } + private fun createRunnable(directionStr: String): Runnable { return object : Runnable { override fun run() { @@ -366,10 +362,10 @@ class RangeBoxView : ConstraintLayout { // Refresh onscreen controls updateCurrentRange(rangeID[paging - 1]) saveLastPlot() - if (cRange.plot_id.isEmpty()) return + if (cRange.uniqueId.isEmpty()) return if (controller.getPreferences().getBoolean(GeneralKeys.PRIMARY_SOUND, false)) { - if (cRange.range != lastRange && lastRange != "") { - lastRange = cRange.range + if (cRange.primaryId != lastRange && lastRange != "") { + lastRange = cRange.primaryId controller.getSoundHelper().playPlonk() } } @@ -388,11 +384,15 @@ class RangeBoxView : ConstraintLayout { */ private fun updateCurrentRange(id: Int) { - if (firstName.isNotEmpty() && secondName.isNotEmpty() && uniqueName.isNotEmpty()) { + val primaryId = getPrimaryName() + val secondaryId = getSecondaryName() + val uniqueId = getUniqueName() + + if (primaryId.isNotEmpty() && secondaryId.isNotEmpty() && uniqueId.isNotEmpty()) { try { - cRange = controller.getDatabase().getRange(firstName, secondName, uniqueName, id) + cRange = controller.getDatabase().getRange(primaryId, secondaryId, uniqueId, id) // RangeID is a sorted list of obs unit ids for the current field. // Set bar maximum to number of obs units in the field @@ -420,20 +420,14 @@ class RangeBoxView : ConstraintLayout { } fun reload() { - - firstName = controller.getPreferences().getString(GeneralKeys.PRIMARY_NAME, "") ?: "" - secondName = controller.getPreferences().getString(GeneralKeys.SECONDARY_NAME, "") ?: "" - uniqueName = controller.getPreferences().getString(GeneralKeys.UNIQUE_NAME, "") ?: "" - - switchVisibility(controller.getPreferences().getBoolean(GeneralKeys.QUICK_GOTO, false)) - setName(8) + setName() paging = 1 setAllRangeID() if (rangeID.isNotEmpty()) { updateCurrentRange(rangeID[0]) - lastRange = cRange.range + lastRange = cRange.primaryId display() - controller.getTraitBox().setNewTraits(cRange.plot_id) + controller.getTraitBox().setNewTraits(cRange.uniqueId) } else { //if no fields, print a message and finish with result canceled Utils.makeToast(context, context.getString(R.string.act_collect_no_plots)) controller.cancelAndFinish() @@ -445,8 +439,8 @@ class RangeBoxView : ConstraintLayout { updateCurrentRange(rangeID[paging - 1]) display() if (controller.getPreferences().getBoolean(GeneralKeys.PRIMARY_SOUND, false)) { - if (cRange.range != lastRange && lastRange != "") { - lastRange = cRange.range + if (cRange.primaryId != lastRange && lastRange != "") { + lastRange = cRange.primaryId controller.getSoundHelper().playPlonk() } } @@ -454,12 +448,8 @@ class RangeBoxView : ConstraintLayout { // Updates the data shown in the dropdown fun display() { - rangeEt.setText(cRange.range) - plotEt.setText(cRange.plot) - rangeEt.isCursorVisible = false - plotEt.isCursorVisible = false - tvRange.text = cRange.range - tvPlot.text = cRange.plot + primaryIdTv.text = cRange.primaryId + secondaryIdTv.text = cRange.secondaryId } fun rightClick() { @@ -468,7 +458,7 @@ class RangeBoxView : ConstraintLayout { fun saveLastPlot() { val ed: SharedPreferences.Editor = controller.getPreferences().edit() - ed.putString(GeneralKeys.LAST_PLOT, cRange.plot_id) + ed.putString(GeneralKeys.LAST_PLOT, cRange.uniqueId) ed.apply() } @@ -482,25 +472,7 @@ class RangeBoxView : ConstraintLayout { } } - private fun switchVisibility(textview: Boolean) { - if (textview) { - tvRange.visibility = GONE - tvPlot.visibility = GONE - rangeEt.visibility = VISIBLE - plotEt.visibility = VISIBLE - - //when the et's are visible create text watchers to listen for changes - rangeEt.addTextChangedListener(createTextWatcher("range")) - plotEt.addTextChangedListener(createTextWatcher("plot")) - } else { - tvRange.visibility = VISIBLE - tvPlot.visibility = VISIBLE - rangeEt.visibility = GONE - plotEt.visibility = GONE - } - } - - fun setName(maxLen: Int) { + fun setName() { val primaryName = controller.getPreferences().getString( GeneralKeys.PRIMARY_NAME, context.getString(R.string.search_results_dialog_range) @@ -509,8 +481,8 @@ class RangeBoxView : ConstraintLayout { GeneralKeys.SECONDARY_NAME, context.getString(R.string.search_results_dialog_plot) ) + ":" - rangeName.text = truncate(primaryName, maxLen) - plotName.text = truncate(secondaryName, maxLen) + this.primaryNameTv.text = truncate(primaryName, TRUNCATE_LENGTH) + this.secondaryNameTv.text = truncate(secondaryName, TRUNCATE_LENGTH) } fun setAllRangeID() { @@ -528,11 +500,9 @@ class RangeBoxView : ConstraintLayout { } fun setLastRange() { - lastRange = cRange.range + lastRange = cRange.primaryId } - ///// paging ///// - ///// paging ///// fun moveEntryLeft() { if (!controller.validateData(controller.getCurrentObservation()?.value)) { @@ -609,7 +579,8 @@ class RangeBoxView : ConstraintLayout { moveToNextUncollectedObs(pos, step, arrayListOf(currentTraitObj)) } 2 -> { - val visibleTraits = ArrayList(controller.getDatabase().visibleTraitObjects.filterNotNull()) + val sortOrder = controller.getPreferences().getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position") + val visibleTraits = ArrayList(controller.getDatabase().getVisibleTraitObjects(sortOrder).filterNotNull()) moveToNextUncollectedObs(pos, step, visibleTraits) } else -> moveSimply(pos, step) diff --git a/app/src/main/java/com/fieldbook/tracker/views/TraitBoxView.kt b/app/src/main/java/com/fieldbook/tracker/views/TraitBoxView.kt index 79d2aa58e..f488f661d 100644 --- a/app/src/main/java/com/fieldbook/tracker/views/TraitBoxView.kt +++ b/app/src/main/java/com/fieldbook/tracker/views/TraitBoxView.kt @@ -112,6 +112,13 @@ class TraitBoxView : ConstraintLayout { // Go to next trait traitRight.setOnClickListener { moveTrait("right") } + + traitsStatusBarRv?.adapter = TraitsStatusAdapter(this) + traitsStatusBarRv?.layoutManager = object : LinearLayoutManager(context, HORIZONTAL, false) { + override fun canScrollHorizontally(): Boolean { + return false + } + } } fun initTraitDetails() { @@ -133,13 +140,6 @@ class TraitBoxView : ConstraintLayout { this.visibleTraitsList = visibleTraits this.rangeSuppress = rangeSuppress - traitsStatusBarRv?.adapter = TraitsStatusAdapter(this) - traitsStatusBarRv?.layoutManager = object : LinearLayoutManager(context, HORIZONTAL, false) { - override fun canScrollHorizontally(): Boolean { - return false - } - } - // recyclerView?.viewTreeObserver?.addOnGlobalLayoutListener(object : OnGlobalLayoutListener { // override fun onGlobalLayout() { // recyclerView?.viewTreeObserver?.removeOnGlobalLayoutListener(this) @@ -160,32 +160,40 @@ class TraitBoxView : ConstraintLayout { // Display dialog or menu for trait selection showTraitPickerDialog(visibleTraits) } + + updateTraitsStatusBar() + } fun getRecyclerView(): RecyclerView? { return traitsStatusBarRv } + private var previousSelection = 0 + fun loadLayout(rangeSuppress: Boolean) { val traitPosition = getSelectedItemPosition() setSelection(traitPosition) + traitsStatusBarRv?.adapter?.notifyItemChanged(previousSelection) + traitsStatusBarRv?.adapter?.notifyItemChanged(traitPosition) + + previousSelection = traitPosition + // This updates the in memory hashmap from database currentTrait = controller.getDatabase().getDetail( traitTypeTv.text .toString() ) - updateTraitsStatusBar() - // Update last used trait so it is preserved when entry moves controller.getPreferences().edit().putString(GeneralKeys.LAST_USED_TRAIT,traitTypeTv.text.toString()).apply() traitTypeTv.text = currentTrait?.name - val imm = context.getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager + if (currentTrait?.format != "text") { try { @@ -193,7 +201,9 @@ class TraitBoxView : ConstraintLayout { } catch (ignore: Exception) { } } - traitDetails.text = currentTrait?.details + + traitDetails.text = currentTrait?.details ?: "" + if (!rangeSuppress or (currentTrait?.format != "numeric")) { if (controller.getInputView().visibility == VISIBLE) { controller.getInputView().visibility = GONE @@ -221,8 +231,6 @@ class TraitBoxView : ConstraintLayout { controller.getInputView().visibility = VISIBLE controller.getInputView().isEnabled = true } - - updateTraitsStatusBar() } private fun showTraitPickerDialog(visibleTraits: Array?) { @@ -254,7 +262,9 @@ class TraitBoxView : ConstraintLayout { } private fun updateTraitsStatusBar() { - val visibleTraits: Array = controller.getDatabase().getVisibleTrait() + + val currentSortOrder = controller.getPreferences().getString(GeneralKeys.TRAITS_LIST_SORT_ORDER, "position") + val visibleTraits: Array = controller.getDatabase().getVisibleTrait(currentSortOrder) // images saved are not stored in newTraits hashMap // get the data for current plot_id again @@ -267,6 +277,7 @@ class TraitBoxView : ConstraintLayout { traitsValue.containsKey(trait) ) } + (traitsStatusBarRv?.adapter as TraitsStatusAdapter).submitList(traitBoxItemModels) // the recyclerView height was 0 initially, so calculate the icon size again @@ -278,8 +289,6 @@ class TraitBoxView : ConstraintLayout { } } } - (traitsStatusBarRv?.adapter as TraitsStatusAdapter).notifyDataSetChanged() - } fun getNewTraits(): Map { diff --git a/app/src/main/res/drawable/content_copy.xml b/app/src/main/res/drawable/content_copy.xml new file mode 100644 index 000000000..ebd6fb78e --- /dev/null +++ b/app/src/main/res/drawable/content_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/crop_handle.xml b/app/src/main/res/drawable/crop_handle.xml new file mode 100644 index 000000000..29cadbe39 --- /dev/null +++ b/app/src/main/res/drawable/crop_handle.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/undo.xml b/app/src/main/res/drawable/undo.xml new file mode 100644 index 000000000..04755d9ce --- /dev/null +++ b/app/src/main/res/drawable/undo.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/undo_variant.xml b/app/src/main/res/drawable/undo_variant.xml new file mode 100644 index 000000000..615ee01f2 --- /dev/null +++ b/app/src/main/res/drawable/undo_variant.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_crop_image.xml b/app/src/main/res/layout/activity_crop_image.xml new file mode 100644 index 000000000..b6ed0ae4a --- /dev/null +++ b/app/src/main/res/layout/activity_crop_image.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_study_importer.xml b/app/src/main/res/layout/activity_study_importer.xml index da9b347fe..e3e9cb8cf 100644 --- a/app/src/main/res/layout/activity_study_importer.xml +++ b/app/src/main/res/layout/activity_study_importer.xml @@ -50,16 +50,6 @@ android:layout_height="wrap_content" android:text="@string/level"/> - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_field_file_import.xml b/app/src/main/res/layout/dialog_field_file_import.xml new file mode 100644 index 000000000..410fe4806 --- /dev/null +++ b/app/src/main/res/layout/dialog_field_file_import.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_import.xml b/app/src/main/res/layout/dialog_import.xml deleted file mode 100644 index adbf504ca..000000000 --- a/app/src/main/res/layout/dialog_import.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_quick_goto.xml b/app/src/main/res/layout/dialog_quick_goto.xml new file mode 100644 index 000000000..e9019742b --- /dev/null +++ b/app/src/main/res/layout/dialog_quick_goto.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_item_image.xml b/app/src/main/res/layout/list_item_image.xml index dac7a5010..04ce80e02 100644 --- a/app/src/main/res/layout/list_item_image.xml +++ b/app/src/main/res/layout/list_item_image.xml @@ -5,7 +5,8 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_margin="4dp" android:layout_width="wrap_content" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:background="@drawable/cell"> @@ -27,9 +29,10 @@ android:id="@+id/list_item_study_title_tv" style="@style/TextViewStyle.Bold.Title" android:layout_width="0dp" - android:layout_height="match_parent" + android:layout_height="0dp" android:layout_margin="16dp" - app:layout_constraintBottom_toBottomOf="parent" + android:gravity="center_vertical" + app:layout_constraintBottom_toTopOf="@id/list_item_study_units_chip" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toStartOf="@id/list_item_study_pb" @@ -46,7 +49,7 @@ android:checkable="false" app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/list_item_study_title_tv" + app:layout_constraintTop_toBottomOf="@id/list_item_study_pb" tools:text="108"/> - + android:layout_marginHorizontal="16dp" + android:layout_marginVertical="8dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + android:text="Preview Date" + android:textSize="@dimen/text_size_xlarge" + android:textStyle="bold" /> - + + + + + diff --git a/app/src/main/res/layout/view_crop_image.xml b/app/src/main/res/layout/view_crop_image.xml new file mode 100644 index 000000000..e18874185 --- /dev/null +++ b/app/src/main/res/layout/view_crop_image.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_range_box.xml b/app/src/main/res/layout/view_range_box.xml index 73587d208..6306e70f2 100644 --- a/app/src/main/res/layout/view_range_box.xml +++ b/app/src/main/res/layout/view_range_box.xml @@ -1,18 +1,21 @@ + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + android:textStyle="bold" + tools:text="row:" /> + android:textStyle="bold" + tools:text="col:" /> - - - - + tools:text="1" /> + tools:text="13RPN00005" /> - \ No newline at end of file diff --git a/app/src/main/res/layout/view_trait_photo_settings.xml b/app/src/main/res/layout/view_trait_photo_settings.xml index bdbbc09d3..a96309b1a 100644 --- a/app/src/main/res/layout/view_trait_photo_settings.xml +++ b/app/src/main/res/layout/view_trait_photo_settings.xml @@ -61,7 +61,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/view_trait_photo_settings_resolution_tv" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@id/view_trait_photo_settings_crop_btn" app:layout_constraintVertical_bias="0"> +