Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
- Loading branch information
Showing
14 changed files
with
614 additions
and
318 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
365 changes: 149 additions & 216 deletions
365
...in/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryActivity.kt
Large diffs are not rendered by default.
Oops, something went wrong.
154 changes: 73 additions & 81 deletions
154
...ain/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryAdapter.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
|
||
} | ||
|
||
|
||
} |
8 changes: 0 additions & 8 deletions
8
...main/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryHolder.kt
This file was deleted.
Oops, something went wrong.
169 changes: 169 additions & 0 deletions
169
...n/java/openfoodfacts/github/scrachx/openfood/features/scanhistory/ScanHistoryViewModel.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
} | ||
} |
Oops, something went wrong.