diff --git a/app/build.gradle b/app/build.gradle
index ae9f5e4..eb0a966 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,6 +1,8 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
+apply plugin: 'com.google.devtools.ksp'
+apply plugin: 'dagger.hilt.android.plugin'
apply plugin: 'com.google.firebase.crashlytics'
apply plugin: 'com.google.gms.google-services'
@@ -61,6 +63,10 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.8.0'
implementation 'androidx.core:core-ktx:1.19.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.11.0'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0'
+ implementation 'androidx.activity:activity-ktx:1.8.0'
+ implementation "com.google.dagger:hilt-android:$hilt_version"
+ ksp "com.google.dagger:hilt-compiler:$hilt_version"
implementation 'androidx.constraintlayout:constraintlayout:2.2.2'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
testImplementation 'junit:junit:4.13.2'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 76560ec..ee41f36 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
@@ -23,27 +24,27 @@
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/CourseGrabApplication.kt b/app/src/main/java/com/cornellappdev/coursegrab/CourseGrabApplication.kt
new file mode 100644
index 0000000..3906615
--- /dev/null
+++ b/app/src/main/java/com/cornellappdev/coursegrab/CourseGrabApplication.kt
@@ -0,0 +1,7 @@
+package com.cornellappdev.coursegrab
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class CourseGrabApplication : Application()
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt
deleted file mode 100644
index 41edf71..0000000
--- a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt
+++ /dev/null
@@ -1,240 +0,0 @@
-package com.cornellappdev.coursegrab
-
-import android.content.Intent
-import android.content.MutableContextWrapper
-import android.os.Bundle
-import android.util.Log
-import androidx.appcompat.app.AppCompatActivity
-import androidx.credentials.ClearCredentialStateRequest
-import androidx.credentials.CredentialManager
-import androidx.credentials.CredentialOption
-import androidx.credentials.CustomCredential
-import androidx.credentials.GetCredentialRequest
-import androidx.credentials.GetCredentialResponse
-import androidx.credentials.exceptions.ClearCredentialException
-import androidx.credentials.exceptions.GetCredentialCancellationException
-import androidx.credentials.exceptions.GetCredentialException
-import androidx.credentials.exceptions.NoCredentialException
-import androidx.lifecycle.lifecycleScope
-import com.cornellappdev.coursegrab.databinding.ActivityLoginBinding
-import com.cornellappdev.coursegrab.models.UserSession
-import com.cornellappdev.coursegrab.networking.CourseGrabRepository
-import com.google.android.libraries.identity.googleid.GetGoogleIdOption
-import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
-import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
-import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
-import com.google.android.material.snackbar.Snackbar
-import com.google.firebase.messaging.FirebaseMessaging
-import kotlinx.coroutines.launch
-
-class LoginActivity : AppCompatActivity() {
- private lateinit var binding: ActivityLoginBinding
-
- private val credentialManager: CredentialManager by lazy {
- CredentialManager.create(this)
- }
- private val credentialContext: MutableContextWrapper by lazy {
- MutableContextWrapper(this)
- }
-
- private val preferencesHelper: PreferencesHelper by lazy {
- PreferencesHelper(this)
- }
-
- private val repository: CourseGrabRepository by lazy {
- CourseGrabRepository(preferencesHelper)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- binding = ActivityLoginBinding.inflate(layoutInflater)
- setContentView(binding.root)
-
- if (preferencesHelper.expiresAt > System.currentTimeMillis() / 1000L) {
- val intent = Intent(this@LoginActivity, MainActivity::class.java)
- startActivity(intent)
- } else {
- val updateToken = preferencesHelper.updateToken
- if (!updateToken.isNullOrBlank()) {
- lifecycleScope.launch {
- repository.updateSession(updateToken)
- .onSuccess { verifySession(it) }
- .onFailure { Log.d(TAG, "Could not resume previous session", it) }
- }
- }
- }
-
- binding.signInButton.setOnClickListener { signIn() }
- }
-
- private fun signIn() {
- lifecycleScope.launch {
- // Step 1: accounts already authorized for this app. Returning users get a
- // streamlined sheet, or no prompt at all when there is exactly one match.
- val googleIdOption = GetGoogleIdOption.Builder()
- .setServerClientId(getString(R.string.default_web_client_id))
- .setFilterByAuthorizedAccounts(true)
- .setAutoSelectEnabled(true)
- .build()
-
- when (val authorized = requestCredential(googleIdOption)) {
- is CredentialResult.Success -> {
- handleSignIn(authorized.response)
- return@launch
- }
-
- CredentialResult.NoneAvailable -> Unit
- CredentialResult.Cancelled, CredentialResult.Failed -> return@launch
- }
-
- // Step 2: first-time (or de-authorized) users get the full account picker.
- val signInWithGoogleOption = GetSignInWithGoogleOption
- .Builder(getString(R.string.default_web_client_id))
- .build()
-
- when (val result = requestCredential(signInWithGoogleOption)) {
- is CredentialResult.Success -> handleSignIn(result.response)
- CredentialResult.NoneAvailable ->
- showLoginError("No Google account found. Add one in system settings.")
-
- CredentialResult.Cancelled, CredentialResult.Failed -> Unit
- }
- }
- }
-
- private suspend fun requestCredential(option: CredentialOption): CredentialResult {
- val request = GetCredentialRequest.Builder()
- .addCredentialOption(option)
- .build()
-
- // TODO: this runs in lifecycleScope, so rotating while the sheet is up cancels
- // sign-in. Move the call into a ViewModel (viewModelScope) to survive recreation.
- return try {
- CredentialResult.Success(
- credentialManager.getCredential(credentialContext, request)
- )
- } catch (e: NoCredentialException) {
- Log.d(TAG, "No matching credential for ${option::class.simpleName}", e)
- CredentialResult.NoneAvailable
- } catch (e: GetCredentialCancellationException) {
- Log.d(TAG, "Sign-in cancelled by user", e)
- CredentialResult.Cancelled
- } catch (e: GetCredentialException) {
- Log.e(TAG, "Credential Manager sign-in failed", e)
- showLoginError("Sign-in failed. Please try again.")
- CredentialResult.Failed
- }
- }
-
- private sealed interface CredentialResult {
- data class Success(val response: GetCredentialResponse) : CredentialResult
- data object NoneAvailable : CredentialResult
- data object Cancelled : CredentialResult
- data object Failed : CredentialResult
- }
-
- private fun handleSignIn(response: GetCredentialResponse) {
- val credential = response.credential
- when (credential) {
- is CustomCredential -> {
- // GetSignInWithGoogleOption returns the SIWG credential type, while the
- // bottom-sheet GetGoogleIdOption flow returns the plain one. Accept either.
- if (credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL &&
- credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_SIWG_CREDENTIAL
- ) {
- Log.e(TAG, "Unexpected credential type: ${credential.type}")
- showLoginError("Sign-in failed. Please try again.")
- return
- }
- }
-
- else -> {
- Log.e(TAG, "Unexpected type of credential: ${credential::class}")
- showLoginError("Sign-in failed. Please try again.")
- return
- }
- }
-
- val googleCredential = try {
- GoogleIdTokenCredential.createFrom(credential.data)
- } catch (e: GoogleIdTokenParsingException) {
- Log.e(TAG, "Failed to parse Google ID token", e)
- showLoginError("Sign-in failed. Please try again.")
- return
- }
-
- if (!isAllowedAccount(googleCredential.id)) {
- showLoginError("Please use a @cornell.edu account")
- clearCredentialState()
- return
- }
-
- lifecycleScope.launch {
- repository.initializeSession(googleCredential.idToken, null)
- .onSuccess { verifySession(it) }
- .onFailure { error ->
- Log.e(TAG, "Failed to initialize session", error)
- showLoginError("Sign-in failed. Please try again.")
- }
- }
- }
-
- private fun isAllowedAccount(email: String): Boolean =
- email.endsWith("@cornell.edu") ||
- email == "appstoreappdev@gmail.com" ||
- email == "coursegrab.droid@gmail.com"
-
- private fun clearCredentialState() {
- lifecycleScope.launch {
- try {
- credentialManager.clearCredentialState(ClearCredentialStateRequest())
- } catch (e: ClearCredentialException) {
- Log.w(TAG, "Failed to clear credential state", e)
- }
- }
- }
-
- private fun showLoginError(message: String) {
- Snackbar.make(binding.loginRootView, message, Snackbar.LENGTH_LONG).show()
- }
-
- private fun sendRegistrationToServer(token: String) {
- lifecycleScope.launch {
- repository.sendDeviceToken(token)
- .onSuccess { Log.d(TAG, "sendRegistrationTokenToServer($token)") }
- .onFailure { Log.w(TAG, "Failed to register device token", it) }
- }
- }
-
- private fun verifySession(userSession: UserSession) {
-
- if (userSession.session_expiration.isNullOrBlank() ||
- userSession.session_token.isNullOrBlank() ||
- userSession.update_token.isNullOrBlank()
- ) return
-
- preferencesHelper.sessionToken = userSession.session_token
- preferencesHelper.updateToken = userSession.update_token
- preferencesHelper.expiresAt = userSession.session_expiration.toLong()
-
- FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
- sendRegistrationToServer(token)
- }
-
- setNotificationsStatus(preferencesHelper.mobileAlertSetting)
-
- val intent = Intent(this@LoginActivity, MainActivity::class.java)
- startActivity(intent)
- }
-
- private fun setNotificationsStatus(enabled: Boolean) {
- lifecycleScope.launch {
- repository.setNotifications(enabled)
- .onFailure { Log.w(TAG, "Failed to update notifications", it) }
- }
- }
-
- companion object {
- private const val TAG = "LoginActivity"
- }
-}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt b/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt
deleted file mode 100644
index 8849b27..0000000
--- a/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.cornellappdev.coursegrab
-
-import android.content.Context
-import android.preference.PreferenceManager
-import androidx.core.content.edit
-
-class PreferencesHelper(context: Context) {
-
- private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
-
- var sessionToken = preferences.getString(SESSION_TOKEN, "")
- set(value) {
- preferences.edit { putString(SESSION_TOKEN, value) }
- field = preferences.getString(SESSION_TOKEN, "")
- }
-
- var updateToken = preferences.getString(UPDATE_TOKEN, "")
- set(value) {
- preferences.edit { putString(UPDATE_TOKEN, value) }
- field = preferences.getString(UPDATE_TOKEN, "")
- }
-
- var expiresAt = preferences.getLong(EXPIRES_AT, 0L)
- set(value) {
- preferences.edit { putLong(EXPIRES_AT, value) }
- field = preferences.getLong(EXPIRES_AT, 0L)
- }
-
- var emailAlertSetting = preferences.getBoolean(EMAIL_ALERTS, true)
- set(value) {
- preferences.edit { putBoolean(EMAIL_ALERTS, value) }
- field = preferences.getBoolean(EMAIL_ALERTS, true)
- }
-
- var mobileAlertSetting = preferences.getBoolean(MOBILE_ALERTS, true)
- set(value) {
- preferences.edit { putBoolean(MOBILE_ALERTS, value) }
- field = preferences.getBoolean(MOBILE_ALERTS, true)
- }
-
- fun clearAll() {
- preferences.edit(commit = true) { clear() }
- }
-
- companion object {
- private const val SESSION_TOKEN = "data.source.prefs.SESSION_TOKEN"
- private const val UPDATE_TOKEN = "data.source.prefs.UPDATE_TOKEN"
- private const val EXPIRES_AT = "data.source.prefs.EXPIRES_AT"
-
- private const val EMAIL_ALERTS = "data.source.prefs.EMAIL_ALERTS"
- private const val MOBILE_ALERTS = "data.source.prefs.MOBILE_ALERTS"
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/data/PreferencesHelper.kt b/app/src/main/java/com/cornellappdev/coursegrab/data/PreferencesHelper.kt
new file mode 100644
index 0000000..cbd45ec
--- /dev/null
+++ b/app/src/main/java/com/cornellappdev/coursegrab/data/PreferencesHelper.kt
@@ -0,0 +1,46 @@
+package com.cornellappdev.coursegrab.data
+
+import android.content.Context
+import android.preference.PreferenceManager
+import androidx.core.content.edit
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PreferencesHelper @Inject constructor(@ApplicationContext context: Context) {
+
+ private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
+
+ var sessionToken: String?
+ get() = preferences.getString(SESSION_TOKEN, "")
+ set(value) = preferences.edit { putString(SESSION_TOKEN, value) }
+
+ var updateToken: String?
+ get() = preferences.getString(UPDATE_TOKEN, "")
+ set(value) = preferences.edit { putString(UPDATE_TOKEN, value) }
+
+ var expiresAt: Long
+ get() = preferences.getLong(EXPIRES_AT, 0L)
+ set(value) = preferences.edit { putLong(EXPIRES_AT, value) }
+
+ var emailAlertSetting: Boolean
+ get() = preferences.getBoolean(EMAIL_ALERTS, true)
+ set(value) = preferences.edit { putBoolean(EMAIL_ALERTS, value) }
+
+ var mobileAlertSetting: Boolean
+ get() = preferences.getBoolean(MOBILE_ALERTS, true)
+ set(value) = preferences.edit { putBoolean(MOBILE_ALERTS, value) }
+
+ fun clearAll() {
+ preferences.edit(commit = true) { clear() }
+ }
+
+ companion object {
+ private const val SESSION_TOKEN = "data.source.prefs.SESSION_TOKEN"
+ private const val UPDATE_TOKEN = "data.source.prefs.UPDATE_TOKEN"
+ private const val EXPIRES_AT = "data.source.prefs.EXPIRES_AT"
+ private const val EMAIL_ALERTS = "data.source.prefs.EMAIL_ALERTS"
+ private const val MOBILE_ALERTS = "data.source.prefs.MOBILE_ALERTS"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt
index d2058ed..59c35e1 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt
@@ -16,4 +16,12 @@ data class Course(
val num_tracking: Int,
val mode: String,
val errors: List?
-) : Parcelable
\ No newline at end of file
+) : Parcelable {
+
+ /** The backend reports section availability through [status] rather than a flag. */
+ val isOpen: Boolean get() = status == STATUS_OPEN
+
+ private companion object {
+ const val STATUS_OPEN = "OPEN"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt
index de90b18..1920397 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt
@@ -1,6 +1,6 @@
package com.cornellappdev.coursegrab.networking
-import com.cornellappdev.coursegrab.PreferencesHelper
+import com.cornellappdev.coursegrab.data.PreferencesHelper
import com.cornellappdev.coursegrab.models.ApiResponse
import com.cornellappdev.coursegrab.models.Course
import com.cornellappdev.coursegrab.models.SearchContainer
@@ -16,10 +16,15 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.coroutines.executeAsync
import java.lang.reflect.Type
+import javax.inject.Inject
+import javax.inject.Singleton
class ApiException(message: String) : Exception(message)
-class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) {
+@Singleton
+class CourseGrabRepository @Inject constructor(
+ private val preferencesHelper: PreferencesHelper
+) {
private val token: String
get() = preferencesHelper.sessionToken.orEmpty()
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt b/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt
similarity index 93%
rename from app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt
rename to app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt
index e1f46e7..fad1b07 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt
@@ -1,4 +1,4 @@
-package com.cornellappdev.coursegrab
+package com.cornellappdev.coursegrab.service
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -7,21 +7,25 @@ import android.content.Intent
import android.media.RingtoneManager
import android.os.Build
import androidx.core.app.NotificationCompat
+import com.cornellappdev.coursegrab.R
import com.cornellappdev.coursegrab.models.CourseNotification
import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import com.cornellappdev.coursegrab.ui.notification.NotificationModal
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
+import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import javax.inject.Inject
+@AndroidEntryPoint
class NotificationService : FirebaseMessagingService() {
- private val repository: CourseGrabRepository by lazy {
- CourseGrabRepository(PreferencesHelper(this))
- }
+ @Inject
+ lateinit var repository: CourseGrabRepository
/**
* Called when message is received.
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsActivity.kt
similarity index 68%
rename from app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsActivity.kt
rename to app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsActivity.kt
index 8869898..1f2966c 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsActivity.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsActivity.kt
@@ -1,8 +1,7 @@
-package com.cornellappdev.coursegrab
+package com.cornellappdev.coursegrab.ui.details
import android.content.Context
import android.os.Bundle
-import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -10,26 +9,25 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.cornellappdev.coursegrab.R
import com.cornellappdev.coursegrab.databinding.ActivityCourseDetailsBinding
import com.cornellappdev.coursegrab.models.Course
import com.cornellappdev.coursegrab.models.SearchResult
-import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
+@AndroidEntryPoint
class CourseDetailsActivity : AppCompatActivity() {
private lateinit var binding: ActivityCourseDetailsBinding
- private lateinit var sectionsRecyclerView: RecyclerView
- private lateinit var sectionsViewAdapter: RecyclerView.Adapter<*>
- private lateinit var sectionsViewManager: RecyclerView.LayoutManager
-
- private val repository: CourseGrabRepository by lazy {
- CourseGrabRepository(PreferencesHelper(this))
- }
+ private val viewModel: CourseDetailsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -44,44 +42,26 @@ class CourseDetailsActivity : AppCompatActivity() {
binding.courseDetails.text =
if (courseDetails.sections.isNotEmpty()) courseDetails.sections.first().instructors.first() else "To Be Assigned"
- // Available Courses Adapter
- sectionsViewManager = LinearLayoutManager(this@CourseDetailsActivity)
- sectionsViewAdapter = SectionAdapter(courseDetails.sections, this@CourseDetailsActivity)
-
- sectionsRecyclerView = binding.sectionsRecyclerview.apply {
- layoutManager = sectionsViewManager
- adapter = sectionsViewAdapter
+ binding.sectionsRecyclerview.apply {
+ layoutManager = LinearLayoutManager(this@CourseDetailsActivity)
+ adapter = SectionAdapter(courseDetails.sections, this@CourseDetailsActivity)
}
- binding.backBtn.setOnClickListener { finish() }
- }
-
- fun addCourse(courseId: Int, context: Context) {
lifecycleScope.launch {
- repository.addTracking(courseId).onFailure { error ->
- Log.e(TAG, "Failed to track course $courseId", error)
- Toast.makeText(
- context,
- error.message ?: "Couldn't track that course.",
- Toast.LENGTH_SHORT
- ).show()
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.trackingErrors.collect { error ->
+ Toast.makeText(this@CourseDetailsActivity, error, Toast.LENGTH_SHORT).show()
+ }
}
}
- }
- fun removeCourse(courseId: Int, context: Context) {
- lifecycleScope.launch {
- repository.removeTracking(courseId).onFailure { error ->
- Log.e(TAG, "Failed to untrack course $courseId", error)
- Toast.makeText(
- context,
- error.message ?: "Couldn't remove that course.",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
+ binding.backBtn.setOnClickListener { finish() }
}
+ fun addCourse(courseId: Int) = viewModel.addCourse(courseId)
+
+ fun removeCourse(courseId: Int) = viewModel.removeCourse(courseId)
+
class SectionAdapter(
private val availableCourses: List,
private val context: Context
@@ -108,7 +88,7 @@ class CourseDetailsActivity : AppCompatActivity() {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.sectionTitle.text = availableCourses[position].section
- holder.sectionStatus.setImageResource(if (availableCourses[position].status == "OPEN") R.drawable.ic_status_open else R.drawable.ic_status_closed)
+ holder.sectionStatus.setImageResource(if (availableCourses[position].isOpen) R.drawable.ic_status_open else R.drawable.ic_status_closed)
//change tracking text
holder.trackText.text = availableCourses[position].num_tracking.toString() + " Tracking"
@@ -121,8 +101,7 @@ class CourseDetailsActivity : AppCompatActivity() {
holder.removeButton.setOnClickListener {
(context as CourseDetailsActivity).removeCourse(
- availableCourses[position].catalog_num,
- context
+ availableCourses[position].catalog_num
)
holder.removeButton.visibility = View.GONE
holder.trackButton.visibility = View.VISIBLE
@@ -136,8 +115,7 @@ class CourseDetailsActivity : AppCompatActivity() {
holder.trackButton.setOnClickListener {
(context as CourseDetailsActivity).addCourse(
- availableCourses[position].catalog_num,
- context
+ availableCourses[position].catalog_num
)
holder.trackButton.visibility = View.GONE
holder.removeButton.visibility = View.VISIBLE
@@ -153,8 +131,4 @@ class CourseDetailsActivity : AppCompatActivity() {
// Return the size of your dataset (invoked by the layout manager)
override fun getItemCount() = availableCourses.size
}
-
- companion object {
- private const val TAG = "CourseDetailsActivity"
- }
}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsViewModel.kt
new file mode 100644
index 0000000..eaa905f
--- /dev/null
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsViewModel.kt
@@ -0,0 +1,43 @@
+package com.cornellappdev.coursegrab.ui.details
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class CourseDetailsViewModel @Inject constructor(
+ private val repository: CourseGrabRepository
+) : ViewModel() {
+
+ private val _trackingErrors = Channel(Channel.BUFFERED)
+ val trackingErrors = _trackingErrors.receiveAsFlow()
+
+ fun addCourse(courseId: Int) = track(courseId, adding = true)
+
+ fun removeCourse(courseId: Int) = track(courseId, adding = false)
+
+ private fun track(courseId: Int, adding: Boolean) {
+ viewModelScope.launch {
+ val result = if (adding) repository.addTracking(courseId)
+ else repository.removeTracking(courseId)
+
+ result.onFailure { error ->
+ Log.e(TAG, "Failed to ${if (adding) "track" else "untrack"} $courseId", error)
+ _trackingErrors.send(
+ error.message ?: if (adding) "Couldn't track that course."
+ else "Couldn't remove that course."
+ )
+ }
+ }
+ }
+
+ private companion object {
+ const val TAG = "CourseDetailsViewModel"
+ }
+}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginActivity.kt
new file mode 100644
index 0000000..942c788
--- /dev/null
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginActivity.kt
@@ -0,0 +1,56 @@
+package com.cornellappdev.coursegrab.ui.login
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.cornellappdev.coursegrab.databinding.ActivityLoginBinding
+import com.cornellappdev.coursegrab.ui.main.MainActivity
+import com.google.android.material.snackbar.Snackbar
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class LoginActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityLoginBinding
+
+ private val viewModel: LoginViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ // Hand Credential Manager the current Activity. On a configuration change this runs
+ // again with the new instance, so a sign-in already in flight keeps its UI host.
+ viewModel.credentialContext.baseContext = this
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.effects.collect(::handleEffect)
+ }
+ }
+
+ binding.signInButton.setOnClickListener { viewModel.signIn() }
+ }
+
+ override fun onDestroy() {
+ // Paired with onCreate: the ViewModel outlives this Activity, so hand the wrapper
+ // back to the application context rather than leaving a destroyed Activity in it.
+ viewModel.credentialContext.baseContext = applicationContext
+ super.onDestroy()
+ }
+
+ private fun handleEffect(effect: LoginEffect) {
+ when (effect) {
+ is LoginEffect.Error ->
+ Snackbar.make(binding.loginRootView, effect.message, Snackbar.LENGTH_LONG).show()
+
+ LoginEffect.NavigateToMain ->
+ startActivity(Intent(this, MainActivity::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt
new file mode 100644
index 0000000..948d0f2
--- /dev/null
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt
@@ -0,0 +1,270 @@
+package com.cornellappdev.coursegrab.ui.login
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.MutableContextWrapper
+import android.util.Log
+import androidx.credentials.ClearCredentialStateRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.CredentialOption
+import androidx.credentials.CustomCredential
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.GetCredentialResponse
+import androidx.credentials.exceptions.ClearCredentialException
+import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.exceptions.NoCredentialException
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.cornellappdev.coursegrab.R
+import com.cornellappdev.coursegrab.data.PreferencesHelper
+import com.cornellappdev.coursegrab.models.UserSession
+import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import com.google.android.libraries.identity.googleid.GetGoogleIdOption
+import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
+import com.google.firebase.messaging.FirebaseMessaging
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed interface LoginEffect {
+ data class Error(val message: String) : LoginEffect
+ data object NavigateToMain : LoginEffect
+}
+
+@HiltViewModel
+class LoginViewModel @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+ private val repository: CourseGrabRepository,
+ private val preferencesHelper: PreferencesHelper
+) : ViewModel() {
+
+ /**
+ * Credential Manager needs an activity context to launch its system UI, but the request
+ * outlives the Activity across a configuration change. The Activity swaps its own
+ * reference in on every [android.app.Activity.onCreate] and swaps it back out in
+ * onDestroy, so a destroyed Activity is never reachable from here.
+ *
+ * Lint can't see that pairing, only that a ViewModel holds a Context.
+ */
+ @SuppressLint("StaticFieldLeak")
+ val credentialContext = MutableContextWrapper(context)
+
+ private val credentialManager by lazy { CredentialManager.create(context) }
+
+ private val _effects = Channel(Channel.BUFFERED)
+ val effects = _effects.receiveAsFlow()
+
+ /** Guards against a second sign-in being started while one is already in flight. */
+ private var signingIn = false
+
+ init {
+ resumeExistingSession()
+ }
+
+ override fun onCleared() {
+ // Don't leave the destroyed Activity reachable through the wrapper.
+ credentialContext.baseContext = context
+ }
+
+ private fun resumeExistingSession() {
+ if (preferencesHelper.expiresAt > System.currentTimeMillis() / 1000L) {
+ viewModelScope.launch { _effects.send(LoginEffect.NavigateToMain) }
+ return
+ }
+
+ val updateToken = preferencesHelper.updateToken
+ if (updateToken.isNullOrBlank()) return
+
+ viewModelScope.launch {
+ repository.updateSession(updateToken)
+ .onSuccess { verifySession(it) }
+ .onFailure { Log.d(TAG, "Could not resume previous session", it) }
+ }
+ }
+
+ fun signIn() {
+ if (signingIn) return
+ signingIn = true
+
+ viewModelScope.launch {
+ try {
+ // Step 1: accounts already authorized for this app. Returning users get a
+ // streamlined sheet, or no prompt at all when there is exactly one match.
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setServerClientId(context.getString(R.string.default_web_client_id))
+ .setFilterByAuthorizedAccounts(true)
+ .setAutoSelectEnabled(true)
+ .build()
+
+ when (val authorized = requestCredential(googleIdOption)) {
+ is CredentialResult.Success -> {
+ handleSignIn(authorized.response)
+ return@launch
+ }
+
+ CredentialResult.NoneAvailable -> Unit
+ CredentialResult.Cancelled, CredentialResult.Failed -> return@launch
+ }
+
+ // Step 2: first-time (or de-authorized) users get the full account picker.
+ val signInWithGoogleOption = GetSignInWithGoogleOption
+ .Builder(context.getString(R.string.default_web_client_id))
+ .build()
+
+ when (val result = requestCredential(signInWithGoogleOption)) {
+ is CredentialResult.Success -> handleSignIn(result.response)
+ CredentialResult.NoneAvailable ->
+ emitError("No Google account found. Add one in system settings.")
+
+ CredentialResult.Cancelled, CredentialResult.Failed -> Unit
+ }
+ } finally {
+ signingIn = false
+ }
+ }
+ }
+
+ private suspend fun requestCredential(option: CredentialOption): CredentialResult {
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(option)
+ .build()
+
+ return try {
+ CredentialResult.Success(
+ credentialManager.getCredential(credentialContext, request)
+ )
+ } catch (e: NoCredentialException) {
+ Log.d(TAG, "No matching credential for ${option::class.simpleName}", e)
+ CredentialResult.NoneAvailable
+ } catch (e: GetCredentialCancellationException) {
+ Log.d(TAG, "Sign-in cancelled by user", e)
+ CredentialResult.Cancelled
+ } catch (e: GetCredentialException) {
+ Log.e(TAG, "Credential Manager sign-in failed", e)
+ emitError(SIGN_IN_FAILED)
+ CredentialResult.Failed
+ }
+ }
+
+ private sealed interface CredentialResult {
+ data class Success(val response: GetCredentialResponse) : CredentialResult
+ data object NoneAvailable : CredentialResult
+ data object Cancelled : CredentialResult
+ data object Failed : CredentialResult
+ }
+
+ private suspend fun handleSignIn(response: GetCredentialResponse) {
+ val credential = response.credential
+ when (credential) {
+ is CustomCredential -> {
+ // GetSignInWithGoogleOption returns the SIWG credential type, while the
+ // bottom-sheet GetGoogleIdOption flow returns the plain one. Accept either.
+ if (credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL &&
+ credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_SIWG_CREDENTIAL
+ ) {
+ Log.e(TAG, "Unexpected credential type: ${credential.type}")
+ emitError(SIGN_IN_FAILED)
+ return
+ }
+ }
+
+ else -> {
+ Log.e(TAG, "Unexpected type of credential: ${credential::class}")
+ emitError(SIGN_IN_FAILED)
+ return
+ }
+ }
+
+ val googleCredential = try {
+ GoogleIdTokenCredential.createFrom(credential.data)
+ } catch (e: GoogleIdTokenParsingException) {
+ Log.e(TAG, "Failed to parse Google ID token", e)
+ emitError(SIGN_IN_FAILED)
+ return
+ }
+
+ if (!isAllowedAccount(googleCredential.id)) {
+ emitError("Please use a @cornell.edu account")
+ clearCredentialState()
+ return
+ }
+
+ repository.initializeSession(googleCredential.idToken, null)
+ .onSuccess { verifySession(it) }
+ .onFailure { failure ->
+ Log.e(TAG, "Failed to initialize session", failure)
+ emitError(SIGN_IN_FAILED)
+ }
+ }
+
+ private fun isAllowedAccount(email: String): Boolean =
+ email.endsWith("@cornell.edu") ||
+ email == "appstoreappdev@gmail.com" ||
+ email == "coursegrab.droid@gmail.com"
+
+ private fun clearCredentialState() {
+ viewModelScope.launch {
+ try {
+ credentialManager.clearCredentialState(ClearCredentialStateRequest())
+ } catch (e: ClearCredentialException) {
+ Log.w(TAG, "Failed to clear credential state", e)
+ }
+ }
+ }
+
+ private suspend fun verifySession(userSession: UserSession) {
+ if (userSession.session_token.isNullOrBlank() ||
+ userSession.update_token.isNullOrBlank()
+ ) return
+
+ val expiresAt = userSession.session_expiration?.toLongOrNull()
+ if (expiresAt == null) {
+ Log.e(TAG, "Unusable session expiration: ${userSession.session_expiration}")
+ emitError(SIGN_IN_FAILED)
+ return
+ }
+
+ preferencesHelper.sessionToken = userSession.session_token
+ preferencesHelper.updateToken = userSession.update_token
+ preferencesHelper.expiresAt = expiresAt
+
+ FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
+ sendRegistrationToServer(token)
+ }
+
+ setNotificationsStatus(preferencesHelper.mobileAlertSetting)
+
+ _effects.send(LoginEffect.NavigateToMain)
+ }
+
+ private fun sendRegistrationToServer(token: String) {
+ viewModelScope.launch {
+ repository.sendDeviceToken(token)
+ // The token itself is a push credential — never log its value.
+ .onSuccess { Log.d(TAG, "Registered device token") }
+ .onFailure { Log.w(TAG, "Failed to register device token", it) }
+ }
+ }
+
+ private fun setNotificationsStatus(enabled: Boolean) {
+ viewModelScope.launch {
+ repository.setNotifications(enabled)
+ .onFailure { Log.w(TAG, "Failed to update notifications", it) }
+ }
+ }
+
+ private suspend fun emitError(message: String) {
+ _effects.send(LoginEffect.Error(message))
+ }
+
+ private companion object {
+ const val TAG = "LoginViewModel"
+ const val SIGN_IN_FAILED = "Sign-in failed. Please try again."
+ }
+}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainActivity.kt
similarity index 63%
rename from app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt
rename to app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainActivity.kt
index 168102f..e225640 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainActivity.kt
@@ -1,4 +1,4 @@
-package com.cornellappdev.coursegrab
+package com.cornellappdev.coursegrab.ui.main
import android.Manifest
import android.content.Context
@@ -8,7 +8,6 @@ import android.os.Build
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
-import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
@@ -18,44 +17,47 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.cornellappdev.coursegrab.R
import com.cornellappdev.coursegrab.databinding.ActivityMainBinding
import com.cornellappdev.coursegrab.models.Course
-import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import com.cornellappdev.coursegrab.ui.details.CourseDetailsActivity
+import com.cornellappdev.coursegrab.ui.search.SearchActivity
+import com.cornellappdev.coursegrab.ui.settings.SettingsActivity
+import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import java.util.Locale.getDefault
+@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
- private lateinit var availableRecyclerView: RecyclerView
- private lateinit var availableViewAdapter: RecyclerView.Adapter<*>
- private lateinit var availableViewManager: RecyclerView.LayoutManager
-
- private lateinit var awaitingRecyclerView: RecyclerView
- private lateinit var awaitingViewAdapter: RecyclerView.Adapter<*>
- private lateinit var awaitingViewManager: RecyclerView.LayoutManager
-
- private val repository: CourseGrabRepository by lazy {
- CourseGrabRepository(PreferencesHelper(this))
- }
+ private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
- refreshAwaiting()
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch { viewModel.state.collect(::render) }
+ launch { viewModel.effects.collect(::handleEffect) }
+ }
+ }
binding.refreshCoursesLayout.setOnRefreshListener {
- refreshAwaiting()
+ viewModel.refresh()
}
binding.settingsBtn.setOnClickListener {
@@ -69,7 +71,7 @@ class MainActivity : AppCompatActivity() {
}
binding.addCourseButton.setOnClickListener {
- addCourse(binding.addCourseEditText.text.toString().toInt(), this)
+ viewModel.addCourse(binding.addCourseEditText.text.toString().toInt())
binding.addCourseEditText.clearFocus()
binding.addCourseEditText.text.clear()
val inputMethodManager =
@@ -87,7 +89,7 @@ class MainActivity : AppCompatActivity() {
binding.addCourseEditText.setOnKeyListener(View.OnKeyListener { v, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
- addCourse(binding.addCourseEditText.text.toString().toInt(), this)
+ viewModel.addCourse(binding.addCourseEditText.text.toString().toInt())
binding.addCourseEditText.clearFocus()
binding.addCourseEditText.text.clear()
val inputMethodManager =
@@ -130,109 +132,50 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
- refreshAwaiting()
+ viewModel.refresh()
}
- private fun refreshAwaiting() {
- lifecycleScope.launch {
- val courseList = repository.getTracking().getOrElse { error ->
- Log.e(TAG, "Failed to load tracked courses", error)
- binding.refreshCoursesLayout.isRefreshing = false
- Toast.makeText(
- this@MainActivity,
- "Couldn't load your courses.",
- Toast.LENGTH_SHORT
- ).show()
- return@launch
- }
-
- val listOpen = courseList.filter { it.status == "OPEN" }
- val listAwaiting = courseList.filter { it.status != "OPEN" }
-
- // Available Courses Adapter
- availableViewManager = LinearLayoutManager(this@MainActivity)
- availableViewAdapter = AvailableAdapter(listOpen, this@MainActivity)
-
- availableRecyclerView = binding.availableList.apply {
- layoutManager = availableViewManager
- adapter = availableViewAdapter
- }
- binding.availableTitle.text = "${binding.availableList.adapter?.itemCount} Available"
-
- // Awaiting Courses Adapter
- awaitingViewManager = LinearLayoutManager(this@MainActivity)
- awaitingViewAdapter = AwaitingAdapter(listAwaiting, this@MainActivity)
-
- awaitingRecyclerView = binding.awaitingList.apply {
- layoutManager = awaitingViewManager
- adapter = awaitingViewAdapter
- }
- binding.awaitingTitle.text = "${binding.awaitingList.adapter?.itemCount} Awaiting"
-
- binding.layoutAvailable.visibility =
- if (listOpen.isNotEmpty()) View.VISIBLE else View.GONE
- binding.layoutAwaiting.visibility =
- if (listAwaiting.isNotEmpty()) View.VISIBLE else View.GONE
-
- binding.noCoursesView.visibility =
- if (listOpen.isEmpty() && listAwaiting.isEmpty()) View.VISIBLE else View.GONE
+ private fun render(state: TrackedCoursesState) {
+ binding.availableList.apply {
+ layoutManager = LinearLayoutManager(this@MainActivity)
+ adapter = AvailableAdapter(state.available, this@MainActivity)
+ }
+ binding.availableTitle.text = "${state.available.size} Available"
- binding.refreshCoursesLayout.isRefreshing = false
+ binding.awaitingList.apply {
+ layoutManager = LinearLayoutManager(this@MainActivity)
+ adapter = AwaitingAdapter(state.awaiting, this@MainActivity)
}
- }
+ binding.awaitingTitle.text = "${state.awaiting.size} Awaiting"
- private fun addCourse(courseId: Int, context: Context) {
- lifecycleScope.launch {
- val result = repository.addTracking(courseId)
+ binding.layoutAvailable.visibility =
+ if (state.available.isNotEmpty()) View.VISIBLE else View.GONE
+ binding.layoutAwaiting.visibility =
+ if (state.awaiting.isNotEmpty()) View.VISIBLE else View.GONE
- refreshAwaiting()
+ binding.noCoursesView.visibility =
+ if (state.hasLoaded && state.available.isEmpty() && state.awaiting.isEmpty())
+ View.VISIBLE else View.GONE
- result.onFailure { error ->
- Log.e(TAG, "Failed to track course $courseId", error)
- Toast.makeText(
- context,
- error.message ?: "Couldn't track that course.",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
+ binding.refreshCoursesLayout.isRefreshing = state.isRefreshing
}
- private fun removeCourse(courseId: Int, context: Context) {
- lifecycleScope.launch {
- val result = repository.removeTracking(courseId)
-
- refreshAwaiting()
+ private fun handleEffect(effect: MainEffect) {
+ when (effect) {
+ is MainEffect.Message ->
+ Toast.makeText(this, effect.text, Toast.LENGTH_SHORT).show()
- result.onFailure { error ->
- Log.e(TAG, "Failed to untrack course $courseId", error)
- Toast.makeText(
- context,
- error.message ?: "Couldn't remove that course.",
- Toast.LENGTH_SHORT
- ).show()
- }
+ is MainEffect.OpenCourse -> startActivity(
+ Intent(this, CourseDetailsActivity::class.java).apply {
+ putExtra("courseDetails", effect.course)
+ }
+ )
}
}
- private fun editCourse(courseId: Int, context: Context) {
- lifecycleScope.launch {
- val course = repository.getCourseById(courseId).getOrElse { error ->
- Log.e(TAG, "Failed to load course $courseId", error)
- Toast.makeText(
- context,
- "Couldn't open that course.",
- Toast.LENGTH_SHORT
- ).show()
- return@launch
- }
+ private fun removeCourse(courseId: Int) = viewModel.removeCourse(courseId)
- val intent = Intent(context, CourseDetailsActivity::class.java).apply {
- putExtra("courseDetails", course)
- }
- context.startActivity(intent)
- }
- }
+ private fun editCourse(courseId: Int) = viewModel.openCourse(courseId)
private fun enrollCourse() {
val browserIntent =
@@ -270,11 +213,11 @@ class MainActivity : AppCompatActivity() {
"${availableCourses[position].subject_code} ${availableCourses[position].course_num}: ${availableCourses[position].title}"
holder.courseTime.text = availableCourses[position].section.uppercase(getDefault())
holder.coursePin.text = availableCourses[position].catalog_num.toString()
- holder.courseStatus.setImageResource(if (availableCourses[position].status == "OPEN") R.drawable.ic_status_open else R.drawable.ic_status_closed)
+ holder.courseStatus.setImageResource(if (availableCourses[position].isOpen) R.drawable.ic_status_open else R.drawable.ic_status_closed)
holder.removeButton.setOnClickListener {
(context as MainActivity).removeCourse(
- availableCourses[position].catalog_num, context
+ availableCourses[position].catalog_num
)
}
@@ -285,7 +228,7 @@ class MainActivity : AppCompatActivity() {
holder.backgroundButton.setOnClickListener {
(context as MainActivity).editCourse(
- availableCourses[position].catalog_num, context
+ availableCourses[position].catalog_num
)
}
}
@@ -321,16 +264,16 @@ class MainActivity : AppCompatActivity() {
"${awaitingCourses[position].subject_code} ${awaitingCourses[position].course_num}: ${awaitingCourses[position].title}"
holder.courseTime.text = awaitingCourses[position].section.uppercase(getDefault())
holder.coursePin.text = awaitingCourses[position].catalog_num.toString()
- holder.courseStatus.setImageResource(if (awaitingCourses[position].status == "OPEN") R.drawable.ic_status_open else R.drawable.ic_status_closed)
+ holder.courseStatus.setImageResource(if (awaitingCourses[position].isOpen) R.drawable.ic_status_open else R.drawable.ic_status_closed)
holder.removeButton.setOnClickListener {
(context as MainActivity).removeCourse(
- awaitingCourses[position].catalog_num, context
+ awaitingCourses[position].catalog_num
)
}
holder.backgroundButton.setOnClickListener {
(context as MainActivity).editCourse(
- awaitingCourses[position].catalog_num, context
+ awaitingCourses[position].catalog_num
)
}
}
@@ -338,8 +281,4 @@ class MainActivity : AppCompatActivity() {
// Return the size of your dataset (invoked by the layout manager)
override fun getItemCount() = awaitingCourses.size
}
-
- companion object {
- private const val TAG = "MainActivity"
- }
}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainViewModel.kt
new file mode 100644
index 0000000..fe68fd9
--- /dev/null
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainViewModel.kt
@@ -0,0 +1,95 @@
+package com.cornellappdev.coursegrab.ui.main
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.cornellappdev.coursegrab.models.Course
+import com.cornellappdev.coursegrab.models.SearchResult
+import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class TrackedCoursesState(
+ val available: List = emptyList(),
+ val awaiting: List = emptyList(),
+ val isRefreshing: Boolean = false,
+ val hasLoaded: Boolean = false
+)
+
+sealed interface MainEffect {
+ data class Message(val text: String) : MainEffect
+ data class OpenCourse(val course: SearchResult) : MainEffect
+}
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val repository: CourseGrabRepository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(TrackedCoursesState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _effects = Channel(Channel.BUFFERED)
+ val effects = _effects.receiveAsFlow()
+
+ init {
+ refresh()
+ }
+
+ fun refresh() {
+ if (_state.value.isRefreshing) return
+ _state.value = _state.value.copy(isRefreshing = true)
+ viewModelScope.launch {
+ repository.getTracking()
+ .onSuccess { courses ->
+ _state.value = TrackedCoursesState(
+ available = courses.filter { it.isOpen },
+ awaiting = courses.filterNot { it.isOpen },
+ isRefreshing = false,
+ hasLoaded = true
+ )
+ }
+ .onFailure {
+ _state.value = _state.value.copy(isRefreshing = false)
+ _effects.send(MainEffect.Message("Couldn't load your courses."))
+ }
+ }
+ }
+
+ fun addCourse(courseId: Int) = track(courseId, adding = true)
+
+ fun removeCourse(courseId: Int) = track(courseId, adding = false)
+
+ private fun track(courseId: Int, adding: Boolean) {
+ viewModelScope.launch {
+ val result =
+ if (adding) repository.addTracking(courseId)
+ else repository.removeTracking(courseId)
+
+ result.onFailure { error ->
+ _effects.send(
+ MainEffect.Message(
+ error.message
+ ?: if (adding) "Couldn't track that course."
+ else "Couldn't remove that course."
+ )
+ )
+ }
+
+ refresh()
+ }
+ }
+
+ fun openCourse(courseId: Int) {
+ viewModelScope.launch {
+ repository.getCourseById(courseId)
+ .onSuccess { _effects.send(MainEffect.OpenCourse(it)) }
+ .onFailure { _effects.send(MainEffect.Message("Couldn't open that course.")) }
+ }
+ }
+}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/NotificationModal.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/notification/NotificationModal.kt
similarity index 92%
rename from app/src/main/java/com/cornellappdev/coursegrab/NotificationModal.kt
rename to app/src/main/java/com/cornellappdev/coursegrab/ui/notification/NotificationModal.kt
index 2ccbe57..6455ab4 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/NotificationModal.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/notification/NotificationModal.kt
@@ -1,4 +1,4 @@
-package com.cornellappdev.coursegrab
+package com.cornellappdev.coursegrab.ui.notification
import android.content.Intent
import android.net.Uri
@@ -7,6 +7,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.cornellappdev.coursegrab.databinding.ActivityNotificationModalBinding
import com.cornellappdev.coursegrab.models.Course
import com.cornellappdev.coursegrab.models.CourseNotification
+import com.cornellappdev.coursegrab.ui.main.MainActivity
class NotificationModal : AppCompatActivity() {
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchActivity.kt
similarity index 60%
rename from app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt
rename to app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchActivity.kt
index 2c73cfd..4d8fd45 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchActivity.kt
@@ -1,9 +1,8 @@
-package com.cornellappdev.coursegrab
+package com.cornellappdev.coursegrab.ui.search
import android.content.Context
import android.content.Intent
import android.os.Bundle
-import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
@@ -11,42 +10,44 @@ import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.ImageButton
import android.widget.TextView
+import androidx.activity.viewModels
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.widget.doOnTextChanged
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import com.cornellappdev.coursegrab.R
import com.cornellappdev.coursegrab.databinding.ActivitySearchBinding
import com.cornellappdev.coursegrab.models.SearchResult
-import com.cornellappdev.coursegrab.networking.CourseGrabRepository
-import kotlinx.coroutines.Job
+import com.cornellappdev.coursegrab.ui.details.CourseDetailsActivity
+import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
+@AndroidEntryPoint
class SearchActivity : AppCompatActivity() {
private lateinit var binding: ActivitySearchBinding
- private lateinit var searchRecyclerView: RecyclerView
- private lateinit var searchViewAdapter: RecyclerView.Adapter<*>
- private lateinit var searchViewManager: RecyclerView.LayoutManager
-
- private val repository: CourseGrabRepository by lazy {
- CourseGrabRepository(PreferencesHelper(this))
- }
-
- /** The only search allowed to update the UI; superseded ones are canceled. */
- private var searchJob: Job? = null
+ private val viewModel: SearchViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySearchBinding.inflate(layoutInflater)
setContentView(binding.root)
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.state.collect(::render)
+ }
+ }
+
binding.editTextSearch.setOnKeyListener(View.OnKeyListener { v, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
- searchCourses(binding.editTextSearch.text.toString())
+ viewModel.onQueryChanged(binding.editTextSearch.text.toString())
val inputMethodManager =
getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(v.windowToken, 0)
@@ -56,53 +57,46 @@ class SearchActivity : AppCompatActivity() {
})
binding.editTextSearch.doOnTextChanged { text, _, _, _ ->
- if ((text ?: "").length > 2) {
- searchCourses(text.toString())
- } else {
- // The query is no longer searchable, so nothing in flight is current.
- searchJob?.cancel()
- showEmptyState(
- icon = R.drawable.ic_status_warning,
- title = R.string.requires_longer_search,
- subtitle = R.string.requires_longer_search_subtext
- )
- }
+ viewModel.onQueryChanged(text?.toString().orEmpty())
}
binding.backBtn.setOnClickListener { finish() }
}
- private fun searchCourses(query: String) {
- searchJob?.cancel()
- searchJob = lifecycleScope.launch {
- val courseList = repository.searchCourses(query).getOrElse { error ->
- Log.e(TAG, "Search failed for query \"$query\"", error)
- showEmptyState(
- icon = R.drawable.ic_status_warning,
- title = R.string.search_failed,
- subtitle = R.string.search_failed_subtext
+ private fun render(state: SearchState) {
+ when (state) {
+ SearchState.QueryTooShort -> showEmptyState(
+ icon = R.drawable.ic_status_warning,
+ title = R.string.requires_longer_search,
+ subtitle = R.string.requires_longer_search_subtext
+ )
+
+ SearchState.Failed -> showEmptyState(
+ icon = R.drawable.ic_status_warning,
+ title = R.string.search_failed,
+ subtitle = R.string.search_failed_subtext
+ )
+
+ is SearchState.Results -> {
+ binding.resultsList.apply {
+ layoutManager = LinearLayoutManager(this@SearchActivity)
+ adapter = ResultsAdapter(state.courses, this@SearchActivity)
+ }
+ binding.resultTitle.text = resources.getQuantityString(
+ R.plurals.search_results_count,
+ state.courses.size,
+ state.courses.size
)
- return@launch
- }
-
- // Results Courses Adapter
- searchViewManager = LinearLayoutManager(this@SearchActivity)
- searchViewAdapter = ResultsAdapter(courseList, this@SearchActivity)
-
- searchRecyclerView = binding.resultsList.apply {
- layoutManager = searchViewManager
- adapter = searchViewAdapter
- }
- binding.resultTitle.text = "${binding.resultsList.adapter?.itemCount} Results"
- if (courseList.isEmpty()) {
- showEmptyState(
- icon = R.drawable.ic_status_closed,
- title = R.string.no_courses_alert,
- subtitle = R.string.no_results_alert_subtext_try_another
- )
- } else {
- showResults()
+ if (state.courses.isEmpty()) {
+ showEmptyState(
+ icon = R.drawable.ic_status_closed,
+ title = R.string.no_courses_alert,
+ subtitle = R.string.no_results_alert_subtext_try_another
+ )
+ } else {
+ showResults()
+ }
}
}
}
@@ -159,8 +153,4 @@ class SearchActivity : AppCompatActivity() {
// Return the size of your dataset (invoked by the layout manager)
override fun getItemCount() = resultsCourses.size
}
-
- companion object {
- private const val TAG = "SearchActivity"
- }
}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchViewModel.kt
new file mode 100644
index 0000000..339ad98
--- /dev/null
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchViewModel.kt
@@ -0,0 +1,52 @@
+package com.cornellappdev.coursegrab.ui.search
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.cornellappdev.coursegrab.models.SearchResult
+import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed interface SearchState {
+ data object QueryTooShort : SearchState
+ data class Results(val courses: List) : SearchState
+ data object Failed : SearchState
+}
+
+@HiltViewModel
+class SearchViewModel @Inject constructor(
+ private val repository: CourseGrabRepository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(SearchState.QueryTooShort)
+ val state: StateFlow = _state.asStateFlow()
+ private var searchJob: Job? = null
+
+ fun onQueryChanged(query: String) {
+ searchJob?.cancel()
+
+ if (query.length <= MIN_QUERY_LENGTH) {
+ _state.value = SearchState.QueryTooShort
+ return
+ }
+
+ searchJob = viewModelScope.launch {
+ repository.searchCourses(query).onSuccess { _state.value = SearchState.Results(it) }
+ .onFailure { error ->
+ Log.e(TAG, "Search failed for query \"$query\"", error)
+ _state.value = SearchState.Failed
+ }
+ }
+ }
+
+ private companion object {
+ const val TAG = "SearchViewModel"
+ const val MIN_QUERY_LENGTH = 2
+ }
+}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsActivity.kt
similarity index 51%
rename from app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt
rename to app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsActivity.kt
index 5d0b149..f863626 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsActivity.kt
@@ -1,50 +1,47 @@
-package com.cornellappdev.coursegrab
+package com.cornellappdev.coursegrab.ui.settings
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
-import android.util.Log
import android.widget.Toast
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
-import androidx.credentials.ClearCredentialStateRequest
-import androidx.credentials.CredentialManager
-import androidx.credentials.exceptions.ClearCredentialException
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import com.cornellappdev.coursegrab.databinding.ActivitySettingsBinding
-import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import com.cornellappdev.coursegrab.ui.login.LoginActivity
import com.google.firebase.messaging.FirebaseMessaging
+import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
+@AndroidEntryPoint
class SettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivitySettingsBinding
- private val credentialManager: CredentialManager by lazy {
- CredentialManager.create(this)
- }
-
- private val preferencesHelper: PreferencesHelper by lazy {
- PreferencesHelper(this)
- }
-
- private val repository: CourseGrabRepository by lazy {
- CourseGrabRepository(preferencesHelper)
- }
+ private val viewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
- binding.emailAlertsSwitch.isChecked = preferencesHelper.emailAlertSetting
- binding.mobileAlertsSwitch.isChecked = preferencesHelper.mobileAlertSetting
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.effects.collect(::handleEffect)
+ }
+ }
+
+ binding.emailAlertsSwitch.isChecked = viewModel.emailAlertsEnabled
+ binding.mobileAlertsSwitch.isChecked = viewModel.mobileAlertsEnabled
binding.emailAlertsSwitch.setOnCheckedChangeListener { _, isChecked ->
- preferencesHelper.emailAlertSetting = isChecked
+ viewModel.setEmailAlerts(isChecked)
}
binding.mobileAlertsSwitch.setOnCheckedChangeListener { _, isChecked ->
@@ -61,10 +58,8 @@ class SettingsActivity : AppCompatActivity() {
Toast.LENGTH_LONG,
).show()
} else {
- preferencesHelper.mobileAlertSetting = isChecked
FirebaseMessaging.getInstance().isAutoInitEnabled = isChecked
-
- setNotificationsStatus(isChecked)
+ viewModel.setMobileAlerts(isChecked)
}
}
@@ -83,10 +78,7 @@ class SettingsActivity : AppCompatActivity() {
startActivity(browserIntent)
}
- binding.signOut.setOnClickListener {
- preferencesHelper.clearAll()
- signOut()
- }
+ binding.signOut.setOnClickListener { viewModel.signOut() }
binding.backBtn.setOnClickListener { finish() }
@@ -103,42 +95,13 @@ class SettingsActivity : AppCompatActivity() {
}
- private fun setNotificationsStatus(enabled: Boolean) {
- lifecycleScope.launch {
- repository.setNotifications(enabled)
- .onSuccess {
- Toast.makeText(
- this@SettingsActivity,
- "Notifications ${if (enabled) "enabled." else "disabled."}",
- Toast.LENGTH_SHORT
- ).show()
- }
- .onFailure { error ->
- Log.e(TAG, "Failed to update notification setting", error)
- Toast.makeText(
- this@SettingsActivity,
- "Couldn't update notification settings.",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
- }
+ private fun handleEffect(effect: SettingsEffect) {
+ when (effect) {
+ is SettingsEffect.Message ->
+ Toast.makeText(this, effect.text, Toast.LENGTH_SHORT).show()
- private fun signOut() {
- lifecycleScope.launch {
- // Clears the Credential Manager provider state so the account picker
- // reappears on the next sign-in attempt.
- try {
- credentialManager.clearCredentialState(ClearCredentialStateRequest())
- } catch (e: ClearCredentialException) {
- Log.w(TAG, "Failed to clear credential state", e)
- }
-
- startActivity(Intent(this@SettingsActivity, LoginActivity::class.java))
+ SettingsEffect.SignedOut ->
+ startActivity(Intent(this, LoginActivity::class.java))
}
}
-
- companion object {
- private const val TAG = "SettingsActivity"
- }
}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..0857c16
--- /dev/null
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,78 @@
+package com.cornellappdev.coursegrab.ui.settings
+
+import android.content.Context
+import android.util.Log
+import androidx.credentials.ClearCredentialStateRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.exceptions.ClearCredentialException
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.cornellappdev.coursegrab.data.PreferencesHelper
+import com.cornellappdev.coursegrab.networking.CourseGrabRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed interface SettingsEffect {
+ data class Message(val text: String) : SettingsEffect
+ data object SignedOut : SettingsEffect
+}
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+ private val repository: CourseGrabRepository,
+ private val preferencesHelper: PreferencesHelper
+) : ViewModel() {
+
+ private val _effects = Channel(Channel.BUFFERED)
+ val effects = _effects.receiveAsFlow()
+
+ val emailAlertsEnabled: Boolean get() = preferencesHelper.emailAlertSetting
+ val mobileAlertsEnabled: Boolean get() = preferencesHelper.mobileAlertSetting
+
+ fun setEmailAlerts(enabled: Boolean) {
+ preferencesHelper.emailAlertSetting = enabled
+ }
+
+ fun setMobileAlerts(enabled: Boolean) {
+ preferencesHelper.mobileAlertSetting = enabled
+ viewModelScope.launch {
+ repository.setNotifications(enabled)
+ .onSuccess {
+ _effects.send(
+ SettingsEffect.Message(
+ "Notifications ${if (enabled) "enabled." else "disabled."}"
+ )
+ )
+ }
+ .onFailure { error ->
+ Log.e(TAG, "Failed to update notification setting", error)
+ _effects.send(
+ SettingsEffect.Message("Couldn't update notification settings.")
+ )
+ }
+ }
+ }
+
+ fun signOut() {
+ preferencesHelper.clearAll()
+ viewModelScope.launch {
+ try {
+ CredentialManager.create(context)
+ .clearCredentialState(ClearCredentialStateRequest())
+ } catch (e: ClearCredentialException) {
+ Log.w(TAG, "Failed to clear credential state", e)
+ }
+
+ _effects.send(SettingsEffect.SignedOut)
+ }
+ }
+
+ private companion object {
+ const val TAG = "SettingsViewModel"
+ }
+}
diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SplashScreenActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/splash/SplashScreenActivity.kt
similarity index 96%
rename from app/src/main/java/com/cornellappdev/coursegrab/SplashScreenActivity.kt
rename to app/src/main/java/com/cornellappdev/coursegrab/ui/splash/SplashScreenActivity.kt
index e0ded1a..764209c 100644
--- a/app/src/main/java/com/cornellappdev/coursegrab/SplashScreenActivity.kt
+++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/splash/SplashScreenActivity.kt
@@ -1,18 +1,19 @@
-package com.cornellappdev.coursegrab
+package com.cornellappdev.coursegrab.ui.splash
-import androidx.appcompat.app.AppCompatActivity
import android.annotation.SuppressLint
+import android.content.Intent
+import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
-import com.cornellappdev.coursegrab.databinding.ActivitySplashScreenBinding
-import android.content.Intent
-import android.net.Uri
import android.widget.VideoView
-import java.lang.Exception
+import androidx.appcompat.app.AppCompatActivity
+import com.cornellappdev.coursegrab.R
+import com.cornellappdev.coursegrab.databinding.ActivitySplashScreenBinding
+import com.cornellappdev.coursegrab.ui.login.LoginActivity
class SplashScreenActivity : AppCompatActivity() {
@@ -134,7 +135,7 @@ class SplashScreenActivity : AppCompatActivity() {
private fun jump() {
if (isFinishing) return
startActivity(Intent(this, LoginActivity::class.java))
- overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
+ overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
finish()
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index efb59d1..1b971f1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -43,6 +43,10 @@
Try a different query
+
+ - %d Result
+ - %d Results
+
Couldn\'t search right now
Check your connection and try again
diff --git a/build.gradle b/build.gradle
index 7e799d1..4d29998 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext.kotlin_version = '2.4.10'
+ // Pegged to 2.2.x: Hilt's annotation processor cannot read Kotlin 2.4 metadata.
+ ext.kotlin_version = '2.2.0'
+ ext.ksp_version = '2.2.0-2.0.2'
+ ext.hilt_version = '2.56.2'
repositories {
google()
mavenCentral()
@@ -11,6 +14,8 @@ buildscript {
classpath 'com.google.gms:google-services:4.5.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.8'
+ classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:$ksp_version"
+ classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}