diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff87d2aa58ef..5726646f567d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,11 @@ dependencies { implementation("io.reactivex.rxjava2:rxkotlin:2.4.0") implementation("io.reactivex.rxjava2:rxjava:2.2.20") implementation("io.reactivex.rxjava2:rxandroid:2.1.1") + implementation("com.jakewharton.rxrelay2:rxrelay:2.1.1") + + //Rx optional + implementation("com.gojuno.koptional:koptional:1.7.0") + implementation("com.gojuno.koptional:koptional-rxjava2-extensions:1.7.0") //Networking implementation("com.squareup.retrofit2:retrofit:2.6.4") diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/productlist/ProductListActivity.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/productlist/ProductListActivity.kt index ddda2bb669ec..a03d8b3834f0 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/productlist/ProductListActivity.kt +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/productlist/ProductListActivity.kt @@ -387,17 +387,6 @@ class ProductListActivity : BaseActivity(), SwipeController.Actions { return notificationManager } - fun getProductBrandsQuantityDetails(brands: String?, quantity: String?): String { - val builder = StringBuilder() - if (!brands.isNullOrEmpty()) { - builder.append(brands.split(",")[0].trim { it <= ' ' }.capitalize(Locale.ROOT)) - } - if (!quantity.isNullOrEmpty()) { - builder.append(" - ").append(quantity) - } - return builder.toString() - } - const val KEY_LIST_ID = "listId" const val KEY_LIST_NAME = "listName" const val KEY_PRODUCT_TO_ADD = "product" diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryActivity.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryActivity.kt index e9789def735a..ea0a7172285b 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryActivity.kt +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryActivity.kt @@ -10,26 +10,22 @@ import android.os.Build import android.os.Bundle import android.os.Environment import android.provider.Settings -import android.util.Log import android.view.Menu import android.view.MenuItem -import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat import androidx.core.app.NavUtils import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import com.afollestad.materialdialogs.MaterialDialog import com.squareup.picasso.Picasso import dagger.hilt.android.AndroidEntryPoint -import io.reactivex.Completable -import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers import openfoodfacts.github.scrachx.openfood.AppFlavors.OFF import openfoodfacts.github.scrachx.openfood.AppFlavors.isFlavors import openfoodfacts.github.scrachx.openfood.BuildConfig @@ -40,32 +36,18 @@ import openfoodfacts.github.scrachx.openfood.features.listeners.CommonBottomList import openfoodfacts.github.scrachx.openfood.features.productlist.CreateCSVContract import openfoodfacts.github.scrachx.openfood.features.scan.ContinuousScanActivity import openfoodfacts.github.scrachx.openfood.features.shared.BaseActivity -import openfoodfacts.github.scrachx.openfood.models.DaoSession -import openfoodfacts.github.scrachx.openfood.models.HistoryProduct -import openfoodfacts.github.scrachx.openfood.models.HistoryProductDao -import openfoodfacts.github.scrachx.openfood.network.OpenFoodAPIClient import openfoodfacts.github.scrachx.openfood.utils.* +import openfoodfacts.github.scrachx.openfood.utils.LocaleHelper.getLocaleFromContext import openfoodfacts.github.scrachx.openfood.utils.SortType.* import java.io.File import java.text.SimpleDateFormat import java.util.* -import javax.inject.Inject @AndroidEntryPoint -class ScanHistoryActivity : BaseActivity(), SwipeController.Actions { - private var _binding: ActivityHistoryScanBinding? = null - private val binding get() = _binding!! +class ScanHistoryActivity : BaseActivity() { + private lateinit var binding: ActivityHistoryScanBinding - /** - * boolean to determine if image should be loaded or not - */ - private val isLowBatteryMode by lazy { this.isDisableImageLoad() && this.isBatteryLevelLow() } - - @Inject - lateinit var client: OpenFoodAPIClient - - @Inject - lateinit var daoSession: DaoSession + private val viewModel: ScanHistoryViewModel by viewModels() @Inject lateinit var picasso: Picasso @@ -73,130 +55,129 @@ class ScanHistoryActivity : BaseActivity(), SwipeController.Actions { /** * boolean to determine if menu buttons should be visible or not */ - private var showMenuButtons = false - - private lateinit var adapter: ScanHistoryAdapter - - private var sortType = NONE - private var dbDisp: Disposable? = null + private var menuButtonsEnabled = false + private val adapter by lazy { + ScanHistoryAdapter(isLowBatteryMode = isDisableImageLoad() && isBatteryLevelLow()) { + openProductActivity(it.barcode) + } + } - private val storagePermLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) - { isGranted -> + private val storagePermLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { exportAsCSV() } else { - MaterialDialog.Builder(this).run { - title(R.string.permission_title) - content(R.string.permission_denied) - negativeText(R.string.txtNo) - positiveText(R.string.txtYes) - onPositive { _, _ -> - startActivity(Intent().apply { - action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS - data = Uri.fromParts("package", this@ScanHistoryActivity.packageName, null) - }) - } - onNegative { dialog, _ -> dialog.dismiss() } - show() - } + MaterialDialog.Builder(this) + .title(R.string.permission_title) + .content(R.string.permission_denied) + .negativeText(R.string.txtNo) + .positiveText(R.string.txtYes) + .onPositive { _, _ -> + startActivity(Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = Uri.fromParts("package", this@ScanHistoryActivity.packageName, null) + }) + } + .onNegative { dialog, _ -> dialog.dismiss() } + .show() } } - private val cameraPermLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) - { isGranted -> + private val cameraPermLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { - startActivity(Intent(this@ScanHistoryActivity, ContinuousScanActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - }) + openContinuousScanActivity() } } + @RequiresApi(Build.VERSION_CODES.KITKAT) + val fileWriterLauncher = registerForActivityResult(CreateCSVContract()) { + writeHistoryToFile(this, adapter.products, it, contentResolver.openOutputStream(it) ?: error("File path must not be null.")) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (resources.getBoolean(R.bool.portrait_only)) { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } - _binding = ActivityHistoryScanBinding.inflate(layoutInflater) + binding = ActivityHistoryScanBinding.inflate(layoutInflater) setContentView(binding.root) title = getString(R.string.scan_history_drawer) supportActionBar?.setDisplayHomeAsUpEnabled(true) - adapter = ScanHistoryAdapter(this@ScanHistoryActivity, client, isLowBatteryMode, mutableListOf(), picasso) + binding.listHistoryScan.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) binding.listHistoryScan.adapter = adapter - binding.listHistoryScan.layoutManager = LinearLayoutManager(this@ScanHistoryActivity) - val swipeController = SwipeController(this@ScanHistoryActivity, this@ScanHistoryActivity) + val swipeController = SwipeController(this) { position -> + adapter.products.getOrNull(position)?.let { + viewModel.removeProductFromHistory(it) + } + } ItemTouchHelper(swipeController).attachToRecyclerView(binding.listHistoryScan) - setInfo() - binding.scanFirst.setOnClickListener { startScan() } - binding.srRefreshHistoryScanList.setOnRefreshListener { - adapter.products.clear() - setInfo() - fillView() - binding.srRefreshHistoryScanList.isRefreshing = false - } + binding.srRefreshHistoryScanList.setOnRefreshListener { viewModel.refreshItems() } binding.navigationBottom.bottomNavigation.installBottomNavigation(this) - } - - override fun onStart() { - super.onStart() - //to fill the view in any case even if the user scans products from History screen... - fillView() - } - - override fun onDestroy() { - super.onDestroy() - dbDisp?.dispose() - _binding = null - } - override fun onRightClicked(position: Int) { - if (adapter.products.isNotEmpty()) { - daoSession.historyProductDao.delete(adapter.products[position]) - } - adapter.removeAndNotify(adapter.products[position]) - if (adapter.itemCount == 0) { - binding.emptyHistoryInfo.visibility = View.VISIBLE - binding.scanFirst.visibility = View.VISIBLE - } + viewModel.observeFetchProductState() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeLifecycle(this) { state -> + when (state) { + is ScanHistoryViewModel.FetchProductsState.Data -> { + binding.srRefreshHistoryScanList.isRefreshing = false + binding.historyProgressbar.isVisible = false + + adapter.products = state.items + + if (state.items.isEmpty()) { + setMenuEnabled(false) + binding.emptyHistoryInfo.isVisible = true + binding.scanFirst.isVisible = true + } else { + setMenuEnabled(true) + } + + adapter.notifyDataSetChanged() + } + ScanHistoryViewModel.FetchProductsState.Error -> { + setMenuEnabled(false) + binding.srRefreshHistoryScanList.isRefreshing = false + binding.historyProgressbar.isVisible = false + binding.emptyHistoryInfo.isVisible = true + binding.scanFirst.isVisible = true + } + ScanHistoryViewModel.FetchProductsState.Loading -> { + setMenuEnabled(false) + if (binding.srRefreshHistoryScanList.isRefreshing.not()) { + binding.historyProgressbar.isVisible = true + } + } + } + } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_history, menu) - menu.findItem(R.id.action_export_all_history).isVisible = showMenuButtons - menu.findItem(R.id.action_remove_all_history).isVisible = showMenuButtons - menu.findItem(R.id.sort_history).isVisible = showMenuButtons + with(menu) { + val alpha = if (menuButtonsEnabled) 255 else 130 + findItem(R.id.action_export_all_history).isEnabled = menuButtonsEnabled + findItem(R.id.action_export_all_history).icon.alpha = alpha + + findItem(R.id.action_remove_all_history).isEnabled = menuButtonsEnabled + findItem(R.id.action_remove_all_history).icon.alpha = alpha + + findItem(R.id.sort_history).isEnabled = menuButtonsEnabled + findItem(R.id.sort_history).icon.alpha = alpha + } return true } - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { android.R.id.home -> { NavUtils.navigateUpFromSameTask(this) true } R.id.action_remove_all_history -> { - MaterialDialog.Builder(this).run { - title(R.string.title_clear_history_dialog) - content(R.string.text_clear_history_dialog) - onPositive { _, _ -> - daoSession.historyProductDao.deleteAll() - adapter.products.clear() - adapter.notifyDataSetChanged() - showMenuButtons = false - invalidateOptionsMenu() - - binding.emptyHistoryInfo.visibility = View.VISIBLE - binding.scanFirst.visibility = View.VISIBLE - } - positiveText(R.string.txtYes) - negativeText(R.string.txtNo) - show() - } + showDeleteConfirmationDialog() true } R.id.action_export_all_history -> { @@ -220,53 +201,38 @@ class ScanHistoryActivity : BaseActivity(), SwipeController.Actions { true } R.id.sort_history -> { - MaterialDialog.Builder(this).run { - title(R.string.sort_by) - val sortTypes = if (BuildConfig.FLAVOR == "off") { - arrayOf( - getString(R.string.by_title), - getString(R.string.by_brand), - getString(R.string.by_nutrition_grade), - getString(R.string.by_barcode), - getString(R.string.by_time) - ) - } else { - arrayOf( - getString(R.string.by_title), - getString(R.string.by_brand), - getString(R.string.by_time), - getString(R.string.by_barcode) - ) - } - items(*sortTypes) - itemsCallback { _, _, position, _ -> - sortType = when (position) { - 0 -> TITLE - 1 -> BRAND - 2 -> if (isFlavors(OFF)) GRADE else TIME - 3 -> BARCODE - else -> TIME - } - fillView() - } - show() - } + showListSortingDialog() true } else -> super.onOptionsItemSelected(item) } - public override fun onResume() { + override fun onResume() { super.onResume() binding.navigationBottom.bottomNavigation.selectNavigationItem(R.id.history_bottom_nav) } + private fun setMenuEnabled(enabled: Boolean) { + menuButtonsEnabled = enabled + invalidateOptionsMenu() + } + + private fun openProductActivity(barcode: String) { + viewModel.openProduct(barcode, this) + } + + private fun openContinuousScanActivity() { + Intent(this, ContinuousScanActivity::class.java) + .apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } + .also { startActivity(it) } + } + private fun exportAsCSV() { Toast.makeText(this, R.string.txt_exporting_history, Toast.LENGTH_LONG).show() val flavor = BuildConfig.FLAVOR.toUpperCase(Locale.ROOT) - val date = SimpleDateFormat("yyyy-MM-dd", LocaleHelper.getLocaleFromContext(this)).format(Date()) + val date = SimpleDateFormat("yyyy-MM-dd", getLocaleFromContext(this)).format(Date()) val fileName = "$flavor-history_$date.csv" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { @@ -275,103 +241,70 @@ class ScanHistoryActivity : BaseActivity(), SwipeController.Actions { val baseDir = File(Environment.getExternalStorageDirectory(), getCsvFolderName()) if (!baseDir.exists()) baseDir.mkdirs() val file = File(baseDir, fileName) - writeHistoryToFile(this, adapter.products, Uri.fromFile(file),file.outputStream()) + writeHistoryToFile(this, adapter.products, Uri.fromFile(file), file.outputStream()) } } - @RequiresApi(Build.VERSION_CODES.KITKAT) - val fileWriterLauncher = registerForActivityResult(CreateCSVContract()) { - writeHistoryToFile(this, adapter.products, it,contentResolver.openOutputStream(it)?: error("File path must not be null.")) - } - private fun startScan() { if (!isHardwareCameraInstalled(baseContext)) return val perm = Manifest.permission.CAMERA if (ContextCompat.checkSelfPermission(baseContext, perm) != PackageManager.PERMISSION_GRANTED) { if (ActivityCompat.shouldShowRequestPermissionRationale(this, perm)) { - MaterialDialog.Builder(this).run { - title(R.string.action_about) - content(R.string.permission_camera) - positiveText(R.string.txtOk) - onPositive { _, _ -> - cameraPermLauncher.launch(perm) - } - show() - } + MaterialDialog.Builder(this) + .title(R.string.action_about) + .content(R.string.permission_camera) + .positiveText(R.string.txtOk) + .onPositive { _, _ -> cameraPermLauncher.launch(perm) } + .show() } else { cameraPermLauncher.launch(perm) } } else { - Intent(this, ContinuousScanActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(this) - } + openContinuousScanActivity() } } - private fun setInfo() { - binding.emptyHistoryInfo.text = getString(R.string.scan_first_string) - } - - /** - * Function to compare history items based on title, brand, barcode, time and nutrition grade - * - * @param sortType String to determine type of sorting - */ - private fun MutableList.customSortBy(sortType: SortType) = when (sortType) { - TITLE -> sortWith { item1, item2 -> - if (item1.title.isNullOrEmpty()) item1.title = resources.getString(R.string.no_title) - if (item2.title.isNullOrEmpty()) item2.title = resources.getString(R.string.no_title) - item1.title.compareTo(item2.title, true) - } - - BRAND -> sortWith { item1, item2 -> - if (item1.brands.isNullOrEmpty()) item1.brands = resources.getString(R.string.no_brand) - if (item2.brands.isNullOrEmpty()) item2.brands = resources.getString(R.string.no_brand) - item1.brands!!.compareTo(item2.brands!!, true) - } - BARCODE -> sortBy { it.barcode } - GRADE -> sortBy { it.nutritionGrade } - TIME -> sortBy { it.lastSeen } - NONE -> sortWith { _, _ -> 0 } + private fun showDeleteConfirmationDialog() { + MaterialDialog.Builder(this) + .title(R.string.title_clear_history_dialog) + .content(R.string.text_clear_history_dialog) + .onPositive { _, _ -> viewModel.clearHistory() } + .positiveText(R.string.txtYes) + .negativeText(R.string.txtNo) + .show() } - private fun fillView() { - dbDisp?.dispose() - dbDisp = getFillViewCompletable() - .doOnSubscribe { Log.i(LOG_TAG, "Task fillview started...") } - .subscribe { Log.i(LOG_TAG, "Task fillview ended.") } - - } - - private fun getFillViewCompletable(): Completable { - val refreshAct = Completable.fromAction { - binding.historyProgressbar.visibility = - if (binding.srRefreshHistoryScanList.isRefreshing) View.GONE else View.VISIBLE - } - val getProducts = Single.fromCallable { - daoSession.historyProductDao.queryBuilder().orderDesc(HistoryProductDao.Properties.LastSeen).list() + private fun showListSortingDialog() { + val sortTypes = if (BuildConfig.FLAVOR == "off") { + arrayOf( + getString(R.string.by_title), + getString(R.string.by_brand), + getString(R.string.by_nutrition_grade), + getString(R.string.by_barcode), + getString(R.string.by_time) + ) + } else { + arrayOf( + getString(R.string.by_title), + getString(R.string.by_brand), + getString(R.string.by_time), + getString(R.string.by_barcode) + ) } - return refreshAct.subscribeOn(AndroidSchedulers.mainThread()) // Change ui on main thread - .observeOn(Schedulers.io()) // Switch for db operations - .andThen(getProducts) - .observeOn(AndroidSchedulers.mainThread()) // Change ui on main thread - .flatMapCompletable { newProducts: List -> - adapter.products.clear() - if (newProducts.isEmpty()) { - binding.historyProgressbar.visibility = View.GONE - binding.emptyHistoryInfo.visibility = View.VISIBLE - binding.scanFirst.visibility = View.VISIBLE - return@flatMapCompletable Completable.complete() + MaterialDialog.Builder(this) + .title(R.string.sort_by) + .items(*sortTypes) + .itemsCallback { _, _, position, _ -> + val newType = when (position) { + 0 -> TITLE + 1 -> BRAND + 2 -> if (isFlavors(OFF)) GRADE else TIME + 3 -> BARCODE + else -> TIME } - showMenuButtons = true - invalidateOptionsMenu() - adapter.products.addAll(newProducts) - adapter.products.customSortBy(sortType) - adapter.notifyDataSetChanged() - binding.historyProgressbar.visibility = View.GONE - return@flatMapCompletable Completable.complete() + viewModel.updateSortType(newType) } + .show() } companion object { diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryAdapter.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryAdapter.kt index ecf57443d1f4..d86c58a7d498 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryAdapter.kt +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryAdapter.kt @@ -1,10 +1,8 @@ package openfoodfacts.github.scrachx.openfood.features.scanhistory -import android.app.Activity import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.squareup.picasso.Callback import com.squareup.picasso.Picasso @@ -12,101 +10,95 @@ import openfoodfacts.github.scrachx.openfood.AppFlavors.OFF import openfoodfacts.github.scrachx.openfood.AppFlavors.isFlavors import openfoodfacts.github.scrachx.openfood.R import openfoodfacts.github.scrachx.openfood.databinding.HistoryListItemBinding -import openfoodfacts.github.scrachx.openfood.features.productlist.ProductListActivity.Companion.getProductBrandsQuantityDetails import openfoodfacts.github.scrachx.openfood.models.HistoryProduct -import openfoodfacts.github.scrachx.openfood.network.OpenFoodAPIClient -import openfoodfacts.github.scrachx.openfood.utils.getEcoscoreResource -import openfoodfacts.github.scrachx.openfood.utils.getNovaGroupResource -import openfoodfacts.github.scrachx.openfood.utils.getNutriScoreResource -import java.util.* -import java.util.concurrent.TimeUnit +import openfoodfacts.github.scrachx.openfood.models.getProductBrandsQuantityDetails +import openfoodfacts.github.scrachx.openfood.utils.* +import openfoodfacts.github.scrachx.openfood.utils.Utils.picassoBuilder +import kotlin.properties.Delegates +/** + * @param isLowBatteryMode determine if image should be loaded or not + */ class ScanHistoryAdapter( - private val activity: Activity, - private val client: OpenFoodAPIClient, private val isLowBatteryMode: Boolean, - val products: MutableList, + private val onItemClicked: (HistoryProduct) -> Unit, private val picasso: Picasso -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScanHistoryHolder { - val view = HistoryListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ScanHistoryHolder(view) - } +) : RecyclerView.Adapter(), AutoUpdatableAdapter { - private fun HistoryProduct.getProductBrandsQuantityDetails() = getProductBrandsQuantityDetails(brands, quantity) - - override fun onBindViewHolder(holder: ScanHistoryHolder, position: Int) { - - val hProduct = products[position] - val productBrandsQuantityDetails = hProduct.getProductBrandsQuantityDetails() - - // Use the provided View Holder on the onCreateViewHolder method to populate the current row on the RecyclerView - holder.binding.productName.text = hProduct.title - holder.binding.barcode.text = hProduct.barcode - holder.binding.productDetails.text = productBrandsQuantityDetails - holder.binding.imgProgress.visibility = if (hProduct.url == null) View.GONE else View.VISIBLE - - // Load Image if isBatteryLoad is false - if (!isLowBatteryMode) { - picasso - .load(hProduct.url) - .placeholder(R.drawable.placeholder_thumb) - .error(R.drawable.ic_no_red_24dp) - .fit() - .centerCrop() - .into(holder.binding.productImage, object : Callback { - override fun onSuccess() { - holder.binding.imgProgress.visibility = View.GONE - } - - override fun onError(ex: Exception) { - holder.binding.imgProgress.visibility = View.GONE - } - }) - } else { - holder.binding.productImage.background = ContextCompat.getDrawable(activity, R.drawable.placeholder_thumb) - holder.binding.imgProgress.visibility = View.INVISIBLE - } - holder.binding.lastScan.text = calcTime(hProduct.lastSeen) + var products: List by Delegates.observable(emptyList()) { _, oldList, newList -> + autoNotify(oldList, newList) { o, n -> o.barcode == n.barcode } + } - if (isFlavors(OFF)) { - holder.binding.nutriscore.setImageResource(hProduct.getNutriScoreResource()) - holder.binding.ecoscore.setImageResource(hProduct.getEcoscoreResource()) - holder.binding.novaGroup.setImageResource(hProduct.getNovaGroupResource()) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = HistoryListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(view, isLowBatteryMode, picasso) + } - holder.binding.root.setOnClickListener { - client.openProduct(hProduct.barcode, activity) + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val historyProduct = products[position] + viewHolder.bind(historyProduct) { + onItemClicked(it) } } override fun getItemCount() = products.size - // Insert a new item to the RecyclerView on a predefined position - fun insert(position: Int, data: HistoryProduct) { - products.add(position, data) - notifyItemInserted(position) + override fun onViewRecycled(viewHolder: ViewHolder) { + viewHolder.unbind() + super.onViewRecycled(viewHolder) } - // Remove a RecyclerView item containing a specified Data object - fun removeAndNotify(data: HistoryProduct) { - val position = products.indexOf(data) - products.removeAt(position) - notifyItemRemoved(position) - } + class ViewHolder( + val binding: HistoryListItemBinding, + val isLowBatteryMode: Boolean, + private val picasso: Picasso + ) : RecyclerView.ViewHolder(binding.root) { + + private val context = binding.root.context - private fun calcTime(date: Date): String { - val duration = Date().time - date.time - val seconds = TimeUnit.MILLISECONDS.toSeconds(duration) - val minutes = TimeUnit.MILLISECONDS.toMinutes(duration) - val hours = TimeUnit.MILLISECONDS.toHours(duration) - val days = TimeUnit.MILLISECONDS.toDays(duration) - return when { - seconds < 60 -> activity.resources.getQuantityString(R.plurals.seconds, seconds.toInt(), seconds.toInt()) - minutes < 60 -> activity.resources.getQuantityString(R.plurals.minutes, minutes.toInt(), minutes.toInt()) - hours < 24 -> activity.resources.getQuantityString(R.plurals.hours, hours.toInt(), hours.toInt()) - else -> activity.resources.getQuantityString(R.plurals.days, days.toInt(), days.toInt()) + fun bind(product: HistoryProduct, onClicked: (HistoryProduct) -> Unit) { + binding.productName.text = product.title + binding.barcode.text = product.barcode + binding.productDetails.text = product.getProductBrandsQuantityDetails() + + // Load Image if isBatteryLoad is false + if (!isLowBatteryMode && product.url != null) { + binding.imgProgress.isVisible = true + picasso + .load(product.url) + .placeholder(R.drawable.placeholder_thumb) + .error(R.drawable.ic_no_red_24dp) + .fit() + .centerCrop() + .into(binding.productImage, object : Callback { + override fun onSuccess() { + binding.imgProgress.isVisible = false + } + + override fun onError(ex: Exception) { + binding.imgProgress.isVisible = false + } + }) + } else { + binding.productImage.setImageResource(R.drawable.placeholder_thumb) + binding.imgProgress.isVisible = false + } + binding.lastScan.text = product.lastSeen.timeFormatted(context) + + if (isFlavors(OFF)) { + binding.nutriscore.setImageResource(product.getNutriScoreResource()) + binding.ecoscore.setImageResource(product.getEcoscoreResource()) + binding.novaGroup.setImageResource(product.getNovaGroupResource()) + } + + binding.root.setOnClickListener { onClicked(product) } } + + fun unbind() { + binding.root.setOnClickListener(null) + } + } + } diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryHolder.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryHolder.kt deleted file mode 100644 index 970354e99a22..000000000000 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryHolder.kt +++ /dev/null @@ -1,8 +0,0 @@ -package openfoodfacts.github.scrachx.openfood.features.scanhistory - -import androidx.recyclerview.widget.RecyclerView -import openfoodfacts.github.scrachx.openfood.databinding.HistoryListItemBinding - -class ScanHistoryHolder( - val binding: HistoryListItemBinding -) : RecyclerView.ViewHolder(binding.root) \ No newline at end of file diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryViewModel.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryViewModel.kt new file mode 100644 index 000000000000..93d2f3c2bcf9 --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryViewModel.kt @@ -0,0 +1,169 @@ +package openfoodfacts.github.scrachx.openfood.features.scanhistory + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import androidx.lifecycle.ViewModel +import com.gojuno.koptional.rxjava2.filterSome +import com.gojuno.koptional.toOptional +import com.jakewharton.rxrelay2.BehaviorRelay +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import io.reactivex.schedulers.Schedulers +import openfoodfacts.github.scrachx.openfood.R +import openfoodfacts.github.scrachx.openfood.models.DaoSession +import openfoodfacts.github.scrachx.openfood.models.HistoryProduct +import openfoodfacts.github.scrachx.openfood.models.HistoryProductDao +import openfoodfacts.github.scrachx.openfood.network.OpenFoodAPIClient +import openfoodfacts.github.scrachx.openfood.utils.LocaleHelper +import openfoodfacts.github.scrachx.openfood.utils.SortType +import javax.inject.Inject + +@HiltViewModel +@SuppressLint("StaticFieldLeak") +class ScanHistoryViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val daoSession: DaoSession, + private val client: OpenFoodAPIClient +) : ViewModel() { + + private val compositeDisposable = CompositeDisposable() + private val fetchProductsStateRelay = BehaviorRelay.createDefault(FetchProductsState.Loading) + private var sortType = SortType.TIME + + init { + refreshItems() + } + + override fun onCleared() { + compositeDisposable.clear() + super.onCleared() + } + + fun observeFetchProductState(): Observable = fetchProductsStateRelay + + fun refreshItems() { + fetchProductsStateRelay.accept(FetchProductsState.Loading) + Observable + .fromCallable { + daoSession.historyProductDao.queryBuilder().list() + } + .flatMapIterable { it } + .flatMap { + client.getProductStateFull(it.barcode) + .toObservable() + .map { it.product.toOptional() } + } + .filterSome() + .flatMap { product -> + Observable.fromCallable { + val historyProduct = daoSession.historyProductDao.queryBuilder() + .where(HistoryProductDao.Properties.Barcode.eq(product.code)) + .build() + .unique() + product.productName?.let { historyProduct.title = it } + product.brands?.let { historyProduct.brands = it } + product.getImageSmallUrl(LocaleHelper.getLanguage(context))?.let { historyProduct.url = it } + product.quantity?.let { historyProduct.quantity = it } + product.nutritionGradeFr?.let { historyProduct.nutritionGrade = it } + product.ecoscore?.let { historyProduct.ecoscore = it } + product.novaGroups?.let { historyProduct.novaGroup = it } + daoSession.historyProductDao.update(historyProduct) + } + } + .toList() + .flatMap { + Single.fromCallable { + daoSession.historyProductDao.queryBuilder().list() + } + } + .map { it.customSort() } + .subscribeOn(Schedulers.io()) + .subscribe( + { items -> fetchProductsStateRelay.accept(FetchProductsState.Data(items)) }, + { fetchProductsStateRelay.accept(FetchProductsState.Error) } + ) + .addTo(compositeDisposable) + } + + fun clearHistory() { + fetchProductsStateRelay.accept(FetchProductsState.Loading) + Observable + .fromCallable { + daoSession.historyProductDao.deleteAll() + } + .subscribeOn(Schedulers.io()) + .subscribe( + { fetchProductsStateRelay.accept(FetchProductsState.Data(emptyList())) }, + { fetchProductsStateRelay.accept(FetchProductsState.Error) } + ) + .addTo(compositeDisposable) + } + + fun removeProductFromHistory(product: HistoryProduct) { + Observable + .fromCallable { + daoSession.historyProductDao.delete(product) + daoSession.historyProductDao.queryBuilder().list() + } + .map { it.customSort() } + .subscribeOn(Schedulers.io()) + .subscribe( + { products -> fetchProductsStateRelay.accept(FetchProductsState.Data(products)) }, + { fetchProductsStateRelay.accept(FetchProductsState.Error) } + ) + .addTo(compositeDisposable) + + } + + fun updateSortType(type: SortType) { + sortType = type + fetchProductsStateRelay + .take(1) + .map { + (it as? FetchProductsState.Data)?.items?.customSort() ?: emptyList() + } + .filter { it.isNotEmpty() } + .subscribeOn(Schedulers.io()) + .subscribe( + { products -> fetchProductsStateRelay.accept(FetchProductsState.Data(products)) }, + { fetchProductsStateRelay.accept(FetchProductsState.Error) } + ) + .addTo(compositeDisposable) + } + + fun openProduct(barcode: String, activity: Activity) { + client.openProduct(barcode, activity) + } + + /** + * Function to compare history items based on title, brand, barcode, time and nutrition grade + */ + private fun List.customSort() = when (sortType) { + SortType.TITLE -> sortedWith { item1, item2 -> + if (item1.title.isNullOrEmpty()) item1.title = context.getString(R.string.no_title) + if (item2.title.isNullOrEmpty()) item2.title = context.getString(R.string.no_title) + item1.title.compareTo(item2.title, true) + } + + SortType.BRAND -> sortedWith { item1, item2 -> + if (item1.brands.isNullOrEmpty()) item1.brands = context.getString(R.string.no_brand) + if (item2.brands.isNullOrEmpty()) item2.brands = context.getString(R.string.no_brand) + item1.brands!!.compareTo(item2.brands!!, true) + } + SortType.BARCODE -> sortedBy { it.barcode } + SortType.GRADE -> sortedBy { it.nutritionGrade } + SortType.TIME -> sortedByDescending { it.lastSeen } + SortType.NONE -> this + } + + sealed class FetchProductsState { + object Loading : FetchProductsState() + data class Data(val items: List) : FetchProductsState() + object Error : FetchProductsState() + } +} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/HistoryProduct.java b/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/HistoryProduct.java index 8fe7e9699c3d..0f0d6b20bc47 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/HistoryProduct.java +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/HistoryProduct.java @@ -61,6 +61,62 @@ public HistoryProduct(Long id, String title, String brands, String url, Date las public HistoryProduct() { } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + HistoryProduct that = (HistoryProduct) o; + + if (id != null ? !id.equals(that.id) : that.id != null) { + return false; + } + if (title != null ? !title.equals(that.title) : that.title != null) { + return false; + } + if (brands != null ? !brands.equals(that.brands) : that.brands != null) { + return false; + } + if (url != null ? !url.equals(that.url) : that.url != null) { + return false; + } + if (lastSeen != null ? !lastSeen.equals(that.lastSeen) : that.lastSeen != null) { + return false; + } + if (barcode != null ? !barcode.equals(that.barcode) : that.barcode != null) { + return false; + } + if (quantity != null ? !quantity.equals(that.quantity) : that.quantity != null) { + return false; + } + if (nutritionGrade != null ? !nutritionGrade.equals(that.nutritionGrade) : that.nutritionGrade != null) { + return false; + } + if (ecoscore != null ? !ecoscore.equals(that.ecoscore) : that.ecoscore != null) { + return false; + } + return novaGroup != null ? novaGroup.equals(that.novaGroup) : that.novaGroup == null; + } + + @Override + public int hashCode() { + int result = id != null ? id.hashCode() : 0; + result = 31 * result + (title != null ? title.hashCode() : 0); + result = 31 * result + (brands != null ? brands.hashCode() : 0); + result = 31 * result + (url != null ? url.hashCode() : 0); + result = 31 * result + (lastSeen != null ? lastSeen.hashCode() : 0); + result = 31 * result + (barcode != null ? barcode.hashCode() : 0); + result = 31 * result + (quantity != null ? quantity.hashCode() : 0); + result = 31 * result + (nutritionGrade != null ? nutritionGrade.hashCode() : 0); + result = 31 * result + (ecoscore != null ? ecoscore.hashCode() : 0); + result = 31 * result + (novaGroup != null ? novaGroup.hashCode() : 0); + return result; + } + public Long getId() { return this.id; } diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/HistoryProductExtensions.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/HistoryProductExtensions.kt new file mode 100644 index 000000000000..d983fe300f50 --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/HistoryProductExtensions.kt @@ -0,0 +1,19 @@ +@file:JvmName("HistoryProductExtensions") + +package openfoodfacts.github.scrachx.openfood.models + +import java.util.* + +fun HistoryProduct.getProductBrandsQuantityDetails(): String { + return StringBuilder() + .apply { + if (!brands.isNullOrEmpty()) { + append(brands.split(",").first().trim { it <= ' ' }.capitalize(Locale.ROOT)) + } + if (!quantity.isNullOrEmpty()) { + append(" - ") + append(quantity) + } + } + .toString() +} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/AutoUpdatableAdapter.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/AutoUpdatableAdapter.kt new file mode 100644 index 000000000000..6bd43d92db29 --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/AutoUpdatableAdapter.kt @@ -0,0 +1,26 @@ +package openfoodfacts.github.scrachx.openfood.utils + +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView + +interface AutoUpdatableAdapter { + + fun RecyclerView.Adapter<*>.autoNotify(old: List, new: List, compare: (T, T) -> Boolean) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return compare(old[oldItemPosition], new[newItemPosition]) + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return old[oldItemPosition] == new[newItemPosition] + } + + override fun getOldListSize() = old.size + + override fun getNewListSize() = new.size + }) + + diff.dispatchUpdatesTo(this) + } +} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/DateExtensions.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/DateExtensions.kt new file mode 100644 index 000000000000..4a6fae42ed14 --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/DateExtensions.kt @@ -0,0 +1,22 @@ +@file:JvmName("DateExtensions") + +package openfoodfacts.github.scrachx.openfood.utils + +import android.content.Context +import openfoodfacts.github.scrachx.openfood.R +import java.util.* +import java.util.concurrent.TimeUnit + +fun Date.timeFormatted(context: Context): String { + val duration = Date().time - time + val seconds = TimeUnit.MILLISECONDS.toSeconds(duration) + val minutes = TimeUnit.MILLISECONDS.toMinutes(duration) + val hours = TimeUnit.MILLISECONDS.toHours(duration) + val days = TimeUnit.MILLISECONDS.toDays(duration) + return when { + seconds < 60 -> context.resources.getQuantityString(R.plurals.seconds, seconds.toInt(), seconds.toInt()) + minutes < 60 -> context.resources.getQuantityString(R.plurals.minutes, minutes.toInt(), minutes.toInt()) + hours < 24 -> context.resources.getQuantityString(R.plurals.hours, hours.toInt(), hours.toInt()) + else -> context.resources.getQuantityString(R.plurals.days, days.toInt(), days.toInt()) + } +} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/ProductExt.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/ProductExt.kt index 3af785a4502d..17721c3f1dd7 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/ProductExt.kt +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/ProductExt.kt @@ -5,7 +5,6 @@ import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import openfoodfacts.github.scrachx.openfood.R -import openfoodfacts.github.scrachx.openfood.features.productlist.ProductListActivity import openfoodfacts.github.scrachx.openfood.models.HistoryProduct import openfoodfacts.github.scrachx.openfood.models.Product import openfoodfacts.github.scrachx.openfood.models.Units @@ -20,7 +19,19 @@ fun OfflineSavedProduct.toOnlineProduct(client: OpenFoodAPIClient) = toState(cli fun Product.isPerServingInLiter() = servingSize?.contains(Units.UNIT_LITER, true) -fun Product.getProductBrandsQuantityDetails() = ProductListActivity.getProductBrandsQuantityDetails(brands, quantity) +fun Product.getProductBrandsQuantityDetails(): String { + return StringBuilder() + .apply { + brands?.takeIf { it.isNotEmpty() }?.let { + append(it.split(",").first().trim { it <= ' ' }.capitalize(Locale.ROOT)) + } + if (!quantity.isNullOrEmpty()) { + append(" - ") + append(quantity) + } + } + .toString() +} @DrawableRes diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/RxEx.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/RxEx.kt new file mode 100644 index 000000000000..5a89573fb148 --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/RxEx.kt @@ -0,0 +1,33 @@ +package openfoodfacts.github.scrachx.openfood.utils + +import android.util.Log +import androidx.lifecycle.LifecycleOwner +import io.reactivex.Observable + +fun Observable.subscribeLifecycle(lifecycleOwner: LifecycleOwner, observer: (T) -> Unit) { + RxLifecycleHandler(lifecycleOwner, this, observer) +} + +/** + * Logs all lifecycle events of the [io.reactivex.Observable]. + * Should NOT be used in code at all, only for temporary debugging purposes. + */ +fun Observable.log(streamName: String, message: String? = null): Observable { + val msg = if (message == null) "" else " $message" + return this + .doOnNext { + Log.d(streamName, "$msg: $it") + } + .doOnError { + Log.w(streamName, "$msg [Error]: $it: ${it.message}") + } + .doOnComplete { + Log.w(streamName, "$msg [Complete]") + } + .doOnSubscribe { + Log.w(streamName, "$msg [Subscribed]") + } + .doOnDispose { + Log.w(streamName, "$msg [Disposed]") + } +} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/RxLifecycleHandler.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/RxLifecycleHandler.kt new file mode 100644 index 000000000000..ff9c866fd82f --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/RxLifecycleHandler.kt @@ -0,0 +1,48 @@ +package openfoodfacts.github.scrachx.openfood.utils + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.OnLifecycleEvent +import io.reactivex.Observable +import io.reactivex.disposables.Disposable + +class RxLifecycleHandler( + owner: LifecycleOwner, + private val observable: Observable, + private val observer: (T) -> Unit +) : LifecycleObserver { + private val lifecycle = owner.lifecycle + private var disposable: Disposable? = null + + init { + if (lifecycle.currentState != Lifecycle.State.DESTROYED) { + owner.lifecycle.addObserver(this) + observeIfPossible() + } + } + + private fun observeIfPossible() { + if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + disposable ?: let { + disposable = observable.subscribe { data -> observer(data) } + } + } + } + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun onStart() { + observeIfPossible() + } + + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onStop() { + disposable?.dispose() + disposable = null + } + + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fun onDestroy() { + lifecycle.removeObserver(this) + } +} diff --git a/app/src/main/res/layout/activity_history_scan.xml b/app/src/main/res/layout/activity_history_scan.xml index b23a7c595b42..a99b7e47c7e1 100644 --- a/app/src/main/res/layout/activity_history_scan.xml +++ b/app/src/main/res/layout/activity_history_scan.xml @@ -54,6 +54,7 @@ android:layout_weight="1" android:gravity="center" android:padding="12dp" + android:text="@string/scan_first_string" android:textAlignment="center" android:textColor="@android:color/darker_gray" android:visibility="invisible" />