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))) - - } -} 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/ImportZPLFragment.kt b/app/src/main/java/org/phenoapps/intercross/fragments/ImportZPLFragment.kt index 33d5c2a7..40fdb6a5 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) { @@ -29,7 +32,10 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay mBinding.codeTextView.text = text - mPref.edit().putString(mKeyUtil.zplCodeKey, text).apply() + mPref.edit { + putString(mKeyUtil.zplTemplateKey, context?.getString(R.string.none) ?: "None") + putString(mKeyUtil.zplCodeKey, text) + } } } @@ -42,6 +48,9 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay show() } + // Setup template spinner + setupTemplateSpinner() + //import a file when button is pressed importButton.setOnClickListener { @@ -54,4 +63,60 @@ class ImportZPLFragment : IntercrossBaseFragment(R.lay if (code.isNotBlank()) codeTextView.text = code } + + 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(context?.getString(R.string.none) ?: "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) + } + + // Show the default template code in preview if nothing is saved yet + if (savedTemplateName.isBlank()) { + 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, context?.getString(R.string.none) ?: "None") } + } + } + + override fun onNothingSelected(parent: android.widget.AdapterView<*>?) { + // Do nothing + } + } + } } 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/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/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 9f27554e..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,94 +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) { - - - /*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, "") - - if (mBtName.isBlank()) { - - mBluetoothAdapter?.let { - - val pairedDevices = it.bondedDevices - - 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 - } + private fun choose(ctx: Context, f: (BluetoothDevice) -> Unit) { + val pref = PreferenceManager.getDefaultSharedPreferences(ctx) + val keyUtil = KeyUtil(ctx) - val builder = AlertDialog.Builder(ctx).apply { + val pairedDevices = getDevices(ctx) + val savedDeviceName = pref.getString(keyUtil.printerDeviceNameKey, "") ?: "" - setTitle("Choose bluetooth device to print from.") - - setView(input) - - setNegativeButton("Cancel") { _, _ -> + if (savedDeviceName.isNotBlank()) { + pairedDevices?.entries?.find { it.key == savedDeviceName }?.value?.let { savedDevice -> + f(savedDevice) + } + return + } - } + val map = HashMap>() + val input = RadioGroup(ctx) - setPositiveButton("OK") { _, _ -> + pairedDevices?.entries?.forEach { entry -> + val button = RadioButton(ctx) + button.text = entry.key + input.addView(button) + map[button.id] = entry + } - 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 ?: "" - } - 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 @@ -102,85 +87,67 @@ 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) - fun print(ctx: Context, events: Array) { + val selectedTemplateName = pref.getString(keyUtil.zplTemplateKey, "") ?: "" + val importedZpl = pref.getString(keyUtil.zplCodeKey, "") ?: "" - val pref = androidx.preference.PreferenceManager.getDefaultSharedPreferences(ctx) + // Check if user has explicitly imported custom ZPL (not just the default template) + val hasCustomZpl = importedZpl.isNotBlank() + && selectedTemplateName.equals(ctx.getString(R.string.none), ignoreCase = true) - choose(ctx) { - - val importedZpl = pref.getString(KeyUtil(ctx).zplCodeKey, "") ?: "" - - if (importedZpl.isNotBlank()) { + // If custom ZPL imported, use it + if (hasCustomZpl) { + onComplete(importedZpl) + return + } - PrintThread(ctx, importedZpl, mBtName).printEvents(events) + // If template already selected and it's a valid predefined template, use it + if (selectedTemplateName.isNotBlank() && !selectedTemplateName.equals(ctx.getString(R.string.none), ignoreCase = true)) { + ZplTemplate.getTemplateByDisplayName(ctx, selectedTemplateName)?.let { + onComplete(it.zplCode) + return + } + } - } else { + // 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) + } - PrintThread(ctx, template, mBtName).printEvents(events) + builder.show() + } + fun print(ctx: Context, events: Array) { + resolvePrintTemplate(ctx) { template -> + choose(ctx) { device -> + ZebraPrinterUtil(ctx, template, device).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) - + resolvePrintTemplate(ctx) { template -> + choose(ctx) { device -> + ZebraPrinterUtil(ctx, template, device).printParents(parents) } } } 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..c69c83fa 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,8 @@ 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 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 deleted file mode 100644 index 868d54e3..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^FDH, ${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^FDH, ${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 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;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 64ad92df..46b0a2f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -70,6 +70,11 @@ ^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 + Select ZPL Template + Please select a ZPL template for printing. + Choose Bluetooth Printer Device + ZPL Code Preview Import ZPL Female Male @@ -525,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 @@ -672,5 +680,7 @@ Graphs Parents Settings + Simple (2\\\" x 1\\\") + Simple (3\\\" x 2\\\") 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