Compare commits

...

11 Commits

Author SHA1 Message Date
e9ba43e09a Merge pull request 'My Resumes history, modern M3 redesign, authenticated exports' (#2) from feature/my-resumes-modern into master
Reviewed-on: #2
2026-08-04 23:58:12 +10:00
9b0253cb6c
build: inject API base URL via buildConfigField instead of hardcoding
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-04 23:56:38 +10:00
e90af27e66
fix: download PDF/DOCX exports through authenticated client instead of browser
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-04 23:50:18 +10:00
5754fcf445
design: modernize Material 3 UI with drawer navigation and richer teal theme
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-04 23:49:37 +10:00
54b002b72f
test: add Gson parsing tests for job list models
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-04 23:14:37 +10:00
1d37371431
build: enable R8 minification and fix release lint namespace typos
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-04 23:14:30 +10:00
1333db5d1d
security: enforce HTTPS via network security config
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-04 23:14:12 +10:00
43d1a6013b
refactor: move network logic into ViewModels and externalize strings
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-04 23:14:06 +10:00
98b15c748a
chore: remove .idea from version control
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-04 23:13:44 +10:00
3c9553ad39
updating colors and api requested for backend 2026-08-04 22:44:19 +10:00
c589e5f9e4
design: Material Design 3 redesign across all layouts and themes
- colors.xml: replaced legacy palette with M3 semantic tokens
  (primary, secondary, tertiary, surface containers, outline, status)
- themes.xml + values-night/themes.xml: switched to
  Theme.Material3.DayNight.NoActionBar, mapped all M3 color roles,
  transparent system bars for edge-to-edge, M3 typography
- attrs.xml: custom semantic attrs (colorSuccess, colorWarning, colorInfo)
- strings.xml: added accessibility labels, loading/empty/error strings
- activity_login.xml: centered branding, M3 headline/body, skeleton card
- activity_main.xml: edge-to-edge CoordinatorLayout, M3 AppBar/toolbar,
  health indicator group, M3 banner, M3 TabLayout
- fragment_job_form.xml: grouped fields in outlined cards (12dp radius),
  FilledBox text inputs, TonalButtons, 16dp margins
- fragment_job_status.xml: chip-style badge, LinearProgressIndicator,
  centered card with error/try-again states
- fragment_result.xml: SuggestionChip badge, WebView fills space,
  bottom action bar in outlined card
- activity_admin.xml + item_admin_user.xml: M3 card list items,
  proper type scale hierarchy
- Updated drawables to use theme attributes

Refs DESIGN.md Material Design 3 + Amazon/Google guidelines.
2026-07-18 23:48:33 +10:00
54 changed files with 2091 additions and 559 deletions

7
.gitignore vendored
View File

@ -1,12 +1,7 @@
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/.idea/caches /.idea/
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build /build
/captures /captures

3
.idea/.gitignore generated vendored
View File

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated
View File

@ -1 +0,0 @@
resBuilder

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

17
.idea/gradle.xml generated
View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/misc.xml generated
View File

@ -1,10 +0,0 @@
<?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">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</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

@ -18,15 +18,25 @@ android {
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE", "\"https://resume.aridgwayweb.com\"")
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true
optimization { optimization {
enable = false enable = false
} }
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
} }
} }
buildFeatures {
buildConfig = true
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
@ -36,11 +46,18 @@ android {
dependencies { dependencies {
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx) 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.material)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.gson) implementation(libs.gson)
implementation(libs.browser) implementation(libs.browser)
implementation(libs.google.play.services.auth) 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) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

11
app/proguard-rules.pro vendored Normal file
View File

@ -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.** { <fields>; }
# 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.** { *; }

View File

@ -11,6 +11,7 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ResBuilder" android:theme="@style/Theme.ResBuilder"
@ -33,6 +34,16 @@
android:name=".ui.AdminActivity" android:name=".ui.AdminActivity"
android:exported="false" /> android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@ -62,3 +62,15 @@ data class AdminUser(
data class AdminUsersResponse( data class AdminUsersResponse(
val users: List<AdminUser> 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.BuildResponse
import com.example.resbuilder.data.model.HealthResponse import com.example.resbuilder.data.model.HealthResponse
import com.example.resbuilder.data.model.Job 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.ScrapeResponse
import com.example.resbuilder.data.model.UploadResponse import com.example.resbuilder.data.model.UploadResponse
import com.example.resbuilder.data.model.User import com.example.resbuilder.data.model.User
@ -17,6 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@ -26,7 +28,7 @@ import java.util.concurrent.TimeUnit
object ApiClient { 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 gson = Gson()
private val cookieStore = mutableListOf<Cookie>() private val cookieStore = mutableListOf<Cookie>()
@ -51,6 +53,8 @@ object ApiClient {
bearerToken?.let { builder.header("Authorization", "Bearer $it") } bearerToken?.let { builder.header("Authorization", "Bearer $it") }
chain.proceed(builder.build()) 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) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(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 getLoginUrl(): String = "$API_BASE/auth/login"
fun getLogoutUrl(): String = "$API_BASE/auth/logout" fun getLogoutUrl(): String = "$API_BASE/auth/logout"
@ -291,4 +313,21 @@ object ApiClient {
fun getExportPdfUrl(jobId: String): String = "$API_BASE/export/$jobId/pdf" fun getExportPdfUrl(jobId: String): String = "$API_BASE/export/$jobId/pdf"
fun getExportDocxUrl(jobId: String): String = "$API_BASE/export/$jobId/docx" 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")
}
}
} }

View File

@ -147,7 +147,7 @@ class JobFormFragment : Fragment() {
private fun scrapeUrl() { private fun scrapeUrl() {
val url = seekUrlInput.text?.toString()?.trim() ?: "" val url = seekUrlInput.text?.toString()?.trim() ?: ""
if (url.isEmpty()) { if (url.isEmpty()) {
seekUrlInput.error = "Enter a URL" seekUrlInput.error = getString(R.string.enter_url)
return return
} }
showLoading(true) showLoading(true)

View File

@ -2,34 +2,46 @@ package com.example.resbuilder.ui
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.util.Log
import android.os.Looper
import android.view.View import android.view.View
import android.widget.ProgressBar
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity 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.R
import com.example.resbuilder.data.remote.ApiClient import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.material.button.MaterialButton
import com.google.android.gms.common.SignInButton import com.google.android.material.progressindicator.CircularProgressIndicator
import com.google.android.gms.common.api.ApiException import kotlinx.coroutines.launch
import com.google.android.gms.tasks.Task import java.security.SecureRandom
import java.util.Base64
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
private lateinit var googleSignInButton: SignInButton private val viewModel: LoginViewModel by viewModels()
private lateinit var progress: ProgressBar
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<Intent> private lateinit var googleSignInLauncher: ActivityResultLauncher<IntentSenderRequest>
// 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 webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com"
private val androidClientId = "416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com"
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -37,23 +49,45 @@ class LoginActivity : AppCompatActivity() {
googleSignInButton = findViewById(R.id.googleSignInButton) googleSignInButton = findViewById(R.id.googleSignInButton)
progress = findViewById(R.id.loginProgress) progress = findViewById(R.id.loginProgress)
credentialManager = CredentialManager.create(this)
googleSignInButton.setOnClickListener { launchGoogleSignIn() } googleSignInButton.setOnClickListener { launchGoogleSignIn() }
googleSignInLauncher = registerForActivityResult( googleSignInLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartIntentSenderForResult()
) { result -> ) { result ->
if (result.resultCode == RESULT_OK && result.data != null) { showLoading(false)
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) Toast.makeText(this, R.string.sign_in_interrupted, Toast.LENGTH_SHORT).show()
handleSignInResult(task)
} else {
showLoading(false)
Toast.makeText(this, "Sign in cancelled", Toast.LENGTH_SHORT).show()
}
} }
observeAuthState()
showLoading(true) 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) { private fun showLoading(loading: Boolean) {
@ -61,86 +95,61 @@ class LoginActivity : AppCompatActivity() {
googleSignInButton.isEnabled = !loading 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() { private fun launchGoogleSignIn() {
showLoading(true) showLoading(true)
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) val googleIdOption = GetGoogleIdOption.Builder()
.requestIdToken(webClientId) .setFilterByAuthorizedAccounts(false)
.requestEmail() .setServerClientId(webClientId)
.setAutoSelectEnabled(false)
.setNonce(generateNonce())
.build() .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<GoogleSignInAccount>) { val request = GetCredentialRequest.Builder()
try { .addCredentialOption(googleIdOption)
val account = task.getResult(ApiException::class.java) .build()
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()
}
}
private fun exchangeToken(idToken: String) { viewModel.viewModelScope.launch {
Thread {
try { try {
val token = ApiClient.exchangeGoogleToken(idToken) val result = credentialManager.getCredential(
ApiClient.setToken(token) request = request,
val user = ApiClient.getMe() context = this@LoginActivity
handler.post { )
showLoading(false) handleCredentialResponse(result)
if (user.authenticated) { } catch (e: GetCredentialException) {
Toast.makeText( showLoading(false)
this, val errorMsg = when (e) {
"Welcome ${user.name ?: user.email}", is androidx.credentials.exceptions.NoCredentialException -> getString(R.string.no_google_accounts)
Toast.LENGTH_SHORT is androidx.credentials.exceptions.GetCredentialCancellationException -> getString(R.string.sign_in_cancelled)
).show() else -> getString(R.string.sign_in_failed, e.message)
launchMain()
} else {
Toast.makeText(
this,
"Server rejected the token",
Toast.LENGTH_LONG
).show()
}
} }
} catch (e: Exception) { Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show()
handler.post { }
}
}
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) showLoading(false)
Toast.makeText( Toast.makeText(this, getString(R.string.invalid_google_id_token, e.message), Toast.LENGTH_LONG).show()
this,
e.message ?: "Token exchange failed",
Toast.LENGTH_LONG
).show()
} }
} }
}.start() else -> {
showLoading(false)
Toast.makeText(this, R.string.unexpected_credential_type, Toast.LENGTH_LONG).show()
}
}
} }
private fun launchMain() { private fun launchMain() {
@ -148,8 +157,10 @@ class LoginActivity : AppCompatActivity() {
finish() finish()
} }
override fun onDestroy() { private fun generateNonce(): String {
super.onDestroy() val secureRandom = SecureRandom()
handler.removeCallbacksAndMessages(null) val bytes = ByteArray(32)
secureRandom.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
} }
} }

View File

@ -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<LoginUiState> = _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)
}
}

View File

@ -5,7 +5,6 @@ import android.graphics.PorterDuff
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
@ -13,18 +12,21 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.drawerlayout.widget.DrawerLayout
import com.example.resbuilder.R import com.example.resbuilder.R
import com.example.resbuilder.data.model.User import com.example.resbuilder.data.model.User
import com.example.resbuilder.data.remote.ApiClient import com.example.resbuilder.data.remote.ApiClient
import com.google.android.material.appbar.MaterialToolbar 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 { class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, ResultFragment.OnNewListener {
private lateinit var drawerLayout: DrawerLayout
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var tabLayout: TabLayout private lateinit var navView: NavigationView
private lateinit var healthIndicator: ImageView private lateinit var healthIndicator: ImageView
private lateinit var userName: TextView private lateinit var userName: TextView
private lateinit var userEmail: TextView
private lateinit var usageBanner: TextView private lateinit var usageBanner: TextView
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@ -32,43 +34,65 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
private var healthRunnable: Runnable? = null private var healthRunnable: Runnable? = null
companion object { companion object {
private const val TAB_RESUME = 0 private const val NAV_BUILD_RESUME = 0
private const val TAB_COVER = 1 private const val NAV_BUILD_COVER_LETTER = 1
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
drawerLayout = findViewById(R.id.drawerLayout)
toolbar = findViewById(R.id.toolbar) toolbar = findViewById(R.id.toolbar)
tabLayout = findViewById(R.id.tabLayout) navView = findViewById(R.id.navView)
healthIndicator = findViewById(R.id.healthIndicator)
userName = findViewById(R.id.userName)
usageBanner = findViewById(R.id.usageBanner) 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) setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_menu_hamburger)
tabLayout.addTab(tabLayout.newTab().setText(R.string.tab_build_resume)) navView.setNavigationItemSelectedListener { item ->
tabLayout.addTab(tabLayout.newTab().setText(R.string.tab_build_cover_letter)) drawerLayout.closeDrawers()
handleNavItem(item.itemId)
tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { true
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)
}
})
loadUser() loadUser()
startHealthPolling() startHealthPolling()
if (savedInstanceState == null) { 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() { private fun loadUser() {
Thread { Thread {
try { try {
@ -86,20 +110,13 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
private fun updateUserUI() { private fun updateUserUI() {
userName.text = user.name ?: user.email ?: getString(R.string.unknown_user) userName.text = user.name ?: user.email ?: getString(R.string.unknown_user)
if (!user.authenticated || (!user.is_admin && !user.can_submit)) { userEmail.text = user.email ?: getString(R.string.unknown_email)
usageBanner.visibility = View.VISIBLE usageBanner.visibility = if (!user.authenticated || (!user.is_admin && !user.can_submit)) {
View.VISIBLE
} else { } else {
usageBanner.visibility = View.GONE View.GONE
} }
invalidateOptionsMenu() navView.menu.findItem(R.id.navAdmin)?.isVisible = user.is_admin
}
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()
} }
private fun startHealthPolling() { private fun startHealthPolling() {
@ -130,27 +147,12 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
}.start() }.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 { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return if (item.itemId == android.R.id.home) {
R.id.menuSignOut -> { drawerLayout.openDrawer(navView)
signOut() true
true } else {
} super.onOptionsItemSelected(item)
R.id.menuAdminPanel -> {
startActivity(Intent(this, AdminActivity::class.java))
true
}
else -> super.onOptionsItemSelected(item)
} }
} }
@ -176,8 +178,8 @@ class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, Resul
fun onResultNewJob() { fun onResultNewJob() {
supportFragmentManager.popBackStackImmediate() supportFragmentManager.popBackStackImmediate()
tabLayout.getTabAt(TAB_RESUME)?.select() navView.menu.getItem(NAV_BUILD_RESUME)?.isChecked = true
switchFormTab(TAB_RESUME) showBuildForm(NAV_BUILD_RESUME)
} }
override fun onNewJob() { override fun onNewJob() {

View File

@ -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<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,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<JobSummary> = emptyList(),
val error: String? = null,
val openedJob: Job? = null
)
class MyResumesViewModel : ViewModel() {
private val _state = MutableStateFlow(MyResumesUiState())
val state: StateFlow<MyResumesUiState> = _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)
}
}

View File

@ -13,11 +13,14 @@ import android.webkit.WebView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.example.resbuilder.R import com.example.resbuilder.R
import com.example.resbuilder.data.model.Job import com.example.resbuilder.data.model.Job
import com.example.resbuilder.data.remote.ApiClient import com.example.resbuilder.data.remote.ApiClient
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import java.io.File
import java.io.FileOutputStream
class ResultFragment : Fragment() { class ResultFragment : Fragment() {
@ -86,14 +89,58 @@ class ResultFragment : Fragment() {
newButton.setOnClickListener { listener?.onNewJob() } newButton.setOnClickListener { listener?.onNewJob() }
jobId?.let { id -> jobId?.let { id ->
pdfButton.setOnClickListener { openUrl(ApiClient.getExportPdfUrl(id)) } pdfButton.setOnClickListener { downloadAndOpen(id, "pdf") }
docxButton.setOnClickListener { openUrl(ApiClient.getExportDocxUrl(id)) } docxButton.setOnClickListener { downloadAndOpen(id, "docx") }
} ?: run { } ?: run {
pdfButton.isEnabled = false pdfButton.isEnabled = false
docxButton.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() { private fun copyText() {
val raw = rawContent val raw = rawContent
if (raw.isNullOrEmpty()) { if (raw.isNullOrEmpty()) {
@ -105,11 +152,6 @@ class ResultFragment : Fragment() {
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() 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) { fun setOnNewListener(l: OnNewListener) {
listener = l listener = l
} }

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android"
<solid android:color="@color/banner_warning" /> android:shape="rectangle">
<corners android:radius="8dp" /> <solid android:color="?attr/colorWarningContainer" />
<corners android:radius="12dp" />
</shape> </shape>

View File

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

View File

@ -0,0 +1,9 @@
<?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:centerColor="?attr/colorPrimaryInverse"
android:endColor="?attr/colorPrimaryContainer"
android:type="linear" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurfaceContainerHighest" />
<corners android:radius="8dp" />
</shape>

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp" android:width="24dp"
android:height="12dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:fillColor="#FF000000" android:fillColor="?attr/colorOnSurface"
android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0" /> android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0" />
</vector> </vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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="?attr/colorOnSurfaceVariant"
android:pathData="M14,2H6C4.9,2 4,2.9 4,4V20C4,21.1 4.9,22 6,22H18C19.1,22 20,21.1 20,20V8L14,2M13,9V3.5L18.5,9H13Z" />
</vector>

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

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M3,6h18v2H3V6zM3,11h18v2H3v-2zM3,16h18v2H3v-2z" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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="?attr/colorOnPrimary"
android:pathData="M12,2L13.5,9.5L21,12L13.5,14.5L12,22L10.5,14.5L3,12L10.5,9.5L12,2Z" />
</vector>

View File

@ -1,25 +1,50 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <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_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"> android:background="?attr/colorSurface"
android:orientation="vertical"
tools:context=".ui.AdminActivity">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:elevation="0dp"
app:liftOnScroll="false">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/adminToolbar" android:id="@+id/adminToolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" android:background="?attr/colorSurface"
app:navigationIcon="?attr/homeAsUpIndicator"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/admin_title" app:title="@string/admin_title"
xmlns:app="http://schemas.android.com/apk/res-auto" /> app:titleCentered="true"
app:titleTextAppearance="?attr/textAppearanceTitleLarge"
app:titleTextColor="?attr/colorOnSurface" />
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView <com.google.android.material.card.MaterialCardView
android:id="@+id/adminRecyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> 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">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/adminRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:contentDescription="@string/admin_title"
android:padding="8dp" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>

View File

@ -1,42 +1,159 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?android:attr/colorBackground"> android:background="?attr/colorSurface"
tools:context=".ui.LoginActivity">
<ProgressBar <!-- Decorative gradient background behind card -->
android:id="@+id/loginProgress" <View
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="0dp"
android:layout_marginBottom="24dp" android:background="@drawable/bg_gradient_primary"
android:visibility="gone" app:layout_constraintBottom_toTopOf="@id/guidelineTop"
app:layout_constraintBottom_toTopOf="@id/loginTitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/loginTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/app_title"
android:textAppearance="?attr/textAppearanceHeadline3"
android:textColor="?attr/colorPrimary"
app:layout_constraintBottom_toTopOf="@id/googleSignInButton"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintVertical_chainStyle="packed" />
<com.google.android.gms.common.SignInButton <ImageView
android:id="@+id/googleSignInButton" android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginEnd="@dimen/space_lg"
android:layout_marginBottom="@dimen/space_lg"
android:alpha="0.15"
android:contentDescription="@null"
android:src="@drawable/ic_sparkle"
app:layout_constraintBottom_toTopOf="@id/guidelineTop"
app:layout_constraintEnd_toEndOf="parent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guidelineTop"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:orientation="horizontal"
app:layout_constraintGuide_percent="0.40" />
<!-- Main content card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/loginCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/space_lg"
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="@dimen/card_radius_large"
app:cardElevation="0dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/loginTitle" /> app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="@dimen/space_xl">
<!-- App icon with tonal surface -->
<com.google.android.material.card.MaterialCardView
android:layout_width="@dimen/space_xxl"
android:layout_height="@dimen/space_xxl"
app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="@dimen/card_radius_large"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/cd_app_logo"
android:padding="@dimen/space_md"
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="@dimen/space_lg"
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="@dimen/space_sm"
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="1dp"
android:layout_marginVertical="@dimen/space_lg"
android:background="?attr/colorOutlineVariant" />
<!-- Sign in button -->
<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:minHeight="@dimen/button_height"
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="@dimen/space_md"
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="@dimen/space_md"
android:text="@string/sign_in_terms"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Progress indicator -->
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/loginProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="?attr/colorOnPrimary"
app:indicatorSize="@dimen/space_xxl"
app:layout_constraintBottom_toTopOf="@id/loginCard"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/guidelineTop"
app:trackColor="?attr/colorPrimaryContainer" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,77 +1,82 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawerLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:background="?attr/colorSurface"
android:fitsSystemWindows="true"
tools:context=".ui.MainActivity"> tools:context=".ui.MainActivity">
<com.google.android.material.appbar.AppBarLayout <!-- Main content -->
android:id="@+id/appBar" <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar"> android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar <!-- Modern colored app bar with gradient surface and centered title -->
android:id="@+id/toolbar" <com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="wrap_content"
app:popupTheme="@style/ThemeOverlay.MaterialComponents.Dark" android:background="?attr/colorPrimary"
app:title="@string/app_title" android:fitsSystemWindows="true"
app:titleTextColor="@color/white"> app:elevation="0dp">
<LinearLayout <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/healthContainer" android:id="@+id/toolbar"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="?attr/actionBarSize"
android:layout_gravity="end|center_vertical" android:background="@drawable/bg_gradient_primary_bottom"
android:layout_marginEnd="16dp" android:elevation="0dp"
android:gravity="center_vertical" app:navigationIcon="@drawable/ic_menu_hamburger"
android:orientation="horizontal"> app:navigationIconTint="?attr/colorOnPrimary"
app:title="@string/app_title"
app:titleCentered="true"
app:titleTextAppearance="?attr/textAppearanceTitleLarge"
app:titleTextColor="?attr/colorOnPrimary" />
</com.google.android.material.appbar.AppBarLayout>
<ImageView <TextView
android:id="@+id/healthIndicator" android:id="@+id/usageBanner"
android:layout_width="12dp" android:layout_width="match_parent"
android:layout_height="12dp" android:layout_height="wrap_content"
android:contentDescription="@string/app_name" android:layout_marginHorizontal="@dimen/space_md"
android:src="@drawable/ic_circle_status" /> android:layout_marginTop="@dimen/space_sm"
android:background="@drawable/bg_banner_warning"
android:drawableStart="@drawable/ic_circle_status"
android:drawableTint="?attr/colorWarning"
android:gravity="center_vertical"
android:minHeight="@dimen/touch_target_min"
android:padding="@dimen/space_sm"
android:text="@string/usage_limit_banner"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface"
android:visibility="gone"
tools:visibility="visible" />
<TextView <FrameLayout
android:id="@+id/userName" android:id="@+id/fragmentContainer"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_marginStart="8dp" android:background="?attr/colorSurface" />
android:text="@string/unknown_user" </LinearLayout>
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="@color/white" />
</LinearLayout> <!-- Navigation drawer with richer surface and modern item styling -->
</com.google.android.material.appbar.MaterialToolbar> <com.google.android.material.navigation.NavigationView
</com.google.android.material.appbar.AppBarLayout> android:id="@+id/navView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="?attr/colorSurfaceContainerLowest"
app:headerLayout="@layout/nav_header"
app:itemHorizontalPadding="@dimen/space_md"
app:itemIconPadding="@dimen/space_md"
app:itemIconTint="?attr/colorOnSurfaceVariant"
app:itemRippleColor="?attr/colorPrimaryContainer"
app:itemTextAppearance="?attr/textAppearanceBodyLarge"
app:itemTextColor="?attr/colorOnSurface"
app:itemVerticalPadding="@dimen/space_md"
app:menu="@menu/menu_nav_drawer" />
<TextView </androidx.drawerlayout.widget.DrawerLayout>
android:id="@+id/usageBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:background="@drawable/bg_banner_warning"
android:padding="12dp"
android:text="@string/usage_limit_banner"
android:textColor="@color/banner_warning_text"
android:visibility="gone" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabGravity="fill"
app:tabMode="fixed" />
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View File

@ -1,111 +1,249 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:fillViewport="true" android:fillViewport="true"
android:padding="16dp"> android:clipToPadding="false"
android:paddingBottom="@dimen/space_md"
tools:context=".ui.JobFormFragment">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout <!-- Hero header with gradient surface and section title -->
<FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:hint="@string/hint_job_title" android:background="@drawable/bg_gradient_primary"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"> android:minHeight="@dimen/hero_header_min_height"
android:padding="@dimen/space_lg">
<com.google.android.material.textfield.TextInputEditText <ImageView
android:id="@+id/jobTitleInput" android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="top|end"
android:alpha="0.25"
android:contentDescription="@null"
android:src="@drawable/ic_sparkle" />
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="text" /> android:layout_gravity="bottom|start"
</com.google.android.material.textfield.TextInputLayout> android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_title"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnPrimary"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_xs"
android:text="@string/hint_job_title"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnPrimary" />
</LinearLayout>
</FrameLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:orientation="vertical"
android:orientation="horizontal"> android:padding="@dimen/space_md">
<com.google.android.material.textfield.TextInputLayout <!-- Job details card -->
android:layout_width="0dp" <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
android:hint="@string/hint_seek_url" app:cardCornerRadius="@dimen/card_radius"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"> app:cardElevation="0dp"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<com.google.android.material.textfield.TextInputEditText <LinearLayout
android:id="@+id/seekUrlInput"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textUri" /> android:orientation="vertical"
</com.google.android.material.textfield.TextInputLayout> android:padding="@dimen/space_md">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hint_job_title"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_sm"
android:hint="@string/hint_job_title"
style="@style/Widget.Material3.TextInputLayout.FilledBox"
app:boxBackgroundColor="?attr/colorSurfaceContainerHighest"
app:boxStrokeColor="?attr/colorPrimary"
app:cursorColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorOnSurfaceVariant">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/jobTitleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:minHeight="@dimen/touch_target_min" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_sm"
android:baselineAligned="false"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/hint_seek_url"
style="@style/Widget.Material3.TextInputLayout.FilledBox"
app:boxBackgroundColor="?attr/colorSurfaceContainerHighest"
app:boxStrokeColor="?attr/colorPrimary"
app:cursorColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorOnSurfaceVariant">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/seekUrlInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:minHeight="@dimen/touch_target_min" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/scrapeButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="@dimen/button_height"
android:layout_marginStart="@dimen/space_sm"
android:contentDescription="@string/cd_scrape_job"
android:minWidth="64dp"
android:text="@string/button_scrape" />
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_md"
android:hint="@string/hint_job_description"
style="@style/Widget.Material3.TextInputLayout.FilledBox"
app:boxBackgroundColor="?attr/colorSurfaceContainerHighest"
app:boxStrokeColor="?attr/colorPrimary"
app:cursorColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorOnSurfaceVariant">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/jobDescriptionInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:inputType="textMultiLine"
android:minLines="4"
android:minHeight="@dimen/touch_target_min" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Resume / cover letter input card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_md"
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="@dimen/card_radius"
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="@dimen/space_md">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/docInputTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/hint_your_resume"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton
android:id="@+id/uploadButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="@dimen/button_height"
android:contentDescription="@string/cd_upload_document"
android:text="@string/button_upload" />
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/docInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_sm"
android:hint="@string/hint_your_resume"
style="@style/Widget.Material3.TextInputLayout.FilledBox"
app:boxBackgroundColor="?attr/colorSurfaceContainerHighest"
app:boxStrokeColor="?attr/colorPrimary"
app:cursorColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorOnSurfaceVariant">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/docInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:inputType="textMultiLine"
android:minLines="6"
android:minHeight="@dimen/touch_target_min" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/scrapeButton" android:id="@+id/submitButton"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="@dimen/button_height"
android:layout_marginStart="8dp" android:layout_marginTop="@dimen/space_md"
android:text="@string/button_scrape" /> android:text="@string/generate_resume" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="@dimen/space_xxl"
android:layout_height="@dimen/space_xxl"
android:layout_gravity="center_horizontal"
android:layout_marginTop="@dimen/space_md"
android:contentDescription="@string/cd_loading"
android:indeterminateTint="?attr/colorPrimary"
android:visibility="gone" />
</LinearLayout> </LinearLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_job_description"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/jobDescriptionInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:inputType="textMultiLine"
android:minLines="4" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/uploadButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/button_upload" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/docInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/hint_your_resume"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/docInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:inputType="textMultiLine"
android:minLines="6" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/submitButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/generate_resume" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:visibility="gone" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@ -1,68 +1,92 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:gravity="center" android:gravity="center"
android:orientation="vertical" android:orientation="vertical"
android:padding="24dp"> android:padding="@dimen/space_md">
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="8dp" android:layout_marginHorizontal="@dimen/space_md"
app:cardCornerRadius="12dp" android:layout_marginVertical="@dimen/space_sm"
app:cardElevation="4dp"> app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="@dimen/card_radius_large"
app:cardElevation="0dp"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical" android:orientation="vertical"
android:padding="24dp"> android:padding="@dimen/space_lg">
<TextView <!-- Status badge: now a tonal chip-style pill -->
android:id="@+id/statusBadge" <com.google.android.material.card.MaterialCardView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" app:cardBackgroundColor="?attr/colorPrimaryContainer"
android:background="@drawable/bg_banner_warning" app:cardCornerRadius="16dp"
android:paddingHorizontal="16dp" app:cardElevation="0dp"
android:paddingVertical="4dp" app:strokeWidth="0dp">
android:text="@string/status_queued"
android:textAppearance="?attr/textAppearanceBody2"
android:textStyle="bold" />
<ProgressBar <TextView
android:id="@+id/statusBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/status_queued"
android:gravity="center"
android:minHeight="32dp"
android:paddingHorizontal="@dimen/space_md"
android:paddingVertical="4dp"
android:text="@string/status_queued"
android:textAppearance="?attr/textAppearanceLabelLarge"
android:textColor="?attr/colorOnPrimaryContainer" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/statusProgress" android:id="@+id/statusProgress"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_marginTop="@dimen/space_lg"
android:layout_marginTop="24dp" /> android:contentDescription="@string/content_generation_running"
android:indeterminate="true"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceContainerHighest" />
<TextView <TextView
android:id="@+id/statusMessage" android:id="@+id/statusMessage"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_marginTop="@dimen/space_md"
android:layout_marginTop="16dp" android:gravity="center"
android:text="@string/job_wait_message" android:text="@string/job_wait_message"
android:textAppearance="?attr/textAppearanceBody1" /> android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView <TextView
android:id="@+id/statusDetail" android:id="@+id/statusDetail"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_marginTop="@dimen/space_sm"
android:layout_marginTop="8dp" android:gravity="center"
android:textAppearance="?attr/textAppearanceCaption" android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" /> android:visibility="gone" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/tryAgainButton" android:id="@+id/tryAgainButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="@dimen/button_height"
android:layout_gravity="center_horizontal" android:layout_marginTop="@dimen/space_lg"
android:layout_marginTop="24dp" android:minWidth="120dp"
android:text="@string/try_again" android:text="@string/try_again"
android:visibility="gone" /> android:visibility="gone" />
</LinearLayout> </LinearLayout>

View File

@ -0,0 +1,88 @@
<?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">
<!-- Hero header for list screen -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_gradient_primary"
android:minHeight="@dimen/hero_header_min_height"
android:padding="@dimen/space_lg">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="top|end"
android:alpha="0.25"
android:contentDescription="@null"
android:src="@drawable/ic_sparkle" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_title"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnPrimary"
android:textStyle="bold" />
<TextView
android:id="@+id/myResumesTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_xs"
android:text="@string/my_resumes_title"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnPrimary" />
</LinearLayout>
</FrameLayout>
<TextView
android:id="@+id/myResumesEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/space_md"
android:layout_marginTop="@dimen/space_xl"
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="@dimen/space_md"
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_marginHorizontal="@dimen/space_md"
android:layout_marginTop="@dimen/space_md"
android:clipToPadding="false"
android:divider="@null"
android:listSelector="@android:color/transparent"
android:paddingBottom="@dimen/space_md"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>

View File

@ -1,33 +1,55 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"> android:background="?attr/colorSurface"
android:orientation="vertical"
tools:context=".ui.ResultFragment">
<!-- Top action bar card with tonal surface -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="12dp" android:layout_marginHorizontal="@dimen/space_md"
app:cardCornerRadius="8dp" android:layout_marginTop="@dimen/space_md"
app:cardElevation="2dp"> 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">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:minHeight="@dimen/touch_target_min"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="12dp"> android:padding="@dimen/space_sm">
<TextView <com.google.android.material.card.MaterialCardView
android:id="@+id/resultBadge"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/bg_banner_warning" app:cardBackgroundColor="?attr/colorSuccessContainer"
android:paddingHorizontal="12dp" app:cardCornerRadius="16dp"
android:paddingVertical="4dp" app:cardElevation="0dp"
android:text="@string/status_completed" app:strokeWidth="0dp">
android:textStyle="bold" />
<TextView
android:id="@+id/resultBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/status_completed"
android:gravity="center"
android:minHeight="32dp"
android:paddingHorizontal="@dimen/space_md"
android:paddingVertical="4dp"
android:text="@string/status_completed"
android:textAppearance="?attr/textAppearanceLabelLarge"
android:textColor="?attr/colorOnSuccessContainer" />
</com.google.android.material.card.MaterialCardView>
<View <View
android:layout_width="0dp" android:layout_width="0dp"
@ -36,16 +58,21 @@
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/copyButton" android:id="@+id/copyButton"
style="@style/Widget.MaterialComponents.Button.TextButton" style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="@dimen/button_height"
android:contentDescription="@string/cd_copy_result"
android:minWidth="64dp"
android:text="@string/copy_text" /> android:text="@string/copy_text" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/newButton" android:id="@+id/newButton"
style="@style/Widget.MaterialComponents.Button.TextButton" style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="@dimen/button_height"
android:layout_marginStart="@dimen/space_xs"
android:contentDescription="@string/cd_new_job"
android:minWidth="64dp"
android:text="@string/new_job" /> android:text="@string/new_job" />
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@ -54,31 +81,48 @@
android:id="@+id/resultWebView" android:id="@+id/resultWebView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginHorizontal="12dp" android:layout_marginHorizontal="@dimen/space_md"
android:layout_weight="1" /> android:layout_weight="1"
android:contentDescription="@string/result_preview" />
<LinearLayout <!-- Export action bar with tonal surface -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="12dp" android:layout_marginHorizontal="@dimen/space_md"
android:orientation="horizontal"> 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">
<com.google.android.material.button.MaterialButton <LinearLayout
android:id="@+id/pdfButton" android:layout_width="match_parent"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:orientation="horizontal"
android:layout_weight="1" android:padding="@dimen/space_sm">
android:text="@string/export_pdf" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/docxButton" android:id="@+id/pdfButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="@dimen/button_height"
android:layout_marginStart="8dp" android:layout_marginEnd="@dimen/space_sm"
android:layout_weight="1" android:layout_weight="1"
android:text="@string/export_docx" /> android:contentDescription="@string/cd_export_pdf"
</LinearLayout> android:text="@string/export_pdf" />
<com.google.android.material.button.MaterialButton
android:id="@+id/docxButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="@dimen/button_height"
android:layout_marginStart="@dimen/space_sm"
android:layout_weight="1"
android:contentDescription="@string/cd_export_docx"
android:text="@string/export_docx" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>

View File

@ -3,13 +3,18 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="8dp" android:layout_marginHorizontal="16dp"
app:cardCornerRadius="8dp" android:layout_marginVertical="4dp"
app:cardElevation="2dp"> app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="16dp"> android:padding="16dp">
@ -23,28 +28,35 @@
android:id="@+id/userEmail" android:id="@+id/userEmail"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBody1" android:textAppearance="?attr/textAppearanceBodyLarge"
android:textStyle="bold" /> android:textColor="?attr/colorOnSurface" />
<TextView <TextView
android:id="@+id/userName" android:id="@+id/userName"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBody2" /> android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView <TextView
android:id="@+id/userUsage" android:id="@+id/userUsage"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceCaption" /> android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout> </LinearLayout>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/actionButton" android:id="@+id/actionButton"
style="@style/Widget.MaterialComponents.Button.TextButton" style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="48dp"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:text="@string/block_user" /> android:contentDescription="@string/cd_admin_action"
android:minWidth="72dp"
android:text="@string/block_user"
android:textColor="?attr/colorError" />
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,76 @@
<?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="@dimen/space_sm"
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="@dimen/card_radius"
app:cardElevation="0dp"
app:rippleColor="?attr/colorPrimaryContainer"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="@dimen/touch_target_min"
android:orientation="horizontal"
android:padding="@dimen/space_md">
<com.google.android.material.card.MaterialCardView
android:layout_width="@dimen/space_xxl"
android:layout_height="@dimen/space_xxl"
app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="@dimen/card_radius"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:contentDescription="@string/result_preview"
android:src="@drawable/ic_document"
app:tint="?attr/colorOnPrimaryContainer" />
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/space_md"
android:layout_weight="1"
android:orientation="vertical">
<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="@dimen/space_xs"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
tools:text="Resume · 12 Aug 2026" />
</LinearLayout>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="@dimen/space_sm"
android:contentDescription="@null"
android:src="@drawable/ic_circle_status"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,49 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_gradient_primary"
android:gravity="center_vertical"
android:orientation="vertical"
android:padding="@dimen/space_lg">
<com.google.android.material.card.MaterialCardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardBackgroundColor="?attr/colorSurfaceContainerLowest"
app:cardCornerRadius="@dimen/card_radius"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<ImageView
android:id="@+id/healthIndicator"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_margin="@dimen/space_xs"
android:contentDescription="@string/cd_health_indicator"
android:src="@drawable/ic_circle_status" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/userName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_md"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/unknown_user"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnPrimary" />
<TextView
android:id="@+id/userEmail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/space_xs"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnPrimary" />
</LinearLayout>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menuAdminPanel"
android:title="@string/menu_admin_panel"
android:visible="false" />
<item
android:id="@+id/menuSignOut"
android:title="@string/menu_sign_out" />
</menu>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group
android:id="@+id/navGroupActions"
android:checkableBehavior="single">
<item
android:id="@+id/navBuildResume"
android:icon="@drawable/ic_sparkle"
android:title="@string/tab_build_resume" />
<item
android:id="@+id/navBuildCoverLetter"
android:icon="@drawable/ic_sparkle"
android:title="@string/tab_build_cover_letter" />
<item
android:id="@+id/navMyResumes"
android:icon="@drawable/ic_circle_status"
android:title="@string/menu_my_resumes" />
</group>
<group
android:id="@+id/navGroupAdmin"
android:checkableBehavior="none">
<item
android:id="@+id/navAdmin"
android:title="@string/menu_admin_panel"
android:visible="false" />
</group>
<group
android:id="@+id/navGroupAccount"
android:checkableBehavior="none">
<item
android:id="@+id/navSignOut"
android:title="@string/menu_sign_out" />
</group>
</menu>

View File

@ -1,16 +1,92 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <style name="Theme.ResBuilder" parent="Theme.Material3.DayNight.NoActionBar">
<style name="Theme.ResBuilder" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> <!-- Primary color roles (dark): bright teal on deep surfaces -->
<!-- Primary brand color. --> <item name="colorPrimary">#80DADC</item>
<item name="colorPrimary">@color/purple_200</item> <item name="colorOnPrimary">#003738</item>
<item name="colorPrimaryVariant">@color/purple_700</item> <item name="colorPrimaryContainer">#005A5C</item>
<item name="colorOnPrimary">@color/black</item> <item name="colorOnPrimaryContainer">#B5F2F3</item>
<!-- Secondary brand color. --> <item name="colorPrimaryInverse">#004F4F</item>
<item name="colorSecondary">@color/teal_200</item> <item name="colorPrimaryFixed">#80DADC</item>
<item name="colorSecondaryVariant">@color/teal_200</item> <item name="colorOnPrimaryFixed">#002020</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. --> <!-- Secondary color roles (dark) -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item> <item name="colorSecondary">#9CBEBD</item>
<!-- Customize your theme here. --> <item name="colorOnSecondary">#1B3534</item>
<item name="colorSecondaryContainer">#2F4A49</item>
<item name="colorOnSecondaryContainer">#BDD6D5</item>
<!-- Tertiary color roles (dark) -->
<item name="colorTertiary">#A4C9E8</item>
<item name="colorOnTertiary">#0B2D45</item>
<item name="colorTertiaryContainer">#27475F</item>
<item name="colorOnTertiaryContainer">#BFE0FF</item>
<!-- Surface color roles (dark): deep charcoal-teal -->
<item name="android:colorBackground">#0C1414</item>
<item name="colorSurface">#0C1414</item>
<item name="colorOnSurface">#E0ECEC</item>
<item name="colorSurfaceVariant">#3F4A4A</item>
<item name="colorOnSurfaceVariant">#BEC9C9</item>
<item name="colorSurfaceContainerLowest">#070E0E</item>
<item name="colorSurfaceContainerLow">#121B1B</item>
<item name="colorSurfaceContainer">#182222</item>
<item name="colorSurfaceContainerHigh">#242D2D</item>
<item name="colorSurfaceContainerHighest">#303939</item>
<item name="colorSurfaceInverse">#E0ECEC</item>
<item name="colorOnSurfaceInverse">#0C1414</item>
<!-- Outline -->
<item name="colorOutline">#899999</item>
<item name="colorOutlineVariant">#3F4A4A</item>
<!-- Error (dark) -->
<item name="colorError">#F2B8B5</item>
<item name="colorOnError">#601410</item>
<item name="colorErrorContainer">#8C1D18</item>
<item name="colorOnErrorContainer">#F9DEDC</item>
<!-- Status and custom semantic color attrs (dark) -->
<item name="colorSuccess">#81C784</item>
<item name="colorOnSuccess">#003D00</item>
<item name="colorSuccessContainer">#1B5E1F</item>
<item name="colorOnSuccessContainer">#D9F6DA</item>
<item name="colorWarning">#FFB74D</item>
<item name="colorOnWarning">#4A2C00</item>
<item name="colorWarningContainer">#693B00</item>
<item name="colorOnWarningContainer">#FFE3B6</item>
<item name="colorInfo">#64B5F6</item>
<item name="colorOnInfo">#0B4576</item>
<item name="colorInfoContainer">#0B4576</item>
<item name="colorOnInfoContainer">#D4EAFB</item>
<!-- System bars -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
<!-- Typography -->
<item name="textAppearanceDisplayLarge">@style/TextAppearance.Material3.DisplayLarge</item>
<item name="textAppearanceDisplayMedium">@style/TextAppearance.Material3.DisplayMedium</item>
<item name="textAppearanceDisplaySmall">@style/TextAppearance.Material3.DisplaySmall</item>
<item name="textAppearanceHeadlineLarge">@style/TextAppearance.Material3.HeadlineLarge</item>
<item name="textAppearanceHeadlineMedium">@style/TextAppearance.Material3.HeadlineMedium</item>
<item name="textAppearanceHeadlineSmall">@style/TextAppearance.Material3.HeadlineSmall</item>
<item name="textAppearanceTitleLarge">@style/TextAppearance.Material3.TitleLarge</item>
<item name="textAppearanceTitleMedium">@style/TextAppearance.Material3.TitleMedium</item>
<item name="textAppearanceTitleSmall">@style/TextAppearance.Material3.TitleSmall</item>
<item name="textAppearanceBodyLarge">@style/TextAppearance.Material3.BodyLarge</item>
<item name="textAppearanceBodyMedium">@style/TextAppearance.Material3.BodyMedium</item>
<item name="textAppearanceBodySmall">@style/TextAppearance.Material3.BodySmall</item>
<item name="textAppearanceLabelLarge">@style/TextAppearance.Material3.LabelLarge</item>
<item name="textAppearanceLabelMedium">@style/TextAppearance.Material3.LabelMedium</item>
<item name="textAppearanceLabelSmall">@style/TextAppearance.Material3.LabelSmall</item>
<!-- Component defaults -->
<item name="materialButtonStyle">@style/Widget.Material3.Button</item>
<item name="materialButtonOutlinedStyle">@style/Widget.Material3.Button.OutlinedButton</item>
<item name="android:windowBackground">?attr/colorSurface</item>
</style> </style>
</resources> </resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Semantic status color attributes used across ResBuilder UI -->
<attr name="colorSuccess" format="color" />
<attr name="colorOnSuccess" format="color" />
<attr name="colorSuccessContainer" format="color" />
<attr name="colorOnSuccessContainer" format="color" />
<attr name="colorWarning" format="color" />
<attr name="colorOnWarning" format="color" />
<attr name="colorWarningContainer" format="color" />
<attr name="colorOnWarningContainer" format="color" />
<attr name="colorInfo" format="color" />
<attr name="colorOnInfo" format="color" />
<attr name="colorInfoContainer" format="color" />
<attr name="colorOnInfoContainer" format="color" />
</resources>

View File

@ -1,20 +1,87 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="purple_200">#FFBB86FC</color> <!-- Material Design 3 semantic color tokens -->
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color> <!-- Primary palette: rich teal with stronger contrast -->
<color name="teal_200">#FF03DAC5</color> <color name="primary">#004F4F</color>
<color name="teal_700">#FF018786</color> <color name="on_primary">#FFFFFF</color>
<color name="primary_container">#B5F2F3</color>
<color name="on_primary_container">#002020</color>
<color name="primary_fixed">#B5F2F3</color>
<color name="on_primary_fixed">#002020</color>
<!-- Secondary palette: cool slate with more depth -->
<color name="secondary">#3D5252</color>
<color name="on_secondary">#FFFFFF</color>
<color name="secondary_container">#BDD6D5</color>
<color name="on_secondary_container">#041E1E</color>
<!-- Tertiary palette: deep blue-grey accent -->
<color name="tertiary">#365064</color>
<color name="on_tertiary">#FFFFFF</color>
<color name="tertiary_container">#BFE0FF</color>
<color name="on_tertiary_container">#001D31</color>
<!-- Surface colors: warmer off-white base with visible tonal elevation -->
<color name="surface">#FAFDFD</color>
<color name="on_surface">#0A1414</color>
<color name="surface_variant">#D8E5E5</color>
<color name="on_surface_variant">#3F4A4A</color>
<color name="surface_container_lowest">#FFFFFF</color>
<color name="surface_container_low">#F0F6F6</color>
<color name="surface_container">#E8F1F1</color>
<color name="surface_container_high">#DFEAEA</color>
<color name="surface_container_highest">#D6E4E4</color>
<!-- Outline -->
<color name="outline">#6F7E7E</color>
<color name="outline_variant">#BEC9C9</color>
<!-- Status / Semantic colors: richer and more saturated -->
<color name="success">#2E7D32</color>
<color name="on_success">#FFFFFF</color>
<color name="success_container">#D9F6DA</color>
<color name="on_success_container">#1F5E22</color>
<color name="warning">#E65100</color>
<color name="on_warning">#FFFFFF</color>
<color name="warning_container">#FFE3B6</color>
<color name="on_warning_container">#693B00</color>
<color name="error">#C62828</color>
<color name="on_error">#FFFFFF</color>
<color name="error_container">#F9DEDC</color>
<color name="on_error_container">#410E0B</color>
<color name="info">#1565C0</color>
<color name="on_info">#FFFFFF</color>
<color name="info_container">#D4EAFB</color>
<color name="on_info_container">#0B4576</color>
<!-- Inverse surface -->
<color name="inverse_surface">#2D3131</color>
<color name="inverse_on_surface">#DFE4E4</color>
<color name="inverse_primary">#80DADC</color>
<!-- Status indicator palettes -->
<color name="status_healthy">@color/success</color>
<color name="status_degraded">@color/warning</color>
<color name="status_unhealthy">@color/error</color>
<!-- Legacy aliases -->
<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/primary_container</color>
<color name="teal_700">@color/primary</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<color name="status_healthy">#FF4CAF50</color> <color name="banner_warning">@color/warning_container</color>
<color name="status_degraded">#FFFFC107</color> <color name="banner_warning_text">@color/on_warning_container</color>
<color name="status_unhealthy">#FFF44336</color>
<color name="banner_warning">#FFFFF3E0</color> <color name="status_badge_queued">@color/surface_container_highest</color>
<color name="banner_warning_text">#FFE65100</color> <color name="status_badge_running">@color/info</color>
<color name="status_badge_queued">#FF9E9E9E</color> <color name="status_badge_completed">@color/success</color>
<color name="status_badge_running">#FF2196F3</color> <color name="status_badge_failed">@color/error</color>
<color name="status_badge_completed">#FF4CAF50</color>
<color name="status_badge_failed">#FFF44336</color>
</resources> </resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Spacing system: multiples of 4dp -->
<dimen name="space_xs">4dp</dimen>
<dimen name="space_sm">8dp</dimen>
<dimen name="space_md">16dp</dimen>
<dimen name="space_lg">24dp</dimen>
<dimen name="space_xl">32dp</dimen>
<dimen name="space_xxl">48dp</dimen>
<!-- Component dimensions -->
<dimen name="card_radius">12dp</dimen>
<dimen name="card_radius_large">16dp</dimen>
<dimen name="button_height">48dp</dimen>
<dimen name="touch_target_min">48dp</dimen>
<dimen name="hero_header_min_height">160dp</dimen>
<dimen name="app_logo_size">64dp</dimen>
</resources>

View File

@ -4,6 +4,16 @@
<!-- Login --> <!-- Login -->
<string name="sign_in_with_google">Sign in with Google</string> <string name="sign_in_with_google">Sign in with Google</string>
<string name="checking_auth">Checking authentication…</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>
<string name="sign_in_interrupted">Sign-in interrupted</string>
<string name="no_id_token">No ID token from Google</string>
<string name="invalid_google_id_token">Invalid Google ID token: %1$s</string>
<string name="unexpected_credential_type">Unexpected credential type</string>
<string name="no_google_accounts">No Google accounts found on device</string>
<string name="sign_in_cancelled">Sign-in cancelled</string>
<string name="sign_in_failed">Sign-in failed: %1$s</string>
<string name="welcome_user">Welcome %1$s</string>
<!-- Main --> <!-- Main -->
<string name="app_title">resBuilder</string> <string name="app_title">resBuilder</string>
@ -13,6 +23,7 @@
<string name="menu_admin_panel">Admin Panel</string> <string name="menu_admin_panel">Admin Panel</string>
<string name="usage_limit_banner">You have reached your free limit. Please contact support to upgrade.</string> <string name="usage_limit_banner">You have reached your free limit. Please contact support to upgrade.</string>
<string name="unknown_user">Unknown user</string> <string name="unknown_user">Unknown user</string>
<string name="unknown_email">Not signed in</string>
<!-- Job form --> <!-- Job form -->
<string name="hint_job_title">Job Title</string> <string name="hint_job_title">Job Title</string>
@ -26,6 +37,7 @@
<string name="generate_cover_letter">Generate Cover Letter</string> <string name="generate_cover_letter">Generate Cover Letter</string>
<string name="please_log_in">Please log in to continue</string> <string name="please_log_in">Please log in to continue</string>
<string name="fields_required">Please fill in all fields</string> <string name="fields_required">Please fill in all fields</string>
<string name="enter_url">Enter a URL</string>
<!-- Status --> <!-- Status -->
<string name="status_queued">Queued</string> <string name="status_queued">Queued</string>
@ -44,6 +56,22 @@
<string name="export_docx">DOCX</string> <string name="export_docx">DOCX</string>
<string name="copied_to_clipboard">Copied to clipboard</string> <string name="copied_to_clipboard">Copied to clipboard</string>
<string name="nothing_to_copy">Nothing to copy</string> <string name="nothing_to_copy">Nothing to copy</string>
<string name="exporting">Exporting…</string>
<string name="export_failed">Export failed</string>
<string name="export_no_viewer">No app found to open this file</string>
<!-- Accessibility labels -->
<string name="cd_app_logo">resBuilder app logo</string>
<string name="cd_google_sign_in">Sign in with Google</string>
<string name="cd_loading">Loading</string>
<string name="cd_health_indicator">Service health indicator</string>
<string name="cd_scrape_job">Scrape job description from SEEK URL</string>
<string name="cd_upload_document">Upload existing resume or cover letter</string>
<string name="cd_copy_result">Copy generated text to clipboard</string>
<string name="cd_new_job">Start a new job</string>
<string name="cd_export_pdf">Export as PDF</string>
<string name="cd_export_docx">Export as DOCX</string>
<string name="cd_admin_action">Toggle user block status</string>
<!-- Admin --> <!-- Admin -->
<string name="admin_title">Admin Panel</string> <string name="admin_title">Admin Panel</string>
@ -52,4 +80,22 @@
<string name="admin_action_success">Action completed</string> <string name="admin_action_success">Action completed</string>
<string name="admin_action_failed">Action failed</string> <string name="admin_action_failed">Action failed</string>
<string name="usage_format">%1$d / %2$d</string> <string name="usage_format">%1$d / %2$d</string>
<!-- Screen reader / empty / loading labels -->
<string name="content_generation_running">Content generation is running</string>
<string name="estimated_time">Estimated: %1$s</string>
<string name="empty_state_title">Nothing here yet</string>
<string name="empty_state_message">Submit a job to see results.</string>
<string name="error_state_title">Something went wrong</string>
<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> </resources>

View File

@ -1,16 +1,93 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <style name="Theme.ResBuilder" parent="Theme.Material3.DayNight.NoActionBar">
<style name="Theme.ResBuilder" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> <!-- Primary color roles -->
<!-- Primary brand color. --> <item name="colorPrimary">@color/primary</item>
<item name="colorPrimary">@color/purple_500</item> <item name="colorOnPrimary">@color/on_primary</item>
<item name="colorPrimaryVariant">@color/purple_700</item> <item name="colorPrimaryContainer">@color/primary_container</item>
<item name="colorOnPrimary">@color/white</item> <item name="colorOnPrimaryContainer">@color/on_primary_container</item>
<!-- Secondary brand color. --> <item name="colorPrimaryInverse">@color/inverse_primary</item>
<item name="colorSecondary">@color/teal_200</item> <item name="colorPrimaryFixed">@color/primary_fixed</item>
<item name="colorSecondaryVariant">@color/teal_700</item> <item name="colorOnPrimaryFixed">@color/on_primary_fixed</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. --> <!-- Secondary color roles -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item> <item name="colorSecondary">@color/secondary</item>
<!-- Customize your theme here. --> <item name="colorOnSecondary">@color/on_secondary</item>
<item name="colorSecondaryContainer">@color/secondary_container</item>
<item name="colorOnSecondaryContainer">@color/on_secondary_container</item>
<!-- Tertiary color roles -->
<item name="colorTertiary">@color/tertiary</item>
<item name="colorOnTertiary">@color/on_tertiary</item>
<item name="colorTertiaryContainer">@color/tertiary_container</item>
<item name="colorOnTertiaryContainer">@color/on_tertiary_container</item>
<!-- Surface color roles -->
<item name="android:colorBackground">@color/surface</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/on_surface</item>
<item name="colorSurfaceVariant">@color/surface_variant</item>
<item name="colorOnSurfaceVariant">@color/on_surface_variant</item>
<item name="colorSurfaceContainerLowest">@color/surface_container_lowest</item>
<item name="colorSurfaceContainerLow">@color/surface_container_low</item>
<item name="colorSurfaceContainer">@color/surface_container</item>
<item name="colorSurfaceContainerHigh">@color/surface_container_high</item>
<item name="colorSurfaceContainerHighest">@color/surface_container_highest</item>
<item name="colorSurfaceInverse">@color/inverse_surface</item>
<item name="colorOnSurfaceInverse">@color/inverse_on_surface</item>
<!-- Outline -->
<item name="colorOutline">@color/outline</item>
<item name="colorOutlineVariant">@color/outline_variant</item>
<!-- Error -->
<item name="colorError">@color/error</item>
<item name="colorOnError">@color/on_error</item>
<item name="colorErrorContainer">@color/error_container</item>
<item name="colorOnErrorContainer">@color/on_error_container</item>
<!-- Status and custom semantic color attrs -->
<item name="colorSuccess">@color/success</item>
<item name="colorOnSuccess">@color/on_success</item>
<item name="colorSuccessContainer">@color/success_container</item>
<item name="colorOnSuccessContainer">@color/on_success_container</item>
<item name="colorWarning">@color/warning</item>
<item name="colorOnWarning">@color/on_warning</item>
<item name="colorWarningContainer">@color/warning_container</item>
<item name="colorOnWarningContainer">@color/on_warning_container</item>
<item name="colorInfo">@color/info</item>
<item name="colorOnInfo">@color/on_info</item>
<item name="colorInfoContainer">@color/info_container</item>
<item name="colorOnInfoContainer">@color/on_info_container</item>
<!-- System bars - edge-to-edge ready -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
<!-- M3 Typography -->
<item name="textAppearanceDisplayLarge">@style/TextAppearance.Material3.DisplayLarge</item>
<item name="textAppearanceDisplayMedium">@style/TextAppearance.Material3.DisplayMedium</item>
<item name="textAppearanceDisplaySmall">@style/TextAppearance.Material3.DisplaySmall</item>
<item name="textAppearanceHeadlineLarge">@style/TextAppearance.Material3.HeadlineLarge</item>
<item name="textAppearanceHeadlineMedium">@style/TextAppearance.Material3.HeadlineMedium</item>
<item name="textAppearanceHeadlineSmall">@style/TextAppearance.Material3.HeadlineSmall</item>
<item name="textAppearanceTitleLarge">@style/TextAppearance.Material3.TitleLarge</item>
<item name="textAppearanceTitleMedium">@style/TextAppearance.Material3.TitleMedium</item>
<item name="textAppearanceTitleSmall">@style/TextAppearance.Material3.TitleSmall</item>
<item name="textAppearanceBodyLarge">@style/TextAppearance.Material3.BodyLarge</item>
<item name="textAppearanceBodyMedium">@style/TextAppearance.Material3.BodyMedium</item>
<item name="textAppearanceBodySmall">@style/TextAppearance.Material3.BodySmall</item>
<item name="textAppearanceLabelLarge">@style/TextAppearance.Material3.LabelLarge</item>
<item name="textAppearanceLabelMedium">@style/TextAppearance.Material3.LabelMedium</item>
<item name="textAppearanceLabelSmall">@style/TextAppearance.Material3.LabelSmall</item>
<!-- Default M3 component styles -->
<item name="materialButtonStyle">@style/Widget.Material3.Button</item>
<item name="materialButtonOutlinedStyle">@style/Widget.Material3.Button.OutlinedButton</item>
<item name="android:windowBackground">?attr/colorSurface</item>
</style> </style>
</resources> </resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="exports"
path="exports/" />
</paths>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Enforce HTTPS for the backend API domain. Cleartext is disallowed. -->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="false">resume.aridgwayweb.com</domain>
</domain-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

View File

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

View File

@ -10,9 +10,18 @@ okhttp = "4.12.0"
gson = "2.10.1" gson = "2.10.1"
browser = "1.7.0" browser = "1.7.0"
googlePlayServicesAuth = "20.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] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 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" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }