From fbad84aa097f3e682fb7bb37613b2ebd8abdd63e Mon Sep 17 00:00:00 2001 From: denizk0461 Date: Sun, 23 Apr 2023 11:57:03 +0200 Subject: [PATCH] Fully documented all Kotlin files and added TODOs to functions that need to be updated in some way --- .../studip/activity/MainActivity.kt | 4 +- .../studip/adapter/CanteenOfferItemAdapter.kt | 72 +++++-- .../studip/adapter/CanteenOfferPageAdapter.kt | 39 +++- .../studip/adapter/StudIPEventItemAdapter.kt | 104 +++++++++- .../studip/adapter/StudIPEventPageAdapter.kt | 39 +++- .../denizk0461/studip/data/Dependencies.kt | 12 +- .../java/com/denizk0461/studip/data/Misc.kt | 57 ++---- .../denizk0461/studip/data/StudIPParser.kt | 84 ++++++-- .../com/denizk0461/studip/data/StwParser.kt | 181 ++++++++++++++---- .../java/com/denizk0461/studip/db/AppDAO.kt | 137 +++++++++++++ .../com/denizk0461/studip/db/AppDatabase.kt | 55 ++++++ .../com/denizk0461/studip/db/AppRepository.kt | 171 +++++++++++++++++ .../java/com/denizk0461/studip/db/EventDAO.kt | 88 --------- .../com/denizk0461/studip/db/EventDatabase.kt | 39 ---- .../denizk0461/studip/db/EventRepository.kt | 111 ----------- .../studip/fragment/CanteenFragment.kt | 148 +++++++++++--- .../studip/fragment/EventFragment.kt | 55 ++++-- .../studip/fragment/ParserFragment.kt | 87 --------- .../studip/fragment/SettingsFragment.kt | 51 ++--- .../denizk0461/studip/model/CanteenOffer.kt | 22 ++- .../studip/model/CanteenOfferGroup.kt | 10 + .../studip/model/CanteenOfferGroupElement.kt | 10 + .../studip/model/DietaryPrefObject.kt | 41 +++- .../studip/model/DietaryPreferences.kt | 8 + .../denizk0461/studip/model/OfferCanteen.kt | 6 + .../denizk0461/studip/model/OfferCategory.kt | 9 + .../com/denizk0461/studip/model/OfferDate.kt | 6 + .../com/denizk0461/studip/model/OfferItem.kt | 20 +- .../studip/viewmodel/CanteenViewModel.kt | 43 ++++- .../studip/viewmodel/EventViewModel.kt | 13 +- .../studip/viewmodel/FetcherViewModel.kt | 15 +- .../studip/viewmodel/TemplateViewModel.kt | 26 ++- app/src/main/res/layout/fragment_parser.xml | 28 --- 33 files changed, 1220 insertions(+), 571 deletions(-) create mode 100644 app/src/main/java/com/denizk0461/studip/db/AppDAO.kt create mode 100644 app/src/main/java/com/denizk0461/studip/db/AppDatabase.kt create mode 100644 app/src/main/java/com/denizk0461/studip/db/AppRepository.kt delete mode 100644 app/src/main/java/com/denizk0461/studip/db/EventDAO.kt delete mode 100644 app/src/main/java/com/denizk0461/studip/db/EventDatabase.kt delete mode 100644 app/src/main/java/com/denizk0461/studip/db/EventRepository.kt delete mode 100644 app/src/main/java/com/denizk0461/studip/fragment/ParserFragment.kt delete mode 100644 app/src/main/res/layout/fragment_parser.xml diff --git a/app/src/main/java/com/denizk0461/studip/activity/MainActivity.kt b/app/src/main/java/com/denizk0461/studip/activity/MainActivity.kt index 297596b..5d432d2 100644 --- a/app/src/main/java/com/denizk0461/studip/activity/MainActivity.kt +++ b/app/src/main/java/com/denizk0461/studip/activity/MainActivity.kt @@ -9,7 +9,7 @@ import androidx.lifecycle.Lifecycle import com.denizk0461.studip.R import com.denizk0461.studip.data.Dependencies import com.denizk0461.studip.databinding.ActivityMainBinding -import com.denizk0461.studip.db.EventRepository +import com.denizk0461.studip.db.AppRepository import com.denizk0461.studip.fragment.EventFragment import com.denizk0461.studip.fragment.CanteenFragment import com.denizk0461.studip.fragment.SettingsFragment @@ -38,7 +38,7 @@ class MainActivity : AppCompatActivity() { * Instantiate repository object that is accessed by the fragments' view models to retrieve * data. */ - Dependencies.repo = EventRepository(application) + Dependencies.repo = AppRepository(application) // Inflate view binding and bind to this activity binding = ActivityMainBinding.inflate(layoutInflater) diff --git a/app/src/main/java/com/denizk0461/studip/adapter/CanteenOfferItemAdapter.kt b/app/src/main/java/com/denizk0461/studip/adapter/CanteenOfferItemAdapter.kt index 0794fb7..bb53c34 100644 --- a/app/src/main/java/com/denizk0461/studip/adapter/CanteenOfferItemAdapter.kt +++ b/app/src/main/java/com/denizk0461/studip/adapter/CanteenOfferItemAdapter.kt @@ -2,18 +2,27 @@ package com.denizk0461.studip.adapter import android.view.LayoutInflater import android.view.ViewGroup -import android.widget.ImageView -import androidx.core.widget.ImageViewCompat import androidx.recyclerview.widget.RecyclerView -import com.denizk0461.studip.data.Misc +import com.denizk0461.studip.R import com.denizk0461.studip.databinding.ItemCanteenBinding import com.denizk0461.studip.databinding.ItemCanteenLineBinding import com.denizk0461.studip.databinding.ItemIconBinding -import com.denizk0461.studip.model.CanteenOffer import com.denizk0461.studip.model.CanteenOfferGroup -class CanteenOfferItemAdapter(private val offers: List) : RecyclerView.Adapter() { +/** + * 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 + */ +class CanteenOfferItemAdapter( + private val offers: List +) : RecyclerView.Adapter() { + /** + * View holder class for parent class + * + * @param binding view binding object + */ class OfferViewHolder(val binding: ItemCanteenBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OfferViewHolder { @@ -26,20 +35,32 @@ class CanteenOfferItemAdapter(private val offers: List) : Rec ) } + // Returns the amount of offers for a given canteen and day override fun getItemCount(): Int = offers.size + // TODO inflate a view saying "no events" if none are available for a given day override fun onBindViewHolder(holder: OfferViewHolder, position: Int) { + // Retrieve item for current position val currentItem = offers[position] + // Set category text / header of the item holder.binding.textCategory.text = currentItem.category + // Create a new view (line) for each item displayed within one category currentItem.offers.forEach { offer -> + // Inflate the new line without binding it to the line container val line = ItemCanteenLineBinding.inflate( LayoutInflater.from(holder.binding.root.context), ) + // Set text values line.textContent.text = offer.title line.textPrice.text = offer.price + + /* + * Check which dietary preferences are met by this item. This is used to set visual + * icons on each line. Example: a leaf on vegan items. + */ val indices = arrayListOf() offer.dietaryPreferences.filterIndexed { index, char -> if (char == 't') { @@ -47,25 +68,44 @@ class CanteenOfferItemAdapter(private val offers: List) : Rec true } else false } + + // If no dietary preferences are met, set the view to display a neutral icon if (indices.isEmpty()) indices.add(10) + + // Iterate through all icons that need to be displayed indices.forEach { index -> + // Inflate a new icon holder val img = ItemIconBinding.inflate(LayoutInflater.from(holder.binding.root.context)) -// holder.binding.root.resources.getDrawable(Misc.indexToDrawable[index]!!, holder.binding.root.context.theme) - img.imageView.setImageDrawable(holder.binding.root.context.getDrawable(Misc.indexToDrawable[index]!!)) + + // Set the appropriate icon + img.imageView.setImageDrawable( + holder.binding.root.context.getDrawable(indexToDrawable[index]!!) + ) + + // Bind the new icon to the line view line.imageViewContainer.addView(img.root) } + // Bind the new line to the line container holder.binding.lineContainer.addView(line.root) } - -// holder.binding.textCategory.text = currentItem.category -// holder.binding.tempContent.text = "${currentItem.title} • ${currentItem.price}" - - // TODO inflate view saying "no offers!" or sth } -// interface OnClickListener { -// fun onClick(event: StudIPEvent) -// fun onLongClick(event: StudIPEvent) -// } + /** + * Provides a converting function between the index of a dietary preference and its according + * icon. + */ + private val indexToDrawable: Map = mapOf( + 0 to R.drawable.handshake, + 1 to R.drawable.fish, + 2 to R.drawable.chicken, + 3 to R.drawable.sheep, + 4 to R.drawable.yoga, + 5 to R.drawable.cow, + 6 to R.drawable.pig, + 7 to R.drawable.leaf, + 8 to R.drawable.carrot, + 9 to R.drawable.deer, + 10 to R.drawable.circle, + ) } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/adapter/CanteenOfferPageAdapter.kt b/app/src/main/java/com/denizk0461/studip/adapter/CanteenOfferPageAdapter.kt index 7977a2b..a9d0160 100644 --- a/app/src/main/java/com/denizk0461/studip/adapter/CanteenOfferPageAdapter.kt +++ b/app/src/main/java/com/denizk0461/studip/adapter/CanteenOfferPageAdapter.kt @@ -7,8 +7,26 @@ import androidx.recyclerview.widget.RecyclerView import com.denizk0461.studip.databinding.ItemScrollablePageBinding import com.denizk0461.studip.model.CanteenOfferGroup -class CanteenOfferPageAdapter(private var offers: List, private var daysCovered: Int, private var prefsRegex: Regex) : RecyclerView.Adapter() { +/** + * Custom RecyclerView adapter for managing multiple pages of canteen offers in a RecyclerView or + * ViewPager. + * + * @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. + */ +class CanteenOfferPageAdapter( + private var offers: List, + private var daysCovered: Int, +) : RecyclerView.Adapter() { + /** + * View holder class for parent class + * + * @param binding view binding object + */ class CanteenOfferPageViewHolder(val binding: ItemScrollablePageBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CanteenOfferPageViewHolder = @@ -20,22 +38,39 @@ class CanteenOfferPageAdapter(private var offers: List, priva ) ) + // Returns the count of days covered override fun getItemCount(): Int = daysCovered override fun onBindViewHolder(holder: CanteenOfferPageViewHolder, position: Int) { holder.binding.pageRecyclerView.apply { - + // Set page to horizontally scroll layoutManager = LinearLayoutManager(holder.binding.root.context, LinearLayoutManager.VERTICAL, false) + /* + * Create new adapter for every page. Attribute position denotes day that will be set up + * by the newly created adapter (0 = Monday, 4 = Friday) + */ adapter = CanteenOfferItemAdapter(offers.filter { it.dateId == position }) // TODO check if empty + + // Animate creation of new page scheduleLayoutAnimation() } } + /** + * Update the entire list of items + * + * @param items the new set of items + */ fun setNewItems(items: List, 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 notifyDataSetChanged() } } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/adapter/StudIPEventItemAdapter.kt b/app/src/main/java/com/denizk0461/studip/adapter/StudIPEventItemAdapter.kt index 5a1256d..c0c4409 100644 --- a/app/src/main/java/com/denizk0461/studip/adapter/StudIPEventItemAdapter.kt +++ b/app/src/main/java/com/denizk0461/studip/adapter/StudIPEventItemAdapter.kt @@ -10,14 +10,40 @@ import com.denizk0461.studip.model.StudIPEvent import com.denizk0461.studip.databinding.ItemEventBinding import java.util.* +/** + * Custom RecyclerView adapter that lays out the Stud.IP events of a given day. + * + * @param events list of all events (still not filtered by day at this point) + * @param currentDay current day that is used to show only events of a given day (0 = Monday, + * 4 = Friday) + * @param onClickListener used for listening to clicks and long presses + */ class StudIPEventItemAdapter( - events: List, private val currentDay: Int, private val onClickListener: OnClickListener + events: List, + private val currentDay: Int, + private val onClickListener: OnClickListener, ) : RecyclerView.Adapter() { + // Filter events to only show those of a given day private val filteredEvents: List = events.filter { it.day == currentDay } + + /* + * Retrieve an instance of Calendar to check whether the adapter's day matches the current day. + * TODO this can be optimised by checking in StudIPEventPageAdapter.kt and delivering a boolean + */ private val currentCalendar = Calendar.getInstance() + + /* + * Used to disallow highlighting more than one course at a time, since only a single course + * could be coming up at a given time. + */ private var isAnyCourseHighlighted = false + /** + * View holder class for parent class + * + * @param binding view binding object + */ class EventViewHolder(val binding: ItemEventBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventViewHolder { @@ -30,57 +56,92 @@ class StudIPEventItemAdapter( ) } + // Set the amount of items to the size of the filtered list, so only events of the current day override fun getItemCount(): Int = filteredEvents.size + // TODO inflate a view saying "no events" if none are available for a given day override fun onBindViewHolder(holder: EventViewHolder, position: Int) { + // Retrieve item for current position val currentItem = filteredEvents[position] + // Set up text fields with corresponding values holder.binding.textTitle.text = currentItem.title holder.binding.textLecturers.text = currentItem.lecturer holder.binding.textRoom.text = currentItem.room + + // Use parsed timeslot string as defined in StudIPEvent#timeslot() holder.binding.textTimeslot.text = currentItem.timeslot() + // Set up colours used for highlighting and un-highlighting an item. val colorPrimaryTranslucent = TypedValue() val colorCardBackground = TypedValue() val colorTextHintLighter = TypedValue() - val theme = holder.binding.root.context.theme - theme.apply { + // Resolve themed attributes to get the right values for day and night modes + holder.binding.root.context.theme.apply { resolveAttribute(R.attr.colorPrimaryTranslucent, colorPrimaryTranslucent, true) resolveAttribute(R.attr.colorCardBackground, colorCardBackground, true) resolveAttribute(R.attr.colorTextHintLighter, colorTextHintLighter, true) } - if (!isAnyCourseHighlighted && currentItem.isCurrentCourse(currentCalendar)) { // highlight + /* Highlight the next upcoming course of the day if the day of the adapter matches the + * current day, and if no other course has been highlighted, to avoid double highlighting. + */ + if (!isAnyCourseHighlighted && currentItem.isCurrentCourse(currentCalendar)) { + // Ensure that no other course will be highlighted isAnyCourseHighlighted = true + holder.binding.cardBackground.apply { + // Set the card's background colour to a desaturated shade of the primary colour backgroundTintList = ColorStateList.valueOf(colorPrimaryTranslucent.data) + + // Hide card stroke strokeColor = context.getColor(android.R.color.transparent) } - } else { // un-highlight + // Otherwise, apply colours to ensure that the item will not be highlighted + } else { holder.binding.cardBackground.apply { + // Set the card's background colour to its default value backgroundTintList = ColorStateList.valueOf(colorCardBackground.data) + + // Set the card's stroke to its default colour strokeColor = colorTextHintLighter.data } } + // Set up single click listener holder.binding.cardBackground.setOnClickListener { onClickListener.onClick(currentItem) } + + // Set up long press listener holder.binding.cardBackground.setOnLongClickListener { onClickListener.onLongClick(currentItem) - true } - - // TODO inflate view saying "no events!" or sth - } + /** + * Checks if a given course is the next course coming up. This is done by checking whether the + * day of the course matches the current real world day, as well as by evaluating if the end + * time stamp of the event has already passed. As the course list is ordered chronologically, + * this should always find the 'next' event, since without ordering the data origin, this could + * be used to highlight any event of the day that hasn't already passed. + * + * @param calendar instance of the current calendar + * @return whether the course is coming up next + */ private fun StudIPEvent.isCurrentCourse(calendar: Calendar): Boolean = (calendar.get(Calendar.DAY_OF_WEEK) == this.day.toCalendarDay()) && ((calendar.get(Calendar.MINUTE) + calendar.get(Calendar.HOUR_OF_DAY) * 60) < this.timeslotEnd.parseToMinutes()) // Converts day as defined in StudIPEvent.kt to day from Calendar + /** + * Converts a numeric value as defined in StudIPEvent.kt to a calendar day. Used to convert from + * American convention (where Sunday is the first day, 1 = Sunday, 2 = Monday, 7 = Saturday) to + * European convention and zero-based indexing (0 = Monday, 6 = Sunday). + * + * @return calendar day as numeric value + */ private fun Int.toCalendarDay(): Int = when (this) { 1 -> Calendar.TUESDAY 2 -> Calendar.WEDNESDAY @@ -91,13 +152,36 @@ class StudIPEventItemAdapter( else -> Calendar.MONDAY } + /** + * Converts a time stamp string to its numeric value in minutes. + * Example: 13:20. (20 minutes) + (13 hours * 60 minutes) = 800 minutes. + * + * @return minute value of the string + */ private fun String.parseToMinutes(): Int { val parts = split(":") return (parts[0].toInt() * 60) + parts[1].toInt() } + /** + * Interface used to evaluate clicks and long presses on a given item. + */ interface OnClickListener { + + /** + * Executed when an item has been clicked. + * + * @param event item that has been clicked + */ fun onClick(event: StudIPEvent) - fun onLongClick(event: StudIPEvent) + + + /** + * Executed when an item has been long-pressed. + * + * @param event item that has been long-pressed + * @return whether the long press was successful + */ + fun onLongClick(event: StudIPEvent): Boolean } } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/adapter/StudIPEventPageAdapter.kt b/app/src/main/java/com/denizk0461/studip/adapter/StudIPEventPageAdapter.kt index afc190e..68d4b7f 100644 --- a/app/src/main/java/com/denizk0461/studip/adapter/StudIPEventPageAdapter.kt +++ b/app/src/main/java/com/denizk0461/studip/adapter/StudIPEventPageAdapter.kt @@ -7,8 +7,23 @@ import androidx.recyclerview.widget.RecyclerView import com.denizk0461.studip.model.StudIPEvent import com.denizk0461.studip.databinding.ItemScrollablePageBinding -class StudIPEventPageAdapter(private var events: List, private val onClickListener: StudIPEventItemAdapter.OnClickListener) : RecyclerView.Adapter() { +/** + * Custom RecyclerView adapter for managing multiple pages of Stud.IP events in a RecyclerView or + * ViewPager. + * + * @param events all events (not filtered by day at this point) + * @param onClickListener for managing click and long press events + */ +class StudIPEventPageAdapter( + private var events: List, + private val onClickListener: StudIPEventItemAdapter.OnClickListener +) : RecyclerView.Adapter() { + /** + * View holder class for parent class + * + * @param binding view binding object + */ class EventPageViewHolder(val binding: ItemScrollablePageBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventPageViewHolder = EventPageViewHolder( @@ -19,19 +34,39 @@ class StudIPEventPageAdapter(private var events: List, private val ) ) + /* + * Assume that there will always be 5 pages, for 5 days - Monday through Friday. + * TODO is it right to assume that the user only has events on weekdays? + */ override fun getItemCount(): Int = 5 + // Set up page override fun onBindViewHolder(holder: EventPageViewHolder, position: Int) { - holder.binding.pageRecyclerView.apply { + // Set page to horizontally scroll layoutManager = LinearLayoutManager(holder.binding.root.context, LinearLayoutManager.VERTICAL, false) + + /* + * Create new adapter for every page. Attribute position denotes day that will be set up + * by the newly created adapter (0 = Monday, 4 = Friday) + */ adapter = StudIPEventItemAdapter(events, currentDay = position, onClickListener) // TODO check if empty + + // Animate creation of new page scheduleLayoutAnimation() } } + /** + * Update the entire list of items + * + * @param items the new set of items + */ fun setNewItems(items: List) { + // Update items events = items + + // Force an update of the view. TODO replace with individual updates, as this is inefficient notifyDataSetChanged() } } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/data/Dependencies.kt b/app/src/main/java/com/denizk0461/studip/data/Dependencies.kt index 3bfaa3d..0c38b68 100644 --- a/app/src/main/java/com/denizk0461/studip/data/Dependencies.kt +++ b/app/src/main/java/com/denizk0461/studip/data/Dependencies.kt @@ -1,7 +1,15 @@ package com.denizk0461.studip.data -import com.denizk0461.studip.db.EventRepository +import com.denizk0461.studip.db.AppRepository +/** + * Static object that holds a reference to the app's repository to provide abstracted access to the + * app database. + */ object Dependencies { - lateinit var repo: EventRepository + /** + * Reference to the app's repository. Since it instantiated once by the main activity, it can be + * accessed statically by all other classes. Used for database transactions. + */ + lateinit var repo: AppRepository } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/data/Misc.kt b/app/src/main/java/com/denizk0461/studip/data/Misc.kt index ed0e827..b7657e3 100644 --- a/app/src/main/java/com/denizk0461/studip/data/Misc.kt +++ b/app/src/main/java/com/denizk0461/studip/data/Misc.kt @@ -1,41 +1,26 @@ package com.denizk0461.studip.data -import com.denizk0461.studip.R - +/** + * Miscellaneous functions and variables that don't belong into any specific class. + */ object Misc { - private val timeslotAcademicQuarter = mapOf( - "6:00" to "6:15", - "8:00" to "7:45", - "8:00" to "8:15", - "10:00" to "9:45", - "10:00" to "10:15", - "12:00" to "11:45", - "12:00" to "12:15", - "14:00" to "13:45", - "14:00" to "14:15", - "16:00" to "15:45", - "16:00" to "16:15", - "18:00" to "17:45", - "18:00" to "18:15", - "20:00" to "19:45", - "20:00" to "20:15", - "22:00" to "21:45", - ) - - val indexToDrawable: Map = mapOf( - 0 to R.drawable.handshake, - 1 to R.drawable.fish, - 2 to R.drawable.chicken, - 3 to R.drawable.sheep, - 4 to R.drawable.yoga, - 5 to R.drawable.cow, - 6 to R.drawable.pig, - 7 to R.drawable.leaf, - 8 to R.drawable.carrot, - 9 to R.drawable.deer, - 10 to R.drawable.circle, - ) - - const val mysteryLink = "https://www.youtube.com/watch?v=nhIQMCXJzLI" +// private val timeslotAcademicQuarter = mapOf( +// "6:00" to "6:15", +// "8:00" to "7:45", +// "8:00" to "8:15", +// "10:00" to "9:45", +// "10:00" to "10:15", +// "12:00" to "11:45", +// "12:00" to "12:15", +// "14:00" to "13:45", +// "14:00" to "14:15", +// "16:00" to "15:45", +// "16:00" to "16:15", +// "18:00" to "17:45", +// "18:00" to "18:15", +// "20:00" to "19:45", +// "20:00" to "20:15", +// "22:00" to "21:45", +// ) } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/data/StudIPParser.kt b/app/src/main/java/com/denizk0461/studip/data/StudIPParser.kt index 0c5fab2..f27d5c2 100644 --- a/app/src/main/java/com/denizk0461/studip/data/StudIPParser.kt +++ b/app/src/main/java/com/denizk0461/studip/data/StudIPParser.kt @@ -3,43 +3,99 @@ package com.denizk0461.studip.data import com.denizk0461.studip.model.StudIPEvent import org.jsoup.Jsoup +/** + * Parser class used for fetching and collecting scheduled events from a Stud.IP timetable. + */ class StudIPParser { + /** + * Parse a given HTML string. HTML must be of a Stud.IP timetable. + * + * @param html website content of the Stud.IP timetable + * @param insert action call to save the contents to persistent storage + */ fun parse(html: String, insert: (events: List) -> Unit) { + // Primary key value to uniquely identify entries in the database var id = 0 + + // Parse the HTML using Jsoup to traverse the document val doc = Jsoup.parse(html) + + /* + * Create the list that all events will be added to temporarily before saving them to + * persistent storage. + */ val newEvents = mutableListOf() - val columns = arrayOf( // TODO THIS NEEDS TO BE DYNAMIC! + + /* + * Fetch all columns from Monday through Friday to parse and assign the events of each day + * individually and accordingly. + * TODO this must be dynamic. As it is, it assumes that the first day is always Monday, + * which needs not be the case. The timetable can be customised by the user by removing + * days. Also, Sunday is treated as the first day in Stud.IP FOR SOME REASON, so the app + * needs to handle this appropriately. + */ + val columns = arrayOf( doc.getElementById("calendar_view_1_column_0"), doc.getElementById("calendar_view_1_column_1"), doc.getElementById("calendar_view_1_column_2"), doc.getElementById("calendar_view_1_column_3"), doc.getElementById("calendar_view_1_column_4"), ) + + // Iterate through each day columns.forEachIndexed { index, element -> - element?.getElementsByClass("schedule_entry")?.forEachIndexed { entryIndex, entry -> + + // Iterate through all events scheduled for a given day + element?.getElementsByClass("schedule_entry")?.forEach { entry -> + /* + * Retrieve the event's header. This will contain the event's title as well as the + * lecturers holding the event. + */ val entryHeader = entry.getElementsByTag("dt")[0].text() + + /* + * Find out the index before which the title ends and after which the lecturer + * name(s) begin. + */ val delimiter = entryHeader.lastIndexOf('(') - val parsedTitle = entryHeader.substring(0 until delimiter) - val parsedLecturers = entryHeader.substring(delimiter + 1 until entryHeader.length - 1) - val entryInfos = entry.getElementsByTag("dd")[0].text().split(", ", limit = 2) -// val timeSlot = Misc.parseTimeslot(entryInfos[0]) - val timeSlot = entryInfos[0].split(" - ") + // Retrieve the title of the event from the header string + val parsedTitle = entryHeader.substring(0 until delimiter).trim() + // Retrieve the lecturer name(s) from the header string + val parsedLecturers = entryHeader.substring(delimiter + 1 until entryHeader.length - 1).trim() + + /* + * Retrieve further event information. This will contain both the time slot the + * event is assigned to (index 0), as well as the room the event will primarily take + * place in (index 1). + */ + val entryInfo = entry.getElementsByTag("dd")[0].text().split(", ", limit = 2) + + // Retrieve the time slot and split it into start and end time stamps + val timeSlot = entryInfo[0].split(" - ") + + // Construct the newly scraped Stud.IP event val event = StudIPEvent( - id = id, - title = parsedTitle, - lecturer = parsedLecturers, - room = entryInfos[1], - day = index, - timeslotStart = timeSlot[0], - timeslotEnd = timeSlot[1], + id = id, + title = parsedTitle, + lecturer = parsedLecturers, + room = entryInfo[1], + day = index, + timeslotStart = timeSlot[0], + timeslotEnd = timeSlot[1], ) + + // Add the new element to the temporary list newEvents.add(event) + + // Raise the primary key value by 1 to avoid not fulfilling the unique constraint id += 1 } } + + // After fetching has finished, save the list of new items into persistent storage insert(newEvents) } } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/data/StwParser.kt b/app/src/main/java/com/denizk0461/studip/data/StwParser.kt index abf9ba0..0caa1ed 100644 --- a/app/src/main/java/com/denizk0461/studip/data/StwParser.kt +++ b/app/src/main/java/com/denizk0461/studip/data/StwParser.kt @@ -1,25 +1,50 @@ package com.denizk0461.studip.data -import android.util.Log -import com.denizk0461.studip.db.EventRepository +import com.denizk0461.studip.db.AppRepository import com.denizk0461.studip.model.* import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.select.Elements -// Parser class for the Studierendenwerk cafeteria plans +/** + * Parser class used for fetching and collecting canteen offers from the website of the + * Studierendenwerk Bremen. + */ class StwParser { + // Unique primary key value for the date elements private var dateId = 0 + + // Unique primary key value for the canteen elements private var canteenId = 0 + + // Unique primary key value for the category elements private var categoryId = 0 + + // Unique primary key value for the item elements private var itemId = 0 + /** + * Parses through a list of canteen plans and saves them to persistent storage. + * + * @param onRefreshUpdate action call for when an update on the status of the fetch is + * available + * @param onFinish action call for when the fetch has finished + */ fun parse(onRefreshUpdate: (status: Int) -> Unit, onFinish: () -> Unit) { + /* + * Reset primary key values. This needs to be done in case this method is called multiple + * times within the app's lifespan + */ dateId = 0 canteenId = 0 categoryId = 0 itemId = 0 + + /* + * Create and populate a list of all the canteen plans that will be fetched. + * TODO implement functionality that will allow for fetching multiple plans, if necessary + */ val links = mutableListOf() if (true) { links.add(urlUniMensa) } // if (true) { links.add(urlCafeCentral) } @@ -32,66 +57,106 @@ class StwParser { // if (true) { links.add(urlMensaBHV) } // if (true) { links.add(urlCafeBHV) } + // Delete all previous entries and start afresh Dependencies.repo.nukeOffers() - // STEP: fetch every canteen + /* + * Fetch offers from every canteen individually. This is done in sequence for + * simplicity's sake. + * TODO perhaps it can be assumed here that the next two weeks will always be available? + * Hence, the date fetch might be superfluous and a database insert call could be + * implemented here instead. Another option would be to set the conflict policy to IGNORE + * and move the dateId reset from parseFromPage() to parse(). + */ links.forEach { link -> - // TODO implement onRefresh(Int) parseFromPage(link, Dependencies.repo) + + // TODO implement onRefresh(Int) } + + // Action call once all fetching activities have finished onFinish() } - private fun parseFromPage(url: String, repo: EventRepository): List { - val items = mutableListOf() + /** + * Fetch and parse the plan of a specific canteen. + * + * @param url link to the canteen to be scraped. Must be a subpage of stw-bremen.de + * @param repo reference to the app repository used to save the items to persistent storage + */ + private fun parseFromPage(url: String, repo: AppRepository) { + // Used to store a parsed date string without creating a new variable on every loop var date: String + // Reset the primary key value for the date objects dateId = 0 + // Parse the HTML using Jsoup to traverse the document val doc = Jsoup.connect(url).get() + // Save the canteen to persistent storage repo.insert(OfferCanteen(canteenId, doc.getElementsByClass("pane-title")[1].text())) - // STEP: get each day + // Iterate through each day for which offers are available doc.getElementsByClass("food-plan").forEach { dayPlan -> + /* + * Fetch the date for a given day. It will be in the following format: + * 24. Apr + * This value will be split between the day (24.) and the month (Apr) + */ val rawDate = doc.getElementsByClass("tabs")[0] .getElementsByClass("tab-date")[dateId].text().split(" ") + /* + * Parse the two elements into a single date. The month will be converted to a numeric + * value. Example: + * Input: 24. Apr + * Output: 24.04. + */ date = "${rawDate[0]}${rawDate[1].monthToNumber()}." - val d = OfferDate(dateId, date) - Log.d("eek!8", d.toString()) - repo.insert(d) + /* + * Save the date to persistent storage. + * TODO this will duplicate date entries for every canteen that is fetched. This is + * inefficient. + */ + repo.insert(OfferDate(dateId, date)) - // STEP: get each category in a day + // Iterate through all categories offered on a given day dayPlan.getElementsByClass("food-category").forEach { category -> + // Retrieve the category text val categoryTitle = category.getElementsByClass("category-name")[0].text() + // Save the category to persistent storage repo.insert(OfferCategory(categoryId, dateId, canteenId, categoryTitle)) - // STEP: get each item in a category + // Iterate through all items in a category category .getElementsByTag("tbody")[0] .getElementsByTag("tr").forEach { element -> + // Retrieve the parent element holding the item's title and price val tableRows = element.getElementsByTag("td") - val prefs = - element.getElementsByClass("field field-name-field-food-types")[0] + // Retrieve the dietary preferences the item meets + val prefs = element + .getElementsByClass("field field-name-field-food-types")[0] + // Parse the preferences into a string for the database val prefString = DietaryPrefObject( - isFair = prefs.isDietaryPreferenceMet(PREFERENCE_FAIR), - isFish = prefs.isDietaryPreferenceMet(PREFERENCE_FISH), - isPoultry = prefs.isDietaryPreferenceMet(PREFERENCE_POULTRY), - isLamb = prefs.isDietaryPreferenceMet(PREFERENCE_LAMB), - isVital = prefs.isDietaryPreferenceMet(PREFERENCE_VITAL), - isBeef = prefs.isDietaryPreferenceMet(PREFERENCE_BEEF), - isPork = prefs.isDietaryPreferenceMet(PREFERENCE_PORK), - isVegan = prefs.isDietaryPreferenceMet(PREFERENCE_VEGAN), - isVegetarian = prefs.isDietaryPreferenceMet(PREFERENCE_VEGETARIAN), - isGame = prefs.isDietaryPreferenceMet(PREFERENCE_GAME), + isFair = prefs.isDietaryPreferenceMet(imageLinkPrefFair), + isFish = prefs.isDietaryPreferenceMet(imageLinkPrefFish), + isPoultry = prefs.isDietaryPreferenceMet(imageLinkPrefPoultry), + isLamb = prefs.isDietaryPreferenceMet(imageLinkPrefLamb), + isVital = prefs.isDietaryPreferenceMet(imageLinkPrefVital), + isBeef = prefs.isDietaryPreferenceMet(imageLinkPrefBeef), + isPork = prefs.isDietaryPreferenceMet(imageLinkPrefPork), + isVegan = prefs.isDietaryPreferenceMet(imageLinkPrefVegan), + isVegetarian = prefs.isDietaryPreferenceMet(imageLinkPrefVegetarian), + isGame = prefs.isDietaryPreferenceMet(imageLinkPrefGame), ).deconstruct() + // Save the item to persistent storage repo.insert( OfferItem( itemId, @@ -101,39 +166,77 @@ class StwParser { prefString, ) ) + + // Increment the item ID to avoid overriding itemId += 1 } + // Increment the category ID to avoid overriding categoryId += 1 } + // Increment the date ID to avoid overriding dateId += 1 } + // Increment the canteen ID to avoid overriding canteenId += 1 - - return items } + /** + * Evaluates whether a dietary preference is met by checking if the link to an image can be + * found in the HTML. + * + * @param preference constraint that needs to be met + * @return whether it is met + */ private fun Element.isDietaryPreferenceMet(preference: String): Boolean = getElementsByAttributeValue("src", preference).isNotEmpty() + /** + * Processes certain character references into human-readable characters. Since the fetched HTML + * contains unresolved symbols such as &, they need to be replaced with their counterpart + * (in this case, &). + * + * @return the filtered string + */ private fun Element.getFilteredText(): String { + // Retrieve the element's inner HTML and replace faulty characters var text = html() .replace("&", "&") .replace(">", ">") .replace("<", "<") + /* + * The Studierendenwerk's website lists allergens, but they are invisible, rendering them + * pointless to the website user. As of now, this information is discarded in the app. + * TODO implement allergen functionality + */ while (text.contains("")) { text = text.substring(0 until text.indexOf("")) + text.substring(text.indexOf("")+6 until text.length) } + // Return the stripped and updated HTML string return text } + /** + * Retrieve the text of an element in a certain position if the element can be found. Else, + * return an empty string. + * + * @param index position at which a child + * @return text content or, if no element is found, an empty string + */ private fun Elements.getTextOrEmpty(index: Int): String = try { get(index).text() } catch (e: java.lang.IndexOutOfBoundsException) { "" } + /** + * Converts a month text string to its numeric value. Example: + * Input: Apr + * Output: 04 + * + * @return the numeric value of the month + */ private fun String.monthToNumber(): String = when (this) { "Jan" -> "01" "Feb" -> "02" @@ -150,22 +253,24 @@ class StwParser { else -> "00" // shouldn't occur } - private val PREFERENCE_FAIR = "https://www.stw-bremen.de/sites/default/files/images/pictograms/at_small.png" - private val PREFERENCE_FISH = "https://www.stw-bremen.de/sites/default/files/images/pictograms/fisch.png" - private val PREFERENCE_POULTRY = "https://www.stw-bremen.de/sites/default/files/images/pictograms/geflugel.png" - private val PREFERENCE_LAMB = "https://www.stw-bremen.de/sites/default/files/images/pictograms/lamm.png" - private val PREFERENCE_VITAL = "https://www.stw-bremen.de/sites/default/files/images/pictograms/mensa_vital.png" - private val PREFERENCE_BEEF = "https://www.stw-bremen.de/sites/default/files/images/pictograms/rindfleisch.png" - private val PREFERENCE_PORK = "https://www.stw-bremen.de/sites/default/files/images/pictograms/schwein.png" - private val PREFERENCE_VEGAN = "https://www.stw-bremen.de/sites/default/files/images/pictograms/mensa_vegan.png" - private val PREFERENCE_VEGETARIAN = "https://www.stw-bremen.de/sites/default/files/images/pictograms/vegetarisch.png" - private val PREFERENCE_GAME = "https://www.stw-bremen.de/sites/default/files/images/pictograms/wild.png" + // Image links to all dietary preferences used for checking whether a preference is met + private val imageLinkPrefFair = "https://www.stw-bremen.de/sites/default/files/images/pictograms/at_small.png" + private val imageLinkPrefFish = "https://www.stw-bremen.de/sites/default/files/images/pictograms/fisch.png" + private val imageLinkPrefPoultry = "https://www.stw-bremen.de/sites/default/files/images/pictograms/geflugel.png" + private val imageLinkPrefLamb = "https://www.stw-bremen.de/sites/default/files/images/pictograms/lamm.png" + private val imageLinkPrefVital = "https://www.stw-bremen.de/sites/default/files/images/pictograms/mensa_vital.png" + private val imageLinkPrefBeef = "https://www.stw-bremen.de/sites/default/files/images/pictograms/rindfleisch.png" + private val imageLinkPrefPork = "https://www.stw-bremen.de/sites/default/files/images/pictograms/schwein.png" + private val imageLinkPrefVegan = "https://www.stw-bremen.de/sites/default/files/images/pictograms/mensa_vegan.png" + private val imageLinkPrefVegetarian = "https://www.stw-bremen.de/sites/default/files/images/pictograms/vegetarisch.png" + private val imageLinkPrefGame = "https://www.stw-bremen.de/sites/default/files/images/pictograms/wild.png" + // URLs to all canteens in Bremen and Bremerhaven managed by the Studierendenwerk Bremen private val urlUniMensa = "https://www.stw-bremen.de/de/mensa/uni-mensa" private val urlCafeCentral = "https://www.stw-bremen.de/de/mensa/cafe-central" private val urlNW1 = "https://www.stw-bremen.de/de/mensa/nw-1" private val urlGW2 = "https://www.stw-bremen.de/de/cafeteria/gw2" -// private val urlGraz = "" +// private val urlGraz = "" // No link since there are only snacks on offer that are not listed online private val urlHSBNeustadt = "https://www.stw-bremen.de/de/mensa/neustadtswall" private val urlHSBWerder = "https://www.stw-bremen.de/de/mensa/werderstra%C3%9Fe" private val urlHSBAirport = "https://www.stw-bremen.de/de/mensa/airport" diff --git a/app/src/main/java/com/denizk0461/studip/db/AppDAO.kt b/app/src/main/java/com/denizk0461/studip/db/AppDAO.kt new file mode 100644 index 0000000..80c0b16 --- /dev/null +++ b/app/src/main/java/com/denizk0461/studip/db/AppDAO.kt @@ -0,0 +1,137 @@ +package com.denizk0461.studip.db + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.denizk0461.studip.model.* + +/** + * Database access object used for all database transactions in the app. Can be accessed through an + * instance of AppRepository.kt. + */ +@Dao +interface AppDAO { + + /* --- Stud.IP schedule events --- */ + + /** + * Retrieves all Stud.IP events. + * + * @return all Stud.IP events exposed through a LiveData object + */ + @get:Query("SELECT * FROM events ORDER BY id") + val allEvents: LiveData> + + /** + * Inserts a Stud.IP event into the database. + * + * @param event object to be saved to the database + */ + @Insert fun insert(event: StudIPEvent) + + /** + * Inserts a list of Stud.IP events into the database. + * + * @param events objects to be saved to the database + */ + @Insert fun insert(events: List) + + /** + * Deletes all Stud.IP events from the database. + */ + @Query("DELETE FROM events") + fun nukeEvents() + + /* --- canteen offers --- */ + + /** + * Retrieves all canteen offers. 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 exposed through a LiveData object + */ + @get: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" + ) + val allOffers: LiveData> + + /** + * Retrieves all canteen offers that match given dietary preferences. 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 preference 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 dietary_preferences = :prefs " + ) + fun getOffersByPreference(prefs: String): LiveData> + + /** + * Retrieves all canteen offer date objects. + * + * @return a list of instances of canteen offer dates + */ + @Query("SELECT * FROM offer_date ORDER BY id") + fun getDates(): List + + /** + * Deletes all dates from the database. + */ + @Query("DELETE FROM offer_date") + fun nukeOfferDates() + + /** + * Deletes all canteens from the database. + */ + @Query("DELETE FROM offer_canteen") + fun nukeOfferCanteens() + + /** + * Deletes all canteen offer categories from the database. + */ + @Query("DELETE FROM offer_category") + fun nukeOfferCategories() + + /** + * Deletes all canteen offer items from the database. + */ + @Query("DELETE FROM offer_item") + fun nukeOfferItems() + + /** + * Inserts a date into the database. + * + * @param date object to be saved to the database + */ + @Insert fun insert(date: OfferDate) + + /** + * Inserts a canteen into the database. + * + * @param canteen object to be saved to the database + */ + @Insert fun insert(canteen: OfferCanteen) + + /** + * Inserts a canteen offer category into the database. + * + * @param category object to be saved to the database + */ + @Insert fun insert(category: OfferCategory) + + /** + * Inserts a canteen offer item into the database. + * + * @param item object to be saved to the database + */ + @Insert fun insert(item: OfferItem) +} \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/db/AppDatabase.kt b/app/src/main/java/com/denizk0461/studip/db/AppDatabase.kt new file mode 100644 index 0000000..40fe84e --- /dev/null +++ b/app/src/main/java/com/denizk0461/studip/db/AppDatabase.kt @@ -0,0 +1,55 @@ +package com.denizk0461.studip.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.denizk0461.studip.model.* + +/** + * Room database that holds all objects that need to be saved to persistent storage. + */ +@Database( + entities = [ + StudIPEvent::class, + OfferDate::class, + OfferCanteen::class, + OfferCategory::class, + OfferItem::class + ], + version = 10, +) +abstract class AppDatabase : RoomDatabase() { + + // The database access object + abstract fun dao(): AppDAO + + companion object { + // The singular instance of this database + private var instance: AppDatabase? = null + + /** + * Retrieve the instance of the database. + * + * @param context used to resolve the object + * @return the database + */ + fun getInstance(context: Context): AppDatabase { + /* + * Check if an object has already been instantiated. Only create a new instance if none + * exists. + */ + if (instance == null) { + synchronized(AppDatabase::class) { + instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "event_db" + ).fallbackToDestructiveMigration().build() + } + } + // Instance can never be null at this point + return instance!! + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/db/AppRepository.kt b/app/src/main/java/com/denizk0461/studip/db/AppRepository.kt new file mode 100644 index 0000000..1c5217e --- /dev/null +++ b/app/src/main/java/com/denizk0461/studip/db/AppRepository.kt @@ -0,0 +1,171 @@ +package com.denizk0461.studip.db + +import android.app.Application +import androidx.lifecycle.LiveData +import androidx.preference.PreferenceManager +import com.denizk0461.studip.model.* + +/** + * Repository objects that acts as a mediator between view models and the database to retrieve data + * without being bound to a view lifecycle. + * + * @param app reference to the application + */ +class AppRepository(app: Application) { + + // Reference to the database access object used to handle database transactions + private val dao: AppDAO = AppDatabase.getInstance(app.applicationContext).dao() + + // Shared preferences used to store small values + private val prefs = PreferenceManager.getDefaultSharedPreferences(app) + + // Tag used to store the user's dietary preferences in persistent storage (shared preferences) + private val dietaryPrefString = "prefs_obj" + + // Blank dietary preference regular expression + private val blankDietaryPrefs: String = DietaryPrefObject.C_FALSE.toString().repeat(10) + + /** + * Retrieves all Stud.IP events. + * + * @return all Stud.IP events exposed through a LiveData object + */ + val allEvents: LiveData> = dao.allEvents + + /** + * Inserts a list of Stud.IP events into the database. + * + * @param events objects to be saved to the database + */ + fun insertEvents(events: List) { dao.insert(events) } + + /** + * Deletes all Stud.IP events from the database. + */ + fun nukeEvents() { dao.nukeEvents() } + + /** + * Retrieves all canteen offers. + * + * @return all canteen offers exposed through a LiveData object + */ + val allOffers: LiveData> = dao.allOffers + + /** + * Retrieves all canteen offer date objects. + * + * @return a list of instances of canteen offer dates + */ + fun getDates(): List = dao.getDates() + + /** + * Retrieves the regex string used to determine the user's dietary preferences, or, if no + * preference has been set, a 'blank' expression denoting such. + * + * @return dietary preferences as a regular expression string + */ + private fun getDietaryPrefs(): String { + return prefs.getString(dietaryPrefString, blankDietaryPrefs) ?: blankDietaryPrefs + } + + /** + * Retrieve the user's dietary preferences as a DietaryPrefObject. + * + * @return dietary preferences as an instance of DietaryPrefObject.kt + */ + fun getDietaryPrefsAsObj(): DietaryPrefObject = DietaryPrefObject.construct(getDietaryPrefs()) + + /** + * Updates a given dietary preference to a new value. + * + * @param pref preference to be updated + * @param newValue value to set the preference to + */ + fun setPreference(pref: DietaryPreferences, newValue: Boolean) { + val oldString = getDietaryPrefs().toMutableList() + + oldString[pref.ordinal] = newValue.toChar() +// when (pref) { +// DietaryPreferences.FAIR -> oldString[0] = newValue.toChar() +// DietaryPreferences.FISH -> oldString[1] = newValue.toChar() +// DietaryPreferences.POULTRY -> oldString[2] = newValue.toChar() +// DietaryPreferences.LAMB -> oldString[3] = newValue.toChar() +// DietaryPreferences.VITAL -> oldString[4] = newValue.toChar() +// DietaryPreferences.BEEF -> oldString[5] = newValue.toChar() +// DietaryPreferences.PORK -> oldString[6] = newValue.toChar() +// DietaryPreferences.VEGAN -> oldString[7] = newValue.toChar() +// DietaryPreferences.VEGETARIAN -> oldString[8] = newValue.toChar() +// DietaryPreferences.GAME -> oldString[9] = newValue.toChar() +// } + val newString = oldString.joinToString("") + prefs.edit().putString(dietaryPrefString, newString).apply() + } + + /** + * Converts a boolean value to the corresponding value used to construct a regular expression + * for determining whether a dietary preference is met. + * + * @return char denoting whether a preference needs to be met + */ + private fun Boolean.toChar() = if (this) DietaryPrefObject.C_TRUE else DietaryPrefObject.C_FALSE + + /** + * Retrieves a single user-specified dietary preference. + * + * @param pref the dietary preference that should be retrieved + * @return whether the preference needs to be met1 + */ + fun getPreference(pref: DietaryPreferences): Boolean = + getDietaryPrefs()[pref.ordinal] == DietaryPrefObject.C_TRUE +// val prefString = getDietaryPrefs().toList() +// return when (pref) { +// DietaryPreferences.FAIR -> prefString[0] == 't' +// DietaryPreferences.FISH -> prefString[1] == 't' +// DietaryPreferences.POULTRY -> prefString[2] == 't' +// DietaryPreferences.LAMB -> prefString[3] == 't' +// DietaryPreferences.VITAL -> prefString[4] == 't' +// DietaryPreferences.BEEF -> prefString[5] == 't' +// DietaryPreferences.PORK -> prefString[6] == 't' +// DietaryPreferences.VEGAN -> prefString[7] == 't' +// DietaryPreferences.VEGETARIAN -> prefString[8] == 't' +// DietaryPreferences.GAME -> prefString[9] == 't' +// } + + /** + * Deletes all canteen offers from the database. + */ + fun nukeOffers() { + dao.nukeOfferItems() + dao.nukeOfferCategories() + dao.nukeOfferCanteens() + dao.nukeOfferDates() + } + + /** + * Inserts a date into the database. + * + * @param date object to be saved to the database + */ + fun insert(date: OfferDate) { dao.insert(date) } + + /** + * Inserts a canteen into the database. + * + * @param canteen object to be saved to the database + */ + fun insert(canteen: OfferCanteen) { dao.insert(canteen) } + + /** + * Inserts a canteen offer category into the database. + * + * @param category object to be saved to the database + */ + fun insert(category: OfferCategory) { dao.insert(category) } + + /** + * Inserts a canteen offer item into the database. + * + * @param item object to be saved to the database + */ + fun insert(item: OfferItem) { dao.insert(item) } +} \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/db/EventDAO.kt b/app/src/main/java/com/denizk0461/studip/db/EventDAO.kt deleted file mode 100644 index afc1c85..0000000 --- a/app/src/main/java/com/denizk0461/studip/db/EventDAO.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.denizk0461.studip.db - -import androidx.lifecycle.LiveData -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import com.denizk0461.studip.model.* - -@Dao -interface EventDAO { - - // Stud.IP schedule events - @get:Query("SELECT * FROM events ORDER BY id") - val allEvents: LiveData> - - @Insert - fun insertEvent(event: StudIPEvent) - - @Insert - fun insertEvents(event: List) - - @Query("DELETE FROM events") - fun nukeEvents() - -// @get:Query("SELECT * FROM offers ORDER BY id") -// val allOffers: LiveData> -// -// @Insert -// fun insertOffers(offers: List) -// -// @Query("DELETE from offers") -// fun nukeOffers() - - // canteen offers - // get all offers - @get: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" - ) - val allOffers: LiveData> - - @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 dietary_preferences = :prefs " -// "WHERE isFair = :isFair " + -// "OR isFish = :isFish " + -// "OR isPoultry = :isPoultry " + -// "OR isLamb = :isLamb " + -// "OR isVital = :isVital " + -// "OR isBeef = :isBeef " + -// "OR isPork = :isPork " + -// "OR isVegan = :isVegan " + -// "OR isVegetarian = :isVegetarian " + -// "OR isGame = :isGame " - ) - fun getOffersByPreference(prefs: String -// isFair: Boolean, isFish: Boolean, isPoultry: Boolean, isLamb: Boolean, isVital: Boolean, -// isBeef: Boolean, isPork: Boolean, isVegan: Boolean, isVegetarian: Boolean, isGame: Boolean - ): LiveData> - - @get:Query("SELECT id FROM offer_date") - val updateObserver: LiveData> - - @Query("SELECT * FROM offer_date ORDER BY id") - fun getDates(): List - - @Query("DELETE FROM offer_date") - fun nukeOfferDates() - - @Query("DELETE FROM offer_category") - fun nukeOfferCategories() - - @Query("DELETE FROM offer_canteen") - fun nukeOfferCanteens() - - @Query("DELETE FROM offer_item") - fun nukeOfferItems() - - @Insert fun insert(date: OfferDate) - @Insert fun insert(canteen: OfferCanteen) - @Insert fun insert(category: OfferCategory) - @Insert fun insert(item: OfferItem) -} \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/db/EventDatabase.kt b/app/src/main/java/com/denizk0461/studip/db/EventDatabase.kt deleted file mode 100644 index 40b8c7b..0000000 --- a/app/src/main/java/com/denizk0461/studip/db/EventDatabase.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.denizk0461.studip.db - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import com.denizk0461.studip.model.* - -@Database( - entities = [ - StudIPEvent::class, - OfferDate::class, - OfferCanteen::class, - OfferCategory::class, - OfferItem::class - ], - version = 10, -) -abstract class EventDatabase : RoomDatabase() { - - abstract fun dao(): EventDAO - - companion object { - private var instance: EventDatabase? = null - - fun getInstance(context: Context): EventDatabase { - if (instance == null) { - synchronized(EventDatabase::class) { - instance = Room.databaseBuilder( - context.applicationContext, - EventDatabase::class.java, - "event_db" - ).fallbackToDestructiveMigration().build() - } - } - return instance!! - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/db/EventRepository.kt b/app/src/main/java/com/denizk0461/studip/db/EventRepository.kt deleted file mode 100644 index 19d98bc..0000000 --- a/app/src/main/java/com/denizk0461/studip/db/EventRepository.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.denizk0461.studip.db - -import android.app.Application -import android.util.Log -import androidx.lifecycle.LiveData -import androidx.preference.PreferenceManager -import com.denizk0461.studip.model.* - -class EventRepository(app: Application) { - - private val dao: EventDAO = EventDatabase.getInstance(app.applicationContext).dao() - private val prefs = PreferenceManager.getDefaultSharedPreferences(app) - - val allEvents: LiveData> = dao.allEvents - - private val dietaryPrefString = "prefs_obj" - - fun insertEvent(event: StudIPEvent) { dao.insertEvent(event) } - fun insertEvents(events: List) { dao.insertEvents(events) } - fun nukeEvents() { dao.nukeEvents() } - - val allOffers: LiveData> = dao.allOffers - - fun getDates(): List = dao.getDates() - - fun getDietaryPrefs(): String { - return prefs.getString(dietaryPrefString, "..........") ?: ".........." -// return DietaryPrefObject( -// isFair = prefs.getBoolean(DietaryPreferences.FAIR.value, false), -// isFish = prefs.getBoolean(DietaryPreferences.FISH.value, false), -// isPoultry = prefs.getBoolean(DietaryPreferences.POULTRY.value, false), -// isLamb = prefs.getBoolean(DietaryPreferences.LAMB.value, false), -// isVital = prefs.getBoolean(DietaryPreferences.VITAL.value, false), -// isBeef = prefs.getBoolean(DietaryPreferences.BEEF.value, false), -// isPork = prefs.getBoolean(DietaryPreferences.PORK.value, false), -// isVegan = prefs.getBoolean(DietaryPreferences.VEGAN.value, false), -// isVegetarian = prefs.getBoolean(DietaryPreferences.VEGETARIAN.value, false), -// isGame = prefs.getBoolean(DietaryPreferences.GAME.value, false), -// ) - } - - fun getDietaryPrefsAsObj(): DietaryPrefObject = DietaryPrefObject.construct(getDietaryPrefs()) - -// private fun isPreferenceSet(): Boolean { -// getDietaryPrefs().also { p -> -// if (p.isFair) return true -// if (p.isFish) return true -// if (p.isPoultry) return true -// if (p.isLamb) return true -// if (p.isVital) return true -// if (p.isBeef) return true -// if (p.isPork) return true -// if (p.isVegan) return true -// if (p.isVegetarian) return true -// if (p.isGame) return true -// } -// return false -// } - - fun setPreference(pref: DietaryPreferences, newValue: Boolean) { -// prefs.edit().putBoolean(pref.value, newValue).apply() - val oldString = getDietaryPrefs().toMutableList() - when (pref) { - DietaryPreferences.FAIR -> oldString[0] = newValue.toChar() - DietaryPreferences.FISH -> oldString[1] = newValue.toChar() - DietaryPreferences.POULTRY -> oldString[2] = newValue.toChar() - DietaryPreferences.LAMB -> oldString[3] = newValue.toChar() - DietaryPreferences.VITAL -> oldString[4] = newValue.toChar() - DietaryPreferences.BEEF -> oldString[5] = newValue.toChar() - DietaryPreferences.PORK -> oldString[6] = newValue.toChar() - DietaryPreferences.VEGAN -> oldString[7] = newValue.toChar() - DietaryPreferences.VEGETARIAN -> oldString[8] = newValue.toChar() - DietaryPreferences.GAME -> oldString[9] = newValue.toChar() - } - val newString = oldString.joinToString("") - Log.d("eek!4", newString) - prefs.edit().putString(dietaryPrefString, newString).apply() - } - - private fun Boolean.toChar() = if (this) 't' else '.' - - fun getPreference(pref: DietaryPreferences): Boolean { - val prefString = getDietaryPrefs().toList() - return when (pref) { - DietaryPreferences.FAIR -> prefString[0] == 't' - DietaryPreferences.FISH -> prefString[1] == 't' - DietaryPreferences.POULTRY -> prefString[2] == 't' - DietaryPreferences.LAMB -> prefString[3] == 't' - DietaryPreferences.VITAL -> prefString[4] == 't' - DietaryPreferences.BEEF -> prefString[5] == 't' - DietaryPreferences.PORK -> prefString[6] == 't' - DietaryPreferences.VEGAN -> prefString[7] == 't' - DietaryPreferences.VEGETARIAN -> prefString[8] == 't' - DietaryPreferences.GAME -> prefString[9] == 't' - } - } -// prefs.getBoolean(pref.value, false) - -// fun insertOffers(offers: List) { dao.insertOffers(offers) } - fun nukeOffers() { - dao.nukeOfferItems() - dao.nukeOfferCategories() - dao.nukeOfferCanteens() - dao.nukeOfferDates() - } - - fun insert(date: OfferDate) { dao.insert(date) } - fun insert(canteen: OfferCanteen) { dao.insert(canteen) } - fun insert(category: OfferCategory) { dao.insert(category) } - fun insert(item: OfferItem) { dao.insert(item) } -} \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/fragment/CanteenFragment.kt b/app/src/main/java/com/denizk0461/studip/fragment/CanteenFragment.kt index 285b327..1c5f2b2 100644 --- a/app/src/main/java/com/denizk0461/studip/fragment/CanteenFragment.kt +++ b/app/src/main/java/com/denizk0461/studip/fragment/CanteenFragment.kt @@ -7,29 +7,44 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.LiveData import com.denizk0461.studip.adapter.CanteenOfferPageAdapter import com.denizk0461.studip.databinding.FragmentCanteenBinding import com.denizk0461.studip.model.* import com.denizk0461.studip.viewmodel.CanteenViewModel 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 : Fragment() { + // Nullable view binding reference private var _binding: FragmentCanteenBinding? = null - // This property is only valid between onCreateView and - // onDestroyView. - // BUG: if the fragment is changed before the refresh is finished, the app crashes with a NPE + /* + * Non-null reference to the view binding. This property is only valid between onCreateView and + * onDestroyView. + * BUG if the fragment is changed before the refresh is finished, the app crashes with a NPE + */ private val binding get() = _binding!! + // Adapter for the view pager displaying all offers private lateinit var viewPagerAdapter: CanteenOfferPageAdapter + + // View model reference for providing access to the database private val viewModel: CanteenViewModel by viewModels() - + // Elements in the canteen plan private var elements: List = listOf() - private var dateSize = 0 + // Count of days displayed in the canteen plan + private var dateSize: Int = 0 + + // Used if no preference has been set or none can be found + private val emptyPreferenceRegex: String = ".........." + + // Instantiate the view binding override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentCanteenBinding.inflate(inflater, container, false) return binding.root @@ -38,6 +53,7 @@ class CanteenFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + // Assign a preference value to every button to filter for dietary preferences val chipMap = mapOf( binding.chipPrefFair to DietaryPreferences.FAIR, binding.chipPrefFish to DietaryPreferences.FISH, @@ -51,15 +67,21 @@ class CanteenFragment : Fragment() { binding.chipPrefGame to DietaryPreferences.GAME, ) + // Set up every chip chipMap.forEach { (chip, pref) -> + // Set chip to be checked if user has set this preference before chip.isChecked = getPreference(pref) - chip.setOnCheckedChangeListener { buttonView, newValue -> - setPreference(pref, newValue) - Log.d("eek!4", "object as : ${viewModel.getDietaryPrefs().deconstruct()}") - /* Since this removes the view before immediately adding it back, this method causes a - * flicker. Note for the future: implement a more efficient / better-looking method. - * + // Set a listener for when the user clicks the chip + chip.setOnCheckedChangeListener { buttonView, newValue -> + // Save the preference change to persistent storage + setPreference(pref, newValue) + + /* + * Force an animation on click. This is a subpar method from StackOverflow. Since + * it removes the view before immediately adding it back, this method causes a + * flicker. + * TODO implement a more efficient / better-looking method * TODO order the chips alphabetically */ val index = binding.chipsPreference.indexOfChild(buttonView) @@ -68,29 +90,42 @@ class CanteenFragment : Fragment() { } } - viewPagerAdapter = CanteenOfferPageAdapter(listOf(), 0, getPrefRegex()) + // Set up the view pager's adapter + viewPagerAdapter = CanteenOfferPageAdapter(listOf(), 0) + + // Assign the adapter to the view pager binding.viewPager.adapter = viewPagerAdapter + // Create and attach the tab mediator for the view pager createTabLayoutMediator() + // 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() - val newDates = groupedElements.map { it.date }.distinct()//.map { item -> OfferDate(item.) } -// val newDates = viewModel.getDates() + // Find all dates stored in the database + val newDates = groupedElements.map { it.date }.distinct() // TODO viewModel.getDates() + // 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) -// createTabLayoutMediator(newDates) - // TODO this must be changed. if an exception is raised, then this will not fire + /* + * Tell the swipe refresh layout to stop refreshing. + * TODO if an exception is raised, this will not be fired. This must be changed + */ binding.swipeRefreshLayout.isRefreshing = false } + // Set up functions for when the user swipes to refresh the view binding.swipeRefreshLayout.setOnRefreshListener { + // Retrieve new offers from the website(s) viewModel.fetchOffers(onRefreshUpdate = { status -> // TODO refresh updates }, onFinish = { @@ -100,7 +135,11 @@ class CanteenFragment : Fragment() { } } - private fun createTabLayoutMediator(){//List) { + /** + * Create and attach the object mediating the tabs for the view pager. + */ + private fun createTabLayoutMediator() { + // Fetch all dates from the database val dates = viewModel.getDates() if (dates.isNotEmpty()) { Log.d("eek!7", "dates: ${dates}, viewpager pages: ${binding.viewPager.adapter?.itemCount}") @@ -111,57 +150,106 @@ class CanteenFragment : Fragment() { } } + // Invalidate the view binding override fun onDestroyView() { super.onDestroyView() _binding = null } + /** + * Updates a given preference and refreshes the view. + * + * @param pref preference to be updated + * @param newValue value to set the preference to + */ private fun setPreference(pref: DietaryPreferences, newValue: Boolean) { + // Update the preference viewModel.setPreference(pref, newValue) + + // Update the view viewPagerAdapter.setNewItems(elements.groupElements().distinct(), dateSize) } + /** + * Retrieves a single user-specified dietary preference. + * + * @param pref the dietary preference that should be retrieved + * @return whether the preference needs to be met + */ 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 { - val prefs = viewModel.getDietaryPrefs().deconstruct()//.replace('f', '.') + // Retrieve the user's dietary preference as a string + val prefs = viewModel.getDietaryPrefs().deconstruct() - val template = ".........." - - return if (prefs == "..........") { - Regex(template) + // 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() + + // Find all preferences that need to be met prefs.forEachIndexed { index, c -> - if (c == 't') indices.add(index) + if (c == DietaryPrefObject.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 += '|' - regexString += template.substring(0 until index) + 't' + template.substring(index+1) + + // Add the expression looking for the single preference to the entire string + regexString += emptyPreferenceRegex.substring(0 until index) + + DietaryPrefObject.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.groupElements(): List { - + // Get dietary preference regex val prefsRegex = getPrefRegex() - val filteredForPreferences = if (prefsRegex.toString() == "ffffffffff") { - // show all elements and skip filtering + val filteredForPreferences = 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) } } - val b = filteredForPreferences.map { offer -> + return filteredForPreferences.map { offer -> + // Create new group elements to group the already filtered elements by their categories CanteenOfferGroup( offer.date, offer.dateId, @@ -170,6 +258,7 @@ class CanteenFragment : Fragment() { filteredForPreferences.filter { it.category == offer.category && it.date == offer.date && it.canteen == offer.canteen }.map { + // Map the individual items to their respective groups CanteenOfferGroupElement( it.title, it.price, @@ -178,6 +267,5 @@ class CanteenFragment : Fragment() { } ) } - return b } } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/fragment/EventFragment.kt b/app/src/main/java/com/denizk0461/studip/fragment/EventFragment.kt index f88617b..dc8d84e 100644 --- a/app/src/main/java/com/denizk0461/studip/fragment/EventFragment.kt +++ b/app/src/main/java/com/denizk0461/studip/fragment/EventFragment.kt @@ -16,17 +16,29 @@ import com.google.android.material.tabs.TabLayoutMediator import java.util.* /** - * A simple [Fragment] subclass as the default destination in the navigation. + * User-facing fragment view that displays the user's Stud.IP schedule. */ class EventFragment : Fragment() { + // Nullable view binding reference private var _binding: FragmentEventBinding? = null - // This property is only valid between onCreateView and - // onDestroyView. + /* + * Non-null reference to the view binding. This property is only valid between onCreateView and + * onDestroyView. + */ private val binding get() = _binding!! + // Adapter for the view pager displaying all events + private lateinit var viewPagerAdapter: StudIPEventPageAdapter + + // View model reference for providing access to the database + private val viewModel: EventViewModel by viewModels() + + // Used to determine the current day private var dayOfWeek: Int = 0 + + // Titles for the view pager's tabs private val dayStrings = listOf( R.string.monday, R.string.tuesday, @@ -35,9 +47,7 @@ class EventFragment : Fragment() { R.string.friday, ) - private lateinit var viewPagerAdapter: StudIPEventPageAdapter - private val viewModel: EventViewModel by viewModels() - + // Instantiate the view binding override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentEventBinding.inflate(inflater, container, false) return binding.root @@ -46,6 +56,10 @@ class EventFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + /* + * Determine the current day. + * TODO expand to weekend + */ dayOfWeek = when (Calendar.getInstance().get(Calendar.DAY_OF_WEEK)) { Calendar.TUESDAY -> 1 Calendar.WEDNESDAY -> 2 @@ -54,42 +68,53 @@ class EventFragment : Fragment() { else -> 0 } + // Set up the view pager's adapter viewPagerAdapter = StudIPEventPageAdapter(listOf(), object : StudIPEventItemAdapter.OnClickListener { override fun onClick(event: StudIPEvent) { - + // TODO implement functionality or delete } - override fun onLongClick(event: StudIPEvent) { - + override fun onLongClick(event: StudIPEvent): Boolean { + // TODO implement functionality or delete + return false } - })//DummyData.events.toList()) + }) + + // Assign the adapter to the view pager binding.viewPager.adapter = viewPagerAdapter + // Create and attach the object mediating the tabs for the view pager TabLayoutMediator(binding.dayTabLayout, binding.viewPager) { tab, position -> tab.text = getString(dayStrings[position]) }.attach() + // Set up LiveData observer to refresh the view on update viewModel.allEvents.observe(viewLifecycleOwner) { events -> + // Update the item list in the view pager's adapter viewPagerAdapter.setNewItems(events) + + // Scroll to the current day switchToCurrentDayView() } - -// binding.buttonFirst.setOnClickListener { -// findNavController().navigate(R.id.action_FirstFragment_to_SecondFragment) -// } } override fun onResume() { super.onResume() + + // Scroll to the current day switchToCurrentDayView() } + // Invalidate the view binding override fun onDestroyView() { super.onDestroyView() _binding = null } + /** + * Scroll to the current day. Current day is determined after the fragment's view has been + * created. + */ private fun switchToCurrentDayView() { binding.viewPager.currentItem = dayOfWeek -// binding.dayTabLayout.getTabAt(dayOfWeek)?.select() } } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/fragment/ParserFragment.kt b/app/src/main/java/com/denizk0461/studip/fragment/ParserFragment.kt deleted file mode 100644 index 3a92e63..0000000 --- a/app/src/main/java/com/denizk0461/studip/fragment/ParserFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.denizk0461.studip.fragment - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.denizk0461.studip.data.StudIPParser -import com.denizk0461.studip.databinding.FragmentParserBinding -import com.denizk0461.studip.viewmodel.FetcherViewModel -import java.net.URLDecoder - -/** - * A simple [Fragment] subclass as the second destination in the navigation. - */ -class ParserFragment : Fragment() { - - private var html = "" // temporary storage for the website HTML - - private var _binding: FragmentParserBinding? = null - // This property is only valid between onCreateView and - // onDestroyView. - private val binding get() = _binding!! - - private val viewModel: FetcherViewModel by viewModels() - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - _binding = FragmentParserBinding.inflate(inflater, container, false) - return binding.root - - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - -// binding.buttonSecond.setOnClickListener { -// findNavController().navigate(R.id.action_SecondFragment_to_FirstFragment) -// } - - binding.webview.settings.apply { - javaScriptEnabled = true - } - binding.webview.webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView, - request: WebResourceRequest - ): Boolean { - return false - } - override fun onPageFinished(view: WebView, url: String) { - binding.webview.loadUrl( - "javascript:window.HtmlViewer.showHTML" + - "(''+document.getElementsByTagName('html')[0].innerHTML+'');" - ) - } - } - - binding.webview.loadUrl("https://elearning.uni-bremen.de/index.php?again=yes") - - binding.fab.setOnClickListener { view -> - binding.webview.evaluateJavascript( - "(function(){return encodeURI(document.getElementsByTagName('html')[0].innerHTML)})();" - ) { p0 -> - viewModel.nukeEvents() - StudIPParser().parse(URLDecoder.decode(p0, "UTF-8")) { events -> - viewModel.insertEvents(events) - } - } -// html.chunked(3000).forEach { -// Log.d("HELLO", it) -// } - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/fragment/SettingsFragment.kt b/app/src/main/java/com/denizk0461/studip/fragment/SettingsFragment.kt index e7a0453..6fcd27a 100644 --- a/app/src/main/java/com/denizk0461/studip/fragment/SettingsFragment.kt +++ b/app/src/main/java/com/denizk0461/studip/fragment/SettingsFragment.kt @@ -7,23 +7,32 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.fragment.app.Fragment import com.denizk0461.studip.BuildConfig import com.denizk0461.studip.activity.FetcherActivity -import com.denizk0461.studip.data.Misc import com.denizk0461.studip.databinding.FragmentSettingsBinding +/** + * User-facing fragment view that is used to change app settings. + */ class SettingsFragment : Fragment() { + // Nullable view binding reference private var _binding: FragmentSettingsBinding? = null - // This property is only valid between onCreateView and - // onDestroyView. + + /* + * Non-null reference to the view binding. This property is only valid between onCreateView and + * onDestroyView. + */ private val binding get() = _binding!! + + // Click counter on the app version button private var appVersionClick = 0 - private var isQuarterChecked = false + // 222 + private val mysteryLink = "https://www.youtube.com/watch?v=nhIQMCXJzLI" + // Instantiate the view binding override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentSettingsBinding.inflate(inflater, container, false) return binding.root @@ -32,47 +41,39 @@ class SettingsFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) -// binding.layoutQuarter.setOnClickListener { -// isQuarterChecked = !isQuarterChecked -// binding.switchQuarter.isChecked = isQuarterChecked -// } - + // Launch the Stud.IP schedule fetcher activity binding.buttonRefreshSchedule.setOnClickListener { launchWebView() } - binding.switchQuarter.setOnClickListener { - isQuarterChecked = !isQuarterChecked - } - + // Set click listener for the app version button binding.buttonAppVersion.setOnClickListener { when (appVersionClick) { - 19 -> { - appVersionClick += 1 - } - 20 -> { - appVersionClick += 1 - } - 21 -> { - appVersionClick += 1 - } 22 -> { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Misc.mysteryLink))) + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(mysteryLink))) appVersionClick = 0 } else -> appVersionClick += 1 } } + /* + * Display the app's version as set in the build.gradle. Also display if the app is a + * development version. + */ @SuppressLint("SetTextI18n") - binding.appVersionText.text = "${BuildConfig.VERSION_NAME}-${if (BuildConfig.DEBUG) "debug" else "release"}" + binding.appVersionText.text = "${BuildConfig.VERSION_NAME}-${if (BuildConfig.DEBUG) "dev" else "release"}" } + // Invalidate the view binding override fun onDestroyView() { super.onDestroyView() _binding = null } + /** + * Launch the Stud.IP schedule fetcher activity. + */ private fun launchWebView() { startActivity(Intent(context, FetcherActivity::class.java)) } diff --git a/app/src/main/java/com/denizk0461/studip/model/CanteenOffer.kt b/app/src/main/java/com/denizk0461/studip/model/CanteenOffer.kt index a8dc00f..7daf957 100644 --- a/app/src/main/java/com/denizk0461/studip/model/CanteenOffer.kt +++ b/app/src/main/java/com/denizk0461/studip/model/CanteenOffer.kt @@ -2,9 +2,25 @@ package com.denizk0461.studip.model import androidx.room.ColumnInfo -//@Entity(tableName = "offers") +/** + * Entity that is constructed by combining instances of OfferItem.kt (itemId, title, price, + * dietaryPreferences), OfferCategory.kt (category, categoryId), OfferCanteen.kt (canteen, + * canteenId), and OfferDate.kt (date, dateId) to be used as a data type for a custom RecyclerView + * adapter class. + * + * @param itemId unique identifier of the item + * @param date date formatted as dd.MM. + * @param dateId ID of the corresponding OfferDate.kt instance + * @param category category text content of the item + * @param categoryId ID of the corresponding OfferCategory.kt instance + * @param canteen name of the canteen + * @param canteenId ID of the corresponding OfferCanteen.kt instance + * @param title text content of the individual canteen offer + * @param price students' price for the individual canteen offer + * @param dietaryPreferences dietary preferences used to filter for the user's needs - see + * DietaryPrefObject.kt + */ data class CanteenOffer( -// @PrimaryKey val id: Int, val itemId: Int, val date: String, val dateId: Int, @@ -13,6 +29,6 @@ data class CanteenOffer( val canteen: String, val canteenId: Int, val title: String, - val price: String, // price for students + val price: String, @ColumnInfo(name = "dietary_preferences") val dietaryPreferences: String, ) diff --git a/app/src/main/java/com/denizk0461/studip/model/CanteenOfferGroup.kt b/app/src/main/java/com/denizk0461/studip/model/CanteenOfferGroup.kt index 3ce0e5b..2a887a6 100644 --- a/app/src/main/java/com/denizk0461/studip/model/CanteenOfferGroup.kt +++ b/app/src/main/java/com/denizk0461/studip/model/CanteenOfferGroup.kt @@ -1,5 +1,15 @@ package com.denizk0461.studip.model +/** + * Grouping element that holds instances of CanteenOfferGroupElement.kt that share date, category, + * and canteen values. Used to filter more easily + * + * @param date date formatted as dd.MM. + * @param dateId ID of the corresponding OfferDate.kt instance + * @param category category text content of the item + * @param canteen name of the canteen + * @param offers elements of CanteenOfferGroupElement.kt with common values + */ data class CanteenOfferGroup( val date: String, val dateId: Int, diff --git a/app/src/main/java/com/denizk0461/studip/model/CanteenOfferGroupElement.kt b/app/src/main/java/com/denizk0461/studip/model/CanteenOfferGroupElement.kt index 1ac0465..81dd943 100644 --- a/app/src/main/java/com/denizk0461/studip/model/CanteenOfferGroupElement.kt +++ b/app/src/main/java/com/denizk0461/studip/model/CanteenOfferGroupElement.kt @@ -1,5 +1,15 @@ package com.denizk0461.studip.model +/** + * Stripped version of CanteenOffer.kt that only carries essential info. Assumes that instances of + * this are assigned to corresponding date, canteen, and category via an instance of + * CanteenOfferGroup.kt. + * + * @param title text content of the individual canteen offer + * @param price students' price for the individual canteen offer + * @param dietaryPreferences dietary preferences used to filter for the user's needs - see + * DietaryPrefObject.kt + */ data class CanteenOfferGroupElement( val title: String, val price: String, diff --git a/app/src/main/java/com/denizk0461/studip/model/DietaryPrefObject.kt b/app/src/main/java/com/denizk0461/studip/model/DietaryPrefObject.kt index e1eb167..3d3e1c9 100644 --- a/app/src/main/java/com/denizk0461/studip/model/DietaryPrefObject.kt +++ b/app/src/main/java/com/denizk0461/studip/model/DietaryPrefObject.kt @@ -1,5 +1,20 @@ package com.denizk0461.studip.model +/** + * Object that holds dietary preferences. Can be used to hold both the user-set values as well as + * the values of an individual canteen item. + * + * @param isFair item contains meat from fairly-treated animals (lol sure) + * @param isFish item contains fish + * @param isPoultry item contains chicken + * @param isLamb item contains lamb + * @param isVital i don't actually know + * @param isBeef item contains beef + * @param isPork item contains pork + * @param isVegan item is plant-based; contains no animal-derived ingredients + * @param isVegetarian item is vegetarian; contains no animal parts + * @param isGame item contains game meat (e.g. deer) + */ data class DietaryPrefObject( val isFair: Boolean, val isFish: Boolean, @@ -13,11 +28,18 @@ data class DietaryPrefObject( val isGame: Boolean, ) { companion object { - private const val C_TRUE = 't' - private const val C_FALSE = '.' - /* assume that the values must be parsed as follows: - * "xxxxxxxxxx" - * t = true, . = false + // Char used to construct a regex to denote that a preference is met + const val C_TRUE = 't' + // Char used to construct a regex to denote that a preference is not met + const val C_FALSE = '.' + + /** + * 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 + * false boolean value. + * + * @param values the regex string + * @return an instance of DietaryPrefObject */ fun construct(values: String) = DietaryPrefObject( isFair = values[0] == C_TRUE, @@ -33,6 +55,15 @@ data class DietaryPrefObject( ) } + /** + * Constructs a regular expression string from a DietaryPrefObject. This can be used to filter for specific + * preferences. Example: + * .......t.. + * This expression filters for items that are vegan ('t' in position 8) and ignores all other + * preference options ('.' in all other positions). + * + * @return a 10-character long regular expression + */ fun deconstruct() = String( charArrayOf( if (this.isFair) C_TRUE else C_FALSE, diff --git a/app/src/main/java/com/denizk0461/studip/model/DietaryPreferences.kt b/app/src/main/java/com/denizk0461/studip/model/DietaryPreferences.kt index a48fb7b..940d49e 100644 --- a/app/src/main/java/com/denizk0461/studip/model/DietaryPreferences.kt +++ b/app/src/main/java/com/denizk0461/studip/model/DietaryPreferences.kt @@ -1,5 +1,13 @@ package com.denizk0461.studip.model +/** + * Enumeration that can be used in conjunction with DietaryPrefObject.kt to more concisely look up, + * specify, and iterate through dietary preferences. Order of these elements matches the order used + * on the website of the Studierendenwerk Bremen. + * + * @param value string value that can be used where the enum value itself cannot be used (e.g. + * SharedPreferences) + */ enum class DietaryPreferences(val value: String) { FAIR("isFair"), FISH("isFish"), diff --git a/app/src/main/java/com/denizk0461/studip/model/OfferCanteen.kt b/app/src/main/java/com/denizk0461/studip/model/OfferCanteen.kt index fc215cb..3f734ea 100644 --- a/app/src/main/java/com/denizk0461/studip/model/OfferCanteen.kt +++ b/app/src/main/java/com/denizk0461/studip/model/OfferCanteen.kt @@ -3,6 +3,12 @@ package com.denizk0461.studip.model import androidx.room.Entity import androidx.room.PrimaryKey +/** + * Entity for binding several canteen items to a specific date. Registered in the app database. + * + * @param id primary key that uniquely identifies the item + * @param canteen name of the canteen + */ @Entity( tableName = "offer_canteen", ) diff --git a/app/src/main/java/com/denizk0461/studip/model/OfferCategory.kt b/app/src/main/java/com/denizk0461/studip/model/OfferCategory.kt index 0520c76..d73de1d 100644 --- a/app/src/main/java/com/denizk0461/studip/model/OfferCategory.kt +++ b/app/src/main/java/com/denizk0461/studip/model/OfferCategory.kt @@ -4,6 +4,15 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey +/** + * Entity for storing categories/headers grouping individual instances of OfferItem.kt. Registered + * in the app database. + * + * @param id key that uniquely identifies the item + * @param dateId foreign key binding the item to an instance of OfferDate.kt + * @param canteenId foreign key binding the item to an instance of OfferCanteen.kt + * @param category text content of the item + */ @Entity( tableName = "offer_category", foreignKeys = [ diff --git a/app/src/main/java/com/denizk0461/studip/model/OfferDate.kt b/app/src/main/java/com/denizk0461/studip/model/OfferDate.kt index 549056f..8721f0c 100644 --- a/app/src/main/java/com/denizk0461/studip/model/OfferDate.kt +++ b/app/src/main/java/com/denizk0461/studip/model/OfferDate.kt @@ -3,6 +3,12 @@ package com.denizk0461.studip.model import androidx.room.Entity import androidx.room.PrimaryKey +/** + * Entity for binding several canteen items to a specific date. Registered in the app database. + * + * @param id primary key that uniquely identifies the item + * @param date date formatted as dd.MM. + */ @Entity( tableName = "offer_date", ) diff --git a/app/src/main/java/com/denizk0461/studip/model/OfferItem.kt b/app/src/main/java/com/denizk0461/studip/model/OfferItem.kt index 9a2bb3d..38ae4d3 100644 --- a/app/src/main/java/com/denizk0461/studip/model/OfferItem.kt +++ b/app/src/main/java/com/denizk0461/studip/model/OfferItem.kt @@ -5,6 +5,16 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey +/** + * Entity for storing individual items of a university canteen. Registered in the app database. + * + * @param itemId primary key that uniquely identifies the item + * @param categoryId foreign key binding the item to an instance of OfferCategory.kt + * @param title text content of the individual canteen offer + * @param price students' price for the individual canteen offer + * @param dietaryPreferences dietary preferences used to filter for the user's needs - see + * DietaryPrefObject.kt + */ @Entity( tableName = "offer_item", foreignKeys = [ @@ -22,14 +32,4 @@ data class OfferItem( val title: String, val price: String, @ColumnInfo(name = "dietary_preferences") val dietaryPreferences: String, -// val isFair: Boolean, -// val isFish: Boolean, -// val isPoultry: Boolean, -// val isLamb: Boolean, -// val isVital: Boolean, -// val isBeef: Boolean, -// val isPork: Boolean, -// val isVegan: Boolean, -// val isVegetarian: Boolean, -// val isGame: Boolean, ) \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/viewmodel/CanteenViewModel.kt b/app/src/main/java/com/denizk0461/studip/viewmodel/CanteenViewModel.kt index ad26d52..9d4d5d8 100644 --- a/app/src/main/java/com/denizk0461/studip/viewmodel/CanteenViewModel.kt +++ b/app/src/main/java/com/denizk0461/studip/viewmodel/CanteenViewModel.kt @@ -8,18 +8,57 @@ import com.denizk0461.studip.model.DietaryPrefObject import com.denizk0461.studip.model.DietaryPreferences import com.denizk0461.studip.model.OfferDate +/** + * View model for [com.denizk0461.studip.fragment.CanteenFragment] + * + * @param app reference to the app + */ class CanteenViewModel(app: Application) : TemplateViewModel(app) { + // Instantiate a parser, should the user want to refresh the canteen offers private val parser = StwParser() + /** + * Retrieves all Stud.IP events. + * + * @return all Stud.IP events exposed through a LiveData object + */ val allOffers: LiveData> = repo.allOffers + /** + * Retrieve the user's dietary preferences. + * + * @return dietary preferences + */ fun getDietaryPrefs(): DietaryPrefObject = repo.getDietaryPrefsAsObj() + /** + * Retrieves all canteen offer date objects. + * + * @return a list of instances of canteen offer dates + */ fun getDates(): List = returnBlocking { repo.getDates() } - fun fetchOffers(onRefreshUpdate: (status: Int) -> Unit, onFinish: () -> Unit) { doAsync { parser.parse(onRefreshUpdate, onFinish) }} + // Fetch the canteen offers asynchronously. Updates will be provided through a LiveData object. + fun fetchOffers(onRefreshUpdate: (status: Int) -> Unit, onFinish: () -> Unit) { + doAsync { parser.parse(onRefreshUpdate, onFinish) } + } - fun setPreference(pref: DietaryPreferences, newValue: Boolean) { repo.setPreference(pref, newValue) } + /** + * Updates a given dietary preference to a new value. + * + * @param pref preference to be updated + * @param newValue value to set the preference to + */ + fun setPreference(pref: DietaryPreferences, newValue: Boolean) { + repo.setPreference(pref, newValue) + } + + /** + * Retrieves a single user-specified dietary preference. + * + * @param pref the dietary preference that should be retrieved + * @return whether the preference needs to be met + */ fun getPreference(pref: DietaryPreferences): Boolean = repo.getPreference(pref) } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/viewmodel/EventViewModel.kt b/app/src/main/java/com/denizk0461/studip/viewmodel/EventViewModel.kt index 0e05bd7..186f6ce 100644 --- a/app/src/main/java/com/denizk0461/studip/viewmodel/EventViewModel.kt +++ b/app/src/main/java/com/denizk0461/studip/viewmodel/EventViewModel.kt @@ -4,10 +4,17 @@ import android.app.Application import androidx.lifecycle.LiveData import com.denizk0461.studip.model.StudIPEvent +/** + * View model for [com.denizk0461.studip.fragment.EventFragment] + * + * @param app reference to the app + */ class EventViewModel(app: Application) : TemplateViewModel(app) { + /** + * Retrieves all Stud.IP events. + * + * @return all Stud.IP events exposed through a LiveData object + */ val allEvents: LiveData> = repo.allEvents - - fun insertEvent(event: StudIPEvent) { repo.insertEvent(event) } - fun nukeEvents() { repo.nukeEvents() } } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/viewmodel/FetcherViewModel.kt b/app/src/main/java/com/denizk0461/studip/viewmodel/FetcherViewModel.kt index 6aa5822..d687a65 100644 --- a/app/src/main/java/com/denizk0461/studip/viewmodel/FetcherViewModel.kt +++ b/app/src/main/java/com/denizk0461/studip/viewmodel/FetcherViewModel.kt @@ -3,9 +3,22 @@ package com.denizk0461.studip.viewmodel import android.app.Application import com.denizk0461.studip.model.StudIPEvent +/** + * View model for [com.denizk0461.studip.activity.FetcherActivity] + * + * @param app reference to the app + */ class FetcherViewModel(app: Application) : TemplateViewModel(app) { - fun insertEvent(event: StudIPEvent) { repo.insertEvent(event) } + /** + * Save a list of Stud.IP events to persistent storage asynchronously. + * + * @param events list of events to be saved + */ fun insertEvents(events: List) { doAsync { repo.insertEvents(events) } } + + /** + * Delete all Stud.IP events from the database asynchronously. + */ fun nukeEvents() { doAsync { repo.nukeEvents() } } } \ No newline at end of file diff --git a/app/src/main/java/com/denizk0461/studip/viewmodel/TemplateViewModel.kt b/app/src/main/java/com/denizk0461/studip/viewmodel/TemplateViewModel.kt index 900f245..bdcb7b5 100644 --- a/app/src/main/java/com/denizk0461/studip/viewmodel/TemplateViewModel.kt +++ b/app/src/main/java/com/denizk0461/studip/viewmodel/TemplateViewModel.kt @@ -4,21 +4,43 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.denizk0461.studip.data.Dependencies -import com.denizk0461.studip.db.EventRepository +import com.denizk0461.studip.db.AppRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +/** + * View model super class providing common functionality. View model is used to provide an + * abstraction between the view classes and the data providers. All view models should inherit from + * this. + * + * @param app reference to the app + */ open class TemplateViewModel(app: Application) : AndroidViewModel(app) { - protected val repo: EventRepository = Dependencies.repo + // Reference to the app's repository for database transactions + protected val repo: AppRepository = Dependencies.repo + /** + * Execute a function asynchronously on the I/O thread. Be careful not to execute UI commands + * with this. + * + * @param function the action that will be executed asynchronously + */ fun doAsync(function: () -> Unit) { viewModelScope.launch(Dispatchers.IO) { function() } } + /** + * Execute a function that needs to be ran on an I/O thread and return a value. This is a simple + * but inefficient solution to retrieving data from the database without the need for a LiveData + * object, which should always be preferred. + * + * @param function the action that will be executed on the I/O thread + * @return any value returned by the executing function + */ fun returnBlocking(function: () -> T): T = runBlocking(Dispatchers.IO) { function() } diff --git a/app/src/main/res/layout/fragment_parser.xml b/app/src/main/res/layout/fragment_parser.xml deleted file mode 100644 index 99c6f0d..0000000 --- a/app/src/main/res/layout/fragment_parser.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - \ No newline at end of file