Skip to content

Commit

Permalink
feat: Add history refreshing [#3162] (#3840)
Browse files Browse the repository at this point in the history
  • Loading branch information
naivekook committed Apr 1, 2021
1 parent f4c27ff commit 4740512
Show file tree
Hide file tree
Showing 14 changed files with 614 additions and 318 deletions.
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Expand Up @@ -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")
Expand Down
Expand Up @@ -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"
Expand Down

Large diffs are not rendered by default.

@@ -1,112 +1,104 @@
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
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<HistoryProduct>,
private val onItemClicked: (HistoryProduct) -> Unit,
private val picasso: Picasso
) : RecyclerView.Adapter<ScanHistoryHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScanHistoryHolder {
val view = HistoryListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ScanHistoryHolder(view)
}
) : RecyclerView.Adapter<ScanHistoryAdapter.ViewHolder>(), 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<HistoryProduct> 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)
}

}


}

This file was deleted.

@@ -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>(FetchProductsState.Loading)
private var sortType = SortType.TIME

init {
refreshItems()
}

override fun onCleared() {
compositeDisposable.clear()
super.onCleared()
}

fun observeFetchProductState(): Observable<FetchProductsState> = 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<HistoryProduct>.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<HistoryProduct>) : FetchProductsState()
object Error : FetchProductsState()
}
}

0 comments on commit 4740512

Please sign in to comment.