From 10033d999f85e4903060c2657dd1cfb393b38bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20D=C3=BCzg=C3=B6ren?= Date: Wed, 23 Oct 2019 21:15:12 +0200 Subject: [PATCH] Started converting the app to a true MVVM architecture; fixed 7 month old bug that would cause substitution items to not be shown on the personal plan if they contained a question mark (e.g. 'INF7?inf999') --- app/build.gradle | 8 +- .../adapters/SubstitutionAdapter.kt | 11 +- .../denizd/substitutionplan/data/Caller.kt | 8 + .../substitutionplan/data/DataFetcher.kt | 279 ------------- .../denizd/substitutionplan/data/SubstUtil.kt | 48 +-- .../com/denizd/substitutionplan/data/Topic.kt | 12 - .../database/FoodRepository.kt | 27 -- .../database/FoodViewModel.kt | 32 -- .../database/SettingsRepository.kt | 31 ++ .../database/SubstDatabase.kt | 10 +- .../database/SubstRepository.kt | 373 +++++++++++++++++- .../database/SubstViewModel.kt | 34 -- .../fragments/FoodFragment.kt | 48 ++- .../fragments/GeneralPlanFragment.kt | 3 +- .../fragments/PersonalPlanFragment.kt | 21 +- .../fragments/PlanFragment.kt | 79 ++-- .../fragments/SettingsFragment.kt | 121 +++--- .../services/FBNotificationService.kt | 12 +- .../services/FBPingService.kt | 16 +- .../viewmodels/FoodViewModel.kt | 31 ++ .../viewmodels/SettingsViewModel.kt | 25 ++ .../viewmodels/SubstViewModel.kt | 47 +++ app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- 24 files changed, 658 insertions(+), 626 deletions(-) create mode 100644 app/src/main/java/com/denizd/substitutionplan/data/Caller.kt delete mode 100644 app/src/main/java/com/denizd/substitutionplan/data/DataFetcher.kt delete mode 100644 app/src/main/java/com/denizd/substitutionplan/data/Topic.kt delete mode 100644 app/src/main/java/com/denizd/substitutionplan/database/FoodRepository.kt delete mode 100644 app/src/main/java/com/denizd/substitutionplan/database/FoodViewModel.kt create mode 100644 app/src/main/java/com/denizd/substitutionplan/database/SettingsRepository.kt delete mode 100644 app/src/main/java/com/denizd/substitutionplan/database/SubstViewModel.kt create mode 100644 app/src/main/java/com/denizd/substitutionplan/viewmodels/FoodViewModel.kt create mode 100644 app/src/main/java/com/denizd/substitutionplan/viewmodels/SettingsViewModel.kt create mode 100644 app/src/main/java/com/denizd/substitutionplan/viewmodels/SubstViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index 231e107..ee204ff 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "com.denizd.substitutionplan" minSdkVersion 21 targetSdkVersion 29 - versionCode 42 - versionName "2.3.4" + versionCode 43 + versionName "2.4.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -48,6 +48,10 @@ dependencies { def room_version = "2.1.0-alpha04" implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0-beta01' } repositories { diff --git a/app/src/main/java/com/denizd/substitutionplan/adapters/SubstitutionAdapter.kt b/app/src/main/java/com/denizd/substitutionplan/adapters/SubstitutionAdapter.kt index a5e6578..349c436 100644 --- a/app/src/main/java/com/denizd/substitutionplan/adapters/SubstitutionAdapter.kt +++ b/app/src/main/java/com/denizd/substitutionplan/adapters/SubstitutionAdapter.kt @@ -2,7 +2,6 @@ package com.denizd.substitutionplan.adapters import android.content.ActivityNotFoundException import android.content.Context -import android.content.SharedPreferences import android.net.Uri import android.text.SpannableString import android.text.style.StrikethroughSpan @@ -26,9 +25,9 @@ import java.util.* * PlanFragment.kt and its subclasses * * @param substitutions a list of all substitutions of data type Substitution - * @param prefs a reference to the app's Shared Preferences used to get user-set colours + * @param colours a map of all user-defined colours for the individual courses */ -internal class SubstitutionAdapter(private var substitutions: List, private val prefs: SharedPreferences) : RecyclerView.Adapter() { +internal class SubstitutionAdapter(private var substitutions: List, private val colours: Map) : RecyclerView.Adapter() { /** * The ViewHolder class used by SubstitutionAdapter.kt to resolve references to views in @@ -114,11 +113,7 @@ internal class SubstitutionAdapter(private var substitutions: List View.GONE } else { val colourString = SubstUtil.getColourString(holder.course.text.toString()) - val colourPrefsInt = if (colourString.isNotEmpty()) { - prefs.getString("card$colourString", "") ?: "" - } else { - "" - } + val colourPrefsInt = colours[colourString] ?: "" colour = SubstUtil.getColourForString(colourPrefsInt, holder.context) cardBackgroundColour = if (colour != 0) { colour diff --git a/app/src/main/java/com/denizd/substitutionplan/data/Caller.kt b/app/src/main/java/com/denizd/substitutionplan/data/Caller.kt new file mode 100644 index 0000000..9c96618 --- /dev/null +++ b/app/src/main/java/com/denizd/substitutionplan/data/Caller.kt @@ -0,0 +1,8 @@ +package com.denizd.substitutionplan.data + +internal enum class Caller { + SUBSTITUTION, + FOODMENU, + JOBSERVICE, + SETTINGS +} \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/data/DataFetcher.kt b/app/src/main/java/com/denizd/substitutionplan/data/DataFetcher.kt deleted file mode 100644 index 1023037..0000000 --- a/app/src/main/java/com/denizd/substitutionplan/data/DataFetcher.kt +++ /dev/null @@ -1,279 +0,0 @@ -package com.denizd.substitutionplan.data - -import android.app.* -import android.content.Context -import android.content.Intent -import android.media.RingtoneManager -import android.net.Uri -import android.os.AsyncTask -import android.os.Build -import android.util.Log -import androidx.preference.PreferenceManager -import android.view.View -import android.widget.RemoteViews -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.denizd.substitutionplan.database.FoodRepository -import com.denizd.substitutionplan.R -import com.denizd.substitutionplan.database.SubstRepository -import com.denizd.substitutionplan.activities.Main -import com.denizd.substitutionplan.models.Food -import com.denizd.substitutionplan.models.Substitution -import com.google.android.material.snackbar.Snackbar -import org.jsoup.Jsoup -import java.lang.ref.WeakReference -import kotlin.collections.ArrayList - -/** - * This class gets data from the djd4rkn355.github.io domain asynchronously and persists it in the - * database. By using booleans as parameters, network usage and speed can be improved as only - * requested data will be downloaded and processed. Furthermore, the class provides a boolean - * to determine whether to send a notification to the user, if applicable. - * - * @param isPlan substitution plan will be downloaded and persisted in the database if true - * @param isMenu food menu will be downloaded and persisted in the database if true - * @param isJobService enables sending of a notification if true - * @param forced if true, times for food menu and substitution table will be overwritten - * with empty values to enable a forced refresh - * @param context the context of the application that will be stored as a WeakReference - * to avoid memory leakage - * @param application a reference to the application that will be stored as a WeakReference - * @param parentView a reference to the parent view that will be stored as a WeakReference - */ -internal class DataFetcher( - isPlan: Boolean, - isMenu: Boolean, - isJobService: Boolean = false, - forced: Boolean = false, - context: Context, - application: Application, - parentView: View? -) : AsyncTask() { - - private var jobService = isJobService - private var plan = isPlan - private var menu = isMenu - private var forceRefresh = forced - - private var mContext = WeakReference(context) - private var mApplication = application - private var mView = WeakReference(parentView) - private var notificationText = "" - private var informational = "" - private val prefs = PreferenceManager.getDefaultSharedPreferences(mContext.get()) - private val edit = prefs.edit() - private var currentTime = "" - private var currentFoodTime = "" - private var substUrl = "https://djd4rkn355.github.io/avh_substitutions.html" - private var foodUrl = "https://djd4rkn355.github.io/food.html" - private var websitePriority = 0 - - override fun doInBackground(vararg params: Void?): Void? { - try { - if (prefs.getBoolean("testUrls", false)) { - substUrl = "https://djd4rkn355.github.io/subst_test.html" - foodUrl = "https://djd4rkn355.github.io/food_test.html" - } else if ((prefs.getString("custom_test_url", "") ?: "").isNotEmpty()) { - substUrl = "https://djd4rkn355.github.io/${prefs.getString("custom_test_url", "")}" - } - - if (forceRefresh) { - edit.putString("timeNew", "").putString("newFoodTime", "").apply() - } - - if (menu) { - requestFoodMenuData() - } - if (plan) { - requestSubstPlan() - } - - if (plan || menu) { - mView.get()?.let { v: View -> - var snackText = "${mContext.get()?.getText(R.string.last_updated)} ${when { - plan -> currentTime - menu -> currentFoodTime - else -> "---" - }}" - snackText = if (forceRefresh) mContext.get()?.getString(R.string.force_refresh_successful) ?: "" else snackText - val snackBarView = v.findViewById(R.id.coordination) - Snackbar.make(snackBarView, snackText, Snackbar.LENGTH_LONG).show() - } - } - } catch (e: Exception) { - mView.get()?.let { v: View -> - val snackBarView = v.findViewById(R.id.coordination) - Snackbar.make(snackBarView, mContext.get()?.getString(R.string.no_internet_connection) ?: "", Snackbar.LENGTH_LONG).setBackgroundTint(ContextCompat.getColor(mContext.get()!!, - R.color.colorError - )).show() - } - prefs.edit().putString("debug_recent_exception", Log.getStackTraceString(e)).apply() - } - return null - } - - override fun onPostExecute(result: Void?) { - mView.get()?.let { - try { - it.findViewById(R.id.swipe_refresh_layout).isRefreshing = false - } catch (ignored: Exception) { - } - } - mView.clear() - mContext.clear() - } - - private fun requestFoodMenuData() { - val docFood = Jsoup.connect(foodUrl).get() - currentFoodTime = docFood.select("h1")[0].text() - val foodRepository = FoodRepository(mApplication) - if (currentFoodTime != prefs.getString("newFoodTime", "")) { - val foodElements = docFood.select("th") - foodRepository.deleteAll() - - val indices = ArrayList() - val daysAndVon = arrayOf("montag", "dienstag", "mittwoch", "donnerstag", "freitag", "von", "wünschen") - for (i in 0 until foodElements.size) { - if (SubstUtil.checkStringForArray(foodElements[i].text(), daysAndVon, true)) indices.add(i) - } - indices.add(foodElements.size) - - for (l in 0 until indices.size - 1) { - var s = "" - for (i2 in indices[l] until indices[l + 1]) { - if (s.isEmpty()) { - s = foodElements[i2].text() - } else { - s += "\n${foodElements[i2].text()}" - } - } - foodRepository.insert(Food(s, l)) - } - edit.putString("newFoodTime", currentFoodTime).apply() - } - } - - private fun requestSubstPlan() { - val doc = Jsoup.connect(substUrl).get() - currentTime = doc.select("h1")[0].text() - val substRepo = SubstRepository(mApplication) - if (currentTime != prefs.getString("timeNew", "")) { - val rows = doc.select("tr") - val paragraphs = doc.select("h6") - val substArray = ArrayList() - - val coursePreference = prefs.getString("courses", "") ?: "" - val classPreference = prefs.getString("classes", "") ?: "" - - for (i in 0 until paragraphs.size) { - if (i == 0) { - informational = formatElement(paragraphs[i].html()) - } else { - informational += "\n\n" + formatElement(paragraphs[i].html()) - } - } - edit.putString("informational", informational).apply() - - substRepo.deleteAllSubst() - - for (i in 0 until rows.size) { - val row = rows[i] - val cols = row.select("th") - - val group = cols[0].text() - val date = cols[1].text() - val subst = Substitution( - group = group, - date = date, - time = cols[2].text(), - course = cols[3].text(), - room = cols[4].text(), - additional = cols[5].text(), - teacher = cols[6].text(), - type = cols[7].text(), - priority = SubstUtil.assignRanking(group, (date.length > 2 && date.substring(0, 3) == "psa")), - date_priority = SubstUtil.assignDatePriority(date), - website_priority = websitePriority - ) - substArray.add(subst) - substRepo.insert(subst) - websitePriority += 1 - } - var countOfNotificationItems = 0 - var countOfMoreNotificationItems = 0 - if (jobService && prefs.getBoolean("notif", true)) { - substArray.filter { substItem -> - SubstUtil.checkPersonalSubstitutions( - substItem, - coursePreference, - classPreference, - false - ) - }.forEach { substItem -> - if (countOfNotificationItems < 4) { - notificationText += ("${if (notificationText.isNotEmpty()) ",\n" else ""}${substItem.course}: ${if (substItem.additional.isNotEmpty()) substItem.additional else if (substItem.type.isNotEmpty()) substItem.type else "---"}") - countOfNotificationItems += 1 - } else { - countOfMoreNotificationItems += 1 - } - } - if (countOfMoreNotificationItems > 0) { - notificationText += mContext.get()?.resources?.getQuantityString(R.plurals.notification_more_messages, countOfMoreNotificationItems, countOfMoreNotificationItems) - } - } - - if (notificationText.isNotEmpty()) { - sendNotification() - } - - edit.putString("timeNew", currentTime).apply() - } - } - - private fun sendNotification() { - mContext.get()?.let { context -> - val openApp = Intent(context, Main::class.java) - openApp.flags = Intent.FLAG_ACTIVITY_NEW_TASK - openApp.flags += Intent.FLAG_ACTIVITY_CLEAR_TASK - val openAppPending = PendingIntent.getActivity(context, 0, openApp, 0) - - val notificationLayout = RemoteViews(context.packageName, R.layout.notification) - notificationLayout.setTextViewText(R.id.notification_title, context.getString( - R.string.substitution_plan - )) - notificationLayout.setTextViewText(R.id.notification_textview, notificationText) - - val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - SubstUtil.getNotificationChannel(context, prefs) - } - - val notification = NotificationCompat.Builder(context, SubstUtil.notificationChannelId) - .setStyle(NotificationCompat.DecoratedCustomViewStyle()) - .setCustomContentView(notificationLayout) - .setContentIntent(openAppPending) - .setAutoCancel(true) - .setColor(ContextCompat.getColor(context, R.color.colorAccent)) - - notification.setSmallIcon(if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) R.drawable.ic_avh else R.drawable.ic_avhlogo) - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - val sound = if ((prefs.getString("ringtoneUri", "") ?: "").isNotEmpty()) { - Uri.parse(prefs.getString("ringtoneUri", "") ?: "") - } else { - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) - } - notification.setSound(sound) - } - - manager.notify(1, notification.build()) - } - } - - private fun formatElement(element: String): String { - return element - .replace("
", "\n") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/data/SubstUtil.kt b/app/src/main/java/com/denizd/substitutionplan/data/SubstUtil.kt index a11f914..2a019b9 100644 --- a/app/src/main/java/com/denizd/substitutionplan/data/SubstUtil.kt +++ b/app/src/main/java/com/denizd/substitutionplan/data/SubstUtil.kt @@ -81,16 +81,11 @@ internal object SubstUtil { const val notificationChannelId = "general" - fun getEmptyGeneralSubstitution(context: Context) = arrayOf( - Substitution("", "", "", context.getString(R.string.plan_empty), "", "", "", "", 0, 0, 0) - ).toList() - - fun getEmptyPersonalSubstitution(context: Context) = - Substitution("", "", "", context.getString(R.string.personal_plan_empty), "", "", "", "", 0, 0, 0) - - fun getEmptyFoodMenu(context: Context) = arrayOf( - Food("\n${context.getString(R.string.food_menu_empty)}\n", 0) - ).toList() + // Constants for Firebase Cloud Messaging Topics + const val FB_TOPIC_ANDROID = "substitutions-android" + const val FB_TOPIC_BROADCAST = "substitutions-broadcast" + const val FB_TOPIC_IOS = "substitutions-ios" + const val FB_TOPIC_DEVELOPMENT = "substitutions-debug" /** * This function returns an array that contains all data relevant to tinting individual entries @@ -248,39 +243,6 @@ internal object SubstUtil { edit.apply() } - /** - * This function serves for checking whether a course on the substitution plan is relevant to - * the user and should be shown on their personal plan as well as in their notifications - * - * @param substitution the substitution item - * @param coursePreference a string that represents the courses the user is enrolled in - * @param classPreference a string that represents the group the user is in - * @param psa decide whether or not any PSA items should be included in the filter - * - * @return true if the substitution is relevant to the user, false otherwise - */ - fun checkPersonalSubstitutions(substitution: Substitution, coursePreference: String, classPreference: String, psa: Boolean): Boolean { - val group = substitution.group - val course = substitution.course - if (psa && substitution.date.isNotEmpty() && substitution.date.substring(0, 3) == "psa") { - return true - } - if (coursePreference.isEmpty() && classPreference.isNotEmpty()) { - if (group.isNotEmpty()) { - if (classPreference.contains(group) || group.contains(classPreference)) { - return true - } - } - } else if (classPreference.isNotEmpty() && coursePreference.isNotEmpty()) { - if (group != "" && course != "") { - if ((classPreference.contains(group) || group.contains(classPreference)) && coursePreference.contains(course)) { - return true - } - } - } - return false - } - /** * This function is used throughout some classes to set the theme according to the device's * Android version before an app theme may be picked in the settings. As Main.kt as well as diff --git a/app/src/main/java/com/denizd/substitutionplan/data/Topic.kt b/app/src/main/java/com/denizd/substitutionplan/data/Topic.kt deleted file mode 100644 index 169a595..0000000 --- a/app/src/main/java/com/denizd/substitutionplan/data/Topic.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.denizd.substitutionplan.data - -/** - * Enum class for declaring Firebase Cloud Messaging topics without using the raw strings - * throughout the project to avoid human error - */ -internal enum class Topic(val tag: String) { - ANDROID("substitutions-android"), - BROADCAST("substitutions-broadcast"), - IOS("substitutions-ios"), - DEVELOPMENT("substitutions-debug") -} diff --git a/app/src/main/java/com/denizd/substitutionplan/database/FoodRepository.kt b/app/src/main/java/com/denizd/substitutionplan/database/FoodRepository.kt deleted file mode 100644 index dfdbc1c..0000000 --- a/app/src/main/java/com/denizd/substitutionplan/database/FoodRepository.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.denizd.substitutionplan.database - -import android.app.Application -import android.os.AsyncTask -import androidx.lifecycle.LiveData -import com.denizd.substitutionplan.models.Food - -internal class FoodRepository(application: Application) { - - private val substDao: SubstDao? - val allFoods: LiveData>? - - init { - val database = - SubstDatabase.getFoodInstance(application) - substDao = database?.substDao() - allFoods = substDao?.allFoods - } - - fun insert(food: Food) { - substDao?.insertFood(food) - } - - fun deleteAll() { - substDao?.deleteAllFoods() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/database/FoodViewModel.kt b/app/src/main/java/com/denizd/substitutionplan/database/FoodViewModel.kt deleted file mode 100644 index 87e3670..0000000 --- a/app/src/main/java/com/denizd/substitutionplan/database/FoodViewModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.denizd.substitutionplan.database - -import android.app.Application -import android.view.View -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.denizd.substitutionplan.data.DataFetcher -import com.denizd.substitutionplan.models.Food - -internal class FoodViewModel(application: Application) : AndroidViewModel(application) { - - private val repository: FoodRepository = FoodRepository(application) - val allFoods: LiveData>? - private val app = application - - init { - allFoods = repository.allFoods - } - - fun refresh(swipeRefreshLayout: SwipeRefreshLayout, rootView: View) { - swipeRefreshLayout.isRefreshing = true - DataFetcher( - isPlan = false, - isMenu = true, - isJobService = false, - context = app, - application = app, - parentView = rootView - ).execute() - } -} diff --git a/app/src/main/java/com/denizd/substitutionplan/database/SettingsRepository.kt b/app/src/main/java/com/denizd/substitutionplan/database/SettingsRepository.kt new file mode 100644 index 0000000..a14df73 --- /dev/null +++ b/app/src/main/java/com/denizd/substitutionplan/database/SettingsRepository.kt @@ -0,0 +1,31 @@ +package com.denizd.substitutionplan.database + +import android.app.Application +import android.database.Cursor +import android.media.RingtoneManager +import com.denizd.substitutionplan.models.Ringtone + +internal class SettingsRepository(private val application: Application) { + + val ringtones: List by lazy { + lateinit var ringtoneCursor: Cursor + val ringtoneManager = RingtoneManager(application).apply { + setType(RingtoneManager.TYPE_NOTIFICATION) + ringtoneCursor = cursor + } + val alarms = ArrayList() + + while (!ringtoneCursor.isAfterLast && ringtoneCursor.moveToNext()) { + val position = ringtoneCursor.position + alarms.add( + Ringtone( + ringtoneManager.getRingtone(position).getTitle(application), + ringtoneManager.getRingtoneUri(position).toString() + ) + ) + } + alarms + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/database/SubstDatabase.kt b/app/src/main/java/com/denizd/substitutionplan/database/SubstDatabase.kt index 7dbc5a4..b82493e 100644 --- a/app/src/main/java/com/denizd/substitutionplan/database/SubstDatabase.kt +++ b/app/src/main/java/com/denizd/substitutionplan/database/SubstDatabase.kt @@ -37,12 +37,12 @@ internal abstract class SubstDatabase : RoomDatabase() { } } - private var instance: SubstDatabase? = null + private var substInstance: SubstDatabase? = null - fun getInstance(context: Context): SubstDatabase? { - if (instance == null) { + fun getSubstInstance(context: Context): SubstDatabase? { + if (substInstance == null) { synchronized (SubstDatabase::class) { - instance = Room.databaseBuilder(context.applicationContext, + substInstance = Room.databaseBuilder(context.applicationContext, SubstDatabase::class.java, "subst_database") .addMigrations(addTeacherColumn, addTypeColumn, addDatePriorityColumn, addWebsitePriorityColumn) @@ -50,7 +50,7 @@ internal abstract class SubstDatabase : RoomDatabase() { .build() } } - return instance + return substInstance } private var foodInstance: SubstDatabase? = null diff --git a/app/src/main/java/com/denizd/substitutionplan/database/SubstRepository.kt b/app/src/main/java/com/denizd/substitutionplan/database/SubstRepository.kt index d60108d..0a2ef58 100644 --- a/app/src/main/java/com/denizd/substitutionplan/database/SubstRepository.kt +++ b/app/src/main/java/com/denizd/substitutionplan/database/SubstRepository.kt @@ -1,30 +1,379 @@ package com.denizd.substitutionplan.database import android.app.Application -import android.os.AsyncTask +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import android.util.Log +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat import androidx.lifecycle.LiveData +import androidx.preference.PreferenceManager +import com.denizd.substitutionplan.R +import com.denizd.substitutionplan.activities.Main +import com.denizd.substitutionplan.data.Caller +import com.denizd.substitutionplan.data.SubstUtil +import com.denizd.substitutionplan.models.Food import com.denizd.substitutionplan.models.Substitution +import org.jsoup.Jsoup -internal class SubstRepository(application: Application) { +internal class SubstRepository(private val application: Application) { - private val substDao: SubstDao? + private val substitutionDao: SubstDao? + private val foodMenuDao: SubstDao? val allSubstitutionsSorted: LiveData>? val allSubstitutionsOriginal: LiveData>? + val allFoodItems: LiveData>? + + private val prefs = PreferenceManager.getDefaultSharedPreferences(application) + + val substitutionPlanColours: Map + get() = mapOf( + "German" to prefs.getNonNullString("cardGerman"), + "Maths" to prefs.getNonNullString("cardMaths"), + "English" to prefs.getNonNullString("cardEnglish"), + "PhysEd" to prefs.getNonNullString("cardPhysEd"), + "Politics" to prefs.getNonNullString("cardPolitics"), + "Theatre" to prefs.getNonNullString("cardTheatre"), + "Physics" to prefs.getNonNullString("cardPhysics"), + "Biology" to prefs.getNonNullString("cardBiology"), + "Chemistry" to prefs.getNonNullString("cardChemistry"), + "Philosophy" to prefs.getNonNullString("cardPhilosophy"), + "Latin" to prefs.getNonNullString("cardLatin"), + "Spanish" to prefs.getNonNullString("cardSpanish"), + "French" to prefs.getNonNullString("cardFrench"), + "CompSci" to prefs.getNonNullString("cardCompSci"), + "History" to prefs.getNonNullString("cardHistory"), + "Religion" to prefs.getNonNullString("cardReligion"), + "Geography" to prefs.getNonNullString("cardGeography"), + "Arts" to prefs.getNonNullString("cardArts"), + "Music" to prefs.getNonNullString("cardMusic"), + "Turkish" to prefs.getNonNullString("cardTurkish"), + "Chinese" to prefs.getNonNullString("cardChinese"), + "GLL" to prefs.getNonNullString("cardGLL"), + "WAT" to prefs.getNonNullString("cardWAT"), + "Forder" to prefs.getNonNullString("cardForder"), + "WP" to prefs.getNonNullString("cardWP") + ) + val shouldAutoRefresh: Boolean + get() = prefs.getBoolean("autoRefresh", false) + + val shouldUseAppSorting: Boolean + get() = prefs.getBoolean("app_specific_sorting", true) + + val emptyGeneralPlan: List by lazy { + listOf(Substitution("", "", "", application.getString(R.string.plan_empty), "", "", "", "", 0, 0, 0)) + } + + val emptyPersonalSubstitution: Substitution by lazy { + Substitution("", "", "", application.getString(R.string.personal_plan_empty), "", "", "", "", 0, 0, 0) + } + + val emptyFoodMenu: List by lazy { + listOf(Food("\n${application.getString(R.string.food_menu_empty)}\n", 0)) + } init { - val database = - SubstDatabase.getInstance(application) - substDao = database?.substDao() - allSubstitutionsSorted = substDao?.allSubstitutionsSorted - allSubstitutionsOriginal = substDao?.allSubstitutionsOriginal + val substInstance = SubstDatabase.getSubstInstance(application) + val foodInstance = SubstDatabase.getFoodInstance(application) + substitutionDao = substInstance?.substDao() + foodMenuDao = foodInstance?.substDao() + allSubstitutionsSorted = substitutionDao?.allSubstitutionsSorted + allSubstitutionsOriginal = substitutionDao?.allSubstitutionsOriginal + allFoodItems = foodMenuDao?.allFoods } - fun insert(substitution: Substitution) { - substDao?.insertSubst(substitution) + private fun insert(substitution: Substitution) { + substitutionDao?.insertSubst(substitution) } - fun deleteAllSubst() { - substDao?.deleteAllSubst() + private fun insert(food: Food) { + foodMenuDao?.insertFood(food) + } + + private fun deleteAllSubst() { + substitutionDao?.deleteAllSubst() + } + + private fun deleteAllFoodItems() { + foodMenuDao?.deleteAllFoods() + } + + fun getGridColumnCount(config: Configuration): Int { + return if (application.resources.getBoolean(R.bool.isTablet)) { + when (config.orientation) { + Configuration.ORIENTATION_PORTRAIT -> 2 + else -> 3 + } + } else { + when (config.orientation) { + Configuration.ORIENTATION_PORTRAIT -> 1 + else -> 2 + } + } + } + + private fun SharedPreferences.getNonNullString(key: String): String { + return getString(key, "") ?: "" + } + + fun externalNonNullString(key: String): String { + return prefs.getNonNullString(key) + } + + private fun SharedPreferences.putAndApplyString(key: String, value: String) { + edit().putString(key, value).apply() + } + + fun putAndApplyString(key: String, value: String) { + prefs.putAndApplyString(key, value) + } + + private fun Substitution.checkIfPersonal(classPreference: String, coursePreference: String, includesPsa: Boolean = false): Boolean { + if (includesPsa && date.isNotEmpty() && date.substring(0, 3) == "psa") { + return true + } + if (coursePreference.isEmpty() && classPreference.isNotEmpty()) { + if (group.isNotEmpty()) { + if (classPreference.contains(group) || group.contains(classPreference)) { + return true + } + } + } else if (classPreference.isNotEmpty() && coursePreference.isNotEmpty()) { + if (group != "" && course != "") { + if ((classPreference.contains(group) || group.contains(classPreference))) { + val questionMark = course.indexOf("?") + if (questionMark == -1) { + if (coursePreference.contains(course)) { + return true + } + } else { + if (coursePreference.contains(course.substring(0, questionMark - 1)) + || coursePreference.contains(course.substring(questionMark + 1))) { + return true + } + } + } + } + } + return false + } + + fun checkIfSubstitutionPersonal(substitution: Substitution, classPreference: String, coursePreference: String, includesPsa: Boolean): Boolean { + return substitution.checkIfPersonal(classPreference, coursePreference, includesPsa) + } + + private fun getData(caller: Caller): Pair { + + fun requestSubstPlan(url: String): Pair { + var notificationText = "" + var hasUpdated = false + + val doc = Jsoup.connect(url).get() + val currentTime = doc.select("h1")[0].text() + + if (currentTime != prefs.getNonNullString("timeNew")) { + val rows = doc.select("tr") + val paragraphs = doc.select("h6") + val substArray = ArrayList() + + val coursePreference = prefs.getNonNullString("courses") + val classPreference = prefs.getNonNullString("classes") + + var informational = "" + for (i in 0 until paragraphs.size) { + if (i == 0) { + informational = paragraphs[i].html().replace("
", "\n") + } else { + informational += "\n\n" + paragraphs[i].html().replace("
", "\n") + } + } + prefs.putAndApplyString("informational", informational) + + deleteAllSubst() + + for (i in 0 until rows.size) { + val row = rows[i] + val cols = row.select("th") + + val group = cols[0].text() + val date = cols[1].text() + val subst = Substitution( + group = group, + date = date, + time = cols[2].text(), + course = cols[3].text(), + room = cols[4].text(), + additional = cols[5].text(), + teacher = cols[6].text(), + type = cols[7].text(), + priority = SubstUtil.assignRanking(group, (date.length > 2 && date.substring(0, 3) == "psa")), // TODO make fancier pls + date_priority = SubstUtil.assignDatePriority(date), + website_priority = i + ) + if (caller == Caller.JOBSERVICE && prefs.getBoolean("notif", true)) { + if (subst.checkIfPersonal(classPreference, coursePreference)) { + substArray.add(subst) + } + } + insert(subst) + } + var countOfNotificationItems = 0 + var countOfMoreNotificationItems = 0 + if (caller == Caller.JOBSERVICE && prefs.getBoolean("notif", true)) { + substArray.forEach { substItem -> + if (countOfNotificationItems < 4) { + notificationText += ("${ + if (notificationText.isNotEmpty()) ",\n" else "" + }${substItem.course}: ${ + if (substItem.additional.isNotEmpty()) substItem.additional else if (substItem.type.isNotEmpty()) substItem.type else "---" + }") + countOfNotificationItems += 1 + } else { + countOfMoreNotificationItems += 1 + } + } + if (countOfMoreNotificationItems > 0) { + notificationText += application.resources.getQuantityString(R.plurals.notification_more_messages, countOfMoreNotificationItems, countOfMoreNotificationItems) + } + } + prefs.putAndApplyString("timeNew", currentTime) + hasUpdated = true + } + return Pair(notificationText, hasUpdated) + } + fun requestFoodMenuData(url: String): Boolean { + val docFood = Jsoup.connect(url).get() + val currentFoodTime = docFood.select("h1")[0].text() + return if (currentFoodTime != prefs.getString("newFoodTime", "")) { + val foodElements = docFood.select("th") + deleteAllFoodItems() + + val indices = ArrayList() + val daysAndVon = arrayOf("montag", "dienstag", "mittwoch", "donnerstag", "freitag", "von", "wünschen") + for (i in 0 until foodElements.size) { + if (SubstUtil.checkStringForArray(foodElements[i].text(), daysAndVon, true)) indices.add(i) + } + indices.add(foodElements.size) + + for (l in 0 until indices.size - 1) { + var s = "" + for (i2 in indices[l] until indices[l + 1]) { + if (s.isEmpty()) { + s = foodElements[i2].text() + } else { + s += "\n${foodElements[i2].text()}" + } + } + insert(Food(s, l)) + } + prefs.putAndApplyString("newFoodTime", currentFoodTime) + true + } else { + false + } + } + fun sendNotification(notificationText: String) { + val openApp = Intent(application, Main::class.java) + openApp.flags = Intent.FLAG_ACTIVITY_NEW_TASK + openApp.flags += Intent.FLAG_ACTIVITY_CLEAR_TASK + val openAppPending = PendingIntent.getActivity(application, 0, openApp, 0) + + val notificationLayout = RemoteViews(application.packageName, R.layout.notification) + notificationLayout.setTextViewText(R.id.notification_title, application.getString( + R.string.substitution_plan + )) + notificationLayout.setTextViewText(R.id.notification_textview, notificationText) + + val manager = application.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + SubstUtil.getNotificationChannel(application, prefs) + } + + val notification = NotificationCompat.Builder(application, SubstUtil.notificationChannelId) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setCustomContentView(notificationLayout) + .setContentIntent(openAppPending) + .setAutoCancel(true) + .setColor(ContextCompat.getColor(application, R.color.colorAccent)) + .setSmallIcon( + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) R.drawable.ic_avh else R.drawable.ic_avhlogo + ) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + val sound = if (prefs.getNonNullString("ringtoneUri").isNotEmpty()) { + Uri.parse(prefs.getNonNullString("ringtoneUri")) + } else { + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + } + notification.setSound(sound) + } + + manager.notify(1, notification.build()) + } + + val urlPair = when { + prefs.getBoolean("testUrls", false) -> Pair( + "https://djd4rkn355.github.io/subst_test.html", + "https://djd4rkn355.github.io/food_test.html" + ) + prefs.getNonNullString("custom_test_url").isNotEmpty() -> Pair( + "https://djd4rkn355.github.io/${prefs.getString("custom_test_url", "")}", + "https://djd4rkn355.github.io/food.html" + ) + else -> Pair( + "https://djd4rkn355.github.io/avh_substitutions.html", + "https://djd4rkn355.github.io/food.html" + ) + } + + var error = false + val returnText = try { + val foodMenuHasUpdated = requestFoodMenuData(urlPair.second) + val planResult = requestSubstPlan(urlPair.first) + + val notificationText = planResult.first + val planHasUpdated = planResult.second + + if (caller == Caller.JOBSERVICE && notificationText.isNotEmpty()) { + sendNotification(notificationText) + } + when (caller) { + Caller.SUBSTITUTION -> { + if (planHasUpdated) { + application.getString(R.string.plan_updated) + } else { + "${application.getString(R.string.last_updated)} ${prefs.getNonNullString("timeNew")}" + } + } + Caller.FOODMENU -> { + if (foodMenuHasUpdated) { + application.getString(R.string.food_menu_updated) + } else { + "${application.getString(R.string.last_updated)} ${prefs.getNonNullString("newFoodTime")}" + } + } + Caller.SETTINGS -> application.getString(R.string.force_refresh_successful) + else -> "" + } + } catch (e: Exception) { + error = true + prefs.putAndApplyString("debug_recent_exception", Log.getStackTraceString(e)) + application.getString(R.string.an_error_occurred) + } + return Pair(returnText, error) + } + + fun fetchDataOnline(caller: Caller): Pair { + return getData(caller) } } \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/database/SubstViewModel.kt b/app/src/main/java/com/denizd/substitutionplan/database/SubstViewModel.kt deleted file mode 100644 index a598cd8..0000000 --- a/app/src/main/java/com/denizd/substitutionplan/database/SubstViewModel.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.denizd.substitutionplan.database - -import android.app.Application -import android.view.View -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.denizd.substitutionplan.data.DataFetcher -import com.denizd.substitutionplan.models.Substitution - -internal class SubstViewModel(application: Application) : AndroidViewModel(application) { - - private val repository: SubstRepository = SubstRepository(application) - val allSubstitutionsSorted: LiveData>? - val allSubstitutionsOriginal: LiveData>? - private val app = application - - init { - allSubstitutionsSorted = repository.allSubstitutionsSorted - allSubstitutionsOriginal = repository.allSubstitutionsOriginal - } - - fun refresh(swipeRefreshLayout: SwipeRefreshLayout, rootView: View, refreshMenu: Boolean) { - swipeRefreshLayout.isRefreshing = true - DataFetcher( - isPlan = true, - isMenu = refreshMenu, - isJobService = false, - context = app, - application = app, - parentView = rootView - ).execute() - } -} diff --git a/app/src/main/java/com/denizd/substitutionplan/fragments/FoodFragment.kt b/app/src/main/java/com/denizd/substitutionplan/fragments/FoodFragment.kt index 619f5d2..7081698 100644 --- a/app/src/main/java/com/denizd/substitutionplan/fragments/FoodFragment.kt +++ b/app/src/main/java/com/denizd/substitutionplan/fragments/FoodFragment.kt @@ -1,36 +1,29 @@ package com.denizd.substitutionplan.fragments -import android.content.Context -import android.content.SharedPreferences import android.os.Bundle -import androidx.preference.PreferenceManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.GridLayoutManager +import com.denizd.substitutionplan.R import com.denizd.substitutionplan.adapters.FoodAdapter -import com.denizd.substitutionplan.data.SubstUtil -import com.denizd.substitutionplan.database.FoodViewModel +import com.denizd.substitutionplan.viewmodels.FoodViewModel import com.denizd.substitutionplan.databinding.FoodLayoutBinding import com.denizd.substitutionplan.models.Food +import com.google.android.material.snackbar.Snackbar internal class FoodFragment : Fragment() { private val mAdapter = FoodAdapter(ArrayList()) - private lateinit var mContext: Context - private lateinit var prefs: SharedPreferences - private lateinit var foodViewModel: FoodViewModel + private lateinit var viewModel: FoodViewModel private lateinit var binding: FoodLayoutBinding - - override fun onAttach(context: Context) { - super.onAttach(context) - mContext = context - prefs = PreferenceManager.getDefaultSharedPreferences(mContext) - } + private lateinit var snackBarContainer: CoordinatorLayout override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { binding = FoodLayoutBinding.inflate(inflater, container, false) @@ -40,28 +33,41 @@ internal class FoodFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + snackBarContainer = view.rootView.findViewById(R.id.coordination) // TODO replace findViewById with ViewBinding + binding.recyclerView.apply { hasFixedSize() - layoutManager = GridLayoutManager(mContext, 1) + layoutManager = GridLayoutManager(requireContext(), 1) adapter = mAdapter } - foodViewModel = ViewModelProviders.of(this).get(FoodViewModel::class.java) - foodViewModel.allFoods?.observe(this, Observer> { foodList -> + viewModel = ViewModelProviders.of(this).get(FoodViewModel::class.java) + viewModel.allFoodItems?.observe(this, Observer> { foodList -> binding.recyclerView.scheduleLayoutAnimation() mAdapter.setFood(if (foodList.isEmpty()) { - SubstUtil.getEmptyFoodMenu(mContext) + viewModel.emptyFoodMenu } else { foodList }) }) - if (prefs.getBoolean("autoRefresh", false)) { - foodViewModel.refresh(swipeRefreshLayout = binding.swipeRefreshLayout, rootView = view.rootView) + if (viewModel.shouldAutoRefresh) { + refreshAndDisplaySnackBar() } binding.swipeRefreshLayout.setOnRefreshListener { - foodViewModel.refresh(swipeRefreshLayout = binding.swipeRefreshLayout, rootView = view.rootView) + refreshAndDisplaySnackBar() + } + } + + private fun refreshAndDisplaySnackBar() { + viewModel.refresh { result, error -> + val snackBar = Snackbar.make(snackBarContainer, result, Snackbar.LENGTH_LONG) + if (error) { + snackBar.setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.colorError)) + } + snackBar.show() + binding.swipeRefreshLayout.isRefreshing = false } } } \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/fragments/GeneralPlanFragment.kt b/app/src/main/java/com/denizd/substitutionplan/fragments/GeneralPlanFragment.kt index 5612d5b..c7b1d14 100644 --- a/app/src/main/java/com/denizd/substitutionplan/fragments/GeneralPlanFragment.kt +++ b/app/src/main/java/com/denizd/substitutionplan/fragments/GeneralPlanFragment.kt @@ -3,7 +3,6 @@ package com.denizd.substitutionplan.fragments import android.os.Bundle import android.view.View import androidx.lifecycle.Observer -import com.denizd.substitutionplan.data.SubstUtil import com.denizd.substitutionplan.models.Substitution internal class GeneralPlanFragment : PlanFragment() { @@ -13,7 +12,7 @@ internal class GeneralPlanFragment : PlanFragment() { substitutionPlan?.observe(this, Observer> { substitutions -> mAdapter.setSubst(if (substitutions.isEmpty()) { - SubstUtil.getEmptyGeneralSubstitution(mContext) + viewModel.emptyGeneralPlan } else { substitutions }) diff --git a/app/src/main/java/com/denizd/substitutionplan/fragments/PersonalPlanFragment.kt b/app/src/main/java/com/denizd/substitutionplan/fragments/PersonalPlanFragment.kt index d8d7b9c..7cae6a6 100644 --- a/app/src/main/java/com/denizd/substitutionplan/fragments/PersonalPlanFragment.kt +++ b/app/src/main/java/com/denizd/substitutionplan/fragments/PersonalPlanFragment.kt @@ -3,7 +3,6 @@ package com.denizd.substitutionplan.fragments import android.os.Bundle import android.view.View import androidx.lifecycle.Observer -import com.denizd.substitutionplan.data.SubstUtil import com.denizd.substitutionplan.models.Substitution internal class PersonalPlanFragment : PlanFragment() { @@ -11,30 +10,28 @@ internal class PersonalPlanFragment : PlanFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val coursePreference = prefs.getString("courses", "") ?: "" - val classPreference = prefs.getString("classes", "") ?: "" + val coursePreference = viewModel.getNonNullString("courses") + val classPreference = viewModel.getNonNullString("classes") substitutionPlan?.observe(this, Observer> { substitutions -> - planCardList.clear() - isPersonalPlanEmpty = true - binding.recyclerView.visibility = View.VISIBLE + val list = ArrayList() substitutions.filter { substItem -> - SubstUtil.checkPersonalSubstitutions( + viewModel.checkIfSubstitutionPersonal( substItem, - coursePreference, classPreference, + coursePreference, true ) }.forEach { substItem -> - planCardList.add(substItem) + list.add(substItem) } - isPersonalPlanEmpty = (planCardList.size == 1 && planCardList[0].date.substring(0, 3) == "psa") || planCardList.isEmpty() + val isPersonalPlanEmpty = (list.size == 1 && list[0].date.substring(0, 3) == "psa") || list.isEmpty() if (isPersonalPlanEmpty) { - planCardList.add(SubstUtil.getEmptyPersonalSubstitution(mContext)) + list.add(viewModel.emptyPersonalSubstitution) } binding.recyclerView.scheduleLayoutAnimation() - mAdapter.setSubst(planCardList) + mAdapter.setSubst(list) }) } } \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/fragments/PlanFragment.kt b/app/src/main/java/com/denizd/substitutionplan/fragments/PlanFragment.kt index 4ef2508..6dc6cd5 100644 --- a/app/src/main/java/com/denizd/substitutionplan/fragments/PlanFragment.kt +++ b/app/src/main/java/com/denizd/substitutionplan/fragments/PlanFragment.kt @@ -1,43 +1,33 @@ package com.denizd.substitutionplan.fragments -import android.content.Context -import android.content.SharedPreferences import android.content.res.Configuration import android.os.Bundle -import android.os.Handler -import androidx.preference.PreferenceManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.GridLayoutManager import com.denizd.substitutionplan.R import com.denizd.substitutionplan.adapters.SubstitutionAdapter -import com.denizd.substitutionplan.database.SubstViewModel +import com.denizd.substitutionplan.viewmodels.SubstViewModel import com.denizd.substitutionplan.databinding.PlanBinding import com.denizd.substitutionplan.models.Substitution +import com.google.android.material.snackbar.Snackbar import kotlin.collections.ArrayList internal open class PlanFragment : Fragment() { - internal lateinit var mAdapter: SubstitutionAdapter - internal var planCardList = ArrayList() - private lateinit var substViewModel: SubstViewModel - internal lateinit var mContext: Context - internal lateinit var prefs: SharedPreferences - internal var isPersonalPlanEmpty: Boolean = true - internal val handler = Handler() - internal var substitutionPlan: LiveData>? = null - - internal lateinit var binding: PlanBinding - - override fun onAttach(context: Context) { - super.onAttach(context) - mContext = context - prefs = PreferenceManager.getDefaultSharedPreferences(mContext) + internal val mAdapter: SubstitutionAdapter by lazy { + SubstitutionAdapter(ArrayList(), viewModel.substitutionPlanColours) } + lateinit var viewModel: SubstViewModel + internal var substitutionPlan: LiveData>? = null + internal lateinit var binding: PlanBinding + private lateinit var snackBarContainer: CoordinatorLayout override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { binding = PlanBinding.inflate(inflater, container, false) @@ -46,51 +36,50 @@ internal open class PlanFragment : Fragment() { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - binding.recyclerView.layoutManager = GridLayoutManager(mContext, getGridColumnCount(newConfig)) + binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), viewModel.getGridColumnCount(newConfig)) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - substViewModel = ViewModelProviders.of(this).get(SubstViewModel::class.java) - substitutionPlan = if (prefs.getBoolean("app_specific_sorting", true)) { - substViewModel.allSubstitutionsSorted + + snackBarContainer = view.rootView.findViewById(R.id.coordination) // TODO replace findViewById with ViewBinding + + viewModel = ViewModelProviders.of(this).get(SubstViewModel::class.java) + substitutionPlan = if (viewModel.shouldUseAppSorting) { + viewModel.allSubstitutionsSorted } else { - substViewModel.allSubstitutionsOriginal + viewModel.allSubstitutionsOriginal } - mAdapter = SubstitutionAdapter(planCardList, prefs) binding.recyclerView.apply { hasFixedSize() - layoutManager = GridLayoutManager(mContext, getGridColumnCount(resources.configuration)) + layoutManager = GridLayoutManager(requireContext(), viewModel.getGridColumnCount(resources.configuration)) adapter = mAdapter } - if (prefs.getInt("firstTimeOpening", 0) == 1) { - substViewModel.refresh(swipeRefreshLayout = binding.swipeRefreshLayout, rootView = view.rootView, refreshMenu = true) - prefs.edit().putInt("firstTimeOpening", 2).apply() - } + // TODO figure something out +// if (prefs.getInt("firstTimeOpening", 0) == 1) { +// refreshAndDisplaySnackBar() +// prefs.edit().putInt("firstTimeOpening", 2).apply() +// } - if (prefs.getBoolean("autoRefresh", false)) { - substViewModel.refresh(swipeRefreshLayout = binding.swipeRefreshLayout, rootView = view.rootView, refreshMenu = false) + if (viewModel.shouldAutoRefresh) { + refreshAndDisplaySnackBar() } binding.swipeRefreshLayout.setOnRefreshListener { - substViewModel.refresh(swipeRefreshLayout = binding.swipeRefreshLayout, rootView = view.rootView, refreshMenu = false) + refreshAndDisplaySnackBar() } } - private fun getGridColumnCount(config: Configuration): Int { - val tabletSize = resources.getBoolean(R.bool.isTablet) - return if (tabletSize) { - when (config.orientation) { - Configuration.ORIENTATION_PORTRAIT -> 2 - else -> 3 - } - } else { - when (config.orientation) { - Configuration.ORIENTATION_PORTRAIT -> 1 - else -> 2 + private fun refreshAndDisplaySnackBar() { + viewModel.refresh { result, error -> + val snackBar = Snackbar.make(snackBarContainer, result, Snackbar.LENGTH_LONG) + if (error) { + snackBar.setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.colorError)) // TODO check if requireContext() is dangerous ;( } + snackBar.show() + binding.swipeRefreshLayout.isRefreshing = false } } } \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/fragments/SettingsFragment.kt b/app/src/main/java/com/denizd/substitutionplan/fragments/SettingsFragment.kt index 5438730..cb6e985 100644 --- a/app/src/main/java/com/denizd/substitutionplan/fragments/SettingsFragment.kt +++ b/app/src/main/java/com/denizd/substitutionplan/fragments/SettingsFragment.kt @@ -5,8 +5,6 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.SharedPreferences -import android.database.Cursor -import android.media.RingtoneManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -17,25 +15,23 @@ import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.Window import android.widget.* import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDelegate import androidx.browser.customtabs.CustomTabsIntent import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.denizd.substitutionplan.* import com.denizd.substitutionplan.adapters.ColourPickerAdapter import com.denizd.substitutionplan.adapters.CourseColourAdapter import com.denizd.substitutionplan.adapters.RingtoneAdapter -import com.denizd.substitutionplan.data.DataFetcher import com.denizd.substitutionplan.data.SubstUtil -import com.denizd.substitutionplan.data.Topic import com.denizd.substitutionplan.databinding.ContentSettingsBinding import com.denizd.substitutionplan.models.Colour -import com.denizd.substitutionplan.models.Ringtone +import com.denizd.substitutionplan.viewmodels.SettingsViewModel import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.button.MaterialButton import com.google.firebase.messaging.FirebaseMessaging @@ -48,28 +44,21 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC private lateinit var mContext: Context private lateinit var prefs: SharedPreferences private val customTabsIntent = CustomTabsIntent.Builder().build() as CustomTabsIntent - private var window: Window? = null - private var longPressed = false + private var customiseColoursButtonLongPressed = false private var versionCount = 0 private var currentCourseSelectedInColourPicker = "" - private val ringtones: List by lazy { - getRingtones() - } - private lateinit var colourRecycler: RecyclerView private lateinit var ringtoneDialog: AlertDialog private lateinit var colourPickerDialog: AlertDialog private lateinit var binding: ContentSettingsBinding - private lateinit var textCustomiseColoursTitle: TextView - private lateinit var textCustomiseColoursDesc: TextView + private lateinit var viewModel: SettingsViewModel override fun onAttach(context: Context) { super.onAttach(context) mContext = context prefs = PreferenceManager.getDefaultSharedPreferences(mContext) - window = activity?.window } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -80,12 +69,11 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + viewModel = ViewModelProviders.of(this)[SettingsViewModel::class.java] + binding.apply { val thisFragment = this@SettingsFragment - thisFragment.textCustomiseColoursTitle = textCustomiseColoursTitle - thisFragment.textCustomiseColoursDesc = textCustomiseColoursDesc - // Set text setRingtoneText() @@ -174,15 +162,15 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) if (Build.VERSION.SDK_INT in 23..28) { @SuppressLint("InlinedApi") - window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - window?.navigationBarColor = ContextCompat.getColor(mContext, R.color.colorBackground) + activity?.window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + activity?.window?.navigationBarColor = ContextCompat.getColor(mContext, R.color.colorBackground) } } 2 -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) // only accessible on API 28+ else -> { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - window?.navigationBarColor = ContextCompat.getColor(mContext, R.color.colorBackground) + activity?.window?.navigationBarColor = ContextCompat.getColor(mContext, R.color.colorBackground) } } } @@ -219,14 +207,14 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC R.id.button_courses_help -> createDialog(getString(R.string.courses_help_dialog_title), getString(R.string.courses_help_dialog_text)) R.id.button_order_help -> createDialog(getString(R.string.ordering_systems_dialog_title), getString(R.string.ordering_systems_dialog_text)) R.id.button_forced_refresh -> { - DataFetcher( - isPlan = true, - isMenu = true, - forced = true, - context = mContext, - application = activity!!.application, - parentView = view!!.rootView - ).execute() +// val repo = SubstRepository(activity?.application!!) +// repo.putAndApplyString("timeNew", "") +// repo.putAndApplyString("newFoodTime", "") +// repo.fetchDataOnline(Caller.SETTINGS) + // TODO needs async + // TODO put in viewModel + // TODO create viewModel + // TODO remove TODOs } R.id.button_visit_website -> { try { @@ -250,14 +238,14 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC override fun onLongClick(v: View?): Boolean { return when (v?.id) { R.id.button_customise_colours -> { - if (!longPressed) { - textCustomiseColoursTitle.text = getString(R.string.made_by_deniz) - textCustomiseColoursDesc.text = getString(R.string.thanks_for_using) + if (!customiseColoursButtonLongPressed) { + binding.textCustomiseColoursTitle.text = getString(R.string.made_by_deniz) + binding.textCustomiseColoursDesc.text = getString(R.string.thanks_for_using) } else { - textCustomiseColoursTitle.text = getString(R.string.customise_colours_title) - textCustomiseColoursDesc.text = getString(R.string.customise_colours_desc) + binding.textCustomiseColoursTitle.text = getString(R.string.customise_colours_title) + binding.textCustomiseColoursDesc.text = getString(R.string.customise_colours_desc) } - longPressed = !longPressed + customiseColoursButtonLongPressed = !customiseColoursButtonLongPressed true } R.id.button_courses_help -> { @@ -298,9 +286,9 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC R.id.switch_notifications -> { prefs.edit().putBoolean("notif", isChecked).apply() if (isChecked) { - subscribeToTopic(Topic.ANDROID) + subscribeToTopic(SubstUtil.FB_TOPIC_ANDROID) } else { - unsubscribeFromTopic(Topic.ANDROID) + unsubscribeFromTopic(SubstUtil.FB_TOPIC_ANDROID) } } R.id.switch_personalised_plan -> { @@ -389,11 +377,14 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC val titleText = dialogView.findViewById(R.id.empty_textviewtitle) titleText.text = getString(R.string.pick_ringtone_dialog_title) - val recycler = dialogView.findViewById(R.id.recyclerView) - recycler.apply { + dialogView.findViewById(R.id.recyclerView).apply { hasFixedSize() layoutManager = GridLayoutManager(mContext, 1) - adapter = RingtoneAdapter(ringtones, this@SettingsFragment) + if (!viewModel.ringtonesInitialised) { + viewModel.getRingtones { + adapter = RingtoneAdapter(viewModel.ringtones, this@SettingsFragment) + } + } } ringtoneDialog = ringtoneCustomiserBuilder.setView(dialogView).create() @@ -464,10 +455,10 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC } this == "_devchannel" -> { val subbed = if (prefs.getBoolean("subscribedToFBDebugChannel", false)) { - unsubscribeFromTopic(Topic.DEVELOPMENT) + unsubscribeFromTopic(SubstUtil.FB_TOPIC_DEVELOPMENT) "Unsubscribed from" } else { - subscribeToTopic(Topic.DEVELOPMENT) + subscribeToTopic(SubstUtil.FB_TOPIC_DEVELOPMENT) "Subscribed to" } prefs.edit().putBoolean("subscribedToFBDebugChannel", !prefs.getBoolean("subscribedToFBDebugChannel", false)).apply() @@ -475,10 +466,10 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC } this == "_ioschannel" -> { val subbed = if (prefs.getBoolean("subscribedToiOSChannel", false)) { - unsubscribeFromTopic(Topic.IOS) + unsubscribeFromTopic(SubstUtil.FB_TOPIC_IOS) "Unsubscribed from" } else { - subscribeToTopic(Topic.IOS) + subscribeToTopic(SubstUtil.FB_TOPIC_IOS) "Subscribed to" } prefs.edit().putBoolean("subscribedToiOSChannel", !prefs.getBoolean("subscribedToiOSChannel", false)).apply() @@ -570,26 +561,6 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC return colours } - private fun getRingtones(): ArrayList { - lateinit var ringtoneCursor: Cursor - val ringtoneManager = RingtoneManager(activity).apply { - setType(RingtoneManager.TYPE_NOTIFICATION) - ringtoneCursor = cursor - } - val alarms = ArrayList() - - while (!ringtoneCursor.isAfterLast && ringtoneCursor.moveToNext()) { - val position = ringtoneCursor.position - alarms.add( - Ringtone( - ringtoneManager.getRingtone(position).getTitle(mContext), - ringtoneManager.getRingtoneUri(position).toString() - ) - ) - } - return alarms - } - // Functions for handling other things private fun setRingtoneText() { binding.textCustomiseRingtoneDesc.text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -620,16 +591,20 @@ internal class SettingsFragment : Fragment(), View.OnClickListener, View.OnLongC "\n\nSubscribed to dev channel: ${prefs.getBoolean("subscribedToFBDebugChannel", false)}" } - private fun subscribeToTopic(topic: Topic) = FirebaseMessaging.getInstance().subscribeToTopic(topic.tag) - private fun unsubscribeFromTopic(topic: Topic) = FirebaseMessaging.getInstance().unsubscribeFromTopic(topic.tag) + private fun subscribeToTopic(topic: String) = FirebaseMessaging.getInstance().subscribeToTopic(topic) + private fun unsubscribeFromTopic(topic: String) = FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) // Below this point follow string literals that I didn't bother putting in /res/values/strings - private val licences = "Libraries:" + - "\n • jsoup HTML parser © 2009-2018 Jonathan Hedley, licensed under the open source MIT Licence" + - "\n\nFont:" + - "\n • Manrope © 2018-2019 Michael Sharanda, licensed under the SIL Open Font Licence 1.1" + - "\n\nIcons:" + - "\n • bqlqn\n • fjstudio\n • Freepik\n • Smashicons" + - "\n • © 2013-2019 Freepik Company S.L., licensed under Creative Commons BY 3.0" + // TODO check if this is displayed properly + private val licences = """Libraries: + • jsoup HTML parser © 2009-2018 Jonathan Hedley, licensed under the open source MIT Licence + Font: + • Manrope © 2018-2019 Michael Sharanda, licensed under the SIL Open Font Licence 1.1 + Icons: + • bqlqn + • fjstudio + • Freepik + • Smashicons + • © 2013-2019 Freepik Company S.L., licensed under Creative Commons BY 3.0""".trimIndent() } \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/services/FBNotificationService.kt b/app/src/main/java/com/denizd/substitutionplan/services/FBNotificationService.kt index fd2df3b..33a5b4a 100644 --- a/app/src/main/java/com/denizd/substitutionplan/services/FBNotificationService.kt +++ b/app/src/main/java/com/denizd/substitutionplan/services/FBNotificationService.kt @@ -1,7 +1,8 @@ package com.denizd.substitutionplan.services import android.annotation.SuppressLint -import com.denizd.substitutionplan.data.DataFetcher +import com.denizd.substitutionplan.data.Caller +import com.denizd.substitutionplan.database.SubstRepository import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage @@ -13,13 +14,6 @@ import com.google.firebase.messaging.RemoteMessage internal class FBNotificationService : FirebaseMessagingService() { override fun onMessageReceived(p0: RemoteMessage) { - DataFetcher( - isPlan = true, - isMenu = true, - isJobService = true, - context = applicationContext, - application = application, - parentView = null - ).execute() + SubstRepository(application).fetchDataOnline(Caller.JOBSERVICE) } } \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/services/FBPingService.kt b/app/src/main/java/com/denizd/substitutionplan/services/FBPingService.kt index a02a9cc..25639c6 100644 --- a/app/src/main/java/com/denizd/substitutionplan/services/FBPingService.kt +++ b/app/src/main/java/com/denizd/substitutionplan/services/FBPingService.kt @@ -3,7 +3,7 @@ package com.denizd.substitutionplan.services import android.app.job.JobParameters import android.app.job.JobService import androidx.preference.PreferenceManager -import com.denizd.substitutionplan.data.Topic +import com.denizd.substitutionplan.data.SubstUtil import com.google.firebase.FirebaseApp import com.google.firebase.messaging.FirebaseMessaging @@ -35,24 +35,24 @@ internal class FBPingService : JobService() { if (!prefs.getBoolean("firstTime", true)) { FirebaseMessaging.getInstance().apply { if (prefs.getBoolean("notif", true)) { - subscribeToTopic(Topic.ANDROID.tag) + subscribeToTopic(SubstUtil.FB_TOPIC_ANDROID) } else { - unsubscribeFromTopic(Topic.ANDROID.tag) + unsubscribeFromTopic(SubstUtil.FB_TOPIC_ANDROID) } if (prefs.getBoolean("subscribedToFBDebugChannel", false)) { - subscribeToTopic(Topic.DEVELOPMENT.tag) + subscribeToTopic(SubstUtil.FB_TOPIC_DEVELOPMENT) } else { - unsubscribeFromTopic(Topic.DEVELOPMENT.tag) + unsubscribeFromTopic(SubstUtil.FB_TOPIC_DEVELOPMENT) } if (prefs.getBoolean("subscribedToiOSChannel", false)) { - subscribeToTopic(Topic.IOS.tag) + subscribeToTopic(SubstUtil.FB_TOPIC_IOS) } else { - unsubscribeFromTopic(Topic.IOS.tag) + unsubscribeFromTopic(SubstUtil.FB_TOPIC_IOS) } - subscribeToTopic(Topic.BROADCAST.tag) + subscribeToTopic(SubstUtil.FB_TOPIC_BROADCAST) prefs.edit().putInt("pingFB", prefs.getInt("pingFB", 0) + 1).apply() } diff --git a/app/src/main/java/com/denizd/substitutionplan/viewmodels/FoodViewModel.kt b/app/src/main/java/com/denizd/substitutionplan/viewmodels/FoodViewModel.kt new file mode 100644 index 0000000..e75e912 --- /dev/null +++ b/app/src/main/java/com/denizd/substitutionplan/viewmodels/FoodViewModel.kt @@ -0,0 +1,31 @@ +package com.denizd.substitutionplan.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import com.denizd.substitutionplan.data.Caller +import com.denizd.substitutionplan.database.SubstRepository +import com.denizd.substitutionplan.models.Food +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch + +internal class FoodViewModel(application: Application) : AndroidViewModel(application) { + + private val repo = SubstRepository(application) + val allFoodItems: LiveData>? + val emptyFoodMenu = repo.emptyFoodMenu + val shouldAutoRefresh = repo.shouldAutoRefresh + + init { + allFoodItems = repo.allFoodItems + } + + fun refresh(updateUi: (result: String, error: Boolean) -> Unit) { + GlobalScope.launch { + val task = GlobalScope.async { repo.fetchDataOnline(Caller.FOODMENU) } + val result = task.await() + updateUi(result.first, result.second) + } + } +} diff --git a/app/src/main/java/com/denizd/substitutionplan/viewmodels/SettingsViewModel.kt b/app/src/main/java/com/denizd/substitutionplan/viewmodels/SettingsViewModel.kt new file mode 100644 index 0000000..c1ffb1b --- /dev/null +++ b/app/src/main/java/com/denizd/substitutionplan/viewmodels/SettingsViewModel.kt @@ -0,0 +1,25 @@ +package com.denizd.substitutionplan.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import com.denizd.substitutionplan.database.SettingsRepository +import com.denizd.substitutionplan.models.Ringtone +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch + +internal class SettingsViewModel(application: Application) : AndroidViewModel(application) { + + private val repo = SettingsRepository(application) + + lateinit var ringtones: List + val ringtonesInitialised = ::ringtones.isInitialized + + fun getRingtones(updateUi: () -> Unit) { + GlobalScope.launch { + val task = GlobalScope.async { repo.ringtones } + ringtones = task.await() + updateUi() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/denizd/substitutionplan/viewmodels/SubstViewModel.kt b/app/src/main/java/com/denizd/substitutionplan/viewmodels/SubstViewModel.kt new file mode 100644 index 0000000..a79ebaf --- /dev/null +++ b/app/src/main/java/com/denizd/substitutionplan/viewmodels/SubstViewModel.kt @@ -0,0 +1,47 @@ +package com.denizd.substitutionplan.viewmodels + +import android.app.Application +import android.content.res.Configuration +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import com.denizd.substitutionplan.data.Caller +import com.denizd.substitutionplan.database.SubstRepository +import com.denizd.substitutionplan.models.Substitution +import kotlinx.coroutines.* + +internal class SubstViewModel(application: Application) : AndroidViewModel(application) { + + private val repo = SubstRepository(application) + val allSubstitutionsSorted: LiveData>? + val allSubstitutionsOriginal: LiveData>? + val substitutionPlanColours = repo.substitutionPlanColours + val shouldAutoRefresh = repo.shouldAutoRefresh + val shouldUseAppSorting = repo.shouldUseAppSorting + val emptyGeneralPlan = repo.emptyGeneralPlan + val emptyPersonalSubstitution = repo.emptyPersonalSubstitution + + init { + allSubstitutionsSorted = repo.allSubstitutionsSorted + allSubstitutionsOriginal = repo.allSubstitutionsOriginal + } + + fun getNonNullString(key: String): String { + return repo.externalNonNullString(key) + } + + fun checkIfSubstitutionPersonal(substitution: Substitution, classPreference: String, coursePreference: String, includesPsa: Boolean): Boolean { + return repo.checkIfSubstitutionPersonal(substitution, classPreference, coursePreference, includesPsa) + } + + fun getGridColumnCount(config: Configuration): Int { + return repo.getGridColumnCount(config) + } + + fun refresh(updateUi: (result: String, error: Boolean) -> Unit) { + GlobalScope.launch { + val task = GlobalScope.async { repo.fetchDataOnline(Caller.SUBSTITUTION) } + val result = task.await() + updateUi(result.first, result.second) + } + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1fd89fd..c3e0dbb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,7 +2,7 @@ AvH-Plan Filter - Keine Internetverbindung + Ein Fehler ist aufgetreten Allgemein Klasse Kurse @@ -192,4 +192,6 @@ Weiß Schwarz + Der Plan wurde aktualisiert + Der Speiseplan wurde aktualisiert \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7325d6a..9b697ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,7 +3,7 @@ AvH Plan Substitution Plan Filters - No internet connection + An error has occurred General Grade Courses @@ -205,4 +205,6 @@ White Black + The plan has been updated + The food menu has been updated