Edited canteen views to have direct database access in the CanteenPageFragment.kt, allowing for filtering the individual days through the database; filtering now works (with changes animated!!) through updating a LiveData and observing it in the page fragment

This commit is contained in:
denizk0461
2023-04-28 22:44:23 +02:00
parent 509a0434c5
commit 8fb41d6520
11 changed files with 320 additions and 193 deletions
@@ -3,8 +3,10 @@ package com.denizk0461.studip.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.denizk0461.studip.R
import com.denizk0461.studip.data.AppDiffUtilCallback
import com.denizk0461.studip.databinding.ItemCanteenBinding
import com.denizk0461.studip.databinding.ItemCanteenLineBinding
import com.denizk0461.studip.databinding.ItemIconBinding
@@ -15,16 +17,16 @@ import com.denizk0461.studip.model.DietaryPreferences
/**
* Custom RecyclerView adapter that lays out the offers of a given canteen on a given day.
*
* @param offers list of all offers filtered by canteen and day, grouped by category
* @param onClickListener used for listening to clicks and long presses
* @param displayAllergens whether the user wants allergens to be marked
*/
class CanteenOfferItemAdapter(
private val offers: List<CanteenOfferGroup>,
private val onClickListener: OnClickListener,
private val displayAllergens: Boolean,
) : RecyclerView.Adapter<CanteenOfferItemAdapter.OfferViewHolder>() {
private val offers: MutableList<CanteenOfferGroup> = mutableListOf()
/**
* View holder class for parent class
*
@@ -121,7 +123,7 @@ class CanteenOfferItemAdapter(
}
// If no dietary preferences are met, set the view to display a neutral icon
if (indices.isEmpty()) indices.add(10)
if (indices.isEmpty()) indices.add(DietaryPreferences.NONE.ordinal)
// Iterate through all icons that need to be displayed
indices.forEach { index ->
@@ -157,6 +159,26 @@ class CanteenOfferItemAdapter(
}
}
/**
* Updates the data and calculates the difference between the old dataset and the newly provided
* dataset.
*
* @param newData new dataset to be displayed
*/
fun setNewData(newData: List<CanteenOfferGroup>) {
// Calculate the difference between the old list and the new list
val diffResult = DiffUtil.calculateDiff(AppDiffUtilCallback(offers, newData))
// Remove all items from the list
offers.clear()
// Add all items from the new list
offers.addAll(newData)
// Tell the DiffUtil which items have changed between the two lists
diffResult.dispatchUpdatesTo(this)
}
/**
* Adds a star to mark allergens present in an offer. Allergens will be displayed as a star '*'
* next to the title.
@@ -4,53 +4,29 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.denizk0461.studip.fragment.CanteenPageFragment
import com.denizk0461.studip.model.CanteenOfferGroup
/**
* Custom ViewPager adapter for managing multiple pages of canteen offers.
*
* @param fragmentActivity parent fragment activity
* @param offers all offers for a given canteen, grouped by category (not filtered by day at
* this point)
* @param daysCovered tells for how many days offers are available for.
* Example: if the next two weeks are available, Monday through Friday and
* excluding weekends, this value should be 10.
* @param onClickListener for managing click and long press events
* @param displayAllergens whether the user wants allergens to be marked
*/
class CanteenOfferPageAdapter(
fragmentActivity: FragmentActivity,
private var offers: List<CanteenOfferGroup>,
private var daysCovered: Int,
private val onClickListener: CanteenOfferItemAdapter.OnClickListener,
private val displayAllergens: Boolean,
) : FragmentStateAdapter(fragmentActivity) {
private var count: Int = 0
// Returns the count of days covered
override fun getItemCount(): Int = daysCovered
override fun getItemCount(): Int = count
override fun createFragment(position: Int): Fragment =
CanteenPageFragment(
offers,
onClickListener,
displayAllergens,
position,
)
/**
* Update the entire list of items
*
* @param items the new set of items
*/
fun setNewItems(items: List<CanteenOfferGroup>, daysCovered: Int) {
// Update the count of days covered
this.daysCovered = daysCovered
// Update items
offers = items
// Force an update of the view. TODO replace with individual updates, as this is inefficient
fun setItemCount(newCount: Int) {
count = newCount
notifyDataSetChanged()
}
}
@@ -29,7 +29,7 @@ class StudIPEventItemAdapter(
/**
* List of all events.
*/
private var events: MutableList<StudIPEvent> = mutableListOf()
private val events: MutableList<StudIPEvent> = mutableListOf()
/**
* Retrieve an instance of Calendar to check whether the adapter's day matches the current day.
@@ -240,7 +240,7 @@ class StwParser(application: Application) {
categoryId,
title = "NO\$ITEMS",
price = "",
dietaryPreferences = "..........",
dietaryPreferences = "tttttttttt",
allergens = "",
)
)
@@ -324,7 +324,6 @@ class StwParser(application: Application) {
text = text.substring(0 until indexSupOpen) +
text.substring((indexSupClose + 6) until text.length)
}
/*
* Return the stripped and updated HTML string and join the allergen list to a cohesive
* string without duplication.
@@ -97,6 +97,30 @@ interface AppDAO {
)
fun getOffersByPreference(prefs: String): LiveData<List<CanteenOffer>>
/**
* Retrieves all canteen offers that match given day. Objects will be joined through their
* primary/foreign keys from instances of OfferDate.kt, OfferCanteen.kt, OfferCategory.kt, and
* OfferItem.kt.
*
* @return all canteen offers matching the given day exposed through a LiveData object
*/
@Query(
"SELECT * FROM offer_item " +
"JOIN offer_category ON offer_item.categoryId = offer_category.id " +
"JOIN offer_canteen ON offer_category.canteenId = offer_canteen.id " +
"JOIN offer_date ON offer_category.dateId = offer_date.id " +
"WHERE dateId = :day "
)
fun getOffersByDay(day: Int): LiveData<List<CanteenOffer>>
/**
* Retrieves the amount of dates represented in the offers stored locally. Can be observed.
*
* @return date count as LiveData
*/
@Query("SELECT COUNT(*) FROM offer_date")
fun getDateCount(): LiveData<Int>
/**
* Retrieves all canteen offer date objects.
*
@@ -2,6 +2,7 @@ package com.denizk0461.studip.db
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.preference.PreferenceManager
import com.denizk0461.studip.model.*
@@ -25,6 +26,11 @@ class AppRepository(app: Application) {
// Blank dietary preference regular expression
private val blankDietaryPrefs: String = DietaryPreferences.C_FALSE.toString().repeat(10)
/**
* Used to observe just to register an update of the user-set dietary preferences.
*/
val dietaryPreferencesUpdate: MutableLiveData<Int> = MutableLiveData(0)
companion object {
/**
* Reference to the app's repository. It is only instantiated once because it can only be
@@ -79,6 +85,20 @@ class AppRepository(app: Application) {
*/
val allOffers: LiveData<List<CanteenOffer>> = dao.allOffers
/**
* Retrieves all canteen offers that match given day.
*
* @return all canteen offers matching the given day exposed through a LiveData object
*/
fun getOffersByDay(day: Int): LiveData<List<CanteenOffer>> = dao.getOffersByDay(day)
/**
* Retrieves the amount of dates represented in the offers stored locally. Can be observed.
*
* @return date count as LiveData
*/
fun getDateCount(): LiveData<Int> = dao.getDateCount()
/**
* Updates a schedule element.
*
@@ -8,11 +8,9 @@ import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.viewModels
import com.denizk0461.studip.R
import com.denizk0461.studip.adapter.CanteenOfferItemAdapter
import com.denizk0461.studip.adapter.CanteenOfferPageAdapter
import com.denizk0461.studip.databinding.FragmentCanteenBinding
import com.denizk0461.studip.model.*
import com.denizk0461.studip.sheet.AllergenSheet
import com.denizk0461.studip.sheet.TextSheet
import com.denizk0461.studip.viewmodel.CanteenViewModel
import com.google.android.material.tabs.TabLayoutMediator
@@ -21,7 +19,7 @@ import com.google.android.material.tabs.TabLayoutMediator
* User-facing fragment view that displays the canteen offers from the website of the
* Studierendenwerk Bremen.
*/
class CanteenFragment : AppFragment(), CanteenOfferItemAdapter.OnClickListener {
class CanteenFragment : AppFragment() {
// Nullable view binding reference
private var _binding: FragmentCanteenBinding? = null
@@ -83,7 +81,7 @@ class CanteenFragment : AppFragment(), CanteenOfferItemAdapter.OnClickListener {
binding.swipeRefreshLayout.isRefreshing = true
// Refresh the canteen menu for the newly selected canteen
refresh()
// refresh()
true
}
inflate(R.menu.menu_canteens)
@@ -148,40 +146,41 @@ class CanteenFragment : AppFragment(), CanteenOfferItemAdapter.OnClickListener {
// Set up the view pager's adapter
viewPagerAdapter = CanteenOfferPageAdapter(
activity as FragmentActivity,
listOf(),
0,
this,
viewModel.preferenceAllergen,
)
viewModel.getDateCount().observe(viewLifecycleOwner) { dateCount ->
viewPagerAdapter.itemCount = dateCount
binding.swipeRefreshLayout.isRefreshing = false
}
// Assign the adapter to the view pager
binding.viewPager.adapter = viewPagerAdapter
// Set up LiveData observer to refresh the view on update
viewModel.allOffers.observe(viewLifecycleOwner) { offers ->
// Update the element list stored in this fragment
elements = offers
// Group elements by category
val groupedElements = offers.groupElements().distinct()
// Find all dates for which items are available
val newDates = groupedElements.map { it.date }.distinct()
// Update the date count stored in this fragment
dateSize = newDates.size
// Update the item list in the view pager's adapter
viewPagerAdapter.setNewItems(groupedElements, dateSize)
// Set the text for the opening hours dialogue
openingHours = viewModel.getCanteenOpeningHours()
binding.swipeRefreshLayout.isRefreshing = false
// Create and attach the tab layout for the ViewPager
createTabLayoutMediator(newDates)
}
// viewModel.allOffers.observe(viewLifecycleOwner) { offers ->
// // Update the element list stored in this fragment
// elements = offers
//
// // Group elements by category
// val groupedElements = offers.groupElements().distinct()
//
// // Find all dates for which items are available
// val newDates = groupedElements.map { it.date }.distinct()
//
// // Update the date count stored in this fragment
// dateSize = newDates.size
//
// // Update the item list in the view pager's adapter
//// viewPagerAdapter.setNewItems(groupedElements, dateSize)
//
// // Set the text for the opening hours dialogue
// openingHours = viewModel.getCanteenOpeningHours()
//
// binding.swipeRefreshLayout.isRefreshing = false
//
// // Create and attach the tab layout for the ViewPager
// createTabLayoutMediator(newDates)
// }
// Set up functions for when the user swipes to refresh the view
binding.swipeRefreshLayout.setOnRefreshListener {
@@ -223,7 +222,8 @@ class CanteenFragment : AppFragment(), CanteenOfferItemAdapter.OnClickListener {
viewModel.setPreference(pref, newValue)
// Update the view
viewPagerAdapter.setNewItems(elements.groupElements().distinct(), dateSize)
viewModel.registerDietaryPreferencesUpdate()
// viewPagerAdapter.setNewItems(elements.groupElements().distinct(), dateSize)
}
/**
@@ -235,97 +235,6 @@ class CanteenFragment : AppFragment(), CanteenOfferItemAdapter.OnClickListener {
private fun getPreference(pref: DietaryPreferences): Boolean =
viewModel.getPreference(pref)
/**
* Retrieves the user's dietary preferences and compiles them into a regular expression that can
* be used to filter out items that don't fulfil the preferences. Example:
* .......t..|........t.
* This will retrieve all items that marked as either vegetarian or vegan
* *
* @return regular expression object reflecting the user's preferences
*/
private fun getPrefRegex(): Regex {
// Retrieve the user's dietary preference as a string
val prefs = viewModel.getDietaryPrefs().deconstruct()
// Return the 'empty' template string if no preference has been set or found
return if (prefs == emptyPreferenceRegex) {
Regex(emptyPreferenceRegex)
} else {
// Create the variable to insert the regular expression into
var regexString = ""
// Create the object to store which preferences need to be met
val indices = mutableListOf<Int>()
// Find all preferences that need to be met
prefs.forEachIndexed { index, c ->
if (c == DietaryPreferences.C_TRUE) indices.add(index)
}
// Needs to be set to correctly assemble the regular expression
var isFirst = true
// Iterate through every preference that has been set
indices.forEach { index ->
// Set an OR if more than one preference needs to be met
if (!isFirst) regexString += '|'
// Add the expression looking for the single preference to the entire string
regexString += emptyPreferenceRegex.substring(0 until index) +
DietaryPreferences.C_TRUE +
emptyPreferenceRegex.substring(index+1)
// Ensure that OR statements will be placed between the individual expressions
isFirst = false
}
// Compile the string to a regular expression object
Regex(regexString)
}
}
/**
* Groups [CanteenOffer] elements by their categories into [CanteenOfferGroup] elements and
* filters for the user's dietary preferences.
*
* @return the grouped and filtered elements
*/
private fun List<CanteenOffer>.groupElements(): List<CanteenOfferGroup> {
// Get dietary preference regex
val prefsRegex = getPrefRegex()
val filteredForPrefs = if (prefsRegex.toString() == emptyPreferenceRegex) {
// Show all elements and skip filtering if no preference is set
this
} else {
// Filter for dietary preferences
this.filter {
prefsRegex.matches(it.dietaryPreferences)
}
}
return filteredForPrefs.map { (_, date, dateId, category, _, canteen, _, _, _, _, _) ->
// Create new group elements to group the already filtered elements by their categories
CanteenOfferGroup(
date,
dateId,
category,
canteen,
filteredForPrefs.filter {
it.category == category && it.date == date && it.canteen == canteen
}.map {
// Map the individual items to their respective groups
CanteenOfferGroupElement(
it.title,
it.price,
it.dietaryPreferences,
it.allergens
)
}
)
}
}
/**
* Retrieves the localised string for the canteen selected by the user.
*
@@ -370,17 +279,17 @@ class CanteenFragment : AppFragment(), CanteenOfferItemAdapter.OnClickListener {
*
* @param offer item that has been clicked
*/
override fun onClick(offer: CanteenOfferGroupElement, category: String) {
openBottomSheet(AllergenSheet(offer, category))
}
/**
* Executed when an item has been long-pressed.
*
* @param offer item that has been long-pressed
* @return whether the long press was successful
*/
override fun onLongClick(offer: CanteenOfferGroupElement): Boolean {
return false
}
// override fun onClick(offer: CanteenOfferGroupElement, category: String) {
// openBottomSheet(AllergenSheet(offer, category))
// }
//
// /**
// * Executed when an item has been long-pressed.
// *
// * @param offer item that has been long-pressed
// * @return whether the long press was successful
// */
// override fun onLongClick(offer: CanteenOfferGroupElement): Boolean {
// return false
// }
}
@@ -4,28 +4,27 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.denizk0461.studip.adapter.CanteenOfferItemAdapter
import com.denizk0461.studip.adapter.StudIPEventPageAdapter
import com.denizk0461.studip.databinding.RecyclerViewBinding
import com.denizk0461.studip.model.CanteenOffer
import com.denizk0461.studip.model.CanteenOfferGroup
import com.denizk0461.studip.model.CanteenOfferGroupElement
import com.denizk0461.studip.model.DietaryPreferences
import com.denizk0461.studip.sheet.AllergenSheet
import com.denizk0461.studip.viewmodel.CanteenPageViewModel
/**
* Fragment that is instantiated by [StudIPEventPageAdapter] to display individual days' pages and
* their events.
*
* @param offers all offers for a given canteen, grouped by category (not filtered by day at
* this point)
* @param onClickListener for managing click and long press events
* @param displayAllergens whether the user wants allergens to be marked
* @param currentDay current day
* @param currentDay day to display
*/
class CanteenPageFragment(
private var offers: List<CanteenOfferGroup>,
private val onClickListener: CanteenOfferItemAdapter.OnClickListener,
private val displayAllergens: Boolean,
private val currentDay: Int,
) : AppFragment() {
) : AppFragment(), CanteenOfferItemAdapter.OnClickListener {
// Nullable view binding reference
private var _binding: RecyclerViewBinding? = null
@@ -36,6 +35,13 @@ class CanteenPageFragment(
*/
private val binding get() = _binding!!
// View model reference for providing access to the database
private val viewModel: CanteenPageViewModel by viewModels()
private lateinit var recyclerViewAdapter: CanteenOfferItemAdapter
private val offerList: MutableList<CanteenOffer> = mutableListOf()
// Instantiate the view binding
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = RecyclerViewBinding.inflate(inflater, container, false)
@@ -45,6 +51,11 @@ class CanteenPageFragment(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerViewAdapter = CanteenOfferItemAdapter(
this,
viewModel.preferenceAllergen,
)
binding.recyclerView.apply {
// Set page to scroll vertically
layoutManager = LinearLayoutManager(
@@ -56,16 +67,141 @@ class CanteenPageFragment(
* by the newly created adapter (0 = Monday, 4 = Friday)
* TODO check if the filtered list is empty, and tell the user if it is
*/
val o = offers.filter { it.dateId == currentDay }
// val o = offers.filter { it.dateId == currentDay }
adapter = CanteenOfferItemAdapter(
o,
onClickListener,
displayAllergens,
)
adapter = recyclerViewAdapter
// Animate creation of new page
scheduleLayoutAnimation()
viewModel.getOffersByDay(currentDay).observe(viewLifecycleOwner) { offers ->
offerList.clear()
offerList.addAll(offers)
recyclerViewAdapter.setNewData(offers.filterElements().groupElements().distinct())
// filterList()
}
}
viewModel.dietaryPreferencesUpdate.observe(viewLifecycleOwner) {
filterList()
}
}
private fun filterList() {
recyclerViewAdapter.setNewData(offerList.filterElements().groupElements().distinct())
}
/**
* Groups [CanteenOffer] elements by their categories into [CanteenOfferGroup] elements and
* filters for the user's dietary preferences.
*
* @return the grouped and filtered elements
*/
private fun List<CanteenOffer>.groupElements(): List<CanteenOfferGroup> {
return map { (_, date, dateId, category, _, canteen, _, _, _, _, _) ->
// Create new group elements to group the already filtered elements by their categories
CanteenOfferGroup(
date,
dateId,
category,
canteen,
filter {
it.category == category && it.date == date && it.canteen == canteen
}.map {
// Map the individual items to their respective groups
CanteenOfferGroupElement(
it.title,
it.price,
it.dietaryPreferences,
it.allergens
)
}
)
}
}
private fun List<CanteenOffer>.filterElements(): List<CanteenOffer> {
// Get dietary preference regex
val prefsRegex = getPrefRegex()
return if (prefsRegex.toString() == DietaryPreferences.TEMPLATE_EMPTY) {
// Show all elements and skip filtering if no preference is set
this
} else {
// Filter for dietary preferences
this.filter {
prefsRegex.matches(it.dietaryPreferences)
}
}
}
/**
* Retrieves the user's dietary preferences and compiles them into a regular expression that can
* be used to filter out items that don't fulfil the preferences. Example:
* .......t..|........t.
* This will retrieve all items that marked as either vegetarian or vegan
* *
* @return regular expression object reflecting the user's preferences
*/
private fun getPrefRegex(): Regex {
// Retrieve the user's dietary preference as a string
val prefs = viewModel.getDietaryPrefs().deconstruct()
// Return the 'empty' template string if no preference has been set or found
return if (prefs == DietaryPreferences.TEMPLATE_EMPTY) {
Regex(DietaryPreferences.TEMPLATE_EMPTY)
} else {
// Create the variable to insert the regular expression into
var regexString = ""
// Create the object to store which preferences need to be met
val indices = mutableListOf<Int>()
// Find all preferences that need to be met
prefs.forEachIndexed { index, c ->
if (c == DietaryPreferences.C_TRUE) indices.add(index)
}
// Needs to be set to correctly assemble the regular expression
var isFirst = true
// Iterate through every preference that has been set
indices.forEach { index ->
// Set an OR if more than one preference needs to be met
if (!isFirst) regexString += '|'
// Add the expression looking for the single preference to the entire string
regexString += DietaryPreferences.TEMPLATE_EMPTY.substring(0 until index) +
DietaryPreferences.C_TRUE +
DietaryPreferences.TEMPLATE_EMPTY.substring(index+1)
// Ensure that OR statements will be placed between the individual expressions
isFirst = false
}
// Compile the string to a regular expression object
Regex(regexString)
}
}
/**
* Executed when an item has been clicked.
*
* @param offer item that has been clicked
*/
override fun onClick(offer: CanteenOfferGroupElement, category: String) {
openBottomSheet(AllergenSheet(offer, category))
}
/**
* Executed when an item has been long-pressed.
*
* @param offer item that has been long-pressed
* @return whether the long press was successful
*/
override fun onLongClick(offer: CanteenOfferGroupElement): Boolean {
return false
}
}
@@ -137,6 +137,8 @@ enum class DietaryPreferences(val value: String) {
*/
const val C_FALSE: Char = '.'
const val TEMPLATE_EMPTY: String = ".........."
/**
* Constructs a DietaryPrefObject from a regex string. String must be 10 characters long.
* A char equal to C_TRUE is treated as a true boolean value. Any other char is treated as a
@@ -0,0 +1,33 @@
package com.denizk0461.studip.viewmodel
import android.app.Application
import androidx.lifecycle.LiveData
import com.denizk0461.studip.model.CanteenOffer
import com.denizk0461.studip.model.DietaryPreferences
import com.denizk0461.studip.model.SettingsPreferences
class CanteenPageViewModel(app: Application) : AppViewModel(app) {
/**
* Retrieves all canteen offers that match given day.
*
* @return all canteen offers matching the given day exposed through a LiveData object
*/
fun getOffersByDay(day: Int): LiveData<List<CanteenOffer>> =
repo.getOffersByDay(day)
val dietaryPreferencesUpdate: LiveData<Int> = repo.dietaryPreferencesUpdate
/**
* Retrieve the user's dietary preferences.
*
* @return dietary preferences
*/
fun getDietaryPrefs(): DietaryPreferences.Object = repo.getDietaryPrefsAsObject()
/**
* This value determines whether the user wants to have allergens marked.
*/
val preferenceAllergen: Boolean
get() = repo.getBooleanPreference(SettingsPreferences.ALLERGEN, defaultValue = true)
}
@@ -22,12 +22,7 @@ class CanteenViewModel(app: Application) : AppViewModel(app) {
*/
val allOffers: LiveData<List<CanteenOffer>> = repo.allOffers
/**
* Retrieve the user's dietary preferences.
*
* @return dietary preferences
*/
fun getDietaryPrefs(): DietaryPreferences.Object = repo.getDietaryPrefsAsObject()
/**
* Retrieves all canteen offer date objects.
@@ -54,6 +49,17 @@ class CanteenViewModel(app: Application) : AppViewModel(app) {
doAsync { parser.parse(canteen, onRefreshUpdate, onFinish) }
}
fun registerDietaryPreferencesUpdate() {
repo.dietaryPreferencesUpdate.postValue(repo.dietaryPreferencesUpdate.value?.plus(1))
}
/**
* Retrieves the amount of dates represented in the offers stored locally. Can be observed.
*
* @return date count as LiveData
*/
fun getDateCount(): LiveData<Int> = repo.getDateCount()
/**
* Updates a given dietary preference to a new value.
*