From 47bddee8971a2352ce55a7784fd3099ddde2b6f6 Mon Sep 17 00:00:00 2001 From: chaneylc Date: Mon, 9 Mar 2026 17:18:40 -0500 Subject: [PATCH 1/6] added extra ZPL templates to settings --- .../intercross/fragments/ImportZPLFragment.kt | 73 ++++++++++++++++++- .../intercross/util/BluetoothUtil.kt | 53 +++++++------- .../org/phenoapps/intercross/util/KeyUtil.kt | 1 + .../phenoapps/intercross/util/ZplTemplate.kt | 48 ++++++++++++ .../main/res/layout/fragment_import_zpl.xml | 39 +++++++++- app/src/main/res/values/keys.xml | 1 + app/src/main/res/values/strings.xml | 4 + 7 files changed, 190 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/org/phenoapps/intercross/util/ZplTemplate.kt diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt index 33d5c2a7..69a76030 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt @@ -1,14 +1,17 @@ package org.phenoapps.intercross.fragments import android.content.SharedPreferences +import android.widget.ArrayAdapter import androidx.activity.result.contract.ActivityResultContracts import dagger.hilt.android.AndroidEntryPoint import org.phenoapps.intercross.R import org.phenoapps.intercross.activities.MainActivity import org.phenoapps.intercross.databinding.FragmentImportZplBinding import org.phenoapps.intercross.util.KeyUtil +import org.phenoapps.intercross.util.ZplTemplate import java.io.InputStreamReader import javax.inject.Inject +import androidx.core.content.edit @AndroidEntryPoint class ImportZPLFragment : IntercrossBaseFragment(R.layout.fragment_import_zpl) { @@ -19,6 +22,13 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay @Inject lateinit var mKeyUtil: KeyUtil + private val templates by lazy { + ZplTemplate.getDefaultTemplates(requireContext()) + } + + private val templateNames = templates.map { it.displayName }.toMutableList() + private val defaultTemplate = templates.firstOrNull { it.name == "template_2x1" } ?: templates.first() + private val importZplFile = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { @@ -29,7 +39,10 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay mBinding.codeTextView.text = text - mPref.edit().putString(mKeyUtil.zplCodeKey, text).apply() + mPref.edit { + putString(mKeyUtil.zplTemplateKey, "None") + putString(mKeyUtil.zplCodeKey, text) + } } } @@ -42,6 +55,9 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay show() } + // Setup template spinner + setupTemplateSpinner() + //import a file when button is pressed importButton.setOnClickListener { @@ -54,4 +70,59 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay if (code.isNotBlank()) codeTextView.text = code } + + private fun FragmentImportZplBinding.setupTemplateSpinner() { + // Add "None" option at the beginning for custom imported templates + val spinnerItems = mutableListOf("None") + spinnerItems.addAll(templateNames) + + val adapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_item, + spinnerItems + ) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + templateSpinner.adapter = adapter + + val savedTemplateName = mPref.getString(mKeyUtil.zplTemplateKey, "")?.trim().orEmpty() + val resolvedTemplateName = when { + savedTemplateName.isBlank() -> defaultTemplate.displayName + spinnerItems.contains(savedTemplateName) -> savedTemplateName + else -> defaultTemplate.displayName + } + + val selectedPosition = spinnerItems.indexOf(resolvedTemplateName) + if (selectedPosition >= 0) { + templateSpinner.setSelection(selectedPosition) + } + + // Ensure first load has a valid default template persisted. + if (savedTemplateName.isBlank() || !spinnerItems.contains(savedTemplateName)) { + mPref.edit { + putString(mKeyUtil.zplTemplateKey, defaultTemplate.displayName) + putString(mKeyUtil.zplCodeKey, defaultTemplate.zplCode) + } + codeTextView.text = defaultTemplate.zplCode + } + + // Handle template selection + templateSpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: android.view.View?, position: Int, id: Long) { + if (position > 0) { + val selectedTemplate = templates[position - 1] + codeTextView.text = selectedTemplate.zplCode + mPref.edit { + putString(mKeyUtil.zplTemplateKey, selectedTemplate.displayName) + putString(mKeyUtil.zplCodeKey, selectedTemplate.zplCode) + } + } else { + mPref.edit { putString(mKeyUtil.zplTemplateKey, "None") } + } + } + + override fun onNothingSelected(parent: android.widget.AdapterView<*>?) { + // Do nothing + } + } + } } diff --git a/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt b/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt index 9f27554e..d5a4291a 100644 --- a/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt +++ b/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt @@ -145,43 +145,42 @@ class BluetoothUtil { ^XZ" """*/ - fun print(ctx: Context, events: Array) { - + private fun resolvePrintTemplate(ctx: Context): String { val pref = androidx.preference.PreferenceManager.getDefaultSharedPreferences(ctx) + val keyUtil = KeyUtil(ctx) + + val selectedTemplateName = pref.getString(keyUtil.zplTemplateKey, "")?.trim().orEmpty() + val importedZpl = pref.getString(keyUtil.zplCodeKey, "")?.trim().orEmpty() + + val selectedTemplate = if ( + selectedTemplateName.isNotBlank() && + !selectedTemplateName.equals("None", ignoreCase = true) + ) { + ZplTemplate.getTemplateByDisplayName(ctx, selectedTemplateName) + } else { + null + } - choose(ctx) { - - val importedZpl = pref.getString(KeyUtil(ctx).zplCodeKey, "") ?: "" - - if (importedZpl.isNotBlank()) { - - PrintThread(ctx, importedZpl, mBtName).printEvents(events) - - } else { + return when { + selectedTemplate != null -> selectedTemplate.zplCode + importedZpl.isNotBlank() -> importedZpl + else -> template + } + } - PrintThread(ctx, template, mBtName).printEvents(events) + fun print(ctx: Context, events: Array) { - } + choose(ctx) { + val resolvedTemplate = resolvePrintTemplate(ctx) + PrintThread(ctx, resolvedTemplate, mBtName).printEvents(events) } } fun print(ctx: Context, parents: Array) { - val pref = androidx.preference.PreferenceManager.getDefaultSharedPreferences(ctx) - choose(ctx) { - - val importedZpl = pref.getString(KeyUtil(ctx).zplCodeKey, "") ?: "" - - if (importedZpl.isNotBlank()) { - - PrintThread(ctx, importedZpl, mBtName).printParents(parents) - - } else { - - PrintThread(ctx, template, mBtName).printParents(parents) - - } + val resolvedTemplate = resolvePrintTemplate(ctx) + PrintThread(ctx, resolvedTemplate, mBtName).printParents(parents) } } } \ No newline at end of file diff --git a/app/src/main/java/org/phenoapps/intercross/util/KeyUtil.kt b/app/src/main/java/org/phenoapps/intercross/util/KeyUtil.kt index 5ab25c7e..9d1ce9a9 100644 --- a/app/src/main/java/org/phenoapps/intercross/util/KeyUtil.kt +++ b/app/src/main/java/org/phenoapps/intercross/util/KeyUtil.kt @@ -64,6 +64,7 @@ class KeyUtil @Inject constructor( val printerConnectKey by key(R.string.key_pref_print_connect) val zplImportKey by key(R.string.key_pref_print_zpl_import) val zplCodeKey by key(R.string.key_pref_print_zpl_code) + val zplTemplateKey by key(R.string.key_pref_print_zpl_template) val databaseRoot by key(R.string.root_database) val dbStorageDefinerKey by key(R.string.key_pref_storage_definer) diff --git a/app/src/main/java/org/phenoapps/intercross/util/ZplTemplate.kt b/app/src/main/java/org/phenoapps/intercross/util/ZplTemplate.kt new file mode 100644 index 00000000..8ce731b9 --- /dev/null +++ b/app/src/main/java/org/phenoapps/intercross/util/ZplTemplate.kt @@ -0,0 +1,48 @@ +package org.phenoapps.intercross.util + +import android.content.Context +import org.phenoapps.intercross.R + +/** + * Data class representing a predefined ZPL label template + * + * Templates use Zebra field variables: + * FN1 = Cross/Parent ID (barcode and text) + * FN2 = Cross/Parent ID (display text) + * FN3 = Female parent ID + * FN4 = Male parent ID + * FN5 = Timestamp + * FN6 = Person who made the cross + * + * All templates must start with ^XA^DFR:TEMPLATE and end with ^XZ + * to match PrintThread's sendCommand() expectations. + */ +data class ZplTemplate( + val name: String, + val displayName: String, + val zplCode: String, +) { + companion object { + fun getDefaultTemplates(context: Context): List { + return listOf( + ZplTemplate( + name = "template_2x1", + displayName = context.getString(R.string.label_2x1_name), + zplCode = "^XA^DFR:TEMPLATE^FS^PW406^LH10,10^FS^FO0,0^A0,25,20^FN1^FS^FO140,30^BQN,2,3,H,^FN2^FS^FO140,170^A0,25,20^FN5^FS^XZ", + ), + ZplTemplate( + name = "template_3x2", + displayName = context.getString(R.string.label_3x2_name), + zplCode = "^XA^DFR:TEMPLATE^FS^PW609^LH10,10^FS^FO0,0^A0,35,28^FN1^FS^FO210,40^BQN,2,5,H,^FN2^FS^FO210,300^A0,32,24^FN5^FS^XZ", + ) + ) + } + + /** + * Get template by display name + */ + fun getTemplateByDisplayName(context: Context, displayName: String): ZplTemplate? { + return getDefaultTemplates(context).find { it.displayName == displayName } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_import_zpl.xml b/app/src/main/res/layout/fragment_import_zpl.xml index 8af75316..ff2413d6 100644 --- a/app/src/main/res/layout/fragment_import_zpl.xml +++ b/app/src/main/res/layout/fragment_import_zpl.xml @@ -33,6 +33,43 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + + + app:layout_constraintTop_toBottomOf="@+id/codeLabel" /> &package;PRINTER_CONNECT &package;ZPL_IMPORT &package;ZPL_CODE + &package;ZPL_TEMPLATE &package;STORAGE_DEFINER diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64ad92df..4e128e7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,6 +70,8 @@ ^XZ FN1–6 are ZPL fields used to pass data.\n\nRequired: The name of the template must be TEMPLATE as shown in the above example.\n\nFor basic cross prints\n\tFN1=the cross id\n\tFN2=the cross id but should be used for barcodes\n\tFN3=the female cross id\n\tFN4=the male cross id\n\tFN5=the timestamp of the cross\n\tFN6=the person who made the cross.\nNote: parent label prints cannot be customized yet.\nUse Labelary: http://labelary.com/viewer.html to define a label easily.\nThe template is designed for 2"x1" labels\nUse Zebra Printer Setup app to calibrate and define media settings. + Select Pre-defined Template + ZPL Code Preview Import ZPL Female Male @@ -672,5 +674,7 @@ Graphs Parents Settings + Simple (2\\\" x 1\\\") + Simple (3\\\" x 2\\\") From 6067b970415fdf97abf69cd69e7e7781839a2d98 Mon Sep 17 00:00:00 2001 From: chaneylc Date: Mon, 9 Mar 2026 17:51:46 -0500 Subject: [PATCH 2/6] added additional printing preferences --- .../intercross/fragments/ImportZPLFragment.kt | 20 +-- .../fragments/preferences/PrintingFragment.kt | 135 ++++++++++++++++++ .../intercross/util/BluetoothUtil.kt | 132 +++++++++-------- .../org/phenoapps/intercross/util/KeyUtil.kt | 1 + .../phenoapps/intercross/util/PrintThread.kt | 4 +- app/src/main/res/values/keys.xml | 1 + app/src/main/res/values/strings.xml | 6 + app/src/main/res/xml/printing_preferences.xml | 5 + 8 files changed, 227 insertions(+), 77 deletions(-) diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt index 69a76030..46d7a81b 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt @@ -22,13 +22,6 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay @Inject lateinit var mKeyUtil: KeyUtil - private val templates by lazy { - ZplTemplate.getDefaultTemplates(requireContext()) - } - - private val templateNames = templates.map { it.displayName }.toMutableList() - private val defaultTemplate = templates.firstOrNull { it.name == "template_2x1" } ?: templates.first() - private val importZplFile = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { @@ -72,6 +65,11 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay } private fun FragmentImportZplBinding.setupTemplateSpinner() { + + val templates = ZplTemplate.getDefaultTemplates(requireContext()) + val templateNames = templates.map { it.displayName }.toMutableList() + val defaultTemplate = templates.firstOrNull { it.name == "template_2x1" } ?: templates.first() + // Add "None" option at the beginning for custom imported templates val spinnerItems = mutableListOf("None") spinnerItems.addAll(templateNames) @@ -96,12 +94,8 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay templateSpinner.setSelection(selectedPosition) } - // Ensure first load has a valid default template persisted. - if (savedTemplateName.isBlank() || !spinnerItems.contains(savedTemplateName)) { - mPref.edit { - putString(mKeyUtil.zplTemplateKey, defaultTemplate.displayName) - putString(mKeyUtil.zplCodeKey, defaultTemplate.zplCode) - } + // Show the default template code in preview if nothing is saved yet + if (savedTemplateName.isBlank()) { codeTextView.text = defaultTemplate.zplCode } diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/preferences/PrintingFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/preferences/PrintingFragment.kt index b9001ffc..86b17672 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/preferences/PrintingFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/preferences/PrintingFragment.kt @@ -1,8 +1,18 @@ package org.phenoapps.intercross.fragments.preferences +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.view.View +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.Preference import org.phenoapps.intercross.R @@ -10,6 +20,17 @@ import androidx.core.net.toUri class PrintingFragment : BasePreferenceFragment(R.xml.printing_preferences) { + private val requestBluetoothPermissions = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { granted -> + if (granted.filter { it.value == false }.isNotEmpty()) { + Toast.makeText(context, R.string.error_no_bluetooth_permission, Toast.LENGTH_SHORT).show() + } else { + // Permissions granted, now show the device selection dialog + showDeviceSelectionDialog() + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -39,10 +60,124 @@ class PrintingFragment : BasePreferenceFragment(R.xml.printing_preferences) { } true } + + val devicePref = findPreference(getString(R.string.key_pref_print_device_name)) + devicePref?.let { + updateDevicePreferenceSummary(it) + it.setOnPreferenceClickListener { + checkBluetoothPermissionsAndShowDialog() + true + } + } + } + + private fun checkBluetoothPermissionsAndShowDialog() { + context?.let { ctx -> + var permit = true + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED + && ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + ) { + permit = true + } else { + permit = false + requestBluetoothPermissions.launch( + arrayOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT + ) + ) + } + } else { + if (ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED + && ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED + ) { + permit = true + } else { + permit = false + requestBluetoothPermissions.launch( + arrayOf( + android.Manifest.permission.BLUETOOTH, + android.Manifest.permission.BLUETOOTH_ADMIN + ) + ) + } + } + + if (permit) { + showDeviceSelectionDialog() + } + } + } + + @SuppressLint("MissingPermission") + private fun showDeviceSelectionDialog() { + val mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + + mBluetoothAdapter?.let { adapter -> + val pairedDevices = adapter.bondedDevices + + if (pairedDevices.isEmpty()) { + AlertDialog.Builder(requireContext()) + .setTitle(getString(R.string.choose_bluetooth_device_title)) + .setMessage(getString(R.string.no_device_paired)) + .setPositiveButton(android.R.string.ok, null) + .show() + return + } + + val deviceMap = HashMap() + val input = RadioGroup(requireContext()) + + pairedDevices.forEachIndexed { _, device -> + val button = RadioButton(requireContext()) + button.text = device.name + input.addView(button) + deviceMap[button.id] = device.name + } + + // Pre-select the currently saved device if it exists + val currentDevice = mPrefs.getString(mKeyUtil.printerDeviceNameKey, "") + pairedDevices.find { it.name == currentDevice }?.let { device -> + val existingButton = (0 until input.childCount) + .map { input.getChildAt(it) as RadioButton } + .find { it.text == device.name } + existingButton?.isChecked = true + } + + AlertDialog.Builder(requireContext()) + .setTitle(getString(R.string.choose_bluetooth_device_title)) + .setView(input) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + if (input.checkedRadioButtonId != -1) { + val selectedDevice = deviceMap[input.checkedRadioButtonId] + selectedDevice?.let { device -> + mPrefs.edit { putString(mKeyUtil.printerDeviceNameKey, device) } + findPreference(getString(R.string.key_pref_print_device_name))?.let { pref -> + updateDevicePreferenceSummary(pref) + } + } + } + } + .show() + } + } + + private fun updateDevicePreferenceSummary(pref: Preference) { + val deviceName = mPrefs.getString(mKeyUtil.printerDeviceNameKey, "") + pref.summary = if (deviceName.isNullOrBlank()) { + getString(R.string.prefs_zebra_device_summary) + } else { + getString(R.string.prefs_zebra_device_selected, deviceName) + } } override fun onResume() { super.onResume() setToolbar(getString(R.string.prefs_printing_title)) + val devicePref = findPreference(getString(R.string.key_pref_print_device_name)) + devicePref?.let { updateDevicePreferenceSummary(it) } } } diff --git a/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt b/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt index d5a4291a..d69f8583 100644 --- a/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt +++ b/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt @@ -7,6 +7,9 @@ import android.content.Context import android.widget.RadioButton import android.widget.RadioGroup import androidx.appcompat.app.AlertDialog +import androidx.preference.PreferenceManager +import androidx.core.content.edit +import org.phenoapps.intercross.R import org.phenoapps.intercross.data.models.Event import org.phenoapps.intercross.data.models.Parent @@ -24,28 +27,17 @@ class BluetoothUtil { //operation that uses the provided context to prompt the user for a paired bluetooth device @SuppressLint("MissingPermission") private fun choose(ctx: Context, f: () -> Unit) { + val pref = PreferenceManager.getDefaultSharedPreferences(ctx) + val keyUtil = KeyUtil(ctx) - - /*Filter out some classes of bluetooth devices - mBluetoothAdapter.bondedDevices.forEach { - when(it?.bluetoothClass?.majorDeviceClass) { - //BluetoothClass.Device.Major.AUDIO_VIDEO -> Log.d("BTAUDIO_VIDEO", it?.bluetoothClass.toString()) - BluetoothClass.Device.Major.COMPUTER -> Log.d("BTCOMPUTER", it.bluetoothClass.toString()) - //BluetoothClass.Device.Major.HEALTH -> Log.d("BTHEALTH", it?.bluetoothClass.toString()) - BluetoothClass.Device.Major.IMAGING -> Log.d("BTIMAGING", it.bluetoothClass.toString()) - BluetoothClass.Device.Major.MISC -> Log.d("BTMISC", it.bluetoothClass.toString()) - BluetoothClass.Device.Major.NETWORKING -> Log.d("BTNETWORKING", it.bluetoothClass.toString()) - BluetoothClass.Device.Major.PERIPHERAL -> Log.d("BTPERIPHERAL", it.bluetoothClass.toString()) - BluetoothClass.Device.Major.PHONE -> Log.d("BTPHONE", it.bluetoothClass.toString()) - //BluetoothClass.Device.Major.TOY -> Log.d("BTTOY", it?.bluetoothClass.toString()) - BluetoothClass.Device.Major.UNCATEGORIZED -> Log.d("BTUNCATEGORIZED", it.bluetoothClass.toString()) - BluetoothClass.Device.Major.WEARABLE -> Log.d("BTWEARABLE", it.bluetoothClass.toString()) - } - }*/ - - //val btId = pref.getString(SettingsActivity.BT_ID, "") - + // Check if device is already saved if (mBtName.isBlank()) { + val savedDeviceName = pref.getString(keyUtil.printerDeviceNameKey, "") ?: "" + if (savedDeviceName.isNotBlank()) { + mBtName = savedDeviceName + f() + return + } mBluetoothAdapter?.let { @@ -62,29 +54,20 @@ class BluetoothUtil { map[button.id] = t } - val builder = AlertDialog.Builder(ctx).apply { - - setTitle("Choose bluetooth device to print from.") - - setView(input) - - setNegativeButton("Cancel") { _, _ -> - - } - - setPositiveButton("OK") { _, _ -> - - if (input.checkedRadioButtonId == -1) return@setPositiveButton - else { - // PreferenceManager.getDefaultSharedPreferences(ctx).edit() - // .putString(SettingsActivity.BT_ID, map[input.checkedRadioButtonId]?.name) - // .apply() - mBtName = map[input.checkedRadioButtonId]?.name ?: "" + val builder = AlertDialog.Builder(ctx) + builder.setTitle(ctx.getString(R.string.choose_bluetooth_device_title)) + builder.setView(input) + builder.setNegativeButton(android.R.string.cancel) { _, _ -> } + builder.setPositiveButton(android.R.string.ok) { _, _ -> + if (input.checkedRadioButtonId != -1) { + mBtName = map[input.checkedRadioButtonId]?.name ?: "" + // Save the selected device to preferences + pref.edit { + putString(keyUtil.printerDeviceNameKey, mBtName) } f() } } - builder.show() } @@ -145,42 +128,67 @@ class BluetoothUtil { ^XZ" """*/ - private fun resolvePrintTemplate(ctx: Context): String { - val pref = androidx.preference.PreferenceManager.getDefaultSharedPreferences(ctx) + private fun resolvePrintTemplate(ctx: Context, onComplete: (String) -> Unit) { + val pref = PreferenceManager.getDefaultSharedPreferences(ctx) val keyUtil = KeyUtil(ctx) - val selectedTemplateName = pref.getString(keyUtil.zplTemplateKey, "")?.trim().orEmpty() - val importedZpl = pref.getString(keyUtil.zplCodeKey, "")?.trim().orEmpty() + val selectedTemplateName = pref.getString(keyUtil.zplTemplateKey, "") ?: "" + val importedZpl = pref.getString(keyUtil.zplCodeKey, "") ?: "" + + // Check if user has explicitly imported custom ZPL (not just the default template) + val hasCustomZpl = importedZpl.isNotBlank() && selectedTemplateName.equals("None", ignoreCase = true) + + // If custom ZPL imported, use it + if (hasCustomZpl) { + onComplete(importedZpl) + return + } - val selectedTemplate = if ( - selectedTemplateName.isNotBlank() && - !selectedTemplateName.equals("None", ignoreCase = true) - ) { - ZplTemplate.getTemplateByDisplayName(ctx, selectedTemplateName) - } else { - null + // If template already selected and it's a valid predefined template, use it + if (selectedTemplateName.isNotBlank() && !selectedTemplateName.equals("None", ignoreCase = true)) { + ZplTemplate.getTemplateByDisplayName(ctx, selectedTemplateName)?.let { + onComplete(it.zplCode) + return + } } - return when { - selectedTemplate != null -> selectedTemplate.zplCode - importedZpl.isNotBlank() -> importedZpl - else -> template + // If no template selected, show dialog + val templates = ZplTemplate.getDefaultTemplates(ctx) + val templateNames = templates.map { it.displayName }.toTypedArray() + + val builder = AlertDialog.Builder(ctx) + builder.setTitle(ctx.getString(R.string.select_zpl_template_title)) + builder.setSingleChoiceItems(templateNames, -1) { dialog, which -> + val selectedTemplate = templates[which] + pref.edit { + putString(keyUtil.zplTemplateKey, selectedTemplate.displayName) + putString(keyUtil.zplCodeKey, selectedTemplate.zplCode) + } + onComplete(selectedTemplate.zplCode) + dialog.dismiss() } + builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + // Use default template if user cancels + onComplete(template) + } + + builder.show() } fun print(ctx: Context, events: Array) { - - choose(ctx) { - val resolvedTemplate = resolvePrintTemplate(ctx) - PrintThread(ctx, resolvedTemplate, mBtName).printEvents(events) + resolvePrintTemplate(ctx) { template -> + choose(ctx) { + PrintThread(ctx, template, mBtName).printEvents(events) + } } } fun print(ctx: Context, parents: Array) { - - choose(ctx) { - val resolvedTemplate = resolvePrintTemplate(ctx) - PrintThread(ctx, resolvedTemplate, mBtName).printParents(parents) + resolvePrintTemplate(ctx) { template -> + choose(ctx) { + PrintThread(ctx, template, mBtName).printParents(parents) + } } } } \ No newline at end of file diff --git a/app/src/main/java/org/phenoapps/intercross/util/KeyUtil.kt b/app/src/main/java/org/phenoapps/intercross/util/KeyUtil.kt index 9d1ce9a9..c69c83fa 100644 --- a/app/src/main/java/org/phenoapps/intercross/util/KeyUtil.kt +++ b/app/src/main/java/org/phenoapps/intercross/util/KeyUtil.kt @@ -65,6 +65,7 @@ class KeyUtil @Inject constructor( val zplImportKey by key(R.string.key_pref_print_zpl_import) val zplCodeKey by key(R.string.key_pref_print_zpl_code) val zplTemplateKey by key(R.string.key_pref_print_zpl_template) + val printerDeviceNameKey by key(R.string.key_pref_print_device_name) val databaseRoot by key(R.string.root_database) val dbStorageDefinerKey by key(R.string.key_pref_storage_definer) diff --git a/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt b/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt index 868d54e3..2a032095 100644 --- a/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt +++ b/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt @@ -159,8 +159,8 @@ class PrintThread(private val ctx: Context, private val template: String, SGD.SET("device.languages", "zpl", connection) - Toast.makeText(ctx, - "$displayPrinterLanguage\nLanguage set to ZPL", Toast.LENGTH_LONG).show() + //Toast.makeText(ctx, + // "$displayPrinterLanguage\nLanguage set to ZPL", Toast.LENGTH_LONG).show() } } \ No newline at end of file diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 73a52930..140867d7 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -71,6 +71,7 @@ &package;ZPL_IMPORT &package;ZPL_CODE &package;ZPL_TEMPLATE + &package;PRINTER_DEVICE_NAME &package;STORAGE_DEFINER diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e128e7a..46b0a2f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,6 +71,9 @@ FN1–6 are ZPL fields used to pass data.\n\nRequired: The name of the template must be TEMPLATE as shown in the above example.\n\nFor basic cross prints\n\tFN1=the cross id\n\tFN2=the cross id but should be used for barcodes\n\tFN3=the female cross id\n\tFN4=the male cross id\n\tFN5=the timestamp of the cross\n\tFN6=the person who made the cross.\nNote: parent label prints cannot be customized yet.\nUse Labelary: http://labelary.com/viewer.html to define a label easily.\nThe template is designed for 2"x1" labels\nUse Zebra Printer Setup app to calibrate and define media settings. Select Pre-defined Template + Select ZPL Template + Please select a ZPL template for printing. + Choose Bluetooth Printer Device ZPL Code Preview Import ZPL Female @@ -527,6 +530,9 @@ Connect a Zebra printer. ZPL Import Import a ZPL file. + Choose Printer Device + Select a Bluetooth printer device for printing labels. + Selected: %s" Reset Database diff --git a/app/src/main/res/xml/printing_preferences.xml b/app/src/main/res/xml/printing_preferences.xml index 7c4ed9bb..b4f06403 100644 --- a/app/src/main/res/xml/printing_preferences.xml +++ b/app/src/main/res/xml/printing_preferences.xml @@ -13,5 +13,10 @@ android:key="@string/key_pref_print_zpl_import" android:title="@string/prefs_zebra_import_title" android:summary="@string/prefs_zebra_import_summary" /> + \ No newline at end of file From 92aa2eadb3c913f9b23f0446c42cad7cbb0da843 Mon Sep 17 00:00:00 2001 From: chaneylc Date: Tue, 10 Mar 2026 12:11:09 -0500 Subject: [PATCH 3/6] updated example template --- .../main/java/org/phenoapps/intercross/util/PrintThread.kt | 4 ++-- app/src/main/res/raw/example.zpl | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt b/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt index 2a032095..50d5ffd3 100644 --- a/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt +++ b/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt @@ -108,7 +108,7 @@ class PrintThread(private val ctx: Context, private val template: String, printer.sendCommand("^XA^XFR:TEMPLATE" + "^FN1^FD${it.eventDbId}^FS" + - "^FN2^FDH, ${it.eventDbId}^FS" + + "^FN2^FD${it.eventDbId}^FS" + "^FN3^FD${it.femaleObsUnitDbId}^FS" + "^FN4^FD${it.maleObsUnitDbId}^FS" + "^FN5^FD${timestamp}^FS" + @@ -120,7 +120,7 @@ class PrintThread(private val ctx: Context, private val template: String, printer.sendCommand("^XA^XFR:TEMPLATE" + "^FN1^FD${it.codeId}^FS" + - "^FN2^FDH, ${it.codeId}^FS" + + "^FN2^FD${it.codeId}^FS" + "^XZ") } } diff --git a/app/src/main/res/raw/example.zpl b/app/src/main/res/raw/example.zpl index b0f655a0..8a7f950d 100644 --- a/app/src/main/res/raw/example.zpl +++ b/app/src/main/res/raw/example.zpl @@ -1 +1 @@ -^XA^MNA^MMT,N^DFR:DEFAULT_INTERCROSS_SAMPLE.GRF^FS^FWR^FO50,25^A0,20,20^FB200,4,,c,^FN1^FS^FO150,30^BQ,,5,H^FN2^FS^FO325,15^A0,20,20^FB200,4,,c,^FN3^FS^XZ \ No newline at end of file +^XA^DFR:TEMPLATE^FS^PW406^LH10,10^FS^FO0,0^A0,25,20^FN1^FS^FO140,30^BQN,2,3,H,^FN2^FS^FO140,170^A0,25,20^FN5^FS^XZ From f08c26223a4b85fac5580b26331af495d62f70ed Mon Sep 17 00:00:00 2001 From: chaneylc Date: Tue, 10 Mar 2026 12:57:14 -0500 Subject: [PATCH 4/6] minor refactoring --- .../org/phenoapps/intercross/fragments/ImportZPLFragment.kt | 6 +++--- .../fragments/preferences/BehaviorPreferencesFragment.kt | 6 +++--- .../java/org/phenoapps/intercross/util/BluetoothUtil.kt | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt index 46d7a81b..40fdb6a5 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt @@ -33,7 +33,7 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay mBinding.codeTextView.text = text mPref.edit { - putString(mKeyUtil.zplTemplateKey, "None") + putString(mKeyUtil.zplTemplateKey, context?.getString(R.string.none) ?: "None") putString(mKeyUtil.zplCodeKey, text) } @@ -71,7 +71,7 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay val defaultTemplate = templates.firstOrNull { it.name == "template_2x1" } ?: templates.first() // Add "None" option at the beginning for custom imported templates - val spinnerItems = mutableListOf("None") + val spinnerItems = mutableListOf(context?.getString(R.string.none) ?: "None") spinnerItems.addAll(templateNames) val adapter = ArrayAdapter( @@ -110,7 +110,7 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay putString(mKeyUtil.zplCodeKey, selectedTemplate.zplCode) } } else { - mPref.edit { putString(mKeyUtil.zplTemplateKey, "None") } + mPref.edit { putString(mKeyUtil.zplTemplateKey, context?.getString(R.string.none) ?: "None") } } } diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/preferences/BehaviorPreferencesFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/preferences/BehaviorPreferencesFragment.kt index 1f0d9fe1..4c981c39 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/preferences/BehaviorPreferencesFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/preferences/BehaviorPreferencesFragment.kt @@ -92,9 +92,9 @@ class BehaviorPreferencesFragment : BasePreferenceFragment(R.xml.behavior_prefer settings?.let { findPreference(mKeyUtil.crossPatternKey)?.apply { summary = when { - settings.isPattern -> "Pattern" - !settings.isUUID && !settings.isPattern -> "None" - else -> "UUID" + settings.isPattern -> context.getString(R.string.pattern) + !settings.isUUID && !settings.isPattern -> context.getString(R.string.none) + else -> context.getString(R.string.uuid) } } } diff --git a/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt b/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt index d69f8583..a70bd753 100644 --- a/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt +++ b/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt @@ -136,7 +136,8 @@ class BluetoothUtil { val importedZpl = pref.getString(keyUtil.zplCodeKey, "") ?: "" // Check if user has explicitly imported custom ZPL (not just the default template) - val hasCustomZpl = importedZpl.isNotBlank() && selectedTemplateName.equals("None", ignoreCase = true) + val hasCustomZpl = importedZpl.isNotBlank() + && selectedTemplateName.equals(ctx.getString(R.string.none), ignoreCase = true) // If custom ZPL imported, use it if (hasCustomZpl) { @@ -145,7 +146,7 @@ class BluetoothUtil { } // If template already selected and it's a valid predefined template, use it - if (selectedTemplateName.isNotBlank() && !selectedTemplateName.equals("None", ignoreCase = true)) { + if (selectedTemplateName.isNotBlank() && !selectedTemplateName.equals(ctx.getString(R.string.none), ignoreCase = true)) { ZplTemplate.getTemplateByDisplayName(ctx, selectedTemplateName)?.let { onComplete(it.zplCode) return From 614d67a69470464a89d7cf8e45e31b9ee126a20f Mon Sep 17 00:00:00 2001 From: chaneylc Date: Wed, 11 Mar 2026 13:48:57 -0500 Subject: [PATCH 5/6] minor refactoring improved old deprecated bluetooth adapter usage added feedback when pressing print button fixed bug where male parents could not be printed --- app/src/main/AndroidManifest.xml | 1 + .../fragments/EventDetailFragment.kt | 19 +- .../intercross/fragments/ParentsFragment.kt | 124 ++++++------- .../intercross/util/BluetoothUtil.kt | 139 ++++++--------- .../phenoapps/intercross/util/PrintThread.kt | 166 ------------------ .../phenoapps/intercross/util/VibrateUtil.kt | 32 ++++ .../intercross/util/ZebraPrinterUtil.kt | 137 +++++++++++++++ 7 files changed, 287 insertions(+), 331 deletions(-) delete mode 100644 app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt create mode 100644 app/src/main/java/org/phenoapps/intercross/util/VibrateUtil.kt create mode 100644 app/src/main/java/org/phenoapps/intercross/util/ZebraPrinterUtil.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e51eaff..95a46629 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/EventDetailFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/EventDetailFragment.kt index 62a9a449..73f8a9a4 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/EventDetailFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/EventDetailFragment.kt @@ -47,6 +47,7 @@ import org.phenoapps.intercross.util.BluetoothUtil import org.phenoapps.intercross.util.Dialogs import org.phenoapps.intercross.util.FileUtil import org.phenoapps.intercross.util.KeyUtil +import org.phenoapps.intercross.util.VibrateUtil import org.phenoapps.intercross.util.observeOnce import javax.inject.Inject @@ -55,14 +56,14 @@ class EventDetailFragment: IntercrossBaseFragment(R.layout.fragment_event_detail), MetadataManager { - private val requestBluetoothPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> + @Inject + lateinit var vibrateUtil: VibrateUtil - granted?.let { grant -> + private val requestBluetoothPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> - if (grant.filter { it.value == false }.isNotEmpty()) { + if (granted.filter { !it.value }.isNotEmpty()) { - Toast.makeText(context, R.string.error_no_bluetooth_permission, Toast.LENGTH_SHORT).show() - } + Toast.makeText(context, R.string.error_no_bluetooth_permission, Toast.LENGTH_SHORT).show() } } @@ -401,7 +402,7 @@ class EventDetailFragment: private fun startPrintProcess() { context?.let { ctx -> - var permit = true + var permit = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) { @@ -412,7 +413,7 @@ class EventDetailFragment: android.Manifest.permission.BLUETOOTH_CONNECT )) } - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + } else if (ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED && ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED) { permit = true @@ -422,12 +423,12 @@ class EventDetailFragment: android.Manifest.permission.BLUETOOTH_ADMIN )) } - } if (permit) { - BluetoothUtil().print(requireContext(), arrayOf(mEvent)) + BluetoothUtil().print(ctx, arrayOf(mEvent)) + vibrateUtil.vibrate() } } } diff --git a/app/src/main/java/org/phenoapps/intercross/fragments/ParentsFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/ParentsFragment.kt index 6fa1af97..65d1e74e 100644 --- a/app/src/main/java/org/phenoapps/intercross/fragments/ParentsFragment.kt +++ b/app/src/main/java/org/phenoapps/intercross/fragments/ParentsFragment.kt @@ -1,5 +1,6 @@ package org.phenoapps.intercross.fragments +import android.content.Context import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build @@ -42,6 +43,7 @@ import org.phenoapps.intercross.util.BluetoothUtil import org.phenoapps.intercross.util.Dialogs import org.phenoapps.intercross.util.ImportUtil import org.phenoapps.intercross.util.KeyUtil +import org.phenoapps.intercross.util.VibrateUtil import javax.inject.Inject @AndroidEntryPoint @@ -50,12 +52,9 @@ class ParentsFragment: IntercrossBaseFragment(R.layout.f private val requestBluetoothPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> - granted?.let { grant -> + if (granted.filter { !it.value }.isNotEmpty()) { - if (grant.filter { it.value == false }.isNotEmpty()) { - - Toast.makeText(context, R.string.error_no_bluetooth_permission, Toast.LENGTH_SHORT).show() - } + Toast.makeText(context, R.string.error_no_bluetooth_permission, Toast.LENGTH_SHORT).show() } } @@ -75,6 +74,9 @@ class ParentsFragment: IntercrossBaseFragment(R.layout.f ParentsListViewModelFactory(ParentsRepository.getInstance(db.parentsDao())) } + @Inject + lateinit var vibrateUtil: VibrateUtil + @Inject lateinit var mPref: SharedPreferences @@ -425,44 +427,6 @@ class ParentsFragment: IntercrossBaseFragment(R.layout.f } } - private fun checkBluetoothRuntimePermission(): Boolean { - - var permit = true - - context?.let { ctx -> - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED - && ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED - ) { - permit = true - } else { - requestBluetoothPermissions.launch( - arrayOf( - android.Manifest.permission.BLUETOOTH_SCAN, - android.Manifest.permission.BLUETOOTH_CONNECT - ) - ) - } - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED - && ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED - ) { - permit = true - } else { - requestBluetoothPermissions.launch( - arrayOf( - android.Manifest.permission.BLUETOOTH, - android.Manifest.permission.BLUETOOTH_ADMIN - ) - ) - } - } - } - - return permit - } - private fun FragmentParentsBinding.setupBottomNavBar() { bottomNavBar.setOnNavigationItemSelectedListener { item -> @@ -494,37 +458,65 @@ class ParentsFragment: IntercrossBaseFragment(R.layout.f } } - private fun FragmentParentsBinding.printParents() { + private fun FragmentParentsBinding.requestPermissionAndPrintParents() { - if (tabLayout.getTabAt(0)?.isSelected == true) { + context?.let { ctx -> - val outParents = mFemaleAdapter.currentList.filterIsInstance(Parent::class.java) + var permit = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED + && ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) { + permit = true + } else { + requestBluetoothPermissions.launch(arrayOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT + )) + } + } else + if (ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED + && ctx.checkSelfPermission(android.Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED) { + permit = true + } else { + requestBluetoothPermissions.launch(arrayOf( + android.Manifest.permission.BLUETOOTH, + android.Manifest.permission.BLUETOOTH_ADMIN + )) + } + + if (permit) { + printParents(ctx) + } + } + } + + private fun FragmentParentsBinding.printParents(ctx: Context) { + + val outParents: List = if (tabLayout.getTabAt(0)?.isSelected == true) { + + mFemaleAdapter.currentList.filterIsInstance() + + } else { + + val outParents = mMaleAdapter.currentList + .filterIsInstance() + + outParents + mMaleAdapter.currentList + .filterIsInstance() + .map { group -> Parent(group.codeId, 1, group.name)} + } + + if (outParents.isNotEmpty()) { BluetoothUtil().print( - requireContext(), + ctx, outParents.filter { p -> p.selected }.toTypedArray() ) + + vibrateUtil.vibrate() } } -// override fun onCreate(savedInstanceState: Bundle?) { -// -// } else { -// -// val outParents = mMaleAdapter.currentList -// .filterIsInstance(Parent::class.java) -// .filter { p -> p.selected } -// -// val outAll = outParents + mMaleAdapter.currentList -// .filterIsInstance(PollenGroup::class.java) -// .filter { p -> p.selected } -// .map { group -> Parent(group.codeId, 1, group.name) } -// -// BluetoothUtil().print(requireContext(), outAll.toTypedArray()) -// -// } -// } - override fun onResume() { super.onResume() @@ -545,7 +537,7 @@ class ParentsFragment: IntercrossBaseFragment(R.layout.f } R.id.action_parents_print -> { - mBinding.printParents() + mBinding.requestPermissionAndPrintParents() } android.R.id.home -> { diff --git a/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt b/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt index a70bd753..ba05e2d8 100644 --- a/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt +++ b/app/src/main/java/org/phenoapps/intercross/util/BluetoothUtil.kt @@ -1,77 +1,79 @@ package org.phenoapps.intercross.util -import android.annotation.SuppressLint -import android.bluetooth.BluetoothAdapter +import android.Manifest import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.widget.RadioButton import android.widget.RadioGroup import androidx.appcompat.app.AlertDialog +import androidx.core.app.ActivityCompat import androidx.preference.PreferenceManager import androidx.core.content.edit import org.phenoapps.intercross.R import org.phenoapps.intercross.data.models.Event import org.phenoapps.intercross.data.models.Parent +import kotlin.collections.forEach //Bluetooth Utility class for printing ZPL code and choosing bluetooth devices to print from. class BluetoothUtil { - private var mBtName: String = String() + private fun getDevices(ctx: Context): Map? { + val bluetoothManager = ctx.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ActivityCompat.checkSelfPermission( + ctx, + Manifest.permission.BLUETOOTH_CONNECT + ) != PackageManager.PERMISSION_GRANTED + ) return null + } - private val mBluetoothAdapter: BluetoothAdapter? by lazy { - BluetoothAdapter.getDefaultAdapter() + return bluetoothManager.adapter?.bondedDevices?.associate { it.name to it } } //suppressed false positive lint message, permissions is checked on runtime before thread is launched //operation that uses the provided context to prompt the user for a paired bluetooth device - @SuppressLint("MissingPermission") - private fun choose(ctx: Context, f: () -> Unit) { + private fun choose(ctx: Context, f: (BluetoothDevice) -> Unit) { val pref = PreferenceManager.getDefaultSharedPreferences(ctx) val keyUtil = KeyUtil(ctx) - // Check if device is already saved - if (mBtName.isBlank()) { - val savedDeviceName = pref.getString(keyUtil.printerDeviceNameKey, "") ?: "" - if (savedDeviceName.isNotBlank()) { - mBtName = savedDeviceName - f() - return - } - - mBluetoothAdapter?.let { + val pairedDevices = getDevices(ctx) + val savedDeviceName = pref.getString(keyUtil.printerDeviceNameKey, "") ?: "" - val pairedDevices = it.bondedDevices - - val map = HashMap() + if (savedDeviceName.isNotBlank()) { + pairedDevices?.entries?.find { it.key == savedDeviceName }?.value?.let { savedDevice -> + f(savedDevice) + } + return + } - val input = RadioGroup(ctx) + val map = HashMap>() + val input = RadioGroup(ctx) - pairedDevices.forEachIndexed { _, t -> - val button = RadioButton(ctx) - button.text = t.name - input.addView(button) - map[button.id] = t - } + pairedDevices?.entries?.forEach { entry -> + val button = RadioButton(ctx) + button.text = entry.key + input.addView(button) + map[button.id] = entry + } - val builder = AlertDialog.Builder(ctx) - builder.setTitle(ctx.getString(R.string.choose_bluetooth_device_title)) - builder.setView(input) - builder.setNegativeButton(android.R.string.cancel) { _, _ -> } - builder.setPositiveButton(android.R.string.ok) { _, _ -> - if (input.checkedRadioButtonId != -1) { - mBtName = map[input.checkedRadioButtonId]?.name ?: "" - // Save the selected device to preferences - pref.edit { - putString(keyUtil.printerDeviceNameKey, mBtName) - } - f() - } + val builder = AlertDialog.Builder(ctx) + builder.setTitle(ctx.getString(R.string.choose_bluetooth_device_title)) + builder.setView(input) + builder.setNegativeButton(android.R.string.cancel) { _, _ -> } + builder.setPositiveButton(android.R.string.ok) { _, _ -> + if (input.checkedRadioButtonId != -1) { + val entry = map[input.checkedRadioButtonId] ?: return@setPositiveButton + pref.edit { + putString(keyUtil.printerDeviceNameKey, entry.key) } - builder.show() + f(entry.value) } - - } else f() + } + builder.show() } //new smaller template @@ -85,49 +87,6 @@ class BluetoothUtil { ^XZ """.trimIndent() - //qr code with magnification 5 is about 150dots which is <1in - //ZQ510 printer is 208 dots/in, 8dots/mm - //command to store the template format -//Old template -// private var template = "^XA" + //start of ZPL command -// "^MNA^MMT,N" + //set as non-continuous label -// "^DFR:TEMPLATE.ZPL^FS" + //download format as TEMPLATE.ZPL -// "^FO75,0^BQN,2,4,H^FN1^FS" + //qr code for code id -// "^A0N,32,32" + //sets font -// "^FO250,0" + -// "^FB300,1,1,L,0^FN2^FS" + -// "^A0N,32,32" + //sets font -// "^FO250,50" + -// "^FB300,1,1,L,0^FN3^FS" + -// "^A0N,32,32" + //sets font -// "^FO250,100" + -// "^FB300,1,1,L,0^FN4^FS" + -// "^A0N,32,32" + //sets font -// "^FO250,150" + -// "^FB300,1,1,L,0^FN5^FS" + -// "^A0N,32,32" + //sets font -// "^FO250,200" + -// "^FB300,1,1,L,0^FN1^FS" + -// "^XZ" - - /*var template = """ - ^XA - ^MNA - ^MMT,N - ^DFR:DEFAULT_INTERCROSS_SAMPLE.GRF^FS - ^FWR - ^FO50,25 - ^A0,20,20 - ^FN1^FS - ^FO150,30 - ^BQ,,5,H - ^FN2^FS - ^FO400,25 - ^A0,25,20 - ^FN3^FS - ^XZ" - """*/ - private fun resolvePrintTemplate(ctx: Context, onComplete: (String) -> Unit) { val pref = PreferenceManager.getDefaultSharedPreferences(ctx) val keyUtil = KeyUtil(ctx) @@ -179,16 +138,16 @@ class BluetoothUtil { fun print(ctx: Context, events: Array) { resolvePrintTemplate(ctx) { template -> - choose(ctx) { - PrintThread(ctx, template, mBtName).printEvents(events) + choose(ctx) { device -> + ZebraPrinterUtil(ctx, template, device).printEvents(events) } } } fun print(ctx: Context, parents: Array) { resolvePrintTemplate(ctx) { template -> - choose(ctx) { - PrintThread(ctx, template, mBtName).printParents(parents) + choose(ctx) { device -> + ZebraPrinterUtil(ctx, template, device).printParents(parents) } } } diff --git a/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt b/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt deleted file mode 100644 index 50d5ffd3..00000000 --- a/app/src/main/java/org/phenoapps/intercross/util/PrintThread.kt +++ /dev/null @@ -1,166 +0,0 @@ -package org.phenoapps.intercross.util - -import android.annotation.SuppressLint -import android.bluetooth.BluetoothAdapter -import android.content.Context -import android.os.Looper -import android.widget.Toast -import com.zebra.sdk.comm.BluetoothConnection -import com.zebra.sdk.comm.ConnectionException -import com.zebra.sdk.printer.SGD -import com.zebra.sdk.printer.ZebraPrinterFactory -import com.zebra.sdk.printer.ZebraPrinterLanguageUnknownException -import org.phenoapps.intercross.R -import org.phenoapps.intercross.data.models.Event -import org.phenoapps.intercross.data.models.Parent -import org.phenoapps.intercross.data.models.PollenGroup -import java.util.* - - -class PrintThread(private val ctx: Context, private val template: String, - private val btName: String) : Thread() { - - private var mMode = 0 - private lateinit var mEvents: Array - private lateinit var mParents: Array - - fun printEvents(events: Array) { - mEvents = events - mMode = 0 - start() - } - - fun printParents(parents: Array) { - mParents = parents - mMode = 1 - start() - } - - fun printGroup(groups: Array) { - mParents = groups.map { g -> Parent(g.codeId, 1, g.name) }.toTypedArray() - mMode = 1 - start() - } - - //suppressed false positive lint message, permissions is checked on runtime before thread is launched - @SuppressLint("MissingPermission") - override fun run() { - - Looper.prepare() - - if (btName.isBlank()) { - - val notPaired = ctx.getString(R.string.no_device_paired) - - Toast.makeText(ctx, notPaired, Toast.LENGTH_SHORT).show() - - } else { - - val mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter() - - val pairedDevices = mBluetoothAdapter.bondedDevices.filter { - it.name == btName - } - - if (pairedDevices.isNotEmpty()) { - - val bc = BluetoothConnection(pairedDevices[0].address) - - try { - - bc.open() - - val printer = ZebraPrinterFactory.getInstance(bc) - - val linkOsPrinter = ZebraPrinterFactory.createLinkOsPrinter(printer) - - linkOsPrinter?.let { it -> - - val printerStatus = it.currentStatus - - getPrinterStatus(bc) - - val printerOpen = ctx.getString(R.string.printer_open) - val printerPaused = ctx.getString(R.string.printer_paused) - val noPaper = ctx.getString(R.string.printer_empty) - val notConnected = ctx.getString(R.string.printner_not_connected) - - if (printerStatus.isReadyToPrint) { - - if (template.isNotBlank()) { - - printer.sendCommand(template) - - } - - when (mMode) { - 0 -> { - - mEvents.forEach { - - var timestamp = it.timestamp - - if ("_" in timestamp) { - - timestamp = timestamp.split("_")[0] - - } - - printer.sendCommand("^XA^XFR:TEMPLATE" + - "^FN1^FD${it.eventDbId}^FS" + - "^FN2^FD${it.eventDbId}^FS" + - "^FN3^FD${it.femaleObsUnitDbId}^FS" + - "^FN4^FD${it.maleObsUnitDbId}^FS" + - "^FN5^FD${timestamp}^FS" + - "^FN6^FD${it.person}^FS^XZ") - } - } - 1 -> { - mParents.forEach { - - printer.sendCommand("^XA^XFR:TEMPLATE" + - "^FN1^FD${it.codeId}^FS" + - "^FN2^FD${it.codeId}^FS" + - "^XZ") - } - } - } - - /*printer.printImage(new ZebraImageAndroid(BitmapFactory.decodeResource(getApplicationContext().getResources(), - R.drawable.intercross_small)), 75,500,-1,-1,false);*/ - - } else if (printerStatus.isHeadOpen) { - Toast.makeText(ctx, printerOpen, Toast.LENGTH_LONG).show() - } else if (printerStatus.isPaused) { - Toast.makeText(ctx, printerPaused, Toast.LENGTH_LONG).show() - } else if (printerStatus.isPaperOut) { - Toast.makeText(ctx, noPaper, Toast.LENGTH_LONG).show() - } else { - Toast.makeText(ctx, notConnected, Toast.LENGTH_LONG).show() - } - } - } catch (e: ConnectionException) { - e.printStackTrace() - } catch (e: ZebraPrinterLanguageUnknownException) { - e.printStackTrace() - } finally { - bc.close() - } - } - } - } - - @Throws(ConnectionException::class) - private fun getPrinterStatus(connection: BluetoothConnection) { - - val printerLanguage = SGD.GET("device.languages", connection) - - val displayPrinterLanguage = "Printer Language is $printerLanguage" - - SGD.SET("device.languages", "zpl", connection) - - //Toast.makeText(ctx, - // "$displayPrinterLanguage\nLanguage set to ZPL", Toast.LENGTH_LONG).show() - - } -} \ No newline at end of file diff --git a/app/src/main/java/org/phenoapps/intercross/util/VibrateUtil.kt b/app/src/main/java/org/phenoapps/intercross/util/VibrateUtil.kt new file mode 100644 index 00000000..26b4c520 --- /dev/null +++ b/app/src/main/java/org/phenoapps/intercross/util/VibrateUtil.kt @@ -0,0 +1,32 @@ +package org.phenoapps.intercross.util + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class VibrateUtil @Inject constructor(@ApplicationContext context: Context) { + + companion object { + const val DEFAULT_TIME = 3000L + } + + private val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + + fun vibrate(duration: Long = DEFAULT_TIME) { + if (vibrator.hasVibrator()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate( + VibrationEffect.createWaveform( + longArrayOf(1L, 5L, 8L, 5L, 1L), + -1 + ) + ) + } else { + vibrator.vibrate(duration) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/phenoapps/intercross/util/ZebraPrinterUtil.kt b/app/src/main/java/org/phenoapps/intercross/util/ZebraPrinterUtil.kt new file mode 100644 index 00000000..f2ffb994 --- /dev/null +++ b/app/src/main/java/org/phenoapps/intercross/util/ZebraPrinterUtil.kt @@ -0,0 +1,137 @@ +package org.phenoapps.intercross.util + +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.widget.Toast +import com.zebra.sdk.comm.BluetoothConnection +import com.zebra.sdk.comm.ConnectionException +import com.zebra.sdk.printer.SGD +import com.zebra.sdk.printer.ZebraPrinterFactory +import com.zebra.sdk.printer.ZebraPrinterLanguageUnknownException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.phenoapps.intercross.R +import org.phenoapps.intercross.data.models.Event +import org.phenoapps.intercross.data.models.Parent +import org.phenoapps.intercross.data.models.PollenGroup + +class ZebraPrinterUtil( + private val ctx: Context, + private val template: String, + private val printerDevice: BluetoothDevice +) { + + sealed class PrintMode { + object Events : PrintMode() + object Parents : PrintMode() + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun printEvents(events: Array) { + scope.launch { + runPrint(printMode = PrintMode.Events, events = events) + } + } + + fun printParents(parents: Array) { + scope.launch { + runPrint(printMode = PrintMode.Parents, parents = parents) + } + } + + fun printGroup(groups: Array) { + val parents = groups.map { group -> Parent(group.codeId, 1, group.name) }.toTypedArray() + scope.launch { + runPrint(printMode = PrintMode.Parents, parents = parents) + } + } + + private suspend fun runPrint( + printMode: PrintMode, + events: Array = emptyArray(), + parents: Array = emptyArray() + ) { + var connection: BluetoothConnection? = null + + try { + connection = BluetoothConnection(printerDevice.address) + connection.open() + + val printer = ZebraPrinterFactory.getInstance(connection) + val linkOsPrinter = ZebraPrinterFactory.createLinkOsPrinter(printer) + + linkOsPrinter?.let { + val printerStatus = it.currentStatus + + getPrinterStatus(connection) + + if (printerStatus.isReadyToPrint) { + if (template.isNotBlank()) { + printer.sendCommand(template) + } + + when (printMode) { + PrintMode.Events -> { + events.forEach { event -> + var timestamp = event.timestamp + if ("_" in timestamp) { + timestamp = timestamp.split("_")[0] + } + + printer.sendCommand( + "^XA^XFR:TEMPLATE" + + "^FN1^FD${event.eventDbId}^FS" + + "^FN2^FDQA,${event.eventDbId}^FS" + + "^FN3^FD${event.femaleObsUnitDbId}^FS" + + "^FN4^FD${event.maleObsUnitDbId}^FS" + + "^FN5^FD${timestamp}^FS" + + "^FN6^FD${event.person}^FS^XZ" + ) + } + } + + PrintMode.Parents -> { + parents.forEach { parent -> + printer.sendCommand( + "^XA^XFR:TEMPLATE" + + "^FN1^FD${parent.codeId}^FS" + + "^FN2^FDQA,${parent.codeId}^FS" + + "^XZ" + ) + } + } + } + } else if (printerStatus.isHeadOpen) { + showToast(ctx.getString(R.string.printer_open)) + } else if (printerStatus.isPaused) { + showToast(ctx.getString(R.string.printer_paused)) + } else if (printerStatus.isPaperOut) { + showToast(ctx.getString(R.string.printer_empty)) + } else { + showToast(ctx.getString(R.string.printner_not_connected)) + } + } + } catch (e: ConnectionException) { + e.printStackTrace() + } catch (e: ZebraPrinterLanguageUnknownException) { + e.printStackTrace() + } finally { + connection?.close() + } + } + + private suspend fun showToast(message: String) { + withContext(Dispatchers.Main) { + Toast.makeText(ctx, message, Toast.LENGTH_LONG).show() + } + } + + @Throws(ConnectionException::class) + private fun getPrinterStatus(connection: BluetoothConnection) { + SGD.SET("device.languages", "zpl", connection) + } +} \ No newline at end of file From 8788112c69bc0b07dace189f77903105b1619026 Mon Sep 17 00:00:00 2001 From: chaneylc Date: Wed, 11 Mar 2026 13:55:38 -0500 Subject: [PATCH 6/6] removed old test --- .../intercross/test/NavigationTests.kt | 94 ------------------- 1 file changed, 94 deletions(-) delete mode 100644 app/src/androidTest/java/org/phenoapps/intercross/test/NavigationTests.kt diff --git a/app/src/androidTest/java/org/phenoapps/intercross/test/NavigationTests.kt b/app/src/androidTest/java/org/phenoapps/intercross/test/NavigationTests.kt deleted file mode 100644 index 8a20fe3b..00000000 --- a/app/src/androidTest/java/org/phenoapps/intercross/test/NavigationTests.kt +++ /dev/null @@ -1,94 +0,0 @@ -package org.phenoapps.intercross.test - -import android.content.Intent -import android.view.Gravity -import androidx.navigation.NavController -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.* -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.contrib.DrawerActions -import androidx.test.espresso.contrib.DrawerMatchers.isClosed -import androidx.test.espresso.contrib.NavigationViewActions -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.rule.ActivityTestRule -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.mockito.Mockito.mock -import org.phenoapps.intercross.activities.MainActivity -import org.phenoapps.intercross.R -import java.util.* - -class NavigationTests { - - private lateinit var maleString: String - private lateinit var femaleString: String - private lateinit var crossString: String - - @get:Rule - var activityRule: ActivityTestRule - = ActivityTestRule(MainActivity::class.java) - @Before - fun initValidString() { - - maleString = UUID.randomUUID().toString() - - femaleString = UUID.randomUUID().toString() - - crossString = UUID.randomUUID().toString() - } - - private fun openNavDrawer() { - - onView(withId(R.id.drawer_layout)) - .check(matches(isClosed(Gravity.LEFT))) - .perform(DrawerActions.open()) - } - - @Test - fun testEventsToParentsFragment() { - - // Create a TestNavHostController - val navController = mock(NavController::class.java) - - activityRule.launchActivity(Intent()) - -// // Create a graphical FragmentScenario for the TitleScreen -// val titleScenario = launchFragmentInContainer() -// -// // Set the NavController property on the fragment -// titleScenario.onFragment { fragment -> -// Navigation.setViewNavController(fragment.requireView(), navController) -// } - - openNavDrawer() - - // Verify that performing a click changes the NavController’s state - onView(withId(R.id.nvView)) - .perform(NavigationViewActions.navigateTo(R.id.action_nav_parents)) - - assert(navController.currentDestination?.id == R.id.parents_fragment) - - } - - @Test - fun testEventsFragment() { - - // Type text and then press the button. - onView(withId(R.id.firstText)) - .perform(typeText(femaleString), closeSoftKeyboard()) - - onView(withId(R.id.secondText)) - .perform(typeText(maleString), closeSoftKeyboard()) - - onView(withId(R.id.firstText)) - .perform(typeText(crossString), closeSoftKeyboard()) - - onView(withId(R.id.saveButton)).perform(click()) - -// // Check that the text was changed. -// onView(withId(R.id.textToBeChanged)) -// .check(matches(withText(stringToBetyped))) - - } -}