diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d9e68ec..fb81a4c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,9 +22,14 @@ android { buildTypes { release { + isMinifyEnabled = true optimization { enable = false } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } compileOptions { @@ -36,6 +41,9 @@ android { dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.fragment.ktx) implementation(libs.material) implementation(libs.okhttp) implementation(libs.gson) diff --git a/app/src/main/java/com/example/resbuilder/ui/JobFormFragment.kt b/app/src/main/java/com/example/resbuilder/ui/JobFormFragment.kt index cf464df..59d7c24 100644 --- a/app/src/main/java/com/example/resbuilder/ui/JobFormFragment.kt +++ b/app/src/main/java/com/example/resbuilder/ui/JobFormFragment.kt @@ -147,7 +147,7 @@ class JobFormFragment : Fragment() { private fun scrapeUrl() { val url = seekUrlInput.text?.toString()?.trim() ?: "" if (url.isEmpty()) { - seekUrlInput.error = "Enter a URL" + seekUrlInput.error = getString(R.string.enter_url) return } showLoading(true) diff --git a/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt b/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt index 2804f86..5fd7a71 100644 --- a/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt +++ b/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt @@ -14,27 +14,25 @@ import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialResponse import androidx.credentials.exceptions.GetCredentialException +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import androidx.activity.viewModels import com.example.resbuilder.R -import com.example.resbuilder.data.remote.ApiClient -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInAccount -import com.google.android.gms.auth.api.signin.GoogleSignInOptions -import com.google.android.gms.common.api.ApiException -import com.google.android.gms.tasks.Task import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException import com.google.android.material.button.MaterialButton import com.google.android.material.progressindicator.CircularProgressIndicator -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.security.SecureRandom import java.util.Base64 class LoginActivity : AppCompatActivity() { + private val viewModel: LoginViewModel by viewModels() + private lateinit var googleSignInButton: MaterialButton private lateinit var progress: CircularProgressIndicator private lateinit var credentialManager: CredentialManager @@ -44,10 +42,6 @@ class LoginActivity : AppCompatActivity() { // Client IDs from Google Cloud Console private val webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com" private val androidClientId = "416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com" - - companion object { - private const val TAG = "LoginActivity" - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -62,25 +56,36 @@ class LoginActivity : AppCompatActivity() { googleSignInLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult() ) { result -> - Log.w(TAG, "Unexpected activity result: ${result.resultCode}") showLoading(false) - Toast.makeText(this, "Sign-in interrupted", Toast.LENGTH_SHORT).show() + Toast.makeText(this, R.string.sign_in_interrupted, Toast.LENGTH_SHORT).show() } + observeAuthState() + showLoading(true) - checkExistingAuth() + viewModel.checkExistingAuth() } - private fun checkExistingAuth() { + private fun observeAuthState() { lifecycleScope.launch { - try { - val user = withContext(Dispatchers.IO) { ApiClient.getMe() } - showLoading(false) - if (user.authenticated) { - launchMain() + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { state -> + showLoading(state.loading) + state.error?.let { + Toast.makeText(this@LoginActivity, it, Toast.LENGTH_LONG).show() + viewModel.setLoading(false) + } + state.user?.let { user -> + if (state.authenticated) { + Toast.makeText( + this@LoginActivity, + getString(R.string.welcome_user, user.name ?: user.email), + Toast.LENGTH_SHORT + ).show() + launchMain() + } + } } - } catch (_: Exception) { - showLoading(false) } } } @@ -90,42 +95,32 @@ class LoginActivity : AppCompatActivity() { googleSignInButton.isEnabled = !loading } - - private fun launchGoogleSignIn() { showLoading(true) - lifecycleScope.launch { - Log.d(TAG, "Starting Credential Manager flow") - Log.d(TAG, "Web client ID: $webClientId") - Log.d(TAG, "Android client ID: $androidClientId") - val googleIdOption = GetGoogleIdOption.Builder() - .setFilterByAuthorizedAccounts(false) - .setServerClientId(webClientId) - .setAutoSelectEnabled(false) - .setNonce(generateNonce()) - .build() + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) + .setServerClientId(webClientId) + .setAutoSelectEnabled(false) + .setNonce(generateNonce()) + .build() - val request = GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() + val request = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + viewModel.viewModelScope.launch { try { - Log.d(TAG, "Calling getCredential...") val result = credentialManager.getCredential( request = request, context = this@LoginActivity ) - Log.d(TAG, "Credential retrieved successfully") handleCredentialResponse(result) } catch (e: GetCredentialException) { - Log.e(TAG, "Credential Manager failed: ${e.javaClass.simpleName}", e) - Log.e(TAG, "Error message: ${e.message}") - Log.e(TAG, "Error code: ${e.cause}") showLoading(false) val errorMsg = when (e) { - is androidx.credentials.exceptions.NoCredentialException -> "No Google accounts found on device" - is androidx.credentials.exceptions.GetCredentialCancellationException -> "Sign-in cancelled" - else -> "Sign-in failed: ${e.message}" + is androidx.credentials.exceptions.NoCredentialException -> getString(R.string.no_google_accounts) + is androidx.credentials.exceptions.GetCredentialCancellationException -> getString(R.string.sign_in_cancelled) + else -> getString(R.string.sign_in_failed, e.message) } Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show() } @@ -141,56 +136,18 @@ class LoginActivity : AppCompatActivity() { val idToken = googleIdTokenCredential.idToken if (idToken.isNullOrEmpty()) { showLoading(false) - Toast.makeText(this, "No ID token from Google", Toast.LENGTH_LONG).show() + Toast.makeText(this, R.string.no_id_token, Toast.LENGTH_LONG).show() return } - exchangeToken(idToken) + viewModel.exchangeToken(idToken) } catch (e: GoogleIdTokenParsingException) { showLoading(false) - Toast.makeText(this, "Invalid Google ID token: ${e.message}", Toast.LENGTH_LONG).show() + Toast.makeText(this, getString(R.string.invalid_google_id_token, e.message), Toast.LENGTH_LONG).show() } } else -> { showLoading(false) - Toast.makeText(this, "Unexpected credential type", Toast.LENGTH_LONG).show() - } - } - } - - private fun handleSignInError(message: String) { - showLoading(false) - Toast.makeText(this, message, Toast.LENGTH_LONG).show() - } - - private fun exchangeToken(idToken: String) { - lifecycleScope.launch { - try { - val token = withContext(Dispatchers.IO) { ApiClient.exchangeGoogleToken(idToken) } - ApiClient.setToken(token) - val user = withContext(Dispatchers.IO) { ApiClient.getMe() } - showLoading(false) - if (user.authenticated) { - Toast.makeText( - this@LoginActivity, - "Welcome ${user.name ?: user.email}", - Toast.LENGTH_SHORT - ).show() - launchMain() - } else { - Toast.makeText( - this@LoginActivity, - "Server rejected the token", - Toast.LENGTH_LONG - ).show() - } - } catch (e: Exception) { - showLoading(false) - Log.e(TAG, "Token exchange failed", e) - Toast.makeText( - this@LoginActivity, - e.message ?: "Token exchange failed", - Toast.LENGTH_LONG - ).show() + Toast.makeText(this, R.string.unexpected_credential_type, Toast.LENGTH_LONG).show() } } } @@ -200,10 +157,6 @@ class LoginActivity : AppCompatActivity() { finish() } - override fun onDestroy() { - super.onDestroy() - } - private fun generateNonce(): String { val secureRandom = SecureRandom() val bytes = ByteArray(32) diff --git a/app/src/main/java/com/example/resbuilder/ui/LoginViewModel.kt b/app/src/main/java/com/example/resbuilder/ui/LoginViewModel.kt new file mode 100644 index 0000000..fe6bd79 --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/LoginViewModel.kt @@ -0,0 +1,62 @@ +package com.example.resbuilder.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.resbuilder.data.model.User +import com.example.resbuilder.data.remote.ApiClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class LoginUiState( + val loading: Boolean = false, + val authenticated: Boolean = false, + val user: User? = null, + val error: String? = null +) + +class LoginViewModel : ViewModel() { + + private val _state = MutableStateFlow(LoginUiState()) + val state: StateFlow = _state.asStateFlow() + + fun checkExistingAuth() { + viewModelScope.launch { + try { + val user = withContext(Dispatchers.IO) { ApiClient.getMe() } + _state.value = LoginUiState(loading = false, authenticated = user.authenticated, user = user) + } catch (_: Exception) { + _state.value = LoginUiState(loading = false, error = "check auth failed") + } + } + } + + fun exchangeToken(idToken: String) { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + try { + val token = withContext(Dispatchers.IO) { ApiClient.exchangeGoogleToken(idToken) } + ApiClient.setToken(token) + val user = withContext(Dispatchers.IO) { ApiClient.getMe() } + _state.value = LoginUiState( + loading = false, + authenticated = user.authenticated, + user = user + ) + } catch (e: Exception) { + _state.value = LoginUiState(loading = false, error = e.message ?: "Token exchange failed") + } + } + } + + fun setLoading(loading: Boolean) { + _state.value = _state.value.copy(loading = loading) + } + + fun consumeAuth() { + _state.value = _state.value.copy(authenticated = false) + } +} diff --git a/app/src/main/java/com/example/resbuilder/ui/MyResumesFragment.kt b/app/src/main/java/com/example/resbuilder/ui/MyResumesFragment.kt index 5dca59a..2e59ea7 100644 --- a/app/src/main/java/com/example/resbuilder/ui/MyResumesFragment.kt +++ b/app/src/main/java/com/example/resbuilder/ui/MyResumesFragment.kt @@ -1,8 +1,6 @@ package com.example.resbuilder.ui import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,15 +9,18 @@ import android.widget.ListView import android.widget.TextView import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.example.resbuilder.R -import com.example.resbuilder.data.model.Job import com.example.resbuilder.data.model.JobSummary -import com.example.resbuilder.data.remote.ApiClient import com.google.android.material.progressindicator.LinearProgressIndicator +import kotlinx.coroutines.launch class MyResumesFragment : Fragment() { - private val handler = Handler(Looper.getMainLooper()) + private val viewModel: MyResumesViewModel by viewModels() private lateinit var emptyView: TextView private lateinit var progress: LinearProgressIndicator @@ -37,70 +38,53 @@ class MyResumesFragment : Fragment() { listView.setOnItemClickListener { _, _, position, _ -> val item = listView.adapter.getItem(position) as JobSummary - openJob(item.job_id) + viewModel.openJob(item.job_id) } - loadJobs() + observeState() + viewModel.loadJobs() } - private fun loadJobs() { - showLoading(true) - Thread { - try { - val resp = ApiClient.getMyJobs() - handler.post { - showLoading(false) - bindJobs(resp.jobs) - } - } catch (e: Exception) { - handler.post { - showLoading(false) - emptyView.text = getString(R.string.my_resumes_error) - emptyView.visibility = View.VISIBLE - Toast.makeText(requireContext(), e.message ?: getString(R.string.my_resumes_error), Toast.LENGTH_LONG).show() + private fun observeState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { state -> + progress.visibility = if (state.loading) View.VISIBLE else View.GONE + + state.error?.let { + emptyView.text = getString(R.string.my_resumes_error) + emptyView.visibility = View.VISIBLE + Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show() + } + + if (!state.loading && state.jobs.isNotEmpty()) { + emptyView.visibility = View.GONE + listView.visibility = View.VISIBLE + listView.adapter = JobListAdapter(requireContext(), state.jobs) + } else if (!state.loading && state.error == null) { + emptyView.text = getString(R.string.my_resumes_empty) + emptyView.visibility = View.VISIBLE + listView.visibility = View.GONE + } + + state.openedJob?.let { job -> + viewModel.consumeOpenedJob() + openJobResult(job) + } } } - }.start() - } - - private fun bindJobs(jobs: List) { - if (jobs.isEmpty()) { - emptyView.text = getString(R.string.my_resumes_empty) - emptyView.visibility = View.VISIBLE - listView.visibility = View.GONE - return } - listView.visibility = View.VISIBLE - listView.adapter = JobListAdapter(requireContext(), jobs) } - private fun openJob(jobId: String) { - showLoading(true) - Thread { - try { - val job = ApiClient.getJob(jobId) - handler.post { - showLoading(false) - if (job == null) { - Toast.makeText(requireContext(), R.string.my_resumes_error, Toast.LENGTH_LONG).show() - return@post - } - when (job.status.uppercase()) { - "COMPLETED" -> { - val type = job.type ?: JobFormFragment.TYPE_RESUME - replaceWith(ResultFragment.newInstance(job, type)) - } - "FAILED" -> Toast.makeText(requireContext(), job.error ?: getString(R.string.status_failed), Toast.LENGTH_LONG).show() - else -> replaceWith(JobStatusFragment.newInstance(jobId, job.type ?: JobFormFragment.TYPE_RESUME)) - } - } - } catch (e: Exception) { - handler.post { - showLoading(false) - Toast.makeText(requireContext(), e.message ?: getString(R.string.my_resumes_error), Toast.LENGTH_LONG).show() - } + private fun openJobResult(job: com.example.resbuilder.data.model.Job) { + when (job.status.uppercase()) { + "COMPLETED" -> { + val type = job.type ?: JobFormFragment.TYPE_RESUME + replaceWith(ResultFragment.newInstance(job, type)) } - }.start() + "FAILED" -> Toast.makeText(requireContext(), job.error ?: getString(R.string.status_failed), Toast.LENGTH_LONG).show() + else -> replaceWith(JobStatusFragment.newInstance(job.job_id ?: "", job.type ?: JobFormFragment.TYPE_RESUME)) + } } private fun replaceWith(fragment: Fragment) { @@ -110,15 +94,6 @@ class MyResumesFragment : Fragment() { .commitAllowingStateLoss() } - private fun showLoading(loading: Boolean) { - progress.visibility = if (loading) View.VISIBLE else View.GONE - } - - override fun onDestroy() { - super.onDestroy() - handler.removeCallbacksAndMessages(null) - } - private class JobListAdapter( context: android.content.Context, private val jobs: List diff --git a/app/src/main/java/com/example/resbuilder/ui/MyResumesViewModel.kt b/app/src/main/java/com/example/resbuilder/ui/MyResumesViewModel.kt new file mode 100644 index 0000000..43c0308 --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/MyResumesViewModel.kt @@ -0,0 +1,54 @@ +package com.example.resbuilder.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.resbuilder.data.model.Job +import com.example.resbuilder.data.model.JobSummary +import com.example.resbuilder.data.remote.ApiClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class MyResumesUiState( + val loading: Boolean = false, + val jobs: List = emptyList(), + val error: String? = null, + val openedJob: Job? = null +) + +class MyResumesViewModel : ViewModel() { + + private val _state = MutableStateFlow(MyResumesUiState()) + val state: StateFlow = _state.asStateFlow() + + fun loadJobs() { + _state.value = _state.value.copy(loading = true, error = null) + viewModelScope.launch { + try { + val jobs = withContext(Dispatchers.IO) { ApiClient.getMyJobs().jobs } + _state.value = _state.value.copy(loading = false, jobs = jobs) + } catch (e: Exception) { + _state.value = _state.value.copy(loading = false, error = e.message ?: "load failed") + } + } + } + + fun openJob(jobId: String) { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + try { + val job = withContext(Dispatchers.IO) { ApiClient.getJob(jobId) } + _state.value = _state.value.copy(loading = false, openedJob = job) + } catch (e: Exception) { + _state.value = _state.value.copy(loading = false, error = e.message ?: "open failed") + } + } + } + + fun consumeOpenedJob() { + _state.value = _state.value.copy(openedJob = null) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d43eb6..569ad53 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,14 @@ Checking authentication… AI-powered resume and cover letter builder By signing in, you agree to our Terms of Service and Privacy Policy + Sign-in interrupted + No ID token from Google + Invalid Google ID token: %1$s + Unexpected credential type + No Google accounts found on device + Sign-in cancelled + Sign-in failed: %1$s + Welcome %1$s resBuilder @@ -28,6 +36,7 @@ Generate Cover Letter Please log in to continue Please fill in all fields + Enter a URL Queued diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4bcb50f..e87a3cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,9 +13,15 @@ googlePlayServicesAuth = "20.7.0" credentialManager = "1.5.0-beta01" googleid = "1.1.1" kotlinxCoroutines = "1.9.0" +lifecycleViewmodel = "2.8.7" +lifecycleRuntime = "2.8.7" +fragmentKtx = "1.8.5" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodel" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }