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..6b23be6cb 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java @@ -925,7 +925,7 @@ public boolean moveToSearch( } //move to plot id - if (command.equals("id") || command.equals("barcode")) { + if (command.equals("id")) { int rangeSize = plotIndices.length; for (int j = 1; j <= rangeSize; j++) { rangeBox.setRangeByIndex(j - 1); @@ -941,12 +941,148 @@ public boolean moveToSearch( } } + if (command.equals("barcode")) { + int currentFieldId = preferences.getInt(GeneralKeys.SELECTED_FIELD_ID, 0); + Log.d("Field Book", "Barcode search in current field: " + currentFieldId + ", searching for: " + data); + + ObservationUnitModel[] matchingUnits = database.getObservationUnitsBySearchAttribute( + currentFieldId, data); + Log.d("Field Book", "Search attribute results: " + matchingUnits.length + " units found"); + + if (matchingUnits.length > 0) { + // If found by search attribute, move to that observation unit + String matchingObsUnitId = matchingUnits[0].getObservation_unit_db_id(); + Log.d("Field Book", "Found match by search attribute. Unit ID: " + matchingObsUnitId); + + // If multiple matches found, show notification + if (matchingUnits.length > 1) { + Utils.makeToast(this, getString(R.string.search_multiple_matches_found, matchingUnits.length)); + } + + for (int j = 1; j <= plotIndices.length; j++) { + rangeBox.setRangeByIndex(j - 1); + RangeObject ro = rangeBox.getCRange(); + + if (ro.plot_id.equals(matchingObsUnitId)) { + moveToResultCore(j); + return true; + } + } + } + + // Fallback: check if the barcode directly matches a plot_id + Log.d("Field Book", "Falling back to direct plot_id matching"); + for (int j = 1; j <= plotIndices.length; j++) { + rangeBox.setRangeByIndex(j - 1); + RangeObject ro = rangeBox.getCRange(); + + if (ro.plot_id.equals(data)) { + Log.d("Field Book", "Direct match found at index: " + j); + moveToResultCore(j); + return true; + } + } + + // If we didn't find it in the current field, try the cross-field search + Log.d("Field Book", "Not found in current field, trying other fields"); + return performCrossFallbackSearch(data); + } + if (!command.equals("quickgoto") && !command.equals("barcode")) Utils.makeToast(this, getString(R.string.main_toolbar_moveto_no_match)); return false; } + /** + * Searches for a barcode across all fields when not found in the current field. + * @param searchValue The barcode or search value to find + * @return true if found in another field, false otherwise + */ + private boolean performCrossFallbackSearch(String searchValue) { + Log.d("Field Book", "Starting cross-field fallback search for: " + searchValue); + + boolean found = false; + FieldObject studyObj = null; + + // Store search value in inputPlotId for use in the fallback + inputPlotId = searchValue; + + int currentFieldId = preferences.getInt(GeneralKeys.SELECTED_FIELD_ID, 0); + Log.d("Field Book", "Current field ID: " + currentFieldId); + + // Check all other fields by search attribute + ArrayList allFields = database.getAllFieldObjects(); + Log.d("Field Book", "Searching across " + allFields.size() + " fields"); + + for (FieldObject field : allFields) { + // Skip the current field + if (field.getExp_id() == currentFieldId) { + continue; + } + + Log.d("Field Book", "Checking field: " + field.getExp_id() + " (" + field.getExp_name() + ")"); + + ObservationUnitModel[] matchingUnits = database.getObservationUnitsBySearchAttribute( + field.getExp_id(), searchValue); + + Log.d("Field Book", "Found " + matchingUnits.length + " matches in field " + field.getExp_id()); + + if (matchingUnits.length > 0) { + studyObj = field; + String oldPlotId = inputPlotId; + inputPlotId = matchingUnits[0].getObservation_unit_db_id(); + Log.d("Field Book", "Match found! Field: " + field.getExp_name() + + ", unit ID updated from " + oldPlotId + " to " + inputPlotId); + found = true; + break; + } + } + + // If not found by search attribute in any field, try direct plot_id matching + if (!found) { + Log.d("Field Book", "No matches by search attribute, trying direct ID match"); + + ObservationUnitModel[] models = database.getAllObservationUnits(); + + for (ObservationUnitModel m : models) { + if (m.getObservation_unit_db_id().equals(searchValue)) { + FieldObject study = database.getFieldObject(m.getStudy_id()); + if (study != null && study.getExp_name() != null) { + studyObj = study; + found = true; + Log.d("Field Book", "Direct match found in study: " + study.getExp_name()); + break; + } + } + } + } + + // Handle the result of the search + if (found && studyObj != null && studyObj.getExp_name() != null && studyObj.getExp_id() != -1) { + int studyId = studyObj.getExp_id(); + String fieldName = studyObj.getExp_alias(); + + // Save the matching observation unit ID from the matched unit, not the search value + final String matchedObsUnitId = inputPlotId; // This should be the one set earlier from matchingUnits[0] + + Log.d("Field Book", "Showing navigation prompt to field: " + fieldName + " and plot ID: " + matchedObsUnitId); + + String msg = getString(R.string.act_collect_barcode_search_exists_in_other_field, fieldName); + + SnackbarUtils.showNavigateSnack(getLayoutInflater(), findViewById(R.id.traitHolder), + msg, R.id.toolbarBottom, 8000, null, + (v) -> switchField(studyId, matchedObsUnitId)); + + return true; + } else { + Log.d("Field Book", "No match found in any field"); + soundHelper.playError(); + Utils.makeToast(getApplicationContext(), getString(R.string.main_toolbar_moveto_no_match)); + return false; + } + } + private void moveToResultCore(int j) { rangeBox.setPaging(j); @@ -1846,7 +1982,7 @@ public void onClick(DialogInterface dialog, int which) { inputPlotId = barcodeId.getText().toString(); rangeBox.setAllRangeID(); int[] rangeID = rangeBox.getRangeID(); - moveToSearch("id", rangeID, null, null, inputPlotId, -1); + moveToSearch("barcode", rangeID, null, null, inputPlotId, -1); goToId.dismiss(); } }); @@ -1992,57 +2128,42 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } break; case BARCODE_SEARCH_CODE: - if(resultCode == RESULT_OK) { + if (resultCode == RESULT_OK) { + Log.d("Field Book", "Barcode scan successful"); - if (geoNavHelper.getSnackbar() != null) geoNavHelper.getSnackbar().dismiss(); - if(mlkitEnabled) { - inputPlotId = data.getStringExtra("barcode"); - } - else { - IntentResult plotSearchResult = IntentIntegrator.parseActivityResult(resultCode, data); - inputPlotId = plotSearchResult.getContents(); + if (geoNavHelper.getSnackbar() != null) { + geoNavHelper.getSnackbar().dismiss(); } - rangeBox.setAllRangeID(); - int[] rangeID = rangeBox.getRangeID(); - boolean success = moveToSearch("barcode", rangeID, null, null, inputPlotId, -1); - //play success or error sound if the plotId was not found - if (success) { - soundHelper.playCelebrate(); + String barcodeValue; + if (mlkitEnabled) { + barcodeValue = data.getStringExtra("barcode"); } else { - boolean found = false; - FieldObject studyObj = null; - ObservationUnitModel[] models = database.getAllObservationUnits(); - for (ObservationUnitModel m : models) { - if (m.getObservation_unit_db_id().equals(inputPlotId)) { - - FieldObject study = database.getFieldObject(m.getStudy_id()); - if (study != null && study.getExp_name() != null) { - studyObj = study; - found = true; - break; - } - } - } - - if (found && studyObj.getExp_name() != null && studyObj.getExp_id() != -1) { - - int studyId = studyObj.getExp_id(); - String fieldName = studyObj.getExp_alias(); - - String msg = getString(R.string.act_collect_barcode_search_exists_in_other_field, fieldName); + IntentResult plotSearchResult = IntentIntegrator.parseActivityResult(resultCode, data); + barcodeValue = plotSearchResult.getContents(); + } - SnackbarUtils.showNavigateSnack(getLayoutInflater(), findViewById(R.id.traitHolder), msg, R.id.toolbarBottom,8000, null, - (v) -> switchField(studyId, null)); + if (barcodeValue != null && !barcodeValue.isEmpty()) { + Log.d("Field Book", "Scanned barcode: " + barcodeValue); - } else { + // Set inputPlotId globally to ensure it's available everywhere + inputPlotId = barcodeValue; - soundHelper.playError(); + rangeBox.setAllRangeID(); + int[] rangeID = rangeBox.getRangeID(); - Utils.makeToast(getApplicationContext(), getString(R.string.main_toolbar_moveto_no_match)); + boolean success = moveToSearch("barcode", rangeID, null, null, barcodeValue, -1); - } + // If success is true, moveToSearch found the barcode, either in current field + // or via the fallback in another field. Success sound happens in moveToSearch + // if needed. No additional action required here. + } else { + Log.d("Field Book", "Barcode scan returned empty result"); + soundHelper.playError(); + Utils.makeToast(getApplicationContext(), getString(R.string.main_toolbar_moveto_no_match)); } + } else { + Log.d("Field Book", "Barcode scan cancelled or failed"); } break; case BARCODE_COLLECT_CODE: 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..3864fbadb 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/FieldDetailFragment.kt +++ b/app/src/main/java/com/fieldbook/tracker/activities/FieldDetailFragment.kt @@ -11,6 +11,8 @@ import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView import android.widget.EditText import android.widget.ImageView import android.widget.LinearLayout @@ -27,6 +29,7 @@ import com.fieldbook.tracker.adapters.FieldDetailAdapter import com.fieldbook.tracker.adapters.FieldDetailItem import com.fieldbook.tracker.brapi.service.BrAPIService import com.fieldbook.tracker.database.DataHelper +import com.fieldbook.tracker.dialogs.SearchAttributeChooserDialog import com.fieldbook.tracker.dialogs.BrapiSyncObsDialog import com.fieldbook.tracker.interfaces.FieldAdapterController import com.fieldbook.tracker.interfaces.FieldSortController @@ -73,6 +76,7 @@ class FieldDetailFragment : Fragment(), FieldSyncController { private lateinit var entryCountChip: Chip private lateinit var attributeCountChip: Chip private lateinit var sortOrderChip: Chip + private lateinit var editUniqueChip: Chip private lateinit var traitCountChip: Chip private lateinit var observationCountChip: Chip private lateinit var trialNameChip: Chip @@ -97,6 +101,7 @@ class FieldDetailFragment : Fragment(), FieldSyncController { entryCountChip = rootView.findViewById(R.id.entryCountChip) attributeCountChip = rootView.findViewById(R.id.attributeCountChip) sortOrderChip = rootView.findViewById(R.id.sortOrderChip) + editUniqueChip = rootView.findViewById(R.id.editUniqueChip) traitCountChip = rootView.findViewById(R.id.traitCountChip) observationCountChip = rootView.findViewById(R.id.observationCountChip) detailRecyclerView = rootView.findViewById(R.id.fieldDetailRecyclerView) @@ -186,6 +191,15 @@ class FieldDetailFragment : Fragment(), FieldSyncController { } } + editUniqueChip.setOnClickListener { + fieldId?.let { id -> + val field = database.getFieldObject(id) + field?.let { + showChangeSearchAttributeDialog(it) + } + } + } + disableDataChipRipples() Log.d("FieldDetailFragment", "onCreateView End") @@ -265,6 +279,7 @@ class FieldDetailFragment : Fragment(), FieldSyncController { var importFormat: ImportFormat? = field.import_format var entryCount = field.count.toString() val attributeCount = field.attribute_count.toString() + val searchAttribute = (field.search_attribute ?: field.unique_id).toString() if (importFormat == ImportFormat.BRAPI) { cardViewSync.visibility = View.VISIBLE @@ -295,6 +310,8 @@ class FieldDetailFragment : Fragment(), FieldSyncController { entryCountChip.text = entryCount attributeCountChip.text = attributeCount sortOrderChip.text = getString(R.string.field_sort_entries) +// editUniqueChip.text = getString(R.string.field_edit_unique_id) + editUniqueChip.text = searchAttribute val lastEdit = field.date_edit if (!lastEdit.isNullOrEmpty()) { @@ -434,6 +451,86 @@ class FieldDetailFragment : Fragment(), FieldSyncController { dialog.show() } + // private fun showChangeSearchAttributeDialog(field: FieldObject) { + // (activity as? FieldEditorActivity)?.setActiveField(field.exp_id) + + // val dialog = AttributeChooserDialog( + // showTraits = false, + // showOther = false, + // uniqueOnly = true, + // showApplyAllCheckbox = true + // ) + + // dialog.setOnAttributeSelectedListener(object : AttributeChooserDialog.OnAttributeSelectedListener { + // override fun onAttributeSelected(label: String) { + // // Get the apply to all state + // val applyToAll = dialog.getApplyToAllState() + + // if (applyToAll) { + // // Update search attribute for all fields with this attribute + // val count = database.updateSearchAttributeForAllFields(label) + // Toast.makeText( + // context, + // getString(R.string.search_attribute_updated_all, count), + // Toast.LENGTH_SHORT + // ).show() + // } else { + // // Update only the current field + // database.updateSearchAttribute(field.exp_id, label) + // Toast.makeText( + // context, + // getString(R.string.search_attribute_updated), + // Toast.LENGTH_SHORT + // ).show() + // } + + // loadFieldDetails() + + // // If apply to all was selected, refresh the parent activity's field list + // if (applyToAll) { + // (activity as? FieldAdapterController)?.queryAndLoadFields() + // } + // } + // }) + + // dialog.show(parentFragmentManager, AttributeChooserDialog.TAG) + // } + + private fun showChangeSearchAttributeDialog(field: FieldObject) { + (activity as? FieldEditorActivity)?.setActiveField(field.exp_id) + + val dialog = SearchAttributeChooserDialog() + dialog.setOnSearchAttributeSelectedListener(object : SearchAttributeChooserDialog.OnSearchAttributeSelectedListener { + override fun onSearchAttributeSelected(label: String, applyToAll: Boolean) { + if (applyToAll) { + // Update search attribute for all fields with this attribute + val count = database.updateSearchAttributeForAllFields(label) + Toast.makeText( + context, + getString(R.string.search_attribute_updated_all, count), + Toast.LENGTH_SHORT + ).show() + } else { + // Update only the current field + database.updateSearchAttribute(field.exp_id, label) + Toast.makeText( + context, + getString(R.string.search_attribute_updated), + Toast.LENGTH_SHORT + ).show() + } + + loadFieldDetails() + + // If apply to all was selected, refresh the parent activity's field list + if (applyToAll) { + (activity as? FieldAdapterController)?.queryAndLoadFields() + } + } + }) + dialog.show(parentFragmentManager, SearchAttributeChooserDialog.TAG) + } + /** * Checks if the given newName is unique among all fields, considering both import names and aliases. */ 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..80a964c6c 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java +++ b/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java @@ -74,7 +74,7 @@ public class DataHelper { public static final String RANGE = "range"; public static final String TRAITS = "traits"; - public static final int DATABASE_VERSION = 11; + public static final int DATABASE_VERSION = 12; private static final String DATABASE_NAME = "fieldbook.db"; private static final String USER_TRAITS = "user_traits"; private static final String EXP_INDEX = "exp_id"; @@ -888,6 +888,26 @@ public void updateObservations(List observations) { // db.endTransaction(); } + public List getPossibleUniqueAttributes(int studyId) { + open(); + return StudyDao.Companion.getPossibleUniqueAttributes(studyId); + } + + public void updateSearchAttribute(int studyId, String newSearchAttribute) { + open(); + StudyDao.Companion.updateSearchAttribute(studyId, newSearchAttribute); + } + + public int updateSearchAttributeForAllFields(String newSearchAttribute) { + open(); + return StudyDao.Companion.updateSearchAttributeForAllFields(newSearchAttribute); + } + + public ObservationUnitModel[] getObservationUnitsBySearchAttribute(int studyId, String searchValue) { + open(); + return ObservationUnitDao.Companion.getBySearchAttribute(studyId, searchValue); + } + public void updateImages(List images) { ArrayList ids = new ArrayList<>(); @@ -3052,6 +3072,13 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { helper.fixStudyAliases(db); } + + if (oldVersion <= 11 && newVersion >= 12) { + // Add observation_unit_search_attribute column to studies table, use study_unique_id_name as default value + db.execSQL("ALTER TABLE studies ADD COLUMN observation_unit_search_attribute TEXT"); + db.execSQL("UPDATE studies SET observation_unit_search_attribute = study_unique_id_name"); + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationUnitDao.kt b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationUnitDao.kt index 1e58049a6..8d2309fbd 100644 --- a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationUnitDao.kt +++ b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationUnitDao.kt @@ -2,6 +2,7 @@ package com.fieldbook.tracker.database.dao import android.content.ContentValues import android.database.sqlite.SQLiteDatabase +import android.util.Log import com.fieldbook.tracker.database.Migrator.ObservationUnit import com.fieldbook.tracker.database.Migrator.Study import com.fieldbook.tracker.database.models.ObservationUnitModel @@ -59,6 +60,44 @@ class ObservationUnitDao { } } + /** + * Find observation units matching a search attribute value + * @param studyId The study ID to search within + * @param searchValue The value to search for + * @return Array of matching observation unit models, or empty array if none found + */ + fun getBySearchAttribute(studyId: Int, searchValue: String): Array = withDatabase { db -> + + Log.d("Field Book", "getBySearchAttribute called with studyId: $studyId, searchValue: $searchValue") + + val query = """ + SELECT ou.* + FROM ${ObservationUnit.tableName} ou + JOIN observation_units_values ouv ON + ou.${ObservationUnit.PK} = ouv.observation_unit_id + AND ouv.study_id = ? + AND ouv.observation_unit_value_name = ? + AND ouv.observation_unit_attribute_db_id IN ( + SELECT internal_id_observation_unit_attribute + FROM observation_units_attributes + WHERE observation_unit_attribute_name = ( + SELECT observation_unit_search_attribute + FROM ${Study.tableName} + WHERE ${Study.PK} = ? + ) + ) + """ + + db.rawQuery(query, arrayOf( + studyId.toString(), + searchValue, + studyId.toString() + )) + .toTable() + .map { ObservationUnitModel(it) } + .toTypedArray() + } ?: emptyArray() + fun getAll(eid: Int): Array = withDatabase { db -> arrayOf(*db.query(ObservationUnit.tableName, 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..edbc1ac25 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 @@ -412,7 +412,7 @@ class ObservationUnitPropertyDao { $orderByClause """.trimIndent() - Log.d("getSortedObsUnitData", "Executing dynamic query: $query") +// Log.d("getSortedObsUnitData", "Executing dynamic query: $query") db.rawQuery(query, null) } 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..cc2da921f 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 @@ -33,6 +33,85 @@ class StudyDao { companion object { + fun getPossibleUniqueAttributes(studyId: Int): List = withDatabase { db -> + val query = """ + SELECT observation_unit_attribute_name + FROM observation_units_attributes + WHERE internal_id_observation_unit_attribute IN ( + SELECT observation_unit_attribute_db_id + FROM observation_units_values + WHERE study_id = ? + GROUP BY observation_unit_attribute_db_id + HAVING COUNT(DISTINCT observation_unit_value_name) = COUNT(observation_unit_value_name) + ) + """ + + Log.d("StudyDao", "Running query: $query") + + db.rawQuery(query, arrayOf(studyId.toString())).use { cursor -> + val attributes = mutableListOf() + while (cursor.moveToNext()) { + cursor.getString(0)?.let { + attributes.add(it) + Log.d("StudyDao", "Found unique attribute: $it") + } + } + attributes + } + } ?: emptyList() + + /** + * Updates the observation unit search attribute for a study record. + * This attribute is used to identify entries in the barcode search, by default it's the same as the unique_id + */ + + fun updateSearchAttribute(studyId: Int, newSearchAttribute: String) = withDatabase { db -> + db.update(Study.tableName, + contentValuesOf("observation_unit_search_attribute" to newSearchAttribute), + "${Study.PK} = $studyId", + null + ) + } + + /** + * Updates the observation unit search attribute for all studies that have this attribute + * @return The number of studies that were updated + */ + fun updateSearchAttributeForAllFields(newSearchAttribute: String): Int = withDatabase { db -> + // First check which studies have this attribute + val studiesWithAttribute = mutableListOf() + + val query = """ + SELECT DISTINCT study_id + FROM observation_units_attributes + WHERE observation_unit_attribute_name = ? + """ + + Log.d("StudyDao", "Finding studies with attribute: $newSearchAttribute") + + db.rawQuery(query, arrayOf(newSearchAttribute)).use { cursor -> + while (cursor.moveToNext()) { + cursor.getInt(0).let { studyId -> + studiesWithAttribute.add(studyId) + Log.d("StudyDao", "Found study with matching attribute: $studyId") + } + } + } + + // Now update each study that has this attribute + var updatedCount = 0 + if (studiesWithAttribute.isNotEmpty()) { + for (studyId in studiesWithAttribute) { + // Fix: Add null safety with the Elvis operator (?:) + val result = updateSearchAttribute(studyId, newSearchAttribute) + if ((result ?: 0) > 0) updatedCount++ + } + } + + Log.d("StudyDao", "Updated search attribute for $updatedCount studies") + updatedCount + } ?: 0 + private fun fixPlotAttributes(db: SQLiteDatabase) { db.rawQuery("PRAGMA foreign_keys=OFF;", null).close() @@ -188,6 +267,7 @@ class StudyDao { it.trait_count = this["trait_count"]?.toString() it.observation_count = this["observation_count"]?.toString() it.trial_name = this["trial_name"]?.toString() + it.search_attribute = this["observation_unit_search_attribute"]?.toString() } fun getAllFieldObjects(sortOrder: String): ArrayList = withDatabase { db -> @@ -238,6 +318,7 @@ class StudyDao { study_sort_name, trial_name, count, + observation_unit_search_attribute, (SELECT COUNT(*) FROM observation_units_attributes WHERE study_id = Studies.${Study.PK}) AS attribute_count, (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 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..f7cd4ece7 100644 --- a/app/src/main/java/com/fieldbook/tracker/dialogs/AttributeChooserDialog.kt +++ b/app/src/main/java/com/fieldbook/tracker/dialogs/AttributeChooserDialog.kt @@ -4,6 +4,7 @@ import android.app.AlertDialog import android.app.Dialog import android.os.Bundle import android.util.Log +import android.widget.CheckBox import android.widget.ProgressBar import androidx.fragment.app.DialogFragment import androidx.preference.PreferenceManager @@ -20,42 +21,76 @@ 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) } - private lateinit var tabLayout: TabLayout - private lateinit var recyclerView: RecyclerView - private lateinit var progressBar: ProgressBar + protected lateinit var tabLayout: TabLayout + protected lateinit var recyclerView: RecyclerView + protected lateinit var progressBar: ProgressBar + protected lateinit var applyAllCheckbox: CheckBox - private var attributes = arrayOf() - private var traits = arrayOf() - private var other = arrayOf() - private var onAttributeSelectedListener: OnAttributeSelectedListener? = null + protected var attributes = arrayOf() + protected var traits = arrayOf() + protected var other = arrayOf() + protected var attributeSelectedListener: OnAttributeSelectedListener? = null override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 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) progressBar = view.findViewById(R.id.dialog_collect_att_chooser_pb) + applyAllCheckbox = view.findViewById(R.id.dialog_collect_att_chooser_checkbox) 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 { @@ -71,7 +106,7 @@ open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.Attribute } fun setOnAttributeSelectedListener(listener: OnAttributeSelectedListener) { - onAttributeSelectedListener = listener + attributeSelectedListener = listener } private fun loadData() { @@ -81,7 +116,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() @@ -92,7 +129,7 @@ open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.Attribute } } - private fun toggleProgressVisibility(show: Boolean) { + protected fun toggleProgressVisibility(show: Boolean) { progressBar.visibility = if (show) ProgressBar.VISIBLE else ProgressBar.GONE tabLayout.visibility = if (show) TabLayout.GONE else TabLayout.VISIBLE recyclerView.visibility = if (show) RecyclerView.GONE else RecyclerView.VISIBLE @@ -103,7 +140,7 @@ open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.Attribute * select first tab programmatically to load initial data * save the selected tab as preference */ - private fun setupTabLayout() { + protected fun setupTabLayout() { try { @@ -144,7 +181,7 @@ open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.Attribute protected open fun getSelected(): String? = null /** Handles loading data into the recycler view adapter. */ - private fun loadTab(label: String) { + protected fun loadTab(label: String) { val attributesLabel = getString(R.string.dialog_att_chooser_attributes) val traitsLabel = getString(R.string.dialog_att_chooser_traits) @@ -163,10 +200,10 @@ open class AttributeChooserDialog : DialogFragment(), AttributeAdapter.Attribute } override fun onAttributeClicked(label: String, position: Int) { - onAttributeSelectedListener?.onAttributeSelected(label) ?: run { + attributeSelectedListener?.onAttributeSelected(label) ?: run { Log.w(TAG, "No OnAttributeSelectedListener set for AttributeChooserDialog.") } dismiss() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/SearchAttributeChooserDialog.kt b/app/src/main/java/com/fieldbook/tracker/dialogs/SearchAttributeChooserDialog.kt new file mode 100644 index 000000000..66dcb3d25 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/dialogs/SearchAttributeChooserDialog.kt @@ -0,0 +1,148 @@ +// package com.fieldbook.tracker.dialogs + +// import android.app.AlertDialog +// import android.app.Dialog +// import android.os.Bundle +// import android.view.View +// import android.widget.CheckBox +// import com.fieldbook.tracker.R + +// /** +// * Dialog to choose a search attribute for a field. +// * Extends the AttributeChooserDialog to add a checkbox for applying the selection to all fields. +// */ +// class SearchAttributeChooserDialog : AttributeChooserDialog( +// showTraits = false, +// showOther = false, +// showSystemAttributes = false +// ) { + +// companion object { +// const val TAG = "SearchAttributeChooserDialog" +// } + +// interface OnSearchAttributeSelectedListener { +// fun onSearchAttributeSelected(label: String, applyToAll: Boolean) +// } + +// private var onSearchAttributeSelectedListener: OnSearchAttributeSelectedListener? = null +// private var applyAllCheckbox: CheckBox? = null + +// fun setOnSearchAttributeSelectedListener(listener: OnSearchAttributeSelectedListener) { +// onSearchAttributeSelectedListener = listener +// } + +// override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { +// // Create the base dialog +// val dialog = super.onCreateDialog(savedInstanceState) as AlertDialog + +// // Set the title +// dialog.setTitle(R.string.search_attribute_dialog_title) + +// // Find and show the checkbox +// dialog.findViewById(R.id.dialog_collect_att_chooser_checkbox)?.let { +// applyAllCheckbox = it +// it.visibility = View.VISIBLE +// } + +// return dialog +// } + +// override fun onAttributeClicked(label: String, position: Int) { +// val applyToAll = applyAllCheckbox?.isChecked ?: false +// onSearchAttributeSelectedListener?.onSearchAttributeSelected(label, applyToAll) +// dismiss() +// } +// } + +package com.fieldbook.tracker.dialogs + +import android.app.AlertDialog +import android.app.Dialog +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.CheckBox +import androidx.preference.PreferenceManager +import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.FieldEditorActivity +import com.fieldbook.tracker.preferences.GeneralKeys +import com.fieldbook.tracker.utilities.BackgroundUiTask + +/** + * Dialog to choose a search attribute for a field. + * Extends the AttributeChooserDialog to add a checkbox for applying the selection to all fields. + */ +class SearchAttributeChooserDialog : AttributeChooserDialog( + showTraits = false, + showOther = false, + showSystemAttributes = false +) { + + companion object { + const val TAG = "SearchAttributeDialog" + } + + interface OnSearchAttributeSelectedListener { + fun onSearchAttributeSelected(label: String, applyToAll: Boolean) + } + + private var onSearchAttributeSelectedListener: OnSearchAttributeSelectedListener? = null + + fun setOnSearchAttributeSelectedListener(listener: OnSearchAttributeSelectedListener) { + onSearchAttributeSelectedListener = listener + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + // Create the base dialog + val dialog = super.onCreateDialog(savedInstanceState) as AlertDialog + + // Set the title and show checkbox + dialog.setTitle(R.string.search_attribute_dialog_title) + applyAllCheckbox.visibility = View.VISIBLE + + // Override the loading process with our own + dialog.setOnShowListener { + toggleProgressVisibility(true) + BackgroundUiTask.execute( + backgroundBlock = { loadUniqueAttributes() }, + uiBlock = { + toggleProgressVisibility(false) + loadTab(getString(R.string.dialog_att_chooser_attributes)) + }, + onCanceled = { + toggleProgressVisibility(false) + setupTabLayout() + } + ) + } + + return dialog + } + + // Load unique attributes instead of regular attributes + private fun loadUniqueAttributes() { + try { + val prefs = PreferenceManager.getDefaultSharedPreferences(requireActivity()) + val activeFieldId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, -1) + val activity = requireActivity() as FieldEditorActivity + + // Get unique attributes from database + attributes = activity.getDatabase().getPossibleUniqueAttributes(activeFieldId)?.toTypedArray() ?: emptyArray() + + // Update the first tab's text + tabLayout.post { + tabLayout.getTabAt(0)?.text = getString(R.string.dialog_att_chooser_unique_attributes) + } + } catch (e: Exception) { + Log.d(TAG, "Error loading unique attributes: ${e.message}") + e.printStackTrace() + } + } + + override fun onAttributeClicked(label: String, position: Int) { + val applyToAll = applyAllCheckbox?.isChecked ?: false + onSearchAttributeSelectedListener?.onSearchAttributeSelected(label, applyToAll) + dismiss() + } +} diff --git a/app/src/main/java/com/fieldbook/tracker/objects/FieldObject.java b/app/src/main/java/com/fieldbook/tracker/objects/FieldObject.java index f686bca7b..019d41ad5 100644 --- a/app/src/main/java/com/fieldbook/tracker/objects/FieldObject.java +++ b/app/src/main/java/com/fieldbook/tracker/objects/FieldObject.java @@ -29,6 +29,7 @@ public class FieldObject { private String trait_count; private String observation_count; private String trial_name; + private String search_attribute; public String getTrial_name() { return trial_name; @@ -242,4 +243,11 @@ public String getObservation_count() { public void setObservation_count(String observation_count) { this.observation_count = observation_count; } + public String getSearch_attribute() { + return search_attribute; + } + + public void setSearch_attribute(String search_attribute) { + this.search_attribute = search_attribute; + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_tag_text.xml b/app/src/main/res/drawable/ic_tag_text.xml new file mode 100644 index 000000000..f0c0cb4a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_tag_text.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_collect_att_chooser.xml b/app/src/main/res/layout/dialog_collect_att_chooser.xml index c0db5d791..db2255c2c 100644 --- a/app/src/main/res/layout/dialog_collect_att_chooser.xml +++ b/app/src/main/res/layout/dialog_collect_att_chooser.xml @@ -46,5 +46,15 @@ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listitem="@layout/list_item_infobar" /> + + diff --git a/app/src/main/res/layout/dialog_field_edit_unique.xml b/app/src/main/res/layout/dialog_field_edit_unique.xml new file mode 100644 index 000000000..ccbdcf3fa --- /dev/null +++ b/app/src/main/res/layout/dialog_field_edit_unique.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_field_detail.xml b/app/src/main/res/layout/fragment_field_detail.xml index 560bb67ef..b37bb033c 100644 --- a/app/src/main/res/layout/fragment_field_detail.xml +++ b/app/src/main/res/layout/fragment_field_detail.xml @@ -182,6 +182,19 @@ app:chipStrokeColor="?attr/selectableChipStroke" app:chipStrokeWidth="2dp" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d886a00c..1402009bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -204,6 +204,14 @@ import name display name + Choose an attribute + Set Search ID + Unique Attributes + Apply choice to all eligible fields + Search attribute updated + Search attribute updated for %1$d field(s) + Found %1$d matching plots. Showing the first one. + Data