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