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 }