Fully documented all Kotlin files and added TODOs to functions that need to be updated in some way

This commit is contained in:
denizk0461
2023-04-23 11:57:03 +02:00
parent 2534849329
commit fbad84aa09
33 changed files with 1220 additions and 571 deletions
@@ -9,7 +9,7 @@ import androidx.lifecycle.Lifecycle
import com.denizk0461.studip.R import com.denizk0461.studip.R
import com.denizk0461.studip.data.Dependencies import com.denizk0461.studip.data.Dependencies
import com.denizk0461.studip.databinding.ActivityMainBinding 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.EventFragment
import com.denizk0461.studip.fragment.CanteenFragment import com.denizk0461.studip.fragment.CanteenFragment
import com.denizk0461.studip.fragment.SettingsFragment 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 * Instantiate repository object that is accessed by the fragments' view models to retrieve
* data. * data.
*/ */
Dependencies.repo = EventRepository(application) Dependencies.repo = AppRepository(application)
// Inflate view binding and bind to this activity // Inflate view binding and bind to this activity
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
@@ -2,18 +2,27 @@ package com.denizk0461.studip.adapter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.widget.ImageViewCompat
import androidx.recyclerview.widget.RecyclerView 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.ItemCanteenBinding
import com.denizk0461.studip.databinding.ItemCanteenLineBinding import com.denizk0461.studip.databinding.ItemCanteenLineBinding
import com.denizk0461.studip.databinding.ItemIconBinding import com.denizk0461.studip.databinding.ItemIconBinding
import com.denizk0461.studip.model.CanteenOffer
import com.denizk0461.studip.model.CanteenOfferGroup import com.denizk0461.studip.model.CanteenOfferGroup
class CanteenOfferItemAdapter(private val offers: List<CanteenOfferGroup>) : RecyclerView.Adapter<CanteenOfferItemAdapter.OfferViewHolder>() { /**
* 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<CanteenOfferGroup>
) : RecyclerView.Adapter<CanteenOfferItemAdapter.OfferViewHolder>() {
/**
* View holder class for parent class
*
* @param binding view binding object
*/
class OfferViewHolder(val binding: ItemCanteenBinding) : RecyclerView.ViewHolder(binding.root) class OfferViewHolder(val binding: ItemCanteenBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OfferViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OfferViewHolder {
@@ -26,20 +35,32 @@ class CanteenOfferItemAdapter(private val offers: List<CanteenOfferGroup>) : Rec
) )
} }
// Returns the amount of offers for a given canteen and day
override fun getItemCount(): Int = offers.size 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) { override fun onBindViewHolder(holder: OfferViewHolder, position: Int) {
// Retrieve item for current position
val currentItem = offers[position] val currentItem = offers[position]
// Set category text / header of the item
holder.binding.textCategory.text = currentItem.category holder.binding.textCategory.text = currentItem.category
// Create a new view (line) for each item displayed within one category
currentItem.offers.forEach { offer -> currentItem.offers.forEach { offer ->
// Inflate the new line without binding it to the line container
val line = ItemCanteenLineBinding.inflate( val line = ItemCanteenLineBinding.inflate(
LayoutInflater.from(holder.binding.root.context), LayoutInflater.from(holder.binding.root.context),
) )
// Set text values
line.textContent.text = offer.title line.textContent.text = offer.title
line.textPrice.text = offer.price 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<Int>() val indices = arrayListOf<Int>()
offer.dietaryPreferences.filterIndexed { index, char -> offer.dietaryPreferences.filterIndexed { index, char ->
if (char == 't') { if (char == 't') {
@@ -47,25 +68,44 @@ class CanteenOfferItemAdapter(private val offers: List<CanteenOfferGroup>) : Rec
true true
} else false } else false
} }
// If no dietary preferences are met, set the view to display a neutral icon
if (indices.isEmpty()) indices.add(10) if (indices.isEmpty()) indices.add(10)
// Iterate through all icons that need to be displayed
indices.forEach { index -> indices.forEach { index ->
// Inflate a new icon holder
val img = ItemIconBinding.inflate(LayoutInflater.from(holder.binding.root.context)) 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) line.imageViewContainer.addView(img.root)
} }
// Bind the new line to the line container
holder.binding.lineContainer.addView(line.root) 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) * Provides a converting function between the index of a dietary preference and its according
// fun onLongClick(event: StudIPEvent) * icon.
// } */
private val indexToDrawable: Map<Int, Int> = 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,
)
} }
@@ -7,8 +7,26 @@ import androidx.recyclerview.widget.RecyclerView
import com.denizk0461.studip.databinding.ItemScrollablePageBinding import com.denizk0461.studip.databinding.ItemScrollablePageBinding
import com.denizk0461.studip.model.CanteenOfferGroup import com.denizk0461.studip.model.CanteenOfferGroup
class CanteenOfferPageAdapter(private var offers: List<CanteenOfferGroup>, private var daysCovered: Int, private var prefsRegex: Regex) : RecyclerView.Adapter<CanteenOfferPageAdapter.CanteenOfferPageViewHolder>() { /**
* 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<CanteenOfferGroup>,
private var daysCovered: Int,
) : RecyclerView.Adapter<CanteenOfferPageAdapter.CanteenOfferPageViewHolder>() {
/**
* View holder class for parent class
*
* @param binding view binding object
*/
class CanteenOfferPageViewHolder(val binding: ItemScrollablePageBinding) : RecyclerView.ViewHolder(binding.root) class CanteenOfferPageViewHolder(val binding: ItemScrollablePageBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CanteenOfferPageViewHolder = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CanteenOfferPageViewHolder =
@@ -20,22 +38,39 @@ class CanteenOfferPageAdapter(private var offers: List<CanteenOfferGroup>, priva
) )
) )
// Returns the count of days covered
override fun getItemCount(): Int = daysCovered override fun getItemCount(): Int = daysCovered
override fun onBindViewHolder(holder: CanteenOfferPageViewHolder, position: Int) { override fun onBindViewHolder(holder: CanteenOfferPageViewHolder, position: Int) {
holder.binding.pageRecyclerView.apply { holder.binding.pageRecyclerView.apply {
// Set page to horizontally scroll
layoutManager = LinearLayoutManager(holder.binding.root.context, LinearLayoutManager.VERTICAL, false) 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 adapter = CanteenOfferItemAdapter(offers.filter { it.dateId == position }) // TODO check if empty
// Animate creation of new page
scheduleLayoutAnimation() scheduleLayoutAnimation()
} }
} }
/**
* Update the entire list of items
*
* @param items the new set of items
*/
fun setNewItems(items: List<CanteenOfferGroup>, daysCovered: Int) { fun setNewItems(items: List<CanteenOfferGroup>, daysCovered: Int) {
// Update the count of days covered
this.daysCovered = daysCovered this.daysCovered = daysCovered
// Update items
offers = items offers = items
// Force an update of the view. TODO replace with individual updates, as this is inefficient
notifyDataSetChanged() notifyDataSetChanged()
} }
} }
@@ -10,14 +10,40 @@ import com.denizk0461.studip.model.StudIPEvent
import com.denizk0461.studip.databinding.ItemEventBinding import com.denizk0461.studip.databinding.ItemEventBinding
import java.util.* 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( class StudIPEventItemAdapter(
events: List<StudIPEvent>, private val currentDay: Int, private val onClickListener: OnClickListener events: List<StudIPEvent>,
private val currentDay: Int,
private val onClickListener: OnClickListener,
) : RecyclerView.Adapter<StudIPEventItemAdapter.EventViewHolder>() { ) : RecyclerView.Adapter<StudIPEventItemAdapter.EventViewHolder>() {
// Filter events to only show those of a given day
private val filteredEvents: List<StudIPEvent> = events.filter { it.day == currentDay } private val filteredEvents: List<StudIPEvent> = 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() 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 private var isAnyCourseHighlighted = false
/**
* View holder class for parent class
*
* @param binding view binding object
*/
class EventViewHolder(val binding: ItemEventBinding) : RecyclerView.ViewHolder(binding.root) class EventViewHolder(val binding: ItemEventBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventViewHolder { 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 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) { override fun onBindViewHolder(holder: EventViewHolder, position: Int) {
// Retrieve item for current position
val currentItem = filteredEvents[position] val currentItem = filteredEvents[position]
// Set up text fields with corresponding values
holder.binding.textTitle.text = currentItem.title holder.binding.textTitle.text = currentItem.title
holder.binding.textLecturers.text = currentItem.lecturer holder.binding.textLecturers.text = currentItem.lecturer
holder.binding.textRoom.text = currentItem.room holder.binding.textRoom.text = currentItem.room
// Use parsed timeslot string as defined in StudIPEvent#timeslot()
holder.binding.textTimeslot.text = currentItem.timeslot() holder.binding.textTimeslot.text = currentItem.timeslot()
// Set up colours used for highlighting and un-highlighting an item.
val colorPrimaryTranslucent = TypedValue() val colorPrimaryTranslucent = TypedValue()
val colorCardBackground = TypedValue() val colorCardBackground = TypedValue()
val colorTextHintLighter = 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.colorPrimaryTranslucent, colorPrimaryTranslucent, true)
resolveAttribute(R.attr.colorCardBackground, colorCardBackground, true) resolveAttribute(R.attr.colorCardBackground, colorCardBackground, true)
resolveAttribute(R.attr.colorTextHintLighter, colorTextHintLighter, 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 isAnyCourseHighlighted = true
holder.binding.cardBackground.apply { holder.binding.cardBackground.apply {
// Set the card's background colour to a desaturated shade of the primary colour
backgroundTintList = ColorStateList.valueOf(colorPrimaryTranslucent.data) backgroundTintList = ColorStateList.valueOf(colorPrimaryTranslucent.data)
// Hide card stroke
strokeColor = context.getColor(android.R.color.transparent) 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 { holder.binding.cardBackground.apply {
// Set the card's background colour to its default value
backgroundTintList = ColorStateList.valueOf(colorCardBackground.data) backgroundTintList = ColorStateList.valueOf(colorCardBackground.data)
// Set the card's stroke to its default colour
strokeColor = colorTextHintLighter.data strokeColor = colorTextHintLighter.data
} }
} }
// Set up single click listener
holder.binding.cardBackground.setOnClickListener { holder.binding.cardBackground.setOnClickListener {
onClickListener.onClick(currentItem) onClickListener.onClick(currentItem)
} }
// Set up long press listener
holder.binding.cardBackground.setOnLongClickListener { holder.binding.cardBackground.setOnLongClickListener {
onClickListener.onLongClick(currentItem) 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 = private fun StudIPEvent.isCurrentCourse(calendar: Calendar): Boolean =
(calendar.get(Calendar.DAY_OF_WEEK) == this.day.toCalendarDay()) && (calendar.get(Calendar.DAY_OF_WEEK) == this.day.toCalendarDay()) &&
((calendar.get(Calendar.MINUTE) + calendar.get(Calendar.HOUR_OF_DAY) * 60) < this.timeslotEnd.parseToMinutes()) ((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 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) { private fun Int.toCalendarDay(): Int = when (this) {
1 -> Calendar.TUESDAY 1 -> Calendar.TUESDAY
2 -> Calendar.WEDNESDAY 2 -> Calendar.WEDNESDAY
@@ -91,13 +152,36 @@ class StudIPEventItemAdapter(
else -> Calendar.MONDAY 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 { private fun String.parseToMinutes(): Int {
val parts = split(":") val parts = split(":")
return (parts[0].toInt() * 60) + parts[1].toInt() return (parts[0].toInt() * 60) + parts[1].toInt()
} }
/**
* Interface used to evaluate clicks and long presses on a given item.
*/
interface OnClickListener { interface OnClickListener {
/**
* Executed when an item has been clicked.
*
* @param event item that has been clicked
*/
fun onClick(event: StudIPEvent) 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
} }
} }
@@ -7,8 +7,23 @@ import androidx.recyclerview.widget.RecyclerView
import com.denizk0461.studip.model.StudIPEvent import com.denizk0461.studip.model.StudIPEvent
import com.denizk0461.studip.databinding.ItemScrollablePageBinding import com.denizk0461.studip.databinding.ItemScrollablePageBinding
class StudIPEventPageAdapter(private var events: List<StudIPEvent>, private val onClickListener: StudIPEventItemAdapter.OnClickListener) : RecyclerView.Adapter<StudIPEventPageAdapter.EventPageViewHolder>() { /**
* 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<StudIPEvent>,
private val onClickListener: StudIPEventItemAdapter.OnClickListener
) : RecyclerView.Adapter<StudIPEventPageAdapter.EventPageViewHolder>() {
/**
* View holder class for parent class
*
* @param binding view binding object
*/
class EventPageViewHolder(val binding: ItemScrollablePageBinding) : RecyclerView.ViewHolder(binding.root) class EventPageViewHolder(val binding: ItemScrollablePageBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventPageViewHolder = EventPageViewHolder( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventPageViewHolder = EventPageViewHolder(
@@ -19,19 +34,39 @@ class StudIPEventPageAdapter(private var events: List<StudIPEvent>, 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 override fun getItemCount(): Int = 5
// Set up page
override fun onBindViewHolder(holder: EventPageViewHolder, position: Int) { override fun onBindViewHolder(holder: EventPageViewHolder, position: Int) {
holder.binding.pageRecyclerView.apply { holder.binding.pageRecyclerView.apply {
// Set page to horizontally scroll
layoutManager = LinearLayoutManager(holder.binding.root.context, LinearLayoutManager.VERTICAL, false) 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 adapter = StudIPEventItemAdapter(events, currentDay = position, onClickListener) // TODO check if empty
// Animate creation of new page
scheduleLayoutAnimation() scheduleLayoutAnimation()
} }
} }
/**
* Update the entire list of items
*
* @param items the new set of items
*/
fun setNewItems(items: List<StudIPEvent>) { fun setNewItems(items: List<StudIPEvent>) {
// Update items
events = items events = items
// Force an update of the view. TODO replace with individual updates, as this is inefficient
notifyDataSetChanged() notifyDataSetChanged()
} }
} }
@@ -1,7 +1,15 @@
package com.denizk0461.studip.data 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 { 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
} }
@@ -1,41 +1,26 @@
package com.denizk0461.studip.data package com.denizk0461.studip.data
import com.denizk0461.studip.R /**
* Miscellaneous functions and variables that don't belong into any specific class.
*/
object Misc { object Misc {
private val timeslotAcademicQuarter = mapOf( // private val timeslotAcademicQuarter = mapOf(
"6:00" to "6:15", // "6:00" to "6:15",
"8:00" to "7:45", // "8:00" to "7:45",
"8:00" to "8:15", // "8:00" to "8:15",
"10:00" to "9:45", // "10:00" to "9:45",
"10:00" to "10:15", // "10:00" to "10:15",
"12:00" to "11:45", // "12:00" to "11:45",
"12:00" to "12:15", // "12:00" to "12:15",
"14:00" to "13:45", // "14:00" to "13:45",
"14:00" to "14:15", // "14:00" to "14:15",
"16:00" to "15:45", // "16:00" to "15:45",
"16:00" to "16:15", // "16:00" to "16:15",
"18:00" to "17:45", // "18:00" to "17:45",
"18:00" to "18:15", // "18:00" to "18:15",
"20:00" to "19:45", // "20:00" to "19:45",
"20:00" to "20:15", // "20:00" to "20:15",
"22:00" to "21:45", // "22:00" to "21:45",
) // )
val indexToDrawable: Map<Int, Int> = 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"
} }
@@ -3,43 +3,99 @@ package com.denizk0461.studip.data
import com.denizk0461.studip.model.StudIPEvent import com.denizk0461.studip.model.StudIPEvent
import org.jsoup.Jsoup import org.jsoup.Jsoup
/**
* Parser class used for fetching and collecting scheduled events from a Stud.IP timetable.
*/
class StudIPParser { 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<StudIPEvent>) -> Unit) { fun parse(html: String, insert: (events: List<StudIPEvent>) -> Unit) {
// Primary key value to uniquely identify entries in the database
var id = 0 var id = 0
// Parse the HTML using Jsoup to traverse the document
val doc = Jsoup.parse(html) 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<StudIPEvent>() val newEvents = mutableListOf<StudIPEvent>()
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_0"),
doc.getElementById("calendar_view_1_column_1"), doc.getElementById("calendar_view_1_column_1"),
doc.getElementById("calendar_view_1_column_2"), doc.getElementById("calendar_view_1_column_2"),
doc.getElementById("calendar_view_1_column_3"), doc.getElementById("calendar_view_1_column_3"),
doc.getElementById("calendar_view_1_column_4"), doc.getElementById("calendar_view_1_column_4"),
) )
// Iterate through each day
columns.forEachIndexed { index, element -> 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() 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 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) // Retrieve the title of the event from the header string
// val timeSlot = Misc.parseTimeslot(entryInfos[0]) val parsedTitle = entryHeader.substring(0 until delimiter).trim()
val timeSlot = entryInfos[0].split(" - ")
// 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( val event = StudIPEvent(
id = id, id = id,
title = parsedTitle, title = parsedTitle,
lecturer = parsedLecturers, lecturer = parsedLecturers,
room = entryInfos[1], room = entryInfo[1],
day = index, day = index,
timeslotStart = timeSlot[0], timeslotStart = timeSlot[0],
timeslotEnd = timeSlot[1], timeslotEnd = timeSlot[1],
) )
// Add the new element to the temporary list
newEvents.add(event) newEvents.add(event)
// Raise the primary key value by 1 to avoid not fulfilling the unique constraint
id += 1 id += 1
} }
} }
// After fetching has finished, save the list of new items into persistent storage
insert(newEvents) insert(newEvents)
} }
} }
@@ -1,25 +1,50 @@
package com.denizk0461.studip.data package com.denizk0461.studip.data
import android.util.Log import com.denizk0461.studip.db.AppRepository
import com.denizk0461.studip.db.EventRepository
import com.denizk0461.studip.model.* import com.denizk0461.studip.model.*
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.select.Elements 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 { class StwParser {
// Unique primary key value for the date elements
private var dateId = 0 private var dateId = 0
// Unique primary key value for the canteen elements
private var canteenId = 0 private var canteenId = 0
// Unique primary key value for the category elements
private var categoryId = 0 private var categoryId = 0
// Unique primary key value for the item elements
private var itemId = 0 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) { 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 dateId = 0
canteenId = 0 canteenId = 0
categoryId = 0 categoryId = 0
itemId = 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<String>() val links = mutableListOf<String>()
if (true) { links.add(urlUniMensa) } if (true) { links.add(urlUniMensa) }
// if (true) { links.add(urlCafeCentral) } // if (true) { links.add(urlCafeCentral) }
@@ -32,66 +57,106 @@ class StwParser {
// if (true) { links.add(urlMensaBHV) } // if (true) { links.add(urlMensaBHV) }
// if (true) { links.add(urlCafeBHV) } // if (true) { links.add(urlCafeBHV) }
// Delete all previous entries and start afresh
Dependencies.repo.nukeOffers() 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 -> links.forEach { link ->
// TODO implement onRefresh(Int)
parseFromPage(link, Dependencies.repo) parseFromPage(link, Dependencies.repo)
// TODO implement onRefresh(Int)
} }
// Action call once all fetching activities have finished
onFinish() onFinish()
} }
private fun parseFromPage(url: String, repo: EventRepository): List<CanteenOffer> { /**
val items = mutableListOf<CanteenOffer>() * 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 var date: String
// Reset the primary key value for the date objects
dateId = 0 dateId = 0
// Parse the HTML using Jsoup to traverse the document
val doc = Jsoup.connect(url).get() val doc = Jsoup.connect(url).get()
// Save the canteen to persistent storage
repo.insert(OfferCanteen(canteenId, doc.getElementsByClass("pane-title")[1].text())) 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 -> 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] val rawDate = doc.getElementsByClass("tabs")[0]
.getElementsByClass("tab-date")[dateId].text().split(" ") .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()}." date = "${rawDate[0]}${rawDate[1].monthToNumber()}."
val d = OfferDate(dateId, date) /*
Log.d("eek!8", d.toString()) * Save the date to persistent storage.
repo.insert(d) * 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 -> dayPlan.getElementsByClass("food-category").forEach { category ->
// Retrieve the category text
val categoryTitle = category.getElementsByClass("category-name")[0].text() val categoryTitle = category.getElementsByClass("category-name")[0].text()
// Save the category to persistent storage
repo.insert(OfferCategory(categoryId, dateId, canteenId, categoryTitle)) repo.insert(OfferCategory(categoryId, dateId, canteenId, categoryTitle))
// STEP: get each item in a category // Iterate through all items in a category
category category
.getElementsByTag("tbody")[0] .getElementsByTag("tbody")[0]
.getElementsByTag("tr").forEach { element -> .getElementsByTag("tr").forEach { element ->
// Retrieve the parent element holding the item's title and price
val tableRows = element.getElementsByTag("td") val tableRows = element.getElementsByTag("td")
val prefs = // Retrieve the dietary preferences the item meets
element.getElementsByClass("field field-name-field-food-types")[0] val prefs = element
.getElementsByClass("field field-name-field-food-types")[0]
// Parse the preferences into a string for the database
val prefString = DietaryPrefObject( val prefString = DietaryPrefObject(
isFair = prefs.isDietaryPreferenceMet(PREFERENCE_FAIR), isFair = prefs.isDietaryPreferenceMet(imageLinkPrefFair),
isFish = prefs.isDietaryPreferenceMet(PREFERENCE_FISH), isFish = prefs.isDietaryPreferenceMet(imageLinkPrefFish),
isPoultry = prefs.isDietaryPreferenceMet(PREFERENCE_POULTRY), isPoultry = prefs.isDietaryPreferenceMet(imageLinkPrefPoultry),
isLamb = prefs.isDietaryPreferenceMet(PREFERENCE_LAMB), isLamb = prefs.isDietaryPreferenceMet(imageLinkPrefLamb),
isVital = prefs.isDietaryPreferenceMet(PREFERENCE_VITAL), isVital = prefs.isDietaryPreferenceMet(imageLinkPrefVital),
isBeef = prefs.isDietaryPreferenceMet(PREFERENCE_BEEF), isBeef = prefs.isDietaryPreferenceMet(imageLinkPrefBeef),
isPork = prefs.isDietaryPreferenceMet(PREFERENCE_PORK), isPork = prefs.isDietaryPreferenceMet(imageLinkPrefPork),
isVegan = prefs.isDietaryPreferenceMet(PREFERENCE_VEGAN), isVegan = prefs.isDietaryPreferenceMet(imageLinkPrefVegan),
isVegetarian = prefs.isDietaryPreferenceMet(PREFERENCE_VEGETARIAN), isVegetarian = prefs.isDietaryPreferenceMet(imageLinkPrefVegetarian),
isGame = prefs.isDietaryPreferenceMet(PREFERENCE_GAME), isGame = prefs.isDietaryPreferenceMet(imageLinkPrefGame),
).deconstruct() ).deconstruct()
// Save the item to persistent storage
repo.insert( repo.insert(
OfferItem( OfferItem(
itemId, itemId,
@@ -101,39 +166,77 @@ class StwParser {
prefString, prefString,
) )
) )
// Increment the item ID to avoid overriding
itemId += 1 itemId += 1
} }
// Increment the category ID to avoid overriding
categoryId += 1 categoryId += 1
} }
// Increment the date ID to avoid overriding
dateId += 1 dateId += 1
} }
// Increment the canteen ID to avoid overriding
canteenId += 1 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 = private fun Element.isDietaryPreferenceMet(preference: String): Boolean =
getElementsByAttributeValue("src", preference).isNotEmpty() getElementsByAttributeValue("src", preference).isNotEmpty()
/**
* Processes certain character references into human-readable characters. Since the fetched HTML
* contains unresolved symbols such as &amp;, they need to be replaced with their counterpart
* (in this case, &).
*
* @return the filtered string
*/
private fun Element.getFilteredText(): String { private fun Element.getFilteredText(): String {
// Retrieve the element's inner HTML and replace faulty characters
var text = html() var text = html()
.replace("&amp;", "&") .replace("&amp;", "&")
.replace("&gt;", ">") .replace("&gt;", ">")
.replace("&lt;", "<") .replace("&lt;", "<")
/*
* 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("<sup>")) { while (text.contains("<sup>")) {
text = text.substring(0 until text.indexOf("<sup>")) + text.substring(text.indexOf("</sup>")+6 until text.length) text = text.substring(0 until text.indexOf("<sup>")) + text.substring(text.indexOf("</sup>")+6 until text.length)
} }
// Return the stripped and updated HTML string
return text 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 { private fun Elements.getTextOrEmpty(index: Int): String = try {
get(index).text() get(index).text()
} catch (e: java.lang.IndexOutOfBoundsException) { } 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) { private fun String.monthToNumber(): String = when (this) {
"Jan" -> "01" "Jan" -> "01"
"Feb" -> "02" "Feb" -> "02"
@@ -150,22 +253,24 @@ class StwParser {
else -> "00" // shouldn't occur else -> "00" // shouldn't occur
} }
private val PREFERENCE_FAIR = "https://www.stw-bremen.de/sites/default/files/images/pictograms/at_small.png" // Image links to all dietary preferences used for checking whether a preference is met
private val PREFERENCE_FISH = "https://www.stw-bremen.de/sites/default/files/images/pictograms/fisch.png" private val imageLinkPrefFair = "https://www.stw-bremen.de/sites/default/files/images/pictograms/at_small.png"
private val PREFERENCE_POULTRY = "https://www.stw-bremen.de/sites/default/files/images/pictograms/geflugel.png" private val imageLinkPrefFish = "https://www.stw-bremen.de/sites/default/files/images/pictograms/fisch.png"
private val PREFERENCE_LAMB = "https://www.stw-bremen.de/sites/default/files/images/pictograms/lamm.png" private val imageLinkPrefPoultry = "https://www.stw-bremen.de/sites/default/files/images/pictograms/geflugel.png"
private val PREFERENCE_VITAL = "https://www.stw-bremen.de/sites/default/files/images/pictograms/mensa_vital.png" private val imageLinkPrefLamb = "https://www.stw-bremen.de/sites/default/files/images/pictograms/lamm.png"
private val PREFERENCE_BEEF = "https://www.stw-bremen.de/sites/default/files/images/pictograms/rindfleisch.png" private val imageLinkPrefVital = "https://www.stw-bremen.de/sites/default/files/images/pictograms/mensa_vital.png"
private val PREFERENCE_PORK = "https://www.stw-bremen.de/sites/default/files/images/pictograms/schwein.png" private val imageLinkPrefBeef = "https://www.stw-bremen.de/sites/default/files/images/pictograms/rindfleisch.png"
private val PREFERENCE_VEGAN = "https://www.stw-bremen.de/sites/default/files/images/pictograms/mensa_vegan.png" private val imageLinkPrefPork = "https://www.stw-bremen.de/sites/default/files/images/pictograms/schwein.png"
private val PREFERENCE_VEGETARIAN = "https://www.stw-bremen.de/sites/default/files/images/pictograms/vegetarisch.png" private val imageLinkPrefVegan = "https://www.stw-bremen.de/sites/default/files/images/pictograms/mensa_vegan.png"
private val PREFERENCE_GAME = "https://www.stw-bremen.de/sites/default/files/images/pictograms/wild.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 urlUniMensa = "https://www.stw-bremen.de/de/mensa/uni-mensa"
private val urlCafeCentral = "https://www.stw-bremen.de/de/mensa/cafe-central" 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 urlNW1 = "https://www.stw-bremen.de/de/mensa/nw-1"
private val urlGW2 = "https://www.stw-bremen.de/de/cafeteria/gw2" 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 urlHSBNeustadt = "https://www.stw-bremen.de/de/mensa/neustadtswall"
private val urlHSBWerder = "https://www.stw-bremen.de/de/mensa/werderstra%C3%9Fe" private val urlHSBWerder = "https://www.stw-bremen.de/de/mensa/werderstra%C3%9Fe"
private val urlHSBAirport = "https://www.stw-bremen.de/de/mensa/airport" private val urlHSBAirport = "https://www.stw-bremen.de/de/mensa/airport"
@@ -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<List<StudIPEvent>>
/**
* 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<StudIPEvent>)
/**
* 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<List<CanteenOffer>>
/**
* 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<List<CanteenOffer>>
/**
* 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<OfferDate>
/**
* 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)
}
@@ -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!!
}
}
}
@@ -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<List<StudIPEvent>> = 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<StudIPEvent>) { 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<List<CanteenOffer>> = dao.allOffers
/**
* Retrieves all canteen offer date objects.
*
* @return a list of instances of canteen offer dates
*/
fun getDates(): List<OfferDate> = 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) }
}
@@ -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<List<StudIPEvent>>
@Insert
fun insertEvent(event: StudIPEvent)
@Insert
fun insertEvents(event: List<StudIPEvent>)
@Query("DELETE FROM events")
fun nukeEvents()
// @get:Query("SELECT * FROM offers ORDER BY id")
// val allOffers: LiveData<List<CanteenOffer>>
//
// @Insert
// fun insertOffers(offers: List<CanteenOffer>)
//
// @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<List<CanteenOffer>>
@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<List<CanteenOffer>>
@get:Query("SELECT id FROM offer_date")
val updateObserver: LiveData<List<Int>>
@Query("SELECT * FROM offer_date ORDER BY id")
fun getDates(): List<OfferDate>
@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)
}
@@ -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!!
}
}
}
@@ -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<List<StudIPEvent>> = dao.allEvents
private val dietaryPrefString = "prefs_obj"
fun insertEvent(event: StudIPEvent) { dao.insertEvent(event) }
fun insertEvents(events: List<StudIPEvent>) { dao.insertEvents(events) }
fun nukeEvents() { dao.nukeEvents() }
val allOffers: LiveData<List<CanteenOffer>> = dao.allOffers
fun getDates(): List<OfferDate> = 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<CanteenOffer>) { 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) }
}
@@ -7,29 +7,44 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import com.denizk0461.studip.adapter.CanteenOfferPageAdapter import com.denizk0461.studip.adapter.CanteenOfferPageAdapter
import com.denizk0461.studip.databinding.FragmentCanteenBinding import com.denizk0461.studip.databinding.FragmentCanteenBinding
import com.denizk0461.studip.model.* import com.denizk0461.studip.model.*
import com.denizk0461.studip.viewmodel.CanteenViewModel import com.denizk0461.studip.viewmodel.CanteenViewModel
import com.google.android.material.tabs.TabLayoutMediator 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() { class CanteenFragment : Fragment() {
// Nullable view binding reference
private var _binding: FragmentCanteenBinding? = null private var _binding: FragmentCanteenBinding? = 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
// BUG: if the fragment is changed before the refresh is finished, the app crashes with a NPE * onDestroyView.
* BUG if the fragment is changed before the refresh is finished, the app crashes with a NPE
*/
private val binding get() = _binding!! private val binding get() = _binding!!
// Adapter for the view pager displaying all offers
private lateinit var viewPagerAdapter: CanteenOfferPageAdapter private lateinit var viewPagerAdapter: CanteenOfferPageAdapter
// View model reference for providing access to the database
private val viewModel: CanteenViewModel by viewModels() private val viewModel: CanteenViewModel by viewModels()
// Elements in the canteen plan
private var elements: List<CanteenOffer> = listOf() private var elements: List<CanteenOffer> = 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 { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCanteenBinding.inflate(inflater, container, false) _binding = FragmentCanteenBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -38,6 +53,7 @@ class CanteenFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// Assign a preference value to every button to filter for dietary preferences
val chipMap = mapOf( val chipMap = mapOf(
binding.chipPrefFair to DietaryPreferences.FAIR, binding.chipPrefFair to DietaryPreferences.FAIR,
binding.chipPrefFish to DietaryPreferences.FISH, binding.chipPrefFish to DietaryPreferences.FISH,
@@ -51,15 +67,21 @@ class CanteenFragment : Fragment() {
binding.chipPrefGame to DietaryPreferences.GAME, binding.chipPrefGame to DietaryPreferences.GAME,
) )
// Set up every chip
chipMap.forEach { (chip, pref) -> chipMap.forEach { (chip, pref) ->
// Set chip to be checked if user has set this preference before
chip.isChecked = getPreference(pref) 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 // Set a listener for when the user clicks the chip
* flicker. Note for the future: implement a more efficient / better-looking method. 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 * TODO order the chips alphabetically
*/ */
val index = binding.chipsPreference.indexOfChild(buttonView) 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 binding.viewPager.adapter = viewPagerAdapter
// Create and attach the tab mediator for the view pager
createTabLayoutMediator() createTabLayoutMediator()
// Set up LiveData observer to refresh the view on update
viewModel.allOffers.observe(viewLifecycleOwner) { offers -> viewModel.allOffers.observe(viewLifecycleOwner) { offers ->
// Update the element list stored in this fragment
elements = offers elements = offers
// Group elements by category
val groupedElements = offers.groupElements().distinct() val groupedElements = offers.groupElements().distinct()
val newDates = groupedElements.map { it.date }.distinct()//.map { item -> OfferDate(item.) } // Find all dates stored in the database
// val newDates = viewModel.getDates() val newDates = groupedElements.map { it.date }.distinct() // TODO viewModel.getDates()
// Update the date count stored in this fragment
dateSize = newDates.size dateSize = newDates.size
// Update the item list in the view pager's adapter
viewPagerAdapter.setNewItems(groupedElements, dateSize) 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 binding.swipeRefreshLayout.isRefreshing = false
} }
// Set up functions for when the user swipes to refresh the view
binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.setOnRefreshListener {
// Retrieve new offers from the website(s)
viewModel.fetchOffers(onRefreshUpdate = { status -> viewModel.fetchOffers(onRefreshUpdate = { status ->
// TODO refresh updates // TODO refresh updates
}, onFinish = { }, onFinish = {
@@ -100,7 +135,11 @@ class CanteenFragment : Fragment() {
} }
} }
private fun createTabLayoutMediator(){//List<OfferDate>) { /**
* 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() val dates = viewModel.getDates()
if (dates.isNotEmpty()) { if (dates.isNotEmpty()) {
Log.d("eek!7", "dates: ${dates}, viewpager pages: ${binding.viewPager.adapter?.itemCount}") 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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _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) { private fun setPreference(pref: DietaryPreferences, newValue: Boolean) {
// Update the preference
viewModel.setPreference(pref, newValue) viewModel.setPreference(pref, newValue)
// Update the view
viewPagerAdapter.setNewItems(elements.groupElements().distinct(), dateSize) 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 = private fun getPreference(pref: DietaryPreferences): Boolean =
viewModel.getPreference(pref) 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 { 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 the 'empty' template string if no preference has been set or found
return if (prefs == emptyPreferenceRegex) {
return if (prefs == "..........") { Regex(emptyPreferenceRegex)
Regex(template)
} else { } else {
// Create the variable to insert the regular expression into
var regexString = "" var regexString = ""
// Create the object to store which preferences need to be met
val indices = mutableListOf<Int>() val indices = mutableListOf<Int>()
// Find all preferences that need to be met
prefs.forEachIndexed { index, c -> 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 var isFirst = true
// Iterate through every preference that has been set
indices.forEach { index -> indices.forEach { index ->
// Set an OR if more than one preference needs to be met
if (!isFirst) regexString += '|' 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 isFirst = false
} }
// Compile the string to a regular expression object
Regex(regexString) Regex(regexString)
} }
} }
/**
* Groups [CanteenOffer] elements by their categories into [CanteenOfferGroup] elements and
* filters for the user's dietary preferences.
*
* @return the grouped and filtered elements
*/
private fun List<CanteenOffer>.groupElements(): List<CanteenOfferGroup> { private fun List<CanteenOffer>.groupElements(): List<CanteenOfferGroup> {
// Get dietary preference regex
val prefsRegex = getPrefRegex() val prefsRegex = getPrefRegex()
val filteredForPreferences = if (prefsRegex.toString() == "ffffffffff") { val filteredForPreferences = if (prefsRegex.toString() == emptyPreferenceRegex) {
// show all elements and skip filtering // Show all elements and skip filtering if no preference is set
this this
} else { } else {
// Filter for dietary preferences
this.filter { this.filter {
prefsRegex.matches(it.dietaryPreferences) 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( CanteenOfferGroup(
offer.date, offer.date,
offer.dateId, offer.dateId,
@@ -170,6 +258,7 @@ class CanteenFragment : Fragment() {
filteredForPreferences.filter { filteredForPreferences.filter {
it.category == offer.category && it.date == offer.date && it.canteen == offer.canteen it.category == offer.category && it.date == offer.date && it.canteen == offer.canteen
}.map { }.map {
// Map the individual items to their respective groups
CanteenOfferGroupElement( CanteenOfferGroupElement(
it.title, it.title,
it.price, it.price,
@@ -178,6 +267,5 @@ class CanteenFragment : Fragment() {
} }
) )
} }
return b
} }
} }
@@ -16,17 +16,29 @@ import com.google.android.material.tabs.TabLayoutMediator
import java.util.* 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() { class EventFragment : Fragment() {
// Nullable view binding reference
private var _binding: FragmentEventBinding? = null 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!! 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 private var dayOfWeek: Int = 0
// Titles for the view pager's tabs
private val dayStrings = listOf( private val dayStrings = listOf(
R.string.monday, R.string.monday,
R.string.tuesday, R.string.tuesday,
@@ -35,9 +47,7 @@ class EventFragment : Fragment() {
R.string.friday, R.string.friday,
) )
private lateinit var viewPagerAdapter: StudIPEventPageAdapter // Instantiate the view binding
private val viewModel: EventViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentEventBinding.inflate(inflater, container, false) _binding = FragmentEventBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -46,6 +56,10 @@ class EventFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
/*
* Determine the current day.
* TODO expand to weekend
*/
dayOfWeek = when (Calendar.getInstance().get(Calendar.DAY_OF_WEEK)) { dayOfWeek = when (Calendar.getInstance().get(Calendar.DAY_OF_WEEK)) {
Calendar.TUESDAY -> 1 Calendar.TUESDAY -> 1
Calendar.WEDNESDAY -> 2 Calendar.WEDNESDAY -> 2
@@ -54,42 +68,53 @@ class EventFragment : Fragment() {
else -> 0 else -> 0
} }
// Set up the view pager's adapter
viewPagerAdapter = StudIPEventPageAdapter(listOf(), object : StudIPEventItemAdapter.OnClickListener { viewPagerAdapter = StudIPEventPageAdapter(listOf(), object : StudIPEventItemAdapter.OnClickListener {
override fun onClick(event: StudIPEvent) { 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 binding.viewPager.adapter = viewPagerAdapter
// Create and attach the object mediating the tabs for the view pager
TabLayoutMediator(binding.dayTabLayout, binding.viewPager) { tab, position -> TabLayoutMediator(binding.dayTabLayout, binding.viewPager) { tab, position ->
tab.text = getString(dayStrings[position]) tab.text = getString(dayStrings[position])
}.attach() }.attach()
// Set up LiveData observer to refresh the view on update
viewModel.allEvents.observe(viewLifecycleOwner) { events -> viewModel.allEvents.observe(viewLifecycleOwner) { events ->
// Update the item list in the view pager's adapter
viewPagerAdapter.setNewItems(events) viewPagerAdapter.setNewItems(events)
// Scroll to the current day
switchToCurrentDayView() switchToCurrentDayView()
} }
// binding.buttonFirst.setOnClickListener {
// findNavController().navigate(R.id.action_FirstFragment_to_SecondFragment)
// }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Scroll to the current day
switchToCurrentDayView() switchToCurrentDayView()
} }
// Invalidate the view binding
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
/**
* Scroll to the current day. Current day is determined after the fragment's view has been
* created.
*/
private fun switchToCurrentDayView() { private fun switchToCurrentDayView() {
binding.viewPager.currentItem = dayOfWeek binding.viewPager.currentItem = dayOfWeek
// binding.dayTabLayout.getTabAt(dayOfWeek)?.select()
} }
} }
@@ -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" +
"('<html>'+document.getElementsByTagName('html')[0].innerHTML+'</html>');"
)
}
}
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
}
}
@@ -7,23 +7,32 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.denizk0461.studip.BuildConfig import com.denizk0461.studip.BuildConfig
import com.denizk0461.studip.activity.FetcherActivity import com.denizk0461.studip.activity.FetcherActivity
import com.denizk0461.studip.data.Misc
import com.denizk0461.studip.databinding.FragmentSettingsBinding import com.denizk0461.studip.databinding.FragmentSettingsBinding
/**
* User-facing fragment view that is used to change app settings.
*/
class SettingsFragment : Fragment() { class SettingsFragment : Fragment() {
// Nullable view binding reference
private var _binding: FragmentSettingsBinding? = null 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!! private val binding get() = _binding!!
// Click counter on the app version button
private var appVersionClick = 0 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 { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsBinding.inflate(inflater, container, false) _binding = FragmentSettingsBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -32,47 +41,39 @@ class SettingsFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// binding.layoutQuarter.setOnClickListener { // Launch the Stud.IP schedule fetcher activity
// isQuarterChecked = !isQuarterChecked
// binding.switchQuarter.isChecked = isQuarterChecked
// }
binding.buttonRefreshSchedule.setOnClickListener { binding.buttonRefreshSchedule.setOnClickListener {
launchWebView() launchWebView()
} }
binding.switchQuarter.setOnClickListener { // Set click listener for the app version button
isQuarterChecked = !isQuarterChecked
}
binding.buttonAppVersion.setOnClickListener { binding.buttonAppVersion.setOnClickListener {
when (appVersionClick) { when (appVersionClick) {
19 -> {
appVersionClick += 1
}
20 -> {
appVersionClick += 1
}
21 -> {
appVersionClick += 1
}
22 -> { 22 -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Misc.mysteryLink))) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(mysteryLink)))
appVersionClick = 0 appVersionClick = 0
} }
else -> appVersionClick += 1 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") @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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
/**
* Launch the Stud.IP schedule fetcher activity.
*/
private fun launchWebView() { private fun launchWebView() {
startActivity(Intent(context, FetcherActivity::class.java)) startActivity(Intent(context, FetcherActivity::class.java))
} }
@@ -2,9 +2,25 @@ package com.denizk0461.studip.model
import androidx.room.ColumnInfo 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( data class CanteenOffer(
// @PrimaryKey val id: Int,
val itemId: Int, val itemId: Int,
val date: String, val date: String,
val dateId: Int, val dateId: Int,
@@ -13,6 +29,6 @@ data class CanteenOffer(
val canteen: String, val canteen: String,
val canteenId: Int, val canteenId: Int,
val title: String, val title: String,
val price: String, // price for students val price: String,
@ColumnInfo(name = "dietary_preferences") val dietaryPreferences: String, @ColumnInfo(name = "dietary_preferences") val dietaryPreferences: String,
) )
@@ -1,5 +1,15 @@
package com.denizk0461.studip.model 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( data class CanteenOfferGroup(
val date: String, val date: String,
val dateId: Int, val dateId: Int,
@@ -1,5 +1,15 @@
package com.denizk0461.studip.model 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( data class CanteenOfferGroupElement(
val title: String, val title: String,
val price: String, val price: String,
@@ -1,5 +1,20 @@
package com.denizk0461.studip.model 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( data class DietaryPrefObject(
val isFair: Boolean, val isFair: Boolean,
val isFish: Boolean, val isFish: Boolean,
@@ -13,11 +28,18 @@ data class DietaryPrefObject(
val isGame: Boolean, val isGame: Boolean,
) { ) {
companion object { companion object {
private const val C_TRUE = 't' // Char used to construct a regex to denote that a preference is met
private const val C_FALSE = '.' const val C_TRUE = 't'
/* assume that the values must be parsed as follows: // Char used to construct a regex to denote that a preference is not met
* "xxxxxxxxxx" const val C_FALSE = '.'
* t = true, . = 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( fun construct(values: String) = DietaryPrefObject(
isFair = values[0] == C_TRUE, 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( fun deconstruct() = String(
charArrayOf( charArrayOf(
if (this.isFair) C_TRUE else C_FALSE, if (this.isFair) C_TRUE else C_FALSE,
@@ -1,5 +1,13 @@
package com.denizk0461.studip.model 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) { enum class DietaryPreferences(val value: String) {
FAIR("isFair"), FAIR("isFair"),
FISH("isFish"), FISH("isFish"),
@@ -3,6 +3,12 @@ package com.denizk0461.studip.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey 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( @Entity(
tableName = "offer_canteen", tableName = "offer_canteen",
) )
@@ -4,6 +4,15 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey 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( @Entity(
tableName = "offer_category", tableName = "offer_category",
foreignKeys = [ foreignKeys = [
@@ -3,6 +3,12 @@ package com.denizk0461.studip.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey 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( @Entity(
tableName = "offer_date", tableName = "offer_date",
) )
@@ -5,6 +5,16 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey 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( @Entity(
tableName = "offer_item", tableName = "offer_item",
foreignKeys = [ foreignKeys = [
@@ -22,14 +32,4 @@ data class OfferItem(
val title: String, val title: String,
val price: String, val price: String,
@ColumnInfo(name = "dietary_preferences") val dietaryPreferences: 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,
) )
@@ -8,18 +8,57 @@ import com.denizk0461.studip.model.DietaryPrefObject
import com.denizk0461.studip.model.DietaryPreferences import com.denizk0461.studip.model.DietaryPreferences
import com.denizk0461.studip.model.OfferDate 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) { class CanteenViewModel(app: Application) : TemplateViewModel(app) {
// Instantiate a parser, should the user want to refresh the canteen offers
private val parser = StwParser() private val parser = StwParser()
/**
* Retrieves all Stud.IP events.
*
* @return all Stud.IP events exposed through a LiveData object
*/
val allOffers: LiveData<List<CanteenOffer>> = repo.allOffers val allOffers: LiveData<List<CanteenOffer>> = repo.allOffers
/**
* Retrieve the user's dietary preferences.
*
* @return dietary preferences
*/
fun getDietaryPrefs(): DietaryPrefObject = repo.getDietaryPrefsAsObj() fun getDietaryPrefs(): DietaryPrefObject = repo.getDietaryPrefsAsObj()
/**
* Retrieves all canteen offer date objects.
*
* @return a list of instances of canteen offer dates
*/
fun getDates(): List<OfferDate> = returnBlocking { repo.getDates() } fun getDates(): List<OfferDate> = 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) fun getPreference(pref: DietaryPreferences): Boolean = repo.getPreference(pref)
} }
@@ -4,10 +4,17 @@ import android.app.Application
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.denizk0461.studip.model.StudIPEvent 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) { class EventViewModel(app: Application) : TemplateViewModel(app) {
/**
* Retrieves all Stud.IP events.
*
* @return all Stud.IP events exposed through a LiveData object
*/
val allEvents: LiveData<List<StudIPEvent>> = repo.allEvents val allEvents: LiveData<List<StudIPEvent>> = repo.allEvents
fun insertEvent(event: StudIPEvent) { repo.insertEvent(event) }
fun nukeEvents() { repo.nukeEvents() }
} }
@@ -3,9 +3,22 @@ package com.denizk0461.studip.viewmodel
import android.app.Application import android.app.Application
import com.denizk0461.studip.model.StudIPEvent 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) { 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<StudIPEvent>) { doAsync { repo.insertEvents(events) } } fun insertEvents(events: List<StudIPEvent>) { doAsync { repo.insertEvents(events) } }
/**
* Delete all Stud.IP events from the database asynchronously.
*/
fun nukeEvents() { doAsync { repo.nukeEvents() } } fun nukeEvents() { doAsync { repo.nukeEvents() } }
} }
@@ -4,21 +4,43 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.denizk0461.studip.data.Dependencies 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking 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) { 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) { fun doAsync(function: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
function() 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 <T> returnBlocking(function: () -> T): T = runBlocking(Dispatchers.IO) { fun <T> returnBlocking(function: () -> T): T = runBlocking(Dispatchers.IO) {
function() function()
} }
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.ParserFragment">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="@dimen/fab_margin"
android:layout_marginBottom="@dimen/fab_margin"
app:srcCompat="@drawable/check" />
</androidx.constraintlayout.widget.ConstraintLayout>