From 659c7f2c7e855e65d07391c750b5e3f5fb391af3 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 15:38:04 -0400 Subject: [PATCH 1/7] Implement VM --- app/build.gradle | 6 + app/src/main/AndroidManifest.xml | 1 + .../coursegrab/CourseDetailsActivity.kt | 69 ++--- .../coursegrab/CourseDetailsViewModel.kt | 43 +++ .../coursegrab/CourseGrabApplication.kt | 7 + .../cornellappdev/coursegrab/LoginActivity.kt | 231 ++-------------- .../coursegrab/LoginViewModel.kt | 261 ++++++++++++++++++ .../cornellappdev/coursegrab/MainActivity.kt | 167 ++++------- .../cornellappdev/coursegrab/MainViewModel.kt | 95 +++++++ .../coursegrab/NotificationService.kt | 8 +- .../coursegrab/PreferencesHelper.kt | 6 +- .../coursegrab/SearchActivity.kt | 104 +++---- .../coursegrab/SearchViewModel.kt | 52 ++++ .../coursegrab/SettingsActivity.kt | 84 ++---- .../coursegrab/SettingsViewModel.kt | 77 ++++++ .../cornellappdev/coursegrab/models/Course.kt | 10 +- .../networking/CourseGrabRepository.kt | 7 +- build.gradle | 7 +- 18 files changed, 735 insertions(+), 500 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsViewModel.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/CourseGrabApplication.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/MainViewModel.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/SearchViewModel.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/SettingsViewModel.kt 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..0e461c1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ - 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 +41,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 +87,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 +100,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 +114,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 +130,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/CourseDetailsViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsViewModel.kt new file mode 100644 index 0000000..ddf1be4 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsViewModel.kt @@ -0,0 +1,43 @@ +package com.cornellappdev.coursegrab + +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/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 index 41edf71..c50ad12 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt @@ -1,240 +1,55 @@ package com.cornellappdev.coursegrab import android.content.Intent -import android.content.MutableContextWrapper import android.os.Bundle -import android.util.Log +import androidx.activity.viewModels 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.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle 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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +@AndroidEntryPoint 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) - } + private val viewModel: LoginViewModel by viewModels() 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" + // 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 - private fun clearCredentialState() { lifecycleScope.launch { - try { - credentialManager.clearCredentialState(ClearCredentialStateRequest()) - } catch (e: ClearCredentialException) { - Log.w(TAG, "Failed to clear credential state", e) + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.effects.collect(::handleEffect) } } - } - private fun showLoginError(message: String) { - Snackbar.make(binding.loginRootView, message, Snackbar.LENGTH_LONG).show() + binding.signInButton.setOnClickListener { viewModel.signIn() } } - 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) } - } + 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 verifySession(userSession: UserSession) { - - if (userSession.session_expiration.isNullOrBlank() || - userSession.session_token.isNullOrBlank() || - userSession.update_token.isNullOrBlank() - ) return + private fun handleEffect(effect: LoginEffect) { + when (effect) { + is LoginEffect.Error -> + Snackbar.make(binding.loginRootView, effect.message, Snackbar.LENGTH_LONG).show() - preferencesHelper.sessionToken = userSession.session_token - preferencesHelper.updateToken = userSession.update_token - preferencesHelper.expiresAt = userSession.session_expiration.toLong() - - FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> - sendRegistrationToServer(token) + LoginEffect.NavigateToMain -> + startActivity(Intent(this, MainActivity::class.java)) } - - 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/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt new file mode 100644 index 0000000..d1561c9 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt @@ -0,0 +1,261 @@ +package com.cornellappdev.coursegrab + +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.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 -> + error("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) + error(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}") + error(SIGN_IN_FAILED) + return + } + } + + else -> { + Log.e(TAG, "Unexpected type of credential: ${credential::class}") + error(SIGN_IN_FAILED) + return + } + } + + val googleCredential = try { + GoogleIdTokenCredential.createFrom(credential.data) + } catch (e: GoogleIdTokenParsingException) { + Log.e(TAG, "Failed to parse Google ID token", e) + error(SIGN_IN_FAILED) + return + } + + if (!isAllowedAccount(googleCredential.id)) { + error("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) + error(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_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) + + _effects.send(LoginEffect.NavigateToMain) + } + + private fun sendRegistrationToServer(token: String) { + viewModelScope.launch { + repository.sendDeviceToken(token) + .onSuccess { Log.d(TAG, "sendRegistrationTokenToServer($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 error(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/MainActivity.kt index 168102f..d7706ac 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt @@ -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,43 @@ 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.databinding.ActivityMainBinding import com.cornellappdev.coursegrab.models.Course -import com.cornellappdev.coursegrab.networking.CourseGrabRepository +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 +67,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 +85,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 +128,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 +209,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 +224,7 @@ class MainActivity : AppCompatActivity() { holder.backgroundButton.setOnClickListener { (context as MainActivity).editCourse( - availableCourses[position].catalog_num, context + availableCourses[position].catalog_num ) } } @@ -321,16 +260,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 +277,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/MainViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/MainViewModel.kt new file mode 100644 index 0000000..099d119 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/MainViewModel.kt @@ -0,0 +1,95 @@ +package com.cornellappdev.coursegrab + +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/NotificationService.kt b/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt index e1f46e7..3ba2a52 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt @@ -13,15 +13,17 @@ 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/PreferencesHelper.kt b/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt index 8849b27..e70880d 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt @@ -3,8 +3,12 @@ package com.cornellappdev.coursegrab 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 -class PreferencesHelper(context: Context) { +@Singleton +class PreferencesHelper @Inject constructor(@ApplicationContext context: Context) { private val preferences = PreferenceManager.getDefaultSharedPreferences(context) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt index 2c73cfd..3921370 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt @@ -3,7 +3,6 @@ package com.cornellappdev.coursegrab 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,42 @@ 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.databinding.ActivitySearchBinding import com.cornellappdev.coursegrab.models.SearchResult -import com.cornellappdev.coursegrab.networking.CourseGrabRepository -import kotlinx.coroutines.Job +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 +55,42 @@ 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 - ) - 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() + 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 = "${state.courses.size} Results" + + 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 +147,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/SearchViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/SearchViewModel.kt new file mode 100644 index 0000000..5dde806 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/SearchViewModel.kt @@ -0,0 +1,52 @@ +package com.cornellappdev.coursegrab + +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/SettingsActivity.kt index 5d0b149..9f23d06 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt @@ -5,46 +5,42 @@ 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.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 +57,8 @@ class SettingsActivity : AppCompatActivity() { Toast.LENGTH_LONG, ).show() } else { - preferencesHelper.mobileAlertSetting = isChecked FirebaseMessaging.getInstance().isAutoInitEnabled = isChecked - - setNotificationsStatus(isChecked) + viewModel.setMobileAlerts(isChecked) } } @@ -83,10 +77,7 @@ class SettingsActivity : AppCompatActivity() { startActivity(browserIntent) } - binding.signOut.setOnClickListener { - preferencesHelper.clearAll() - signOut() - } + binding.signOut.setOnClickListener { viewModel.signOut() } binding.backBtn.setOnClickListener { finish() } @@ -103,42 +94,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/SettingsViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/SettingsViewModel.kt new file mode 100644 index 0000000..2d74e3f --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/SettingsViewModel.kt @@ -0,0 +1,77 @@ +package com.cornellappdev.coursegrab + +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.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/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..84560f8 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt @@ -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/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 } From 8d58533b94117ebc46fcd61164a1179f92cb2051 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 15:47:23 -0400 Subject: [PATCH 2/7] Rename error to emitError --- .../cornellappdev/coursegrab/LoginViewModel.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt index d1561c9..b78d59e 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt @@ -118,7 +118,7 @@ class LoginViewModel @Inject constructor( when (val result = requestCredential(signInWithGoogleOption)) { is CredentialResult.Success -> handleSignIn(result.response) CredentialResult.NoneAvailable -> - error("No Google account found. Add one in system settings.") + emitError("No Google account found. Add one in system settings.") CredentialResult.Cancelled, CredentialResult.Failed -> Unit } @@ -145,7 +145,7 @@ class LoginViewModel @Inject constructor( CredentialResult.Cancelled } catch (e: GetCredentialException) { Log.e(TAG, "Credential Manager sign-in failed", e) - error(SIGN_IN_FAILED) + emitError(SIGN_IN_FAILED) CredentialResult.Failed } } @@ -167,14 +167,14 @@ class LoginViewModel @Inject constructor( credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_SIWG_CREDENTIAL ) { Log.e(TAG, "Unexpected credential type: ${credential.type}") - error(SIGN_IN_FAILED) + emitError(SIGN_IN_FAILED) return } } else -> { Log.e(TAG, "Unexpected type of credential: ${credential::class}") - error(SIGN_IN_FAILED) + emitError(SIGN_IN_FAILED) return } } @@ -183,12 +183,12 @@ class LoginViewModel @Inject constructor( GoogleIdTokenCredential.createFrom(credential.data) } catch (e: GoogleIdTokenParsingException) { Log.e(TAG, "Failed to parse Google ID token", e) - error(SIGN_IN_FAILED) + emitError(SIGN_IN_FAILED) return } if (!isAllowedAccount(googleCredential.id)) { - error("Please use a @cornell.edu account") + emitError("Please use a @cornell.edu account") clearCredentialState() return } @@ -197,7 +197,7 @@ class LoginViewModel @Inject constructor( .onSuccess { verifySession(it) } .onFailure { failure -> Log.e(TAG, "Failed to initialize session", failure) - error(SIGN_IN_FAILED) + emitError(SIGN_IN_FAILED) } } @@ -250,7 +250,7 @@ class LoginViewModel @Inject constructor( } } - private suspend fun error(message: String) { + private suspend fun emitError(message: String) { _effects.send(LoginEffect.Error(message)) } From 028c2f6f08a2affd91d486cb2b40d0207c62bd9e Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 15:51:11 -0400 Subject: [PATCH 3/7] Use toLongOrNull --- .../com/cornellappdev/coursegrab/LoginViewModel.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt index b78d59e..7decbf5 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt @@ -217,14 +217,20 @@ class LoginViewModel @Inject constructor( } private suspend fun verifySession(userSession: UserSession) { - if (userSession.session_expiration.isNullOrBlank() || - userSession.session_token.isNullOrBlank() || + 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 = userSession.session_expiration.toLong() + preferencesHelper.expiresAt = expiresAt FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> sendRegistrationToServer(token) From 6e2109748c2eacfc80f999c94c6703668bcca557 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 16:03:02 -0400 Subject: [PATCH 4/7] Don't log fcm token --- .../main/java/com/cornellappdev/coursegrab/LoginViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt index 7decbf5..f6efc07 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt @@ -244,7 +244,8 @@ class LoginViewModel @Inject constructor( private fun sendRegistrationToServer(token: String) { viewModelScope.launch { repository.sendDeviceToken(token) - .onSuccess { Log.d(TAG, "sendRegistrationTokenToServer($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) } } } From 91352eec85bb2cb5fd203a04212d888cabf0e003 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 16:06:43 -0400 Subject: [PATCH 5/7] Fix caching issue --- .../coursegrab/PreferencesHelper.kt | 49 +++++++------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt b/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt index e70880d..1540a57 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt @@ -12,35 +12,25 @@ class PreferencesHelper @Inject constructor(@ApplicationContext 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) - } + 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() } @@ -50,7 +40,6 @@ class PreferencesHelper @Inject constructor(@ApplicationContext context: Context 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" } From 6025c86a4a8e794ee8dca9c55119a57c216a2e39 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 16:09:06 -0400 Subject: [PATCH 6/7] Use getQuantityString for plural text --- .../java/com/cornellappdev/coursegrab/SearchActivity.kt | 6 +++++- app/src/main/res/values/strings.xml | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt index 3921370..1072732 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt @@ -80,7 +80,11 @@ class SearchActivity : AppCompatActivity() { layoutManager = LinearLayoutManager(this@SearchActivity) adapter = ResultsAdapter(state.courses, this@SearchActivity) } - binding.resultTitle.text = "${state.courses.size} Results" + binding.resultTitle.text = resources.getQuantityString( + R.plurals.search_results_count, + state.courses.size, + state.courses.size + ) if (state.courses.isEmpty()) { showEmptyState( 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 From da585fa0077a07e3fd185ba7c71ecc9a210cc217 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 19:16:32 -0400 Subject: [PATCH 7/7] Organize into folders --- app/src/main/AndroidManifest.xml | 16 ++++++++-------- .../coursegrab/{ => data}/PreferencesHelper.kt | 2 +- .../networking/CourseGrabRepository.kt | 2 +- .../{ => service}/NotificationService.kt | 4 +++- .../{ => ui/details}/CourseDetailsActivity.kt | 3 ++- .../{ => ui/details}/CourseDetailsViewModel.kt | 2 +- .../coursegrab/{ => ui/login}/LoginActivity.kt | 3 ++- .../coursegrab/{ => ui/login}/LoginViewModel.kt | 4 +++- .../coursegrab/{ => ui/main}/MainActivity.kt | 6 +++++- .../coursegrab/{ => ui/main}/MainViewModel.kt | 2 +- .../{ => ui/notification}/NotificationModal.kt | 3 ++- .../coursegrab/{ => ui/search}/SearchActivity.kt | 4 +++- .../{ => ui/search}/SearchViewModel.kt | 2 +- .../{ => ui/settings}/SettingsActivity.kt | 3 ++- .../{ => ui/settings}/SettingsViewModel.kt | 3 ++- .../{ => ui/splash}/SplashScreenActivity.kt | 15 ++++++++------- 16 files changed, 45 insertions(+), 29 deletions(-) rename app/src/main/java/com/cornellappdev/coursegrab/{ => data}/PreferencesHelper.kt (97%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => service}/NotificationService.kt (96%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/details}/CourseDetailsActivity.kt (98%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/details}/CourseDetailsViewModel.kt (96%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/login}/LoginActivity.kt (95%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/login}/LoginViewModel.kt (98%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/main}/MainActivity.kt (97%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/main}/MainViewModel.kt (98%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/notification}/NotificationModal.kt (92%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/search}/SearchActivity.kt (97%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/search}/SearchViewModel.kt (97%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/settings}/SettingsActivity.kt (97%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/settings}/SettingsViewModel.kt (96%) rename app/src/main/java/com/cornellappdev/coursegrab/{ => ui/splash}/SplashScreenActivity.kt (96%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e461c1..ee41f36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ android:supportsRtl="true" android:theme="@style/AppTheme"> @@ -24,27 +24,27 @@ diff --git a/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt b/app/src/main/java/com/cornellappdev/coursegrab/data/PreferencesHelper.kt similarity index 97% rename from app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt rename to app/src/main/java/com/cornellappdev/coursegrab/data/PreferencesHelper.kt index 1540a57..cbd45ec 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/PreferencesHelper.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/data/PreferencesHelper.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.data import android.content.Context import android.preference.PreferenceManager 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 84560f8..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 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 96% rename from app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt rename to app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt index 3ba2a52..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,8 +7,10 @@ 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 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 98% 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 00aa2aa..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,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.details import android.content.Context import android.os.Bundle @@ -16,6 +16,7 @@ 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 diff --git a/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsViewModel.kt similarity index 96% rename from app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsViewModel.kt rename to app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsViewModel.kt index ddf1be4..eaa905f 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsViewModel.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.details import android.util.Log import androidx.lifecycle.ViewModel diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginActivity.kt similarity index 95% rename from app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt rename to app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginActivity.kt index c50ad12..942c788 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginActivity.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.login import android.content.Intent import android.os.Bundle @@ -8,6 +8,7 @@ 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 diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt similarity index 98% rename from app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt rename to app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt index f6efc07..948d0f2 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.login import android.annotation.SuppressLint import android.content.Context @@ -16,6 +16,8 @@ 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 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 97% 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 d7706ac..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 @@ -27,8 +27,12 @@ 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.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 diff --git a/app/src/main/java/com/cornellappdev/coursegrab/MainViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainViewModel.kt similarity index 98% rename from app/src/main/java/com/cornellappdev/coursegrab/MainViewModel.kt rename to app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainViewModel.kt index 099d119..fe68fd9 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/MainViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainViewModel.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 97% 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 1072732..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,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.search import android.content.Context import android.content.Intent @@ -21,8 +21,10 @@ 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.ui.details.CourseDetailsActivity import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SearchViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchViewModel.kt similarity index 97% rename from app/src/main/java/com/cornellappdev/coursegrab/SearchViewModel.kt rename to app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchViewModel.kt index 5dde806..339ad98 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SearchViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchViewModel.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.search import android.util.Log import androidx.lifecycle.ViewModel 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 97% 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 9f23d06..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,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.settings import android.Manifest import android.content.Intent @@ -14,6 +14,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.cornellappdev.coursegrab.databinding.ActivitySettingsBinding +import com.cornellappdev.coursegrab.ui.login.LoginActivity import com.google.firebase.messaging.FirebaseMessaging import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SettingsViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsViewModel.kt similarity index 96% rename from app/src/main/java/com/cornellappdev/coursegrab/SettingsViewModel.kt rename to app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsViewModel.kt index 2d74e3f..0857c16 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SettingsViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/settings/SettingsViewModel.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.coursegrab +package com.cornellappdev.coursegrab.ui.settings import android.content.Context import android.util.Log @@ -7,6 +7,7 @@ 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 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() }