diff --git a/.gitignore b/.gitignore
index aa724b7..faf530b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,7 @@
*.iml
.gradle
/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
+/.idea/
.DS_Store
/build
/captures
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d3352..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index 72f9fde..0000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-resBuilder
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
deleted file mode 100644
index 4a53bee..0000000
--- a/.idea/AndroidProjectSystem.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b86273d..0000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index ca16a99..0000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
deleted file mode 100644
index 91f9558..0000000
--- a/.idea/deviceManager.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index cdbc250..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index a4f09e2..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
deleted file mode 100644
index 16660f1..0000000
--- a/.idea/runConfigurations.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/GOOGLE_AUTH_SETUP.md b/GOOGLE_AUTH_SETUP.md
new file mode 100644
index 0000000..93708b4
--- /dev/null
+++ b/GOOGLE_AUTH_SETUP.md
@@ -0,0 +1,133 @@
+# Google Authentication Setup
+
+## OAuth Credentials Summary
+
+You now have **TWO** OAuth credentials configured in Google Cloud Console:
+
+### 1. Web Application (for backend)
+- **Client ID**: `416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com`
+- **Type**: Web application
+- **Purpose**: Backend token exchange (`POST /auth/google-token`)
+- **Authorized redirect URIs**: Configured on your backend
+
+### 2. Android Application (for mobile app)
+- **Client ID**: `416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com`
+- **Type**: Android
+- **Package name**: `com.example.resbuilder`
+- **SHA-1 fingerprint**: `38:A9:E7:27:69:40:77:77:A7:3D:A1:AC:21:F2:8E:50:5D:C9:AC:7F`
+- **Purpose**: Android Credential Manager + Google Sign-In
+
+## What Was Changed
+
+### Dependencies Added
+```kotlin
+// gradle/libs.versions.toml
+credentialManager = "1.5.0-beta01"
+googleid = "1.1.1"
+kotlinxCoroutines = "1.9.0"
+
+androidx-credential-manager
+androidx-credential-manager-play-services
+googleid
+kotlinx-coroutines-android
+```
+
+### Code Migration
+
+The `LoginActivity.kt` now uses:
+1. **Credential Manager API** (primary) - Modern, recommended approach
+2. **Legacy Google Sign-In** (fallback) - For devices that don't support Credential Manager
+
+Key changes:
+- Uses `androidClientId` for Credential Manager flow
+- Uses `webClientId` for backend token exchange
+- Automatic fallback to legacy API if Credential Manager fails
+- Coroutines for suspend function support
+- Nonce generation for enhanced security
+
+## Verification Steps
+
+1. **Build the app**:
+ ```bash
+ ./gradlew assembleDebug
+ ```
+ APK: `app/build/outputs/apk/debug/app-debug.apk`
+
+2. **Install on device/emulator** with your debug keystore SHA-1
+
+3. **Test sign-in flow**:
+ - Tap "Sign in with Google"
+ - Account picker should appear
+ - Select an account
+ - Should authenticate and navigate to main screen
+
+## Troubleshooting
+
+### Error 10: Developer Error
+- SHA-1 fingerprint doesn't match Google Cloud Console
+- Package name doesn't match
+- **Fix**: Verify SHA-1 in Google Cloud Console matches `38:A9:E7:27:69:40:77:77:A7:3D:A1:AC:21:F2:8E:50:5D:C9:AC:7F`
+
+### Error 16: Sign In Cancelled
+- User cancelled the account picker
+- Normal behavior, not an error
+
+### Credential Manager not working
+- Device may not support Credential Manager
+- **Fix**: App automatically falls back to legacy Google Sign-In
+
+## API Flow
+
+```
+┌─────────────────┐
+│ User taps │
+│ Sign In │
+└────────┬────────┘
+ │
+ v
+┌─────────────────┐
+│ Credential │
+│ Manager │◄─── Uses androidClientId
+└────────┬────────┘
+ │
+ v
+┌─────────────────┐
+│ Google returns │
+│ ID Token │
+└────────┬────────┘
+ │
+ v
+┌─────────────────┐
+│ App sends ID │
+│ Token to backend│
+│ POST /auth/ │
+│ google-token │◄─── Backend validates with webClientId
+└────────┬────────┘
+ │
+ v
+┌─────────────────┐
+│ Backend returns │
+│ session token │
+└────────┬────────┘
+ │
+ v
+┌─────────────────┐
+│ App stores │
+│ session token │
+│ (Bearer auth) │
+└─────────────────┘
+```
+
+## Next Steps (Optional)
+
+For production release:
+1. Get **release SHA-1** from Play Console or your release keystore
+2. Add release SHA-1 to Google Cloud Console (same Android OAuth client)
+3. Build signed release APK/AAB
+4. Test with release credentials
+
+## References
+
+- [Credential Manager Documentation](https://developer.android.com/identity/sign-in/credential-manager)
+- [Google Sign-In Migration Guide](https://developers.google.com/identity/openid-connect/openid-connect)
+- [OAuth 2.0 for Android](https://developers.google.com/identity/protocols/oauth2/native-app)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e403a8d..faba73b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -18,15 +18,25 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ buildConfigField("String", "API_BASE", "\"https://resume.aridgwayweb.com\"")
}
buildTypes {
release {
+ isMinifyEnabled = true
optimization {
enable = false
}
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
}
}
+ buildFeatures {
+ buildConfig = true
+ }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
@@ -36,11 +46,18 @@ 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)
implementation(libs.browser)
implementation(libs.google.play.services.auth)
+ implementation(libs.androidx.credential.manager)
+ implementation(libs.androidx.credential.manager.play.services)
+ implementation(libs.googleid)
+ implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..22ed33e
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,11 @@
+# R8/ProGuard rules for resbuilder_app.
+
+# Gson reflects over the API data models. Keep their fields intact.
+-keep class com.example.resbuilder.data.model.** { ; }
+
+# Keep the OkHttp client class and its members (OkHttp ships its own consumer rules,
+# but ApiClient is referenced reflectively nowhere; keep it to be safe).
+-keep class com.example.resbuilder.data.remote.ApiClient { *; }
+
+# Credential Manager / Google Identity libs register via reflection.
+-keep class com.google.android.libraries.identity.googleid.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index caf2a3e..8b60d13 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ResBuilder"
@@ -33,6 +34,16 @@
android:name=".ui.AdminActivity"
android:exported="false" />
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/resbuilder/data/model/Models.kt b/app/src/main/java/com/example/resbuilder/data/model/Models.kt
index d2bf3ab..e48689b 100644
--- a/app/src/main/java/com/example/resbuilder/data/model/Models.kt
+++ b/app/src/main/java/com/example/resbuilder/data/model/Models.kt
@@ -61,4 +61,16 @@ data class AdminUser(
data class AdminUsersResponse(
val users: List
+)
+
+data class JobSummary(
+ val job_id: String,
+ val type: String,
+ val status: String,
+ val title: String? = null,
+ val created_at: String? = null
+)
+
+data class JobListResponse(
+ val jobs: List
)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/resbuilder/data/remote/ApiClient.kt b/app/src/main/java/com/example/resbuilder/data/remote/ApiClient.kt
index a808b87..b0de3ee 100644
--- a/app/src/main/java/com/example/resbuilder/data/remote/ApiClient.kt
+++ b/app/src/main/java/com/example/resbuilder/data/remote/ApiClient.kt
@@ -5,6 +5,7 @@ import com.example.resbuilder.data.model.BuildRequest
import com.example.resbuilder.data.model.BuildResponse
import com.example.resbuilder.data.model.HealthResponse
import com.example.resbuilder.data.model.Job
+import com.example.resbuilder.data.model.JobListResponse
import com.example.resbuilder.data.model.ScrapeResponse
import com.example.resbuilder.data.model.UploadResponse
import com.example.resbuilder.data.model.User
@@ -17,6 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
+import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
@@ -26,7 +28,7 @@ import java.util.concurrent.TimeUnit
object ApiClient {
- internal const val API_BASE = "https://resume.aridgwayweb.com"
+ internal const val API_BASE = com.example.resbuilder.BuildConfig.API_BASE
private val gson = Gson()
private val cookieStore = mutableListOf()
@@ -51,6 +53,8 @@ object ApiClient {
bearerToken?.let { builder.header("Authorization", "Bearer $it") }
chain.proceed(builder.build())
}
+ // HTTP/1.1 only: backend/proxy resets HTTP/2 streams mid-response.
+ .protocols(listOf(Protocol.HTTP_1_1))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
@@ -115,6 +119,24 @@ object ApiClient {
}
}
+ @Throws(IOException::class)
+ fun getMyJobs(): JobListResponse {
+ val req = Request.Builder()
+ .url("$API_BASE/jobs")
+ .build()
+ client.newCall(req).execute().use { res ->
+ if (!res.isSuccessful) {
+ val errBody = res.body?.string()
+ val detail = try {
+ gson.fromJson(errBody, Map::class.java)["detail"] as? String
+ } catch (_: Exception) { null }
+ throw IOException(detail ?: "Failed to load your documents")
+ }
+ val body = res.body?.string() ?: throw IOException("Empty response")
+ return gson.fromJson(body, JobListResponse::class.java)
+ }
+ }
+
fun getLoginUrl(): String = "$API_BASE/auth/login"
fun getLogoutUrl(): String = "$API_BASE/auth/logout"
@@ -291,4 +313,21 @@ object ApiClient {
fun getExportPdfUrl(jobId: String): String = "$API_BASE/export/$jobId/pdf"
fun getExportDocxUrl(jobId: String): String = "$API_BASE/export/$jobId/docx"
+
+ @Throws(IOException::class)
+ fun downloadExport(url: String): ByteArray {
+ val req = Request.Builder()
+ .url(url)
+ .build()
+ client.newCall(req).execute().use { res ->
+ if (!res.isSuccessful) {
+ val errBody = res.body?.string()
+ val detail = try {
+ gson.fromJson(errBody, Map::class.java)["detail"] as? String
+ } catch (_: Exception) { null }
+ throw IOException(detail ?: "Export failed")
+ }
+ return res.body?.bytes() ?: throw IOException("Empty response")
+ }
+ }
}
\ No newline at end of file
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 b6dae09..5fd7a71 100644
--- a/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt
+++ b/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt
@@ -2,34 +2,46 @@ package com.example.resbuilder.ui
import android.content.Intent
import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
+import android.util.Log
import android.view.View
-import android.widget.ProgressBar
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
+import androidx.credentials.CredentialManager
+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.SignInButton
-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.launch
+import java.security.SecureRandom
+import java.util.Base64
class LoginActivity : AppCompatActivity() {
- private lateinit var googleSignInButton: SignInButton
- private lateinit var progress: ProgressBar
+ private val viewModel: LoginViewModel by viewModels()
- private val handler = Handler(Looper.getMainLooper())
+ private lateinit var googleSignInButton: MaterialButton
+ private lateinit var progress: CircularProgressIndicator
+ private lateinit var credentialManager: CredentialManager
- private lateinit var googleSignInLauncher: ActivityResultLauncher
+ private lateinit var googleSignInLauncher: ActivityResultLauncher
- // Same client ID registered in Google Cloud Console for the backend OAuth
+ // Client IDs from Google Cloud Console
private val webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com"
+ private val androidClientId = "416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -37,23 +49,45 @@ class LoginActivity : AppCompatActivity() {
googleSignInButton = findViewById(R.id.googleSignInButton)
progress = findViewById(R.id.loginProgress)
+ credentialManager = CredentialManager.create(this)
googleSignInButton.setOnClickListener { launchGoogleSignIn() }
googleSignInLauncher = registerForActivityResult(
- ActivityResultContracts.StartActivityForResult()
+ ActivityResultContracts.StartIntentSenderForResult()
) { result ->
- if (result.resultCode == RESULT_OK && result.data != null) {
- val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
- handleSignInResult(task)
- } else {
- showLoading(false)
- Toast.makeText(this, "Sign in cancelled", Toast.LENGTH_SHORT).show()
- }
+ showLoading(false)
+ Toast.makeText(this, R.string.sign_in_interrupted, Toast.LENGTH_SHORT).show()
}
+ observeAuthState()
+
showLoading(true)
- checkExistingAuth()
+ viewModel.checkExistingAuth()
+ }
+
+ private fun observeAuthState() {
+ lifecycleScope.launch {
+ 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()
+ }
+ }
+ }
+ }
+ }
}
private fun showLoading(loading: Boolean) {
@@ -61,86 +95,61 @@ class LoginActivity : AppCompatActivity() {
googleSignInButton.isEnabled = !loading
}
- private fun checkExistingAuth() {
- Thread {
- try {
- val user = ApiClient.getMe()
- handler.post {
- showLoading(false)
- if (user.authenticated) {
- launchMain()
- }
- }
- } catch (_: Exception) {
- handler.post { showLoading(false) }
- }
- }.start()
- }
-
private fun launchGoogleSignIn() {
showLoading(true)
- val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
- .requestIdToken(webClientId)
- .requestEmail()
+ val googleIdOption = GetGoogleIdOption.Builder()
+ .setFilterByAuthorizedAccounts(false)
+ .setServerClientId(webClientId)
+ .setAutoSelectEnabled(false)
+ .setNonce(generateNonce())
.build()
- val client = GoogleSignIn.getClient(this, gso)
- // Clear any previous sign-in to force account picker
- client.signOut().addOnCompleteListener {
- val signInIntent = client.signInIntent
- googleSignInLauncher.launch(signInIntent)
- }
- }
- private fun handleSignInResult(task: Task) {
- try {
- val account = task.getResult(ApiException::class.java)
- val idToken = account?.idToken
- if (idToken.isNullOrEmpty()) {
- showLoading(false)
- Toast.makeText(this, "No ID token from Google", Toast.LENGTH_LONG).show()
- return
- }
- exchangeToken(idToken)
- } catch (e: ApiException) {
- showLoading(false)
- Toast.makeText(this, "Sign in failed: ${e.message}", Toast.LENGTH_LONG).show()
- }
- }
+ val request = GetCredentialRequest.Builder()
+ .addCredentialOption(googleIdOption)
+ .build()
- private fun exchangeToken(idToken: String) {
- Thread {
+ viewModel.viewModelScope.launch {
try {
- val token = ApiClient.exchangeGoogleToken(idToken)
- ApiClient.setToken(token)
- val user = ApiClient.getMe()
- handler.post {
- showLoading(false)
- if (user.authenticated) {
- Toast.makeText(
- this,
- "Welcome ${user.name ?: user.email}",
- Toast.LENGTH_SHORT
- ).show()
- launchMain()
- } else {
- Toast.makeText(
- this,
- "Server rejected the token",
- Toast.LENGTH_LONG
- ).show()
- }
+ val result = credentialManager.getCredential(
+ request = request,
+ context = this@LoginActivity
+ )
+ handleCredentialResponse(result)
+ } catch (e: GetCredentialException) {
+ showLoading(false)
+ val errorMsg = when (e) {
+ 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)
}
- } catch (e: Exception) {
- handler.post {
+ Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+
+ private fun handleCredentialResponse(response: GetCredentialResponse) {
+ val credential = response.credential
+ when {
+ credential is CustomCredential && credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL -> {
+ try {
+ val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data)
+ val idToken = googleIdTokenCredential.idToken
+ if (idToken.isNullOrEmpty()) {
+ showLoading(false)
+ Toast.makeText(this, R.string.no_id_token, Toast.LENGTH_LONG).show()
+ return
+ }
+ viewModel.exchangeToken(idToken)
+ } catch (e: GoogleIdTokenParsingException) {
showLoading(false)
- Toast.makeText(
- this,
- e.message ?: "Token exchange failed",
- Toast.LENGTH_LONG
- ).show()
+ Toast.makeText(this, getString(R.string.invalid_google_id_token, e.message), Toast.LENGTH_LONG).show()
}
}
- }.start()
+ else -> {
+ showLoading(false)
+ Toast.makeText(this, R.string.unexpected_credential_type, Toast.LENGTH_LONG).show()
+ }
+ }
}
private fun launchMain() {
@@ -148,8 +157,10 @@ class LoginActivity : AppCompatActivity() {
finish()
}
- override fun onDestroy() {
- super.onDestroy()
- handler.removeCallbacksAndMessages(null)
+ private fun generateNonce(): String {
+ val secureRandom = SecureRandom()
+ val bytes = ByteArray(32)
+ secureRandom.nextBytes(bytes)
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
}
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/MainActivity.kt b/app/src/main/java/com/example/resbuilder/ui/MainActivity.kt
index d36a827..a145023 100644
--- a/app/src/main/java/com/example/resbuilder/ui/MainActivity.kt
+++ b/app/src/main/java/com/example/resbuilder/ui/MainActivity.kt
@@ -5,7 +5,6 @@ import android.graphics.PorterDuff
import android.os.Bundle
import android.os.Handler
import android.os.Looper
-import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
@@ -13,18 +12,21 @@ import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
+import androidx.drawerlayout.widget.DrawerLayout
import com.example.resbuilder.R
import com.example.resbuilder.data.model.User
import com.example.resbuilder.data.remote.ApiClient
import com.google.android.material.appbar.MaterialToolbar
-import com.google.android.material.tabs.TabLayout
+import com.google.android.material.navigation.NavigationView
class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, ResultFragment.OnNewListener {
+ private lateinit var drawerLayout: DrawerLayout
private lateinit var toolbar: MaterialToolbar
- private lateinit var tabLayout: TabLayout
+ private lateinit var navView: NavigationView
private lateinit var healthIndicator: ImageView
private lateinit var userName: TextView
+ private lateinit var userEmail: TextView
private lateinit var usageBanner: TextView
private val handler = Handler(Looper.getMainLooper())
@@ -32,43 +34,65 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
private var healthRunnable: Runnable? = null
companion object {
- private const val TAB_RESUME = 0
- private const val TAB_COVER = 1
+ private const val NAV_BUILD_RESUME = 0
+ private const val NAV_BUILD_COVER_LETTER = 1
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
+ drawerLayout = findViewById(R.id.drawerLayout)
toolbar = findViewById(R.id.toolbar)
- tabLayout = findViewById(R.id.tabLayout)
- healthIndicator = findViewById(R.id.healthIndicator)
- userName = findViewById(R.id.userName)
+ navView = findViewById(R.id.navView)
usageBanner = findViewById(R.id.usageBanner)
+ val header = navView.getHeaderView(0)
+ healthIndicator = header.findViewById(R.id.healthIndicator)
+ userName = header.findViewById(R.id.userName)
+ userEmail = header.findViewById(R.id.userEmail)
+
setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_menu_hamburger)
- tabLayout.addTab(tabLayout.newTab().setText(R.string.tab_build_resume))
- tabLayout.addTab(tabLayout.newTab().setText(R.string.tab_build_cover_letter))
-
- tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
- override fun onTabSelected(tab: TabLayout.Tab?) {
- switchFormTab(tab?.position ?: TAB_RESUME)
- }
-
- override fun onTabUnselected(tab: TabLayout.Tab?) {}
- override fun onTabReselected(tab: TabLayout.Tab?) {
- switchFormTab(tab?.position ?: TAB_RESUME)
- }
- })
+ navView.setNavigationItemSelectedListener { item ->
+ drawerLayout.closeDrawers()
+ handleNavItem(item.itemId)
+ true
+ }
loadUser()
startHealthPolling()
if (savedInstanceState == null) {
- switchFormTab(TAB_RESUME)
+ showBuildForm(NAV_BUILD_RESUME)
}
}
+ private fun handleNavItem(itemId: Int) {
+ when (itemId) {
+ R.id.navBuildResume -> showBuildForm(NAV_BUILD_RESUME)
+ R.id.navBuildCoverLetter -> showBuildForm(NAV_BUILD_COVER_LETTER)
+ R.id.navMyResumes -> showMyResumes()
+ R.id.navAdmin -> startActivity(Intent(this, AdminActivity::class.java))
+ R.id.navSignOut -> signOut()
+ }
+ }
+
+ private fun showBuildForm(position: Int) {
+ val type = if (position == NAV_BUILD_COVER_LETTER) JobFormFragment.TYPE_COVER_LETTER else JobFormFragment.TYPE_RESUME
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.fragmentContainer, JobFormFragment.newInstance(type))
+ .commitAllowingStateLoss()
+ }
+
+ private fun showMyResumes() {
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.fragmentContainer, MyResumesFragment())
+ .addToBackStack(null)
+ .commitAllowingStateLoss()
+ }
+
private fun loadUser() {
Thread {
try {
@@ -86,20 +110,13 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
private fun updateUserUI() {
userName.text = user.name ?: user.email ?: getString(R.string.unknown_user)
- if (!user.authenticated || (!user.is_admin && !user.can_submit)) {
- usageBanner.visibility = View.VISIBLE
+ userEmail.text = user.email ?: getString(R.string.unknown_email)
+ usageBanner.visibility = if (!user.authenticated || (!user.is_admin && !user.can_submit)) {
+ View.VISIBLE
} else {
- usageBanner.visibility = View.GONE
+ View.GONE
}
- invalidateOptionsMenu()
- }
-
- private fun switchFormTab(position: Int) {
- val type = if (position == TAB_COVER) JobFormFragment.TYPE_COVER_LETTER else JobFormFragment.TYPE_RESUME
- val fragment = JobFormFragment.newInstance(type)
- supportFragmentManager.beginTransaction()
- .replace(R.id.fragmentContainer, fragment)
- .commitAllowingStateLoss()
+ navView.menu.findItem(R.id.navAdmin)?.isVisible = user.is_admin
}
private fun startHealthPolling() {
@@ -130,27 +147,12 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
}.start()
}
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.menu_main, menu)
- return true
- }
-
- override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
- menu?.findItem(R.id.menuAdminPanel)?.isVisible = user.is_admin
- return super.onPrepareOptionsMenu(menu)
- }
-
override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.menuSignOut -> {
- signOut()
- true
- }
- R.id.menuAdminPanel -> {
- startActivity(Intent(this, AdminActivity::class.java))
- true
- }
- else -> super.onOptionsItemSelected(item)
+ return if (item.itemId == android.R.id.home) {
+ drawerLayout.openDrawer(navView)
+ true
+ } else {
+ super.onOptionsItemSelected(item)
}
}
@@ -176,8 +178,8 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
fun onResultNewJob() {
supportFragmentManager.popBackStackImmediate()
- tabLayout.getTabAt(TAB_RESUME)?.select()
- switchFormTab(TAB_RESUME)
+ navView.menu.getItem(NAV_BUILD_RESUME)?.isChecked = true
+ showBuildForm(NAV_BUILD_RESUME)
}
override fun onNewJob() {
diff --git a/app/src/main/java/com/example/resbuilder/ui/MyResumesFragment.kt b/app/src/main/java/com/example/resbuilder/ui/MyResumesFragment.kt
new file mode 100644
index 0000000..2e59ea7
--- /dev/null
+++ b/app/src/main/java/com/example/resbuilder/ui/MyResumesFragment.kt
@@ -0,0 +1,133 @@
+package com.example.resbuilder.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+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.JobSummary
+import com.google.android.material.progressindicator.LinearProgressIndicator
+import kotlinx.coroutines.launch
+
+class MyResumesFragment : Fragment() {
+
+ private val viewModel: MyResumesViewModel by viewModels()
+
+ private lateinit var emptyView: TextView
+ private lateinit var progress: LinearProgressIndicator
+ private lateinit var listView: ListView
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_my_resumes, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ emptyView = view.findViewById(R.id.myResumesEmpty)
+ progress = view.findViewById(R.id.myResumesProgress)
+ listView = view.findViewById(R.id.myResumesList)
+
+ listView.setOnItemClickListener { _, _, position, _ ->
+ val item = listView.adapter.getItem(position) as JobSummary
+ viewModel.openJob(item.job_id)
+ }
+
+ observeState()
+ viewModel.loadJobs()
+ }
+
+ 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)
+ }
+ }
+ }
+ }
+ }
+
+ 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))
+ }
+ "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) {
+ requireActivity().supportFragmentManager.beginTransaction()
+ .replace(R.id.fragmentContainer, fragment)
+ .addToBackStack(null)
+ .commitAllowingStateLoss()
+ }
+
+ private class JobListAdapter(
+ context: android.content.Context,
+ private val jobs: List
+ ) : ArrayAdapter(context, R.layout.item_job_summary, jobs) {
+
+ private val inflater = android.view.LayoutInflater.from(context)
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+ val v = convertView ?: inflater.inflate(R.layout.item_job_summary, parent, false)
+ val job = jobs[position]
+ v.findViewById(R.id.itemTitle).text =
+ job.title?.takeIf { it.isNotBlank() } ?: getKindLabel(job.type)
+ v.findViewById(R.id.itemSubtitle).text = buildSubtitle(job)
+ return v
+ }
+
+ private fun getKindLabel(type: String?): String =
+ if (type == JobFormFragment.TYPE_COVER_LETTER) context.getString(R.string.type_cover_letter)
+ else context.getString(R.string.type_resume)
+
+ private fun buildSubtitle(job: JobSummary): String {
+ val kind = getKindLabel(job.type)
+ val created = job.created_at?.let(::formatDate)?.let { " · $it" }.orEmpty()
+ return "$kind$created"
+ }
+
+ private fun formatDate(iso: String): String {
+ return try {
+ val date = java.time.OffsetDateTime.parse(iso)
+ val local = date.atZoneSameInstant(java.time.ZoneId.systemDefault())
+ java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy").format(local)
+ } catch (_: Exception) {
+ iso
+ }
+ }
+ }
+}
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/java/com/example/resbuilder/ui/ResultFragment.kt b/app/src/main/java/com/example/resbuilder/ui/ResultFragment.kt
index 5c39cec..595082d 100644
--- a/app/src/main/java/com/example/resbuilder/ui/ResultFragment.kt
+++ b/app/src/main/java/com/example/resbuilder/ui/ResultFragment.kt
@@ -13,11 +13,14 @@ import android.webkit.WebView
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import com.example.resbuilder.R
import com.example.resbuilder.data.model.Job
import com.example.resbuilder.data.remote.ApiClient
import com.google.android.material.button.MaterialButton
+import java.io.File
+import java.io.FileOutputStream
class ResultFragment : Fragment() {
@@ -86,14 +89,58 @@ class ResultFragment : Fragment() {
newButton.setOnClickListener { listener?.onNewJob() }
jobId?.let { id ->
- pdfButton.setOnClickListener { openUrl(ApiClient.getExportPdfUrl(id)) }
- docxButton.setOnClickListener { openUrl(ApiClient.getExportDocxUrl(id)) }
+ pdfButton.setOnClickListener { downloadAndOpen(id, "pdf") }
+ docxButton.setOnClickListener { downloadAndOpen(id, "docx") }
} ?: run {
pdfButton.isEnabled = false
docxButton.isEnabled = false
}
}
+ private fun downloadAndOpen(jobId: String, format: String) {
+ val url = if (format == "pdf") ApiClient.getExportPdfUrl(jobId) else ApiClient.getExportDocxUrl(jobId)
+ val dir = File(requireContext().cacheDir, "exports")
+ if (!dir.exists()) dir.mkdirs()
+ val safeId = jobId.replace("/", "_").replace("..", "_").take(8)
+ val file = File(dir, "$safeId.$format")
+
+ pdfButton.isEnabled = false
+ docxButton.isEnabled = false
+ Toast.makeText(context, R.string.exporting, Toast.LENGTH_SHORT).show()
+
+ Thread {
+ try {
+ val bytes = ApiClient.downloadExport(url)
+ FileOutputStream(file).use { it.write(bytes) }
+ requireActivity().runOnUiThread {
+ pdfButton.isEnabled = true
+ docxButton.isEnabled = true
+ openFile(file)
+ }
+ } catch (e: Exception) {
+ requireActivity().runOnUiThread {
+ pdfButton.isEnabled = true
+ docxButton.isEnabled = true
+ Toast.makeText(context, e.message ?: getString(R.string.export_failed), Toast.LENGTH_LONG).show()
+ }
+ }
+ }.start()
+ }
+
+ private fun openFile(file: File) {
+ val uri = FileProvider.getUriForFile(requireContext(), "${requireContext().packageName}.fileprovider", file)
+ val mime = if (file.extension == "pdf") "application/pdf" else "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, mime)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ try {
+ startActivity(intent)
+ } catch (e: Exception) {
+ Toast.makeText(context, R.string.export_no_viewer, Toast.LENGTH_LONG).show()
+ }
+ }
+
private fun copyText() {
val raw = rawContent
if (raw.isNullOrEmpty()) {
@@ -105,11 +152,6 @@ class ResultFragment : Fragment() {
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
- private fun openUrl(url: String) {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
- startActivity(intent)
- }
-
fun setOnNewListener(l: OnNewListener) {
listener = l
}
diff --git a/app/src/main/res/drawable/bg_banner_warning.xml b/app/src/main/res/drawable/bg_banner_warning.xml
index 24271aa..6f4799b 100644
--- a/app/src/main/res/drawable/bg_banner_warning.xml
+++ b/app/src/main/res/drawable/bg_banner_warning.xml
@@ -1,5 +1,6 @@
-
-
-
+
+
+
diff --git a/app/src/main/res/drawable/bg_gradient_primary.xml b/app/src/main/res/drawable/bg_gradient_primary.xml
new file mode 100644
index 0000000..ed6da10
--- /dev/null
+++ b/app/src/main/res/drawable/bg_gradient_primary.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_gradient_primary_bottom.xml b/app/src/main/res/drawable/bg_gradient_primary_bottom.xml
new file mode 100644
index 0000000..db08bea
--- /dev/null
+++ b/app/src/main/res/drawable/bg_gradient_primary_bottom.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_status_badge.xml b/app/src/main/res/drawable/bg_status_badge.xml
new file mode 100644
index 0000000..3463644
--- /dev/null
+++ b/app/src/main/res/drawable/bg_status_badge.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_circle_status.xml b/app/src/main/res/drawable/ic_circle_status.xml
index 7d3a35f..5aa801b 100644
--- a/app/src/main/res/drawable/ic_circle_status.xml
+++ b/app/src/main/res/drawable/ic_circle_status.xml
@@ -1,9 +1,10 @@
+
diff --git a/app/src/main/res/drawable/ic_document.xml b/app/src/main/res/drawable/ic_document.xml
new file mode 100644
index 0000000..5039c59
--- /dev/null
+++ b/app/src/main/res/drawable/ic_document.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_google.xml b/app/src/main/res/drawable/ic_google.xml
new file mode 100644
index 0000000..8eec6ce
--- /dev/null
+++ b/app/src/main/res/drawable/ic_google.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_hamburger.xml b/app/src/main/res/drawable/ic_menu_hamburger.xml
new file mode 100644
index 0000000..bbe3fff
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_hamburger.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_sparkle.xml b/app/src/main/res/drawable/ic_sparkle.xml
new file mode 100644
index 0000000..f1565a8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sparkle.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_admin.xml b/app/src/main/res/layout/activity_admin.xml
index 8d6f461..0aa4b2a 100644
--- a/app/src/main/res/layout/activity_admin.xml
+++ b/app/src/main/res/layout/activity_admin.xml
@@ -1,25 +1,50 @@
+ android:background="?attr/colorSurface"
+ android:orientation="vertical"
+ tools:context=".ui.AdminActivity">
+ android:layout_height="wrap_content"
+ android:background="?attr/colorSurface"
+ app:elevation="0dp"
+ app:liftOnScroll="false">
+ app:titleCentered="true"
+ app:titleTextAppearance="?attr/textAppearanceTitleLarge"
+ app:titleTextColor="?attr/colorOnSurface" />
-
+ android:layout_height="0dp"
+ android:layout_margin="16dp"
+ android:layout_weight="1"
+ app:cardCornerRadius="12dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
+
+
+
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
index 369520e..74d3ffb 100644
--- a/app/src/main/res/layout/activity_login.xml
+++ b/app/src/main/res/layout/activity_login.xml
@@ -1,42 +1,159 @@
+ android:background="?attr/colorSurface"
+ tools:context=".ui.LoginActivity">
-
-
-
+
+ app:layout_constraintTop_toTopOf="parent" />
-
+
+
+
+
+
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintVertical_chainStyle="packed"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 274b5b5..fcf71bb 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,77 +1,82 @@
-
-
+
+ android:layout_height="match_parent"
+ android:orientation="vertical">
-
+
+ android:layout_height="wrap_content"
+ android:background="?attr/colorPrimary"
+ android:fitsSystemWindows="true"
+ app:elevation="0dp">
-
+
+
-
+
-
+
+
-
-
-
+
+
-
-
-
-
-
-
-
+
diff --git a/app/src/main/res/layout/fragment_job_form.xml b/app/src/main/res/layout/fragment_job_form.xml
index 031e6d0..99cefd3 100644
--- a/app/src/main/res/layout/fragment_job_form.xml
+++ b/app/src/main/res/layout/fragment_job_form.xml
@@ -1,111 +1,249 @@
+ android:clipToPadding="false"
+ android:paddingBottom="@dimen/space_md"
+ tools:context=".ui.JobFormFragment">
-
+
+ android:background="@drawable/bg_gradient_primary"
+ android:minHeight="@dimen/hero_header_min_height"
+ android:padding="@dimen/space_lg">
-
+
+
-
+ android:layout_gravity="bottom|start"
+ android:orientation="vertical">
+
+
+
+
+
+
+ android:orientation="vertical"
+ android:padding="@dimen/space_md">
-
+
+ app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+ app:cardCornerRadius="@dimen/card_radius"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
-
+ android:orientation="vertical"
+ android:padding="@dimen/space_md">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:id="@+id/submitButton"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/button_height"
+ android:layout_marginTop="@dimen/space_md"
+ android:text="@string/generate_resume" />
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_job_status.xml b/app/src/main/res/layout/fragment_job_status.xml
index 6fe9dbc..0196d29 100644
--- a/app/src/main/res/layout/fragment_job_status.xml
+++ b/app/src/main/res/layout/fragment_job_status.xml
@@ -1,68 +1,92 @@
+ android:padding="@dimen/space_md">
+ android:layout_marginHorizontal="@dimen/space_md"
+ android:layout_marginVertical="@dimen/space_sm"
+ app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+ app:cardCornerRadius="@dimen/card_radius_large"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
+ android:padding="@dimen/space_lg">
-
+
+ app:cardBackgroundColor="?attr/colorPrimaryContainer"
+ app:cardCornerRadius="16dp"
+ app:cardElevation="0dp"
+ app:strokeWidth="0dp">
-
+
+
+
+ android:layout_marginTop="@dimen/space_lg"
+ android:contentDescription="@string/content_generation_running"
+ android:indeterminate="true"
+ app:indicatorColor="?attr/colorPrimary"
+ app:trackColor="?attr/colorSurfaceContainerHighest" />
+ android:textAppearance="?attr/textAppearanceBodyLarge"
+ android:textColor="?attr/colorOnSurface" />
diff --git a/app/src/main/res/layout/fragment_my_resumes.xml b/app/src/main/res/layout/fragment_my_resumes.xml
new file mode 100644
index 0000000..b0ae36a
--- /dev/null
+++ b/app/src/main/res/layout/fragment_my_resumes.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml
index b991da0..e516be7 100644
--- a/app/src/main/res/layout/fragment_result.xml
+++ b/app/src/main/res/layout/fragment_result.xml
@@ -1,33 +1,55 @@
+ android:background="?attr/colorSurface"
+ android:orientation="vertical"
+ tools:context=".ui.ResultFragment">
+
+ android:layout_marginHorizontal="@dimen/space_md"
+ android:layout_marginTop="@dimen/space_md"
+ android:layout_marginBottom="@dimen/space_sm"
+ app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+ app:cardCornerRadius="@dimen/card_radius"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
+ android:padding="@dimen/space_sm">
-
+ app:cardBackgroundColor="?attr/colorSuccessContainer"
+ app:cardCornerRadius="16dp"
+ app:cardElevation="0dp"
+ app:strokeWidth="0dp">
+
+
+
@@ -54,31 +81,48 @@
android:id="@+id/resultWebView"
android:layout_width="match_parent"
android:layout_height="0dp"
- android:layout_marginHorizontal="12dp"
- android:layout_weight="1" />
+ android:layout_marginHorizontal="@dimen/space_md"
+ android:layout_weight="1"
+ android:contentDescription="@string/result_preview" />
-
+
+ android:layout_marginHorizontal="@dimen/space_md"
+ android:layout_marginTop="@dimen/space_sm"
+ android:layout_marginBottom="@dimen/space_md"
+ app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+ app:cardCornerRadius="@dimen/card_radius"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
+ android:orientation="horizontal"
+ android:padding="@dimen/space_sm">
-
-
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_admin_user.xml b/app/src/main/res/layout/item_admin_user.xml
index 0d8061e..ee75780 100644
--- a/app/src/main/res/layout/item_admin_user.xml
+++ b/app/src/main/res/layout/item_admin_user.xml
@@ -3,13 +3,18 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_margin="8dp"
- app:cardCornerRadius="8dp"
- app:cardElevation="2dp">
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginVertical="4dp"
+ app:cardCornerRadius="12dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
@@ -23,28 +28,35 @@
android:id="@+id/userEmail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:textAppearance="?attr/textAppearanceBody1"
- android:textStyle="bold" />
+ android:textAppearance="?attr/textAppearanceBodyLarge"
+ android:textColor="?attr/colorOnSurface" />
+ android:layout_marginTop="2dp"
+ android:textAppearance="?attr/textAppearanceBodyMedium"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+ android:layout_marginTop="4dp"
+ android:textAppearance="?attr/textAppearanceLabelSmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+ android:contentDescription="@string/cd_admin_action"
+ android:minWidth="72dp"
+ android:text="@string/block_user"
+ android:textColor="?attr/colorError" />
diff --git a/app/src/main/res/layout/item_job_summary.xml b/app/src/main/res/layout/item_job_summary.xml
new file mode 100644
index 0000000..c506f06
--- /dev/null
+++ b/app/src/main/res/layout/item_job_summary.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/nav_header.xml b/app/src/main/res/layout/nav_header.xml
new file mode 100644
index 0000000..b592fb2
--- /dev/null
+++ b/app/src/main/res/layout/nav_header.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
deleted file mode 100644
index 69dd90e..0000000
--- a/app/src/main/res/menu/menu_main.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
diff --git a/app/src/main/res/menu/menu_nav_drawer.xml b/app/src/main/res/menu/menu_nav_drawer.xml
new file mode 100644
index 0000000..926b79a
--- /dev/null
+++ b/app/src/main/res/menu/menu_nav_drawer.xml
@@ -0,0 +1,39 @@
+
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 9d9df44..6d8acf2 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,16 +1,92 @@
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..da6fcce
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index beaeb61..072c71c 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,20 +1,87 @@
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
+
+
+
+ #004F4F
+ #FFFFFF
+ #B5F2F3
+ #002020
+ #B5F2F3
+ #002020
+
+
+ #3D5252
+ #FFFFFF
+ #BDD6D5
+ #041E1E
+
+
+ #365064
+ #FFFFFF
+ #BFE0FF
+ #001D31
+
+
+ #FAFDFD
+ #0A1414
+ #D8E5E5
+ #3F4A4A
+ #FFFFFF
+ #F0F6F6
+ #E8F1F1
+ #DFEAEA
+ #D6E4E4
+
+
+ #6F7E7E
+ #BEC9C9
+
+
+ #2E7D32
+ #FFFFFF
+ #D9F6DA
+ #1F5E22
+
+ #E65100
+ #FFFFFF
+ #FFE3B6
+ #693B00
+
+ #C62828
+ #FFFFFF
+ #F9DEDC
+ #410E0B
+
+ #1565C0
+ #FFFFFF
+ #D4EAFB
+ #0B4576
+
+
+ #2D3131
+ #DFE4E4
+ #80DADC
+
+
+ @color/success
+ @color/warning
+ @color/error
+
+
+ @color/primary_container
+ @color/primary
+ @color/on_primary_container
+ @color/primary_container
+ @color/primary
#FF000000
#FFFFFFFF
- #FF4CAF50
- #FFFFC107
- #FFF44336
- #FFFFF3E0
- #FFE65100
- #FF9E9E9E
- #FF2196F3
- #FF4CAF50
- #FFF44336
+ @color/warning_container
+ @color/on_warning_container
+
+ @color/surface_container_highest
+ @color/info
+ @color/success
+ @color/error
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..0f72894
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,18 @@
+
+
+
+ 4dp
+ 8dp
+ 16dp
+ 24dp
+ 32dp
+ 48dp
+
+
+ 12dp
+ 16dp
+ 48dp
+ 48dp
+ 160dp
+ 64dp
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b86b09c..196a63c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,6 +4,16 @@
Sign in with Google
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
@@ -13,6 +23,7 @@
Admin Panel
You have reached your free limit. Please contact support to upgrade.
Unknown user
+ Not signed in
Job Title
@@ -26,6 +37,7 @@
Generate Cover Letter
Please log in to continue
Please fill in all fields
+ Enter a URL
Queued
@@ -44,6 +56,22 @@
DOCX
Copied to clipboard
Nothing to copy
+ Exporting…
+ Export failed
+ No app found to open this file
+
+
+ resBuilder app logo
+ Sign in with Google
+ Loading
+ Service health indicator
+ Scrape job description from SEEK URL
+ Upload existing resume or cover letter
+ Copy generated text to clipboard
+ Start a new job
+ Export as PDF
+ Export as DOCX
+ Toggle user block status
Admin Panel
@@ -52,4 +80,22 @@
Action completed
Action failed
%1$d / %2$d
+
+
+ Content generation is running
+ Estimated: %1$s
+ Nothing here yet
+ Submit a job to see results.
+ Something went wrong
+ We could not load this screen. Please try again.
+ Free limit reached
+ Result preview
+
+
+ My Resumes
+ My Documents
+ No generated documents yet. Submit a job to see your results here.
+ Could not load your documents. Please try again.
+ Resume
+ Cover letter
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index bb472dc..651d75d 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,16 +1,93 @@
-
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..ccaa566
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..8bb2cc2
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ resume.aridgwayweb.com
+
+
+
diff --git a/app/src/test/java/com/example/resbuilder/JobListModelsTest.kt b/app/src/test/java/com/example/resbuilder/JobListModelsTest.kt
new file mode 100644
index 0000000..4b9c26f
--- /dev/null
+++ b/app/src/test/java/com/example/resbuilder/JobListModelsTest.kt
@@ -0,0 +1,81 @@
+package com.example.resbuilder
+
+import com.example.resbuilder.data.model.JobListResponse
+import com.example.resbuilder.data.model.JobSummary
+import com.google.gson.Gson
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Tests for the API response models introduced with the "My Resumes" feature.
+ *
+ * These verify the exact wire format expected from `GET /jobs` so that a backend
+ * contract change fails loudly here rather than silently breaking the UI.
+ */
+class JobListModelsTest {
+
+ private val gson = Gson()
+
+ @Test
+ fun `parses a completed resume job from the backend shape`() {
+ val json = """
+ {
+ "jobs": [
+ {
+ "job_id": "abc-123",
+ "type": "resume",
+ "status": "completed",
+ "title": "Senior Android Engineer",
+ "created_at": "2026-08-04T12:00:00Z"
+ }
+ ]
+ }
+ """.trimIndent()
+
+ val parsed = gson.fromJson(json, JobListResponse::class.java)
+
+ assertEquals(1, parsed.jobs.size)
+ val job = parsed.jobs[0]
+ assertEquals("abc-123", job.job_id)
+ assertEquals("resume", job.type)
+ assertEquals("completed", job.status)
+ assertEquals("Senior Android Engineer", job.title)
+ assertEquals("2026-08-04T12:00:00Z", job.created_at)
+ }
+
+ @Test
+ fun `title is null when backend omits it`() {
+ val json = """
+ {"jobs": [{"job_id": "x", "type": "cover_letter", "status": "queued"}]}
+ """.trimIndent()
+
+ val parsed = gson.fromJson(json, JobListResponse::class.java)
+ val job = parsed.jobs[0]
+
+ assertNull(job.title)
+ assertNull(job.created_at)
+ assertEquals("cover_letter", job.type)
+ }
+
+ @Test
+ fun `empty jobs list parses to empty collection`() {
+ val json = """{"jobs": []}"""
+
+ val parsed = gson.fromJson(json, JobListResponse::class.java)
+
+ assertTrue(parsed.jobs.isEmpty())
+ }
+
+ @Test
+ fun `JobSummary fields are immutable`() {
+ val job = JobSummary(job_id = "id", type = "resume", status = "completed")
+
+ // Reflectively assert all fields are val (final). This guards against
+ // regressions that would make the model mutable and thread-unsafe.
+ for (field in JobSummary::class.java.declaredFields) {
+ assertEquals("Field ${field.name} must be final", java.lang.reflect.Modifier.FINAL, field.modifiers and java.lang.reflect.Modifier.FINAL)
+ }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2119376..e87a3cb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,9 +10,18 @@ okhttp = "4.12.0"
gson = "2.10.1"
browser = "1.7.0"
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" }
@@ -22,6 +31,10 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
google-play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "googlePlayServicesAuth" }
+androidx-credential-manager = { group = "androidx.credentials", name = "credentials", version.ref = "credentialManager" }
+androidx-credential-manager-play-services = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentialManager" }
+googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }