updating colors and api requested for backend

This commit is contained in:
Andrew Ridgway 2026-08-04 22:44:19 +10:00
parent c589e5f9e4
commit 3c9553ad39
Signed by: armistace
GPG Key ID: C8D9EAC514B47EF1
21 changed files with 789 additions and 213 deletions

View File

@ -4,6 +4,13 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-07-30T23:29:27.007440701Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=5C051JEA313902" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>

1
.idea/gradle.xml generated
View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

2
.idea/misc.xml generated
View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

133
GOOGLE_AUTH_SETUP.md Normal file
View File

@ -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)

View File

@ -41,6 +41,10 @@ dependencies {
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)

View File

@ -61,4 +61,16 @@ data class AdminUser(
data class AdminUsersResponse(
val users: List<AdminUser>
)
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<JobSummary>
)

View File

@ -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
@ -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"

View File

@ -2,34 +2,52 @@ 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.lifecycleScope
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.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.security.SecureRandom
import java.util.Base64
class LoginActivity : AppCompatActivity() {
private lateinit var googleSignInButton: SignInButton
private lateinit var progress: ProgressBar
private lateinit var googleSignInButton: MaterialButton
private lateinit var progress: CircularProgressIndicator
private lateinit var credentialManager: CredentialManager
private val handler = Handler(Looper.getMainLooper())
private lateinit var googleSignInLauncher: ActivityResultLauncher<IntentSenderRequest>
private lateinit var googleSignInLauncher: ActivityResultLauncher<Intent>
// 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"
companion object {
private const val TAG = "LoginActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -37,110 +55,144 @@ 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()
}
Log.w(TAG, "Unexpected activity result: ${result.resultCode}")
showLoading(false)
Toast.makeText(this, "Sign-in interrupted", Toast.LENGTH_SHORT).show()
}
showLoading(true)
checkExistingAuth()
}
private fun checkExistingAuth() {
lifecycleScope.launch {
try {
val user = withContext(Dispatchers.IO) { ApiClient.getMe() }
showLoading(false)
if (user.authenticated) {
launchMain()
}
} catch (_: Exception) {
showLoading(false)
}
}
}
private fun showLoading(loading: Boolean) {
progress.visibility = if (loading) View.VISIBLE else View.GONE
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()
.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)
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 request = GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()
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}"
}
Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show()
}
}
}
private fun handleSignInResult(task: Task<GoogleSignInAccount>) {
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
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, "No ID token from Google", Toast.LENGTH_LONG).show()
return
}
exchangeToken(idToken)
} catch (e: GoogleIdTokenParsingException) {
showLoading(false)
Toast.makeText(this, "Invalid Google ID token: ${e.message}", Toast.LENGTH_LONG).show()
}
}
else -> {
showLoading(false)
Toast.makeText(this, "Unexpected credential type", Toast.LENGTH_LONG).show()
}
exchangeToken(idToken)
} catch (e: ApiException) {
showLoading(false)
Toast.makeText(this, "Sign in failed: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun handleSignInError(message: String) {
showLoading(false)
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
private fun exchangeToken(idToken: String) {
Thread {
lifecycleScope.launch {
try {
val token = ApiClient.exchangeGoogleToken(idToken)
val token = withContext(Dispatchers.IO) { 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()
}
}
} catch (e: Exception) {
handler.post {
showLoading(false)
val user = withContext(Dispatchers.IO) { ApiClient.getMe() }
showLoading(false)
if (user.authenticated) {
Toast.makeText(
this,
e.message ?: "Token exchange failed",
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()
}
}.start()
}
}
private fun launchMain() {
@ -150,6 +202,12 @@ class LoginActivity : AppCompatActivity() {
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)
}
}

View File

@ -142,6 +142,13 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menuMyResumes -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, MyResumesFragment())
.addToBackStack(null)
.commitAllowingStateLoss()
true
}
R.id.menuSignOut -> {
signOut()
true

View File

@ -0,0 +1,158 @@
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
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
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
class MyResumesFragment : Fragment() {
private val handler = Handler(Looper.getMainLooper())
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
openJob(item.job_id)
}
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()
}
}
}.start()
}
private fun bindJobs(jobs: List<JobSummary>) {
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()
}
}
}.start()
}
private fun replaceWith(fragment: Fragment) {
requireActivity().supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, fragment)
.addToBackStack(null)
.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<JobSummary>
) : ArrayAdapter<JobSummary>(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<TextView>(R.id.itemTitle).text =
job.title?.takeIf { it.isNotBlank() } ?: getKindLabel(job.type)
v.findViewById<TextView>(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
}
}
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:startColor="?attr/colorPrimary"
android:endColor="?attr/colorPrimaryContainer"
android:type="linear" />
</shape>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Google G icon -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#4285F4"
android:pathData="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path
android:fillColor="#34A853"
android:pathData="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path
android:fillColor="#FBBC05"
android:pathData="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path
android:fillColor="#EA4335"
android:pathData="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</vector>

View File

@ -5,22 +5,39 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:padding="16dp"
tools:context=".ui.LoginActivity">
<!-- Skeleton loading placeholder visible while auth is checked -->
<!-- Decorative background element -->
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_gradient_primary"
app:layout_constraintBottom_toTopOf="@id/guidelineTop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guidelineTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.35" />
<!-- Main content card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/loginSkeletonCard"
android:id="@+id/loginCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:visibility="gone"
app:cardCornerRadius="12dp"
android:layout_marginHorizontal="24dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:cardCornerRadius="24dp"
app:cardElevation="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
@ -29,91 +46,101 @@
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="24dp">
android:padding="32dp">
<View
android:layout_width="80dp"
android:layout_height="80dp"
android:background="?attr/colorSurfaceContainerHigh" />
<!-- App icon with elevation -->
<com.google.android.material.card.MaterialCardView
android:layout_width="88dp"
android:layout_height="88dp"
app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="22dp"
app:cardElevation="4dp">
<View
android:layout_width="160dp"
android:layout_height="24dp"
android:layout_marginTop="16dp"
android:background="?attr/colorSurfaceContainerHigh" />
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/cd_app_logo"
android:padding="20dp"
android:scaleType="centerInside"
android:src="@drawable/ic_launcher_foreground"
app:tint="?attr/colorOnPrimaryContainer" />
</com.google.android.material.card.MaterialCardView>
<!-- Title -->
<TextView
android:id="@+id/loginTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/app_title"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold" />
<!-- Subtitle -->
<TextView
android:id="@+id/loginSubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/welcome_subtitle"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="24dp"
android:background="?attr/colorSurfaceContainerHigh" />
android:layout_height="1dp"
android:layout_marginVertical="24dp"
android:background="?attr/colorOutlineVariant" />
<!-- Sign in button container -->
<com.google.android.material.button.MaterialButton
android:id="@+id/googleSignInButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="56dp"
android:fontFamily="sans-serif-medium"
android:text="@string/sign_in_with_google"
android:textAllCaps="false"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
app:cornerRadius="14dp"
app:icon="@drawable/ic_google"
app:iconGravity="textStart"
app:iconPadding="12dp"
app:iconSize="24dp"
app:iconTint="@null"
app:strokeColor="?attr/colorOutline"
app:strokeWidth="1.5dp" />
<!-- Helper text -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/sign_in_terms"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<ImageView
android:id="@+id/appIcon"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_marginBottom="24dp"
android:background="?attr/colorPrimaryContainer"
android:contentDescription="@string/cd_app_logo"
android:importantForAccessibility="yes"
android:padding="24dp"
android:src="@drawable/ic_launcher_foreground"
app:layout_constraintBottom_toTopOf="@id/loginTitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:tint="?attr/colorOnPrimaryContainer" />
<TextView
android:id="@+id/loginTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/app_title"
android:textAppearance="?attr/textAppearanceHeadlineLarge"
android:textColor="?attr/colorOnSurface"
app:layout_constraintBottom_toTopOf="@id/loginSubtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/loginSubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/checking_auth"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintBottom_toTopOf="@id/googleSignInButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/loginTitle" />
<ProgressBar
<!-- Progress indicator -->
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/loginProgress"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginBottom="24dp"
android:contentDescription="@string/cd_loading"
android:indeterminateTint="?attr/colorPrimary"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/loginTitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.gms.common.SignInButton
android:id="@+id/googleSignInButton"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:layout_marginTop="24dp"
android:contentDescription="@string/cd_google_sign_in"
android:minWidth="200dp"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="?attr/colorOnPrimary"
app:indicatorSize="48dp"
app:layout_constraintBottom_toTopOf="@id/loginCard"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/loginSubtitle" />
app:layout_constraintTop_toTopOf="@id/guidelineTop"
app:trackColor="?attr/colorPrimaryContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/myResumesTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="4dp"
android:paddingBottom="8dp"
android:text="@string/my_resumes_title"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/myResumesEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:gravity="center"
android:text="@string/my_resumes_empty"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone"
tools:visibility="visible" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/myResumesProgress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceContainerHighest"
tools:visibility="gone" />
<ListView
android:id="@+id/myResumesList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:divider="@null"
android:listSelector="@android:color/transparent"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="6dp"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/itemTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
tools:text="Senior Android Engineer" />
<TextView
android:id="@+id/itemSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
tools:text="Resume · 12 Aug 2026" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menuMyResumes"
android:title="@string/menu_my_resumes" />
<item
android:id="@+id/menuAdminPanel"
android:title="@string/menu_admin_panel"

View File

@ -1,41 +1,41 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.ResBuilder" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary color roles (dark) -->
<item name="colorPrimary">#D0BCFF</item>
<item name="colorOnPrimary">#381E72</item>
<item name="colorPrimaryContainer">#4F378B</item>
<item name="colorOnPrimaryContainer">#EADDFF</item>
<item name="colorPrimaryInverse">#6750A4</item>
<item name="colorPrimary">#4DD9DB</item>
<item name="colorOnPrimary">#003738</item>
<item name="colorPrimaryContainer">#004F51</item>
<item name="colorOnPrimaryContainer">#6FF6F8</item>
<item name="colorPrimaryInverse">#006A6C</item>
<!-- Secondary color roles (dark) -->
<item name="colorSecondary">#CCC2DC</item>
<item name="colorOnSecondary">#332D41</item>
<item name="colorSecondaryContainer">#4A4458</item>
<item name="colorOnSecondaryContainer">#E8DEF8</item>
<item name="colorSecondary">#B0CCCB</item>
<item name="colorOnSecondary">#1B3535</item>
<item name="colorSecondaryContainer">#324C4B</item>
<item name="colorOnSecondaryContainer">#CCE8E7</item>
<!-- Tertiary color roles (dark) -->
<item name="colorTertiary">#EFB8C8</item>
<item name="colorOnTertiary">#492532</item>
<item name="colorTertiaryContainer">#633B48</item>
<item name="colorOnTertiaryContainer">#FFD8E4</item>
<item name="colorTertiary">#B1C8E8</item>
<item name="colorOnTertiary">#19324C</item>
<item name="colorTertiaryContainer">#324963</item>
<item name="colorOnTertiaryContainer">#D2E4FF</item>
<!-- Surface color roles (dark) -->
<item name="android:colorBackground">#1D1B20</item>
<item name="colorSurface">#1D1B20</item>
<item name="colorOnSurface">#E6E1E5</item>
<item name="colorSurfaceVariant">#49454F</item>
<item name="colorOnSurfaceVariant">#CAC4D0</item>
<item name="colorSurfaceContainerLowest">#16141C</item>
<item name="colorSurfaceContainerLow">#1D1B20</item>
<item name="colorSurfaceContainer">#211F26</item>
<item name="colorSurfaceContainerHigh">#27252D</item>
<item name="colorSurfaceContainerHighest">#36343B</item>
<item name="colorSurfaceInverse">#E6E0E9</item>
<item name="colorOnSurfaceInverse">#322F35</item>
<item name="android:colorBackground">#0E1515</item>
<item name="colorSurface">#0E1515</item>
<item name="colorOnSurface">#DDE4E4</item>
<item name="colorSurfaceVariant">#3F4949</item>
<item name="colorOnSurfaceVariant">#BEC9C8</item>
<item name="colorSurfaceContainerLowest">#090F0F</item>
<item name="colorSurfaceContainerLow">#161D1D</item>
<item name="colorSurfaceContainer">#1A2121</item>
<item name="colorSurfaceContainerHigh">#242B2B</item>
<item name="colorSurfaceContainerHighest">#2F3636</item>
<item name="colorSurfaceInverse">#DDE4E4</item>
<item name="colorOnSurfaceInverse">#2D3131</item>
<!-- Outline -->
<item name="colorOutline">#938F99</item>
<item name="colorOutlineVariant">#49454F</item>
<item name="colorOutline">#889393</item>
<item name="colorOutlineVariant">#3F4949</item>
<!-- Error (dark) -->
<item name="colorError">#F2B8B5</item>

View File

@ -3,37 +3,37 @@
<!-- Material Design 3 semantic color tokens -->
<!-- Primary palette -->
<color name="primary">#6750A4</color>
<color name="primary">#006A6C</color>
<color name="on_primary">#FFFFFF</color>
<color name="primary_container">#EADDFF</color>
<color name="on_primary_container">#21005D</color>
<color name="primary_container">#6FF6F8</color>
<color name="on_primary_container">#002021</color>
<!-- Secondary palette -->
<color name="secondary">#625B71</color>
<color name="secondary">#4A6363</color>
<color name="on_secondary">#FFFFFF</color>
<color name="secondary_container">#E8DEF8</color>
<color name="on_secondary_container">#1D192B</color>
<color name="secondary_container">#CCE8E7</color>
<color name="on_secondary_container">#051F1F</color>
<!-- Tertiary palette -->
<color name="tertiary">#7D5260</color>
<color name="tertiary">#4B617C</color>
<color name="on_tertiary">#FFFFFF</color>
<color name="tertiary_container">#FFD8E4</color>
<color name="on_tertiary_container">#31111D</color>
<color name="tertiary_container">#D2E4FF</color>
<color name="on_tertiary_container">#051E35</color>
<!-- Surface colors -->
<color name="surface">#FFFBFE</color>
<color name="on_surface">#1D1B20</color>
<color name="surface_variant">#E7E0EC</color>
<color name="on_surface_variant">#49454F</color>
<color name="surface">#F4FBFB</color>
<color name="on_surface">#161D1D</color>
<color name="surface_variant">#DAE5E4</color>
<color name="on_surface_variant">#3F4949</color>
<color name="surface_container_lowest">#FFFFFF</color>
<color name="surface_container_low">#F5EFF7</color>
<color name="surface_container">#F3EDF7</color>
<color name="surface_container_high">#ECE6F0</color>
<color name="surface_container_highest">#E6E0E9</color>
<color name="surface_container_low">#EEF6F6</color>
<color name="surface_container">#E8F0F0</color>
<color name="surface_container_high">#E2EAEA</color>
<color name="surface_container_highest">#DDE5E5</color>
<!-- Outline -->
<color name="outline">#79747E</color>
<color name="outline_variant">#CAC4D0</color>
<color name="outline">#6F7979</color>
<color name="outline_variant">#BEC9C8</color>
<!-- Status / Semantic colors -->
<color name="success">#4CAF50</color>
@ -57,9 +57,9 @@
<color name="on_info_container">#0B4576</color>
<!-- Inverse surface -->
<color name="inverse_surface">#322F35</color>
<color name="inverse_on_surface">#F5EFF7</color>
<color name="inverse_primary">#D0BCFF</color>
<color name="inverse_surface">#2D3131</color>
<color name="inverse_on_surface">#DFE4E4</color>
<color name="inverse_primary">#4DD9DB</color>
<!-- Status indicator palettes (kept for programmatic tint compatibility) -->
<color name="status_healthy">@color/success</color>
@ -70,8 +70,8 @@
<color name="purple_200">@color/primary_container</color>
<color name="purple_500">@color/primary</color>
<color name="purple_700">@color/on_primary_container</color>
<color name="teal_200">@color/secondary_container</color>
<color name="teal_700">@color/secondary</color>
<color name="teal_200">@color/primary_container</color>
<color name="teal_700">@color/primary</color>
<!-- Legacy neutral aliases kept for programmatic compatibility in unchanged Kotlin code -->
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>

View File

@ -4,6 +4,8 @@
<!-- Login -->
<string name="sign_in_with_google">Sign in with Google</string>
<string name="checking_auth">Checking authentication…</string>
<string name="welcome_subtitle">AI-powered resume and cover letter builder</string>
<string name="sign_in_terms">By signing in, you agree to our Terms of Service and Privacy Policy</string>
<!-- Main -->
<string name="app_title">resBuilder</string>
@ -75,4 +77,12 @@
<string name="error_state_message">We could not load this screen. Please try again.</string>
<string name="usage_limit_title">Free limit reached</string>
<string name="result_preview">Result preview</string>
<!-- My Resumes -->
<string name="menu_my_resumes">My Resumes</string>
<string name="my_resumes_title">My Documents</string>
<string name="my_resumes_empty">No generated documents yet. Submit a job to see your results here.</string>
<string name="my_resumes_error">Could not load your documents. Please try again.</string>
<string name="type_resume">Resume</string>
<string name="type_cover_letter">Cover letter</string>
</resources>

View File

@ -10,6 +10,9 @@ 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"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -22,6 +25,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" }