Compare commits

...

9 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
50 changed files with 1161 additions and 734 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,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-07-30T23:29:27.007440701Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=5C051JEA313902" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</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>

18
.idea/gradle.xml generated
View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<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" default="true" project-jdk-name="temurin-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>

6
.idea/vcs.xml generated
View File

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

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,6 +46,9 @@ 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)

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

@ -28,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>()
@ -313,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

@ -14,27 +14,25 @@ import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.GetCredentialException
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope 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.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tasks.Task
import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.CircularProgressIndicator import com.google.android.material.progressindicator.CircularProgressIndicator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Base64 import java.util.Base64
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
private lateinit var googleSignInButton: MaterialButton private lateinit var googleSignInButton: MaterialButton
private lateinit var progress: CircularProgressIndicator private lateinit var progress: CircularProgressIndicator
private lateinit var credentialManager: CredentialManager private lateinit var credentialManager: CredentialManager
@ -45,10 +43,6 @@ class LoginActivity : AppCompatActivity() {
private val webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com" private val webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com"
private val androidClientId = "416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com" private val androidClientId = "416332591622-41ioels091g0am6v5m1catf0tt69ohk4.apps.googleusercontent.com"
companion object {
private const val TAG = "LoginActivity"
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login) setContentView(R.layout.activity_login)
@ -62,25 +56,36 @@ class LoginActivity : AppCompatActivity() {
googleSignInLauncher = registerForActivityResult( googleSignInLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult() ActivityResultContracts.StartIntentSenderForResult()
) { result -> ) { result ->
Log.w(TAG, "Unexpected activity result: ${result.resultCode}")
showLoading(false) showLoading(false)
Toast.makeText(this, "Sign-in interrupted", Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.sign_in_interrupted, Toast.LENGTH_SHORT).show()
} }
observeAuthState()
showLoading(true) showLoading(true)
checkExistingAuth() viewModel.checkExistingAuth()
} }
private fun checkExistingAuth() { private fun observeAuthState() {
lifecycleScope.launch { lifecycleScope.launch {
try { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val user = withContext(Dispatchers.IO) { ApiClient.getMe() } viewModel.state.collect { state ->
showLoading(false) showLoading(state.loading)
if (user.authenticated) { state.error?.let {
launchMain() Toast.makeText(this@LoginActivity, it, Toast.LENGTH_LONG).show()
viewModel.setLoading(false)
}
state.user?.let { user ->
if (state.authenticated) {
Toast.makeText(
this@LoginActivity,
getString(R.string.welcome_user, user.name ?: user.email),
Toast.LENGTH_SHORT
).show()
launchMain()
}
}
} }
} catch (_: Exception) {
showLoading(false)
} }
} }
} }
@ -90,42 +95,32 @@ class LoginActivity : AppCompatActivity() {
googleSignInButton.isEnabled = !loading googleSignInButton.isEnabled = !loading
} }
private fun launchGoogleSignIn() { private fun launchGoogleSignIn() {
showLoading(true) showLoading(true)
lifecycleScope.launch { val googleIdOption = GetGoogleIdOption.Builder()
Log.d(TAG, "Starting Credential Manager flow") .setFilterByAuthorizedAccounts(false)
Log.d(TAG, "Web client ID: $webClientId") .setServerClientId(webClientId)
Log.d(TAG, "Android client ID: $androidClientId") .setAutoSelectEnabled(false)
val googleIdOption = GetGoogleIdOption.Builder() .setNonce(generateNonce())
.setFilterByAuthorizedAccounts(false) .build()
.setServerClientId(webClientId)
.setAutoSelectEnabled(false)
.setNonce(generateNonce())
.build()
val request = GetCredentialRequest.Builder() val request = GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption) .addCredentialOption(googleIdOption)
.build() .build()
viewModel.viewModelScope.launch {
try { try {
Log.d(TAG, "Calling getCredential...")
val result = credentialManager.getCredential( val result = credentialManager.getCredential(
request = request, request = request,
context = this@LoginActivity context = this@LoginActivity
) )
Log.d(TAG, "Credential retrieved successfully")
handleCredentialResponse(result) handleCredentialResponse(result)
} catch (e: GetCredentialException) { } catch (e: GetCredentialException) {
Log.e(TAG, "Credential Manager failed: ${e.javaClass.simpleName}", e)
Log.e(TAG, "Error message: ${e.message}")
Log.e(TAG, "Error code: ${e.cause}")
showLoading(false) showLoading(false)
val errorMsg = when (e) { val errorMsg = when (e) {
is androidx.credentials.exceptions.NoCredentialException -> "No Google accounts found on device" is androidx.credentials.exceptions.NoCredentialException -> getString(R.string.no_google_accounts)
is androidx.credentials.exceptions.GetCredentialCancellationException -> "Sign-in cancelled" is androidx.credentials.exceptions.GetCredentialCancellationException -> getString(R.string.sign_in_cancelled)
else -> "Sign-in failed: ${e.message}" else -> getString(R.string.sign_in_failed, e.message)
} }
Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show() Toast.makeText(this@LoginActivity, errorMsg, Toast.LENGTH_LONG).show()
} }
@ -141,56 +136,18 @@ class LoginActivity : AppCompatActivity() {
val idToken = googleIdTokenCredential.idToken val idToken = googleIdTokenCredential.idToken
if (idToken.isNullOrEmpty()) { if (idToken.isNullOrEmpty()) {
showLoading(false) showLoading(false)
Toast.makeText(this, "No ID token from Google", Toast.LENGTH_LONG).show() Toast.makeText(this, R.string.no_id_token, Toast.LENGTH_LONG).show()
return return
} }
exchangeToken(idToken) viewModel.exchangeToken(idToken)
} catch (e: GoogleIdTokenParsingException) { } catch (e: GoogleIdTokenParsingException) {
showLoading(false) showLoading(false)
Toast.makeText(this, "Invalid Google ID token: ${e.message}", Toast.LENGTH_LONG).show() Toast.makeText(this, getString(R.string.invalid_google_id_token, e.message), Toast.LENGTH_LONG).show()
} }
} }
else -> { else -> {
showLoading(false) showLoading(false)
Toast.makeText(this, "Unexpected credential type", Toast.LENGTH_LONG).show() Toast.makeText(this, R.string.unexpected_credential_type, Toast.LENGTH_LONG).show()
}
}
}
private fun handleSignInError(message: String) {
showLoading(false)
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
private fun exchangeToken(idToken: String) {
lifecycleScope.launch {
try {
val token = withContext(Dispatchers.IO) { ApiClient.exchangeGoogleToken(idToken) }
ApiClient.setToken(token)
val user = withContext(Dispatchers.IO) { ApiClient.getMe() }
showLoading(false)
if (user.authenticated) {
Toast.makeText(
this@LoginActivity,
"Welcome ${user.name ?: user.email}",
Toast.LENGTH_SHORT
).show()
launchMain()
} else {
Toast.makeText(
this@LoginActivity,
"Server rejected the token",
Toast.LENGTH_LONG
).show()
}
} catch (e: Exception) {
showLoading(false)
Log.e(TAG, "Token exchange failed", e)
Toast.makeText(
this@LoginActivity,
e.message ?: "Token exchange failed",
Toast.LENGTH_LONG
).show()
} }
} }
} }
@ -200,10 +157,6 @@ class LoginActivity : AppCompatActivity() {
finish() finish()
} }
override fun onDestroy() {
super.onDestroy()
}
private fun generateNonce(): String { private fun generateNonce(): String {
val secureRandom = SecureRandom() val secureRandom = SecureRandom()
val bytes = ByteArray(32) val bytes = ByteArray(32)

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,34 +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.menuMyResumes -> { drawerLayout.openDrawer(navView)
supportFragmentManager.beginTransaction() true
.replace(R.id.fragmentContainer, MyResumesFragment()) } else {
.addToBackStack(null) super.onOptionsItemSelected(item)
.commitAllowingStateLoss()
true
}
R.id.menuSignOut -> {
signOut()
true
}
R.id.menuAdminPanel -> {
startActivity(Intent(this, AdminActivity::class.java))
true
}
else -> super.onOptionsItemSelected(item)
} }
} }
@ -183,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

@ -1,8 +1,6 @@
package com.example.resbuilder.ui package com.example.resbuilder.ui
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -11,15 +9,18 @@ import android.widget.ListView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.Fragment 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.R
import com.example.resbuilder.data.model.Job
import com.example.resbuilder.data.model.JobSummary import com.example.resbuilder.data.model.JobSummary
import com.example.resbuilder.data.remote.ApiClient
import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.progressindicator.LinearProgressIndicator
import kotlinx.coroutines.launch
class MyResumesFragment : Fragment() { class MyResumesFragment : Fragment() {
private val handler = Handler(Looper.getMainLooper()) private val viewModel: MyResumesViewModel by viewModels()
private lateinit var emptyView: TextView private lateinit var emptyView: TextView
private lateinit var progress: LinearProgressIndicator private lateinit var progress: LinearProgressIndicator
@ -37,70 +38,53 @@ class MyResumesFragment : Fragment() {
listView.setOnItemClickListener { _, _, position, _ -> listView.setOnItemClickListener { _, _, position, _ ->
val item = listView.adapter.getItem(position) as JobSummary val item = listView.adapter.getItem(position) as JobSummary
openJob(item.job_id) viewModel.openJob(item.job_id)
} }
loadJobs() observeState()
viewModel.loadJobs()
} }
private fun loadJobs() { private fun observeState() {
showLoading(true) viewLifecycleOwner.lifecycleScope.launch {
Thread { viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
try { viewModel.state.collect { state ->
val resp = ApiClient.getMyJobs() progress.visibility = if (state.loading) View.VISIBLE else View.GONE
handler.post {
showLoading(false) state.error?.let {
bindJobs(resp.jobs) emptyView.text = getString(R.string.my_resumes_error)
} emptyView.visibility = View.VISIBLE
} catch (e: Exception) { Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show()
handler.post { }
showLoading(false)
emptyView.text = getString(R.string.my_resumes_error) if (!state.loading && state.jobs.isNotEmpty()) {
emptyView.visibility = View.VISIBLE emptyView.visibility = View.GONE
Toast.makeText(requireContext(), e.message ?: getString(R.string.my_resumes_error), Toast.LENGTH_LONG).show() listView.visibility = View.VISIBLE
listView.adapter = JobListAdapter(requireContext(), state.jobs)
} else if (!state.loading && state.error == null) {
emptyView.text = getString(R.string.my_resumes_empty)
emptyView.visibility = View.VISIBLE
listView.visibility = View.GONE
}
state.openedJob?.let { job ->
viewModel.consumeOpenedJob()
openJobResult(job)
}
} }
} }
}.start()
}
private fun bindJobs(jobs: List<JobSummary>) {
if (jobs.isEmpty()) {
emptyView.text = getString(R.string.my_resumes_empty)
emptyView.visibility = View.VISIBLE
listView.visibility = View.GONE
return
} }
listView.visibility = View.VISIBLE
listView.adapter = JobListAdapter(requireContext(), jobs)
} }
private fun openJob(jobId: String) { private fun openJobResult(job: com.example.resbuilder.data.model.Job) {
showLoading(true) when (job.status.uppercase()) {
Thread { "COMPLETED" -> {
try { val type = job.type ?: JobFormFragment.TYPE_RESUME
val job = ApiClient.getJob(jobId) replaceWith(ResultFragment.newInstance(job, type))
handler.post {
showLoading(false)
if (job == null) {
Toast.makeText(requireContext(), R.string.my_resumes_error, Toast.LENGTH_LONG).show()
return@post
}
when (job.status.uppercase()) {
"COMPLETED" -> {
val type = job.type ?: JobFormFragment.TYPE_RESUME
replaceWith(ResultFragment.newInstance(job, type))
}
"FAILED" -> Toast.makeText(requireContext(), job.error ?: getString(R.string.status_failed), Toast.LENGTH_LONG).show()
else -> replaceWith(JobStatusFragment.newInstance(jobId, job.type ?: JobFormFragment.TYPE_RESUME))
}
}
} catch (e: Exception) {
handler.post {
showLoading(false)
Toast.makeText(requireContext(), e.message ?: getString(R.string.my_resumes_error), Toast.LENGTH_LONG).show()
}
} }
}.start() "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) { private fun replaceWith(fragment: Fragment) {
@ -110,15 +94,6 @@ class MyResumesFragment : Fragment() {
.commitAllowingStateLoss() .commitAllowingStateLoss()
} }
private fun showLoading(loading: Boolean) {
progress.visibility = if (loading) View.VISIBLE else View.GONE
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
}
private class JobListAdapter( private class JobListAdapter(
context: android.content.Context, context: android.content.Context,
private val jobs: List<JobSummary> private val jobs: List<JobSummary>

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,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="rectangle">
<solid android:color="?attr/colorWarningContainer" /> <solid android:color="?attr/colorWarningContainer" />
<corners android:radius="12dp" /> <corners android:radius="12dp" />

View File

@ -1,8 +1,9 @@
<?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">
<gradient <gradient
android:angle="270" android:angle="135"
android:startColor="?attr/colorPrimary" android:startColor="?attr/colorPrimary"
android:centerColor="?attr/colorPrimaryInverse"
android:endColor="?attr/colorPrimaryContainer" android:endColor="?attr/colorPrimaryContainer"
android:type="linear" /> android:type="linear" />
</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="270"
android:startColor="?attr/colorPrimary"
android:centerColor="?attr/colorPrimaryInverse"
android:endColor="?attr/colorPrimaryContainer"
android:type="linear" />
</shape>

View File

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

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,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,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/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" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -7,7 +7,7 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
tools:context=".ui.LoginActivity"> tools:context=".ui.LoginActivity">
<!-- Decorative background element --> <!-- Decorative gradient background behind card -->
<View <View
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
@ -17,21 +17,32 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<ImageView
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 <androidx.constraintlayout.widget.Guideline
android:id="@+id/guidelineTop" 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:orientation="horizontal" android:orientation="horizontal"
app:layout_constraintGuide_percent="0.35" /> app:layout_constraintGuide_percent="0.40" />
<!-- Main content card --> <!-- Main content card -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:id="@+id/loginCard" android:id="@+id/loginCard"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="@dimen/space_lg"
app:cardBackgroundColor="?attr/colorSurfaceContainer" app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="24dp" app:cardCornerRadius="@dimen/card_radius_large"
app:cardElevation="0dp" app:cardElevation="0dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
@ -46,21 +57,22 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="vertical" android:orientation="vertical"
android:padding="32dp"> android:padding="@dimen/space_xl">
<!-- App icon with elevation --> <!-- App icon with tonal surface -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="88dp" android:layout_width="@dimen/space_xxl"
android:layout_height="88dp" android:layout_height="@dimen/space_xxl"
app:cardBackgroundColor="?attr/colorPrimaryContainer" app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="22dp" app:cardCornerRadius="@dimen/card_radius_large"
app:cardElevation="4dp"> app:cardElevation="0dp"
app:strokeWidth="0dp">
<ImageView <ImageView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:contentDescription="@string/cd_app_logo" android:contentDescription="@string/cd_app_logo"
android:padding="20dp" android:padding="@dimen/space_md"
android:scaleType="centerInside" android:scaleType="centerInside"
android:src="@drawable/ic_launcher_foreground" android:src="@drawable/ic_launcher_foreground"
app:tint="?attr/colorOnPrimaryContainer" /> app:tint="?attr/colorOnPrimaryContainer" />
@ -71,7 +83,7 @@
android:id="@+id/loginTitle" android:id="@+id/loginTitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp" android:layout_marginTop="@dimen/space_lg"
android:text="@string/app_title" android:text="@string/app_title"
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="?attr/textAppearanceHeadlineMedium" android:textAppearance="?attr/textAppearanceHeadlineMedium"
@ -83,7 +95,7 @@
android:id="@+id/loginSubtitle" android:id="@+id/loginSubtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="@dimen/space_sm"
android:text="@string/welcome_subtitle" android:text="@string/welcome_subtitle"
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyLarge" android:textAppearance="?attr/textAppearanceBodyLarge"
@ -93,16 +105,17 @@
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginVertical="24dp" android:layout_marginVertical="@dimen/space_lg"
android:background="?attr/colorOutlineVariant" /> android:background="?attr/colorOutlineVariant" />
<!-- Sign in button container --> <!-- Sign in button -->
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/googleSignInButton" android:id="@+id/googleSignInButton"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="56dp" android:layout_height="56dp"
android:fontFamily="sans-serif-medium" android:fontFamily="sans-serif-medium"
android:minHeight="@dimen/button_height"
android:text="@string/sign_in_with_google" android:text="@string/sign_in_with_google"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface"
@ -110,7 +123,7 @@
app:cornerRadius="14dp" app:cornerRadius="14dp"
app:icon="@drawable/ic_google" app:icon="@drawable/ic_google"
app:iconGravity="textStart" app:iconGravity="textStart"
app:iconPadding="12dp" app:iconPadding="@dimen/space_md"
app:iconSize="24dp" app:iconSize="24dp"
app:iconTint="@null" app:iconTint="@null"
app:strokeColor="?attr/colorOutline" app:strokeColor="?attr/colorOutline"
@ -120,7 +133,7 @@
<TextView <TextView
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:layout_marginTop="@dimen/space_md"
android:text="@string/sign_in_terms" android:text="@string/sign_in_terms"
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodySmall" android:textAppearance="?attr/textAppearanceBodySmall"
@ -136,7 +149,7 @@
android:indeterminate="true" android:indeterminate="true"
android:visibility="gone" android:visibility="gone"
app:indicatorColor="?attr/colorOnPrimary" app:indicatorColor="?attr/colorOnPrimary"
app:indicatorSize="48dp" app:indicatorSize="@dimen/space_xxl"
app:layout_constraintBottom_toTopOf="@id/loginCard" app:layout_constraintBottom_toTopOf="@id/loginCard"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"

View File

@ -1,105 +1,82 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:fitsSystemWindows="true" 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:background="?attr/colorSurface" android:orientation="vertical">
android:fitsSystemWindows="true"
app:elevation="0dp"
app:liftOnScroll="false">
<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"
android:background="?attr/colorSurface" android:background="?attr/colorPrimary"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|enterAlways|snap" app:elevation="0dp">
app:title="@string/app_title"
app:titleCentered="true"
app:titleTextAppearance="?attr/textAppearanceTitleLarge"
app:titleTextColor="?attr/colorOnSurface">
<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:background="?attr/colorSurfaceContainer" app:navigationIcon="@drawable/ic_menu_hamburger"
android:gravity="center_vertical" app:navigationIconTint="?attr/colorOnPrimary"
android:minHeight="32dp" app:title="@string/app_title"
android:orientation="horizontal" app:titleCentered="true"
android:paddingHorizontal="12dp" app:titleTextAppearance="?attr/textAppearanceTitleLarge"
android:paddingVertical="4dp"> app:titleTextColor="?attr/colorOnPrimary" />
</com.google.android.material.appbar.AppBarLayout>
<ImageView
android:id="@+id/healthIndicator"
android:layout_width="12dp"
android:layout_height="12dp"
android:contentDescription="@string/cd_health_indicator"
android:src="@drawable/ic_circle_status" />
<TextView
android:id="@+id/userName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/unknown_user"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
</com.google.android.material.appbar.MaterialToolbar>
<TextView <TextView
android:id="@+id/usageBanner" android:id="@+id/usageBanner"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="@dimen/space_md"
android:layout_marginVertical="8dp" android:layout_marginTop="@dimen/space_sm"
android:background="@drawable/bg_banner_warning" android:background="@drawable/bg_banner_warning"
android:drawableStart="@drawable/ic_circle_status" android:drawableStart="@drawable/ic_circle_status"
android:drawableTint="?attr/colorWarning" android:drawableTint="?attr/colorWarning"
android:gravity="center_vertical" android:gravity="center_vertical"
android:minHeight="48dp" android:minHeight="@dimen/touch_target_min"
android:padding="12dp" android:padding="@dimen/space_sm"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="@string/usage_limit_banner" android:text="@string/usage_limit_banner"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />
<com.google.android.material.tabs.TabLayout <FrameLayout
android:id="@+id/tabLayout" android:id="@+id/fragmentContainer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:background="?attr/colorSurface" android:background="?attr/colorSurface" />
android:contentDescription="@string/app_title" </LinearLayout>
app:tabBackground="?attr/colorSurface"
app:tabGravity="fill"
app:tabIndicatorColor="?attr/colorPrimary"
app:tabMode="fixed"
app:tabTextAppearance="?attr/textAppearanceLabelLarge"
app:tabTextColor="?attr/colorOnSurfaceVariant"
app:tabSelectedTextColor="?attr/colorOnSurface" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout <!-- Navigation drawer with richer surface and modern item styling -->
android:id="@+id/fragmentContainer" <com.google.android.material.navigation.NavigationView
android:layout_width="match_parent" android:id="@+id/navView"
android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface" android:layout_gravity="start"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> 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" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.drawerlayout.widget.DrawerLayout>

View File

@ -6,182 +6,244 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:fillViewport="true" android:fillViewport="true"
android:clipToPadding="false"
android:paddingBottom="@dimen/space_md"
tools:context=".ui.JobFormFragment"> 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">
android:padding="16dp">
<com.google.android.material.card.MaterialCardView <!-- 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"
app:cardCornerRadius="12dp" android:background="@drawable/bg_gradient_primary"
app:cardElevation="0dp" android:minHeight="@dimen/hero_header_min_height"
app:strokeColor="?attr/colorOutlineVariant" android:padding="@dimen/space_lg">
app:strokeWidth="1dp">
<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 <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:layout_gravity="bottom|start"
android:padding="16dp"> android:orientation="vertical">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="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:text="@string/hint_job_title"
android:textAppearance="?attr/textAppearanceTitleMedium" android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface" /> android:textColor="?attr/colorOnPrimary" />
<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_title"
app:boxBackgroundColor="?attr/colorSurfaceContainer"
app:hintTextColor="?attr/colorOnSurfaceVariant"
style="@style/Widget.Material3.TextInputLayout.FilledBox">
<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="48dp" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
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"
app:boxBackgroundColor="?attr/colorSurfaceContainer"
app:hintTextColor="?attr/colorOnSurfaceVariant"
style="@style/Widget.Material3.TextInputLayout.FilledBox">
<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="48dp" />
</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="48dp"
android:layout_marginStart="8dp"
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="16dp"
android:hint="@string/hint_job_description"
app:boxBackgroundColor="?attr/colorSurfaceContainer"
app:hintTextColor="?attr/colorOnSurfaceVariant"
style="@style/Widget.Material3.TextInputLayout.FilledBox">
<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>
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </FrameLayout>
<com.google.android.material.card.MaterialCardView <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"
app:cardCornerRadius="12dp" android:padding="@dimen/space_md">
app:cardElevation="0dp"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout <!-- Job details card -->
<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:orientation="vertical" app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
android:padding="16dp"> 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:orientation="vertical"
android:orientation="horizontal"> android:padding="@dimen/space_md">
<TextView <TextView
android:id="@+id/docInputTitle" android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:text="@string/hint_job_title"
android:text="@string/hint_your_resume"
android:textAppearance="?attr/textAppearanceTitleMedium" android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" /> android:textColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton <com.google.android.material.textfield.TextInputLayout
android:id="@+id/uploadButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="48dp"
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="12dp"
android:hint="@string/hint_your_resume"
app:boxBackgroundColor="?attr/colorSurfaceContainer"
app:hintTextColor="?attr/colorOnSurfaceVariant"
style="@style/Widget.Material3.TextInputLayout.FilledBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/docInput"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="top" android:layout_marginTop="@dimen/space_sm"
android:inputType="textMultiLine" android:hint="@string/hint_job_title"
android:minLines="6" /> style="@style/Widget.Material3.TextInputLayout.FilledBox"
</com.google.android.material.textfield.TextInputLayout> app:boxBackgroundColor="?attr/colorSurfaceContainerHighest"
</LinearLayout> app:boxStrokeColor="?attr/colorPrimary"
</com.google.android.material.card.MaterialCardView> app:cursorColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorOnSurfaceVariant">
<com.google.android.material.button.MaterialButton <com.google.android.material.textfield.TextInputEditText
android:id="@+id/submitButton" android:id="@+id/jobTitleInput"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="48dp" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:inputType="text"
android:text="@string/generate_resume" /> android:minHeight="@dimen/touch_target_min" />
</com.google.android.material.textfield.TextInputLayout>
<ProgressBar <LinearLayout
android:id="@+id/progressBar" android:layout_width="match_parent"
android:layout_width="48dp" android:layout_height="wrap_content"
android:layout_height="48dp" android:layout_marginTop="@dimen/space_sm"
android:layout_gravity="center_horizontal" android:baselineAligned="false"
android:layout_marginTop="16dp" android:orientation="horizontal">
android:contentDescription="@string/cd_loading"
android:indeterminateTint="?attr/colorPrimary" <com.google.android.material.textfield.TextInputLayout
android:visibility="gone" /> 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
android:id="@+id/submitButton"
android:layout_width="match_parent"
android:layout_height="@dimen/button_height"
android:layout_marginTop="@dimen/space_md"
android:text="@string/generate_resume" />
<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> </LinearLayout>
</ScrollView> </ScrollView>

View File

@ -7,14 +7,15 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:gravity="center" android:gravity="center"
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> 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_marginHorizontal="16dp" android:layout_marginHorizontal="@dimen/space_md"
android:layout_marginVertical="8dp" android:layout_marginVertical="@dimen/space_sm"
app:cardCornerRadius="12dp" app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="@dimen/card_radius_large"
app:cardElevation="0dp" app:cardElevation="0dp"
app:strokeColor="?attr/colorOutlineVariant" app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp"> app:strokeWidth="1dp">
@ -24,27 +25,36 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_horizontal" 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:background="@drawable/bg_status_badge" app:cardBackgroundColor="?attr/colorPrimaryContainer"
android:contentDescription="@string/status_queued" app:cardCornerRadius="16dp"
android:gravity="center" app:cardElevation="0dp"
android:minHeight="32dp" app:strokeWidth="0dp">
android:paddingHorizontal="16dp"
android:paddingVertical="4dp" <TextView
android:text="@string/status_queued" android:id="@+id/statusBadge"
android:textAppearance="?attr/textAppearanceLabelLarge" android:layout_width="wrap_content"
android:textColor="?attr/colorOnPrimary" /> 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 <com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/statusProgress" android:id="@+id/statusProgress"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp" android:layout_marginTop="@dimen/space_lg"
android:contentDescription="@string/content_generation_running" android:contentDescription="@string/content_generation_running"
android:indeterminate="true" android:indeterminate="true"
app:indicatorColor="?attr/colorPrimary" app:indicatorColor="?attr/colorPrimary"
@ -54,7 +64,7 @@
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_marginTop="16dp" android:layout_marginTop="@dimen/space_md"
android:gravity="center" android:gravity="center"
android:text="@string/job_wait_message" android:text="@string/job_wait_message"
android:textAppearance="?attr/textAppearanceBodyLarge" android:textAppearance="?attr/textAppearanceBodyLarge"
@ -64,7 +74,7 @@
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_marginTop="8dp" android:layout_marginTop="@dimen/space_sm"
android:gravity="center" android:gravity="center"
android:textAppearance="?attr/textAppearanceBodySmall" android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" android:textColor="?attr/colorOnSurfaceVariant"
@ -74,8 +84,8 @@
android:id="@+id/tryAgainButton" android:id="@+id/tryAgainButton"
style="@style/Widget.Material3.Button.TonalButton" style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="48dp" android:layout_height="@dimen/button_height"
android:layout_marginTop="24dp" android:layout_marginTop="@dimen/space_lg"
android:minWidth="120dp" android:minWidth="120dp"
android:text="@string/try_again" android:text="@string/try_again"
android:visibility="gone" /> android:visibility="gone" />

View File

@ -5,24 +5,55 @@
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:background="?attr/colorSurface"
android:orientation="vertical" android:orientation="vertical">
android:padding="16dp">
<TextView <!-- Hero header for list screen -->
android:id="@+id/myResumesTitle" <FrameLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="4dp" android:background="@drawable/bg_gradient_primary"
android:paddingBottom="8dp" android:minHeight="@dimen/hero_header_min_height"
android:text="@string/my_resumes_title" android:padding="@dimen/space_lg">
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface" /> <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 <TextView
android:id="@+id/myResumesEmpty" android:id="@+id/myResumesEmpty"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="32dp" android:layout_marginHorizontal="@dimen/space_md"
android:layout_marginTop="@dimen/space_xl"
android:gravity="center" android:gravity="center"
android:text="@string/my_resumes_empty" android:text="@string/my_resumes_empty"
android:textAppearance="?attr/textAppearanceBodyLarge" android:textAppearance="?attr/textAppearanceBodyLarge"
@ -34,7 +65,7 @@
android:id="@+id/myResumesProgress" android:id="@+id/myResumesProgress"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:layout_marginTop="@dimen/space_md"
android:indeterminate="true" android:indeterminate="true"
android:visibility="gone" android:visibility="gone"
app:indicatorColor="?attr/colorPrimary" app:indicatorColor="?attr/colorPrimary"
@ -45,9 +76,12 @@
android:id="@+id/myResumesList" android:id="@+id/myResumesList"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_marginTop="8dp" android:layout_marginHorizontal="@dimen/space_md"
android:layout_marginTop="@dimen/space_md"
android:clipToPadding="false"
android:divider="@null" android:divider="@null"
android:listSelector="@android:color/transparent" android:listSelector="@android:color/transparent"
android:paddingBottom="@dimen/space_md"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/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" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -8,13 +8,15 @@
android:orientation="vertical" android:orientation="vertical"
tools:context=".ui.ResultFragment"> 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_marginHorizontal="16dp" android:layout_marginHorizontal="@dimen/space_md"
android:layout_marginTop="16dp" android:layout_marginTop="@dimen/space_md"
android:layout_marginBottom="8dp" android:layout_marginBottom="@dimen/space_sm"
app:cardCornerRadius="12dp" app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="@dimen/card_radius"
app:cardElevation="0dp" app:cardElevation="0dp"
app:strokeColor="?attr/colorOutlineVariant" app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp"> app:strokeWidth="1dp">
@ -23,23 +25,31 @@
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="48dp" 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_status_badge" app:cardBackgroundColor="?attr/colorSuccessContainer"
android:contentDescription="@string/status_completed" app:cardCornerRadius="16dp"
android:gravity="center" app:cardElevation="0dp"
android:minHeight="32dp" app:strokeWidth="0dp">
android:paddingHorizontal="12dp"
android:paddingVertical="4dp" <TextView
android:text="@string/status_completed" android:id="@+id/resultBadge"
android:textAppearance="?attr/textAppearanceLabelLarge" android:layout_width="wrap_content"
android:textColor="?attr/colorOnPrimary" /> 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"
@ -50,7 +60,7 @@
android:id="@+id/copyButton" android:id="@+id/copyButton"
style="@style/Widget.Material3.Button.TextButton" style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="48dp" android:layout_height="@dimen/button_height"
android:contentDescription="@string/cd_copy_result" android:contentDescription="@string/cd_copy_result"
android:minWidth="64dp" android:minWidth="64dp"
android:text="@string/copy_text" /> android:text="@string/copy_text" />
@ -59,8 +69,8 @@
android:id="@+id/newButton" android:id="@+id/newButton"
style="@style/Widget.Material3.Button.TextButton" style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="48dp" android:layout_height="@dimen/button_height"
android:layout_marginStart="4dp" android:layout_marginStart="@dimen/space_xs"
android:contentDescription="@string/cd_new_job" android:contentDescription="@string/cd_new_job"
android:minWidth="64dp" android:minWidth="64dp"
android:text="@string/new_job" /> android:text="@string/new_job" />
@ -71,17 +81,19 @@
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="16dp" android:layout_marginHorizontal="@dimen/space_md"
android:layout_weight="1" android:layout_weight="1"
android:contentDescription="@string/result_preview" /> android:contentDescription="@string/result_preview" />
<!-- Export action bar 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_marginHorizontal="16dp" android:layout_marginHorizontal="@dimen/space_md"
android:layout_marginTop="8dp" android:layout_marginTop="@dimen/space_sm"
android:layout_marginBottom="16dp" android:layout_marginBottom="@dimen/space_md"
app:cardCornerRadius="12dp" app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="@dimen/card_radius"
app:cardElevation="0dp" app:cardElevation="0dp"
app:strokeColor="?attr/colorOutlineVariant" app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp"> app:strokeWidth="1dp">
@ -90,14 +102,14 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="12dp"> android:padding="@dimen/space_sm">
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/pdfButton" android:id="@+id/pdfButton"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="48dp" android:layout_height="@dimen/button_height"
android:layout_marginEnd="8dp" android:layout_marginEnd="@dimen/space_sm"
android:layout_weight="1" android:layout_weight="1"
android:contentDescription="@string/cd_export_pdf" android:contentDescription="@string/cd_export_pdf"
android:text="@string/export_pdf" /> android:text="@string/export_pdf" />
@ -106,8 +118,8 @@
android:id="@+id/docxButton" android:id="@+id/docxButton"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="48dp" android:layout_height="@dimen/button_height"
android:layout_marginStart="8dp" android:layout_marginStart="@dimen/space_sm"
android:layout_weight="1" android:layout_weight="1"
android:contentDescription="@string/cd_export_docx" android:contentDescription="@string/cd_export_docx"
android:text="@string/export_docx" /> android:text="@string/export_docx" />

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/android" <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: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"

View File

@ -4,36 +4,73 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginVertical="6dp" android:layout_marginVertical="@dimen/space_sm"
app:cardCornerRadius="12dp" app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
app:cardCornerRadius="@dimen/card_radius"
app:cardElevation="0dp" app:cardElevation="0dp"
app:rippleColor="?attr/colorPrimaryContainer"
app:strokeColor="?attr/colorOutlineVariant" app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp"> 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:orientation="vertical" android:gravity="center_vertical"
android:padding="16dp"> android:minHeight="@dimen/touch_target_min"
android:orientation="horizontal"
android:padding="@dimen/space_md">
<TextView <com.google.android.material.card.MaterialCardView
android:id="@+id/itemTitle" android:layout_width="@dimen/space_xxl"
android:layout_width="match_parent" android:layout_height="@dimen/space_xxl"
android:layout_height="wrap_content" app:cardBackgroundColor="?attr/colorPrimaryContainer"
android:ellipsize="end" app:cardCornerRadius="@dimen/card_radius"
android:maxLines="1" app:cardElevation="0dp"
android:textAppearance="?attr/textAppearanceTitleMedium" app:strokeWidth="0dp">
android:textColor="?attr/colorOnSurface"
tools:text="Senior Android Engineer" />
<TextView <ImageView
android:id="@+id/itemSubtitle" android:layout_width="24dp"
android:layout_width="match_parent" 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_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginStart="@dimen/space_md"
android:textAppearance="?attr/textAppearanceBodySmall" android:layout_weight="1"
android:textColor="?attr/colorOnSurfaceVariant" android:orientation="vertical">
tools:text="Resume · 12 Aug 2026" />
<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> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </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,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menuMyResumes"
android:title="@string/menu_my_resumes" />
<item
android:id="@+id/menuAdminPanel"
android:title="@string/menu_admin_panel"
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,41 +1,43 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.ResBuilder" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Theme.ResBuilder" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary color roles (dark) --> <!-- Primary color roles (dark): bright teal on deep surfaces -->
<item name="colorPrimary">#4DD9DB</item> <item name="colorPrimary">#80DADC</item>
<item name="colorOnPrimary">#003738</item> <item name="colorOnPrimary">#003738</item>
<item name="colorPrimaryContainer">#004F51</item> <item name="colorPrimaryContainer">#005A5C</item>
<item name="colorOnPrimaryContainer">#6FF6F8</item> <item name="colorOnPrimaryContainer">#B5F2F3</item>
<item name="colorPrimaryInverse">#006A6C</item> <item name="colorPrimaryInverse">#004F4F</item>
<item name="colorPrimaryFixed">#80DADC</item>
<item name="colorOnPrimaryFixed">#002020</item>
<!-- Secondary color roles (dark) --> <!-- Secondary color roles (dark) -->
<item name="colorSecondary">#B0CCCB</item> <item name="colorSecondary">#9CBEBD</item>
<item name="colorOnSecondary">#1B3535</item> <item name="colorOnSecondary">#1B3534</item>
<item name="colorSecondaryContainer">#324C4B</item> <item name="colorSecondaryContainer">#2F4A49</item>
<item name="colorOnSecondaryContainer">#CCE8E7</item> <item name="colorOnSecondaryContainer">#BDD6D5</item>
<!-- Tertiary color roles (dark) --> <!-- Tertiary color roles (dark) -->
<item name="colorTertiary">#B1C8E8</item> <item name="colorTertiary">#A4C9E8</item>
<item name="colorOnTertiary">#19324C</item> <item name="colorOnTertiary">#0B2D45</item>
<item name="colorTertiaryContainer">#324963</item> <item name="colorTertiaryContainer">#27475F</item>
<item name="colorOnTertiaryContainer">#D2E4FF</item> <item name="colorOnTertiaryContainer">#BFE0FF</item>
<!-- Surface color roles (dark) --> <!-- Surface color roles (dark): deep charcoal-teal -->
<item name="android:colorBackground">#0E1515</item> <item name="android:colorBackground">#0C1414</item>
<item name="colorSurface">#0E1515</item> <item name="colorSurface">#0C1414</item>
<item name="colorOnSurface">#DDE4E4</item> <item name="colorOnSurface">#E0ECEC</item>
<item name="colorSurfaceVariant">#3F4949</item> <item name="colorSurfaceVariant">#3F4A4A</item>
<item name="colorOnSurfaceVariant">#BEC9C8</item> <item name="colorOnSurfaceVariant">#BEC9C9</item>
<item name="colorSurfaceContainerLowest">#090F0F</item> <item name="colorSurfaceContainerLowest">#070E0E</item>
<item name="colorSurfaceContainerLow">#161D1D</item> <item name="colorSurfaceContainerLow">#121B1B</item>
<item name="colorSurfaceContainer">#1A2121</item> <item name="colorSurfaceContainer">#182222</item>
<item name="colorSurfaceContainerHigh">#242B2B</item> <item name="colorSurfaceContainerHigh">#242D2D</item>
<item name="colorSurfaceContainerHighest">#2F3636</item> <item name="colorSurfaceContainerHighest">#303939</item>
<item name="colorSurfaceInverse">#DDE4E4</item> <item name="colorSurfaceInverse">#E0ECEC</item>
<item name="colorOnSurfaceInverse">#2D3131</item> <item name="colorOnSurfaceInverse">#0C1414</item>
<!-- Outline --> <!-- Outline -->
<item name="colorOutline">#889393</item> <item name="colorOutline">#899999</item>
<item name="colorOutlineVariant">#3F4949</item> <item name="colorOutlineVariant">#3F4A4A</item>
<!-- Error (dark) --> <!-- Error (dark) -->
<item name="colorError">#F2B8B5</item> <item name="colorError">#F2B8B5</item>
@ -59,12 +61,12 @@
<item name="colorInfoContainer">#0B4576</item> <item name="colorInfoContainer">#0B4576</item>
<item name="colorOnInfoContainer">#D4EAFB</item> <item name="colorOnInfoContainer">#D4EAFB</item>
<!-- System bars - edge-to-edge ready (dark) --> <!-- System bars -->
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
<!-- M3 Typography and shape defaults inherited from Material3 parent --> <!-- Typography -->
<item name="textAppearanceDisplayLarge">@style/TextAppearance.Material3.DisplayLarge</item> <item name="textAppearanceDisplayLarge">@style/TextAppearance.Material3.DisplayLarge</item>
<item name="textAppearanceDisplayMedium">@style/TextAppearance.Material3.DisplayMedium</item> <item name="textAppearanceDisplayMedium">@style/TextAppearance.Material3.DisplayMedium</item>
<item name="textAppearanceDisplaySmall">@style/TextAppearance.Material3.DisplaySmall</item> <item name="textAppearanceDisplaySmall">@style/TextAppearance.Material3.DisplaySmall</item>
@ -81,7 +83,7 @@
<item name="textAppearanceLabelMedium">@style/TextAppearance.Material3.LabelMedium</item> <item name="textAppearanceLabelMedium">@style/TextAppearance.Material3.LabelMedium</item>
<item name="textAppearanceLabelSmall">@style/TextAppearance.Material3.LabelSmall</item> <item name="textAppearanceLabelSmall">@style/TextAppearance.Material3.LabelSmall</item>
<!-- Default M3 component styles inherited from Material3 parent; explicit overrides kept to a minimum --> <!-- Component defaults -->
<item name="materialButtonStyle">@style/Widget.Material3.Button</item> <item name="materialButtonStyle">@style/Widget.Material3.Button</item>
<item name="materialButtonOutlinedStyle">@style/Widget.Material3.Button.OutlinedButton</item> <item name="materialButtonOutlinedStyle">@style/Widget.Material3.Button.OutlinedButton</item>

View File

@ -2,56 +2,58 @@
<resources> <resources>
<!-- Material Design 3 semantic color tokens --> <!-- Material Design 3 semantic color tokens -->
<!-- Primary palette --> <!-- Primary palette: rich teal with stronger contrast -->
<color name="primary">#006A6C</color> <color name="primary">#004F4F</color>
<color name="on_primary">#FFFFFF</color> <color name="on_primary">#FFFFFF</color>
<color name="primary_container">#6FF6F8</color> <color name="primary_container">#B5F2F3</color>
<color name="on_primary_container">#002021</color> <color name="on_primary_container">#002020</color>
<color name="primary_fixed">#B5F2F3</color>
<color name="on_primary_fixed">#002020</color>
<!-- Secondary palette --> <!-- Secondary palette: cool slate with more depth -->
<color name="secondary">#4A6363</color> <color name="secondary">#3D5252</color>
<color name="on_secondary">#FFFFFF</color> <color name="on_secondary">#FFFFFF</color>
<color name="secondary_container">#CCE8E7</color> <color name="secondary_container">#BDD6D5</color>
<color name="on_secondary_container">#051F1F</color> <color name="on_secondary_container">#041E1E</color>
<!-- Tertiary palette --> <!-- Tertiary palette: deep blue-grey accent -->
<color name="tertiary">#4B617C</color> <color name="tertiary">#365064</color>
<color name="on_tertiary">#FFFFFF</color> <color name="on_tertiary">#FFFFFF</color>
<color name="tertiary_container">#D2E4FF</color> <color name="tertiary_container">#BFE0FF</color>
<color name="on_tertiary_container">#051E35</color> <color name="on_tertiary_container">#001D31</color>
<!-- Surface colors --> <!-- Surface colors: warmer off-white base with visible tonal elevation -->
<color name="surface">#F4FBFB</color> <color name="surface">#FAFDFD</color>
<color name="on_surface">#161D1D</color> <color name="on_surface">#0A1414</color>
<color name="surface_variant">#DAE5E4</color> <color name="surface_variant">#D8E5E5</color>
<color name="on_surface_variant">#3F4949</color> <color name="on_surface_variant">#3F4A4A</color>
<color name="surface_container_lowest">#FFFFFF</color> <color name="surface_container_lowest">#FFFFFF</color>
<color name="surface_container_low">#EEF6F6</color> <color name="surface_container_low">#F0F6F6</color>
<color name="surface_container">#E8F0F0</color> <color name="surface_container">#E8F1F1</color>
<color name="surface_container_high">#E2EAEA</color> <color name="surface_container_high">#DFEAEA</color>
<color name="surface_container_highest">#DDE5E5</color> <color name="surface_container_highest">#D6E4E4</color>
<!-- Outline --> <!-- Outline -->
<color name="outline">#6F7979</color> <color name="outline">#6F7E7E</color>
<color name="outline_variant">#BEC9C8</color> <color name="outline_variant">#BEC9C9</color>
<!-- Status / Semantic colors --> <!-- Status / Semantic colors: richer and more saturated -->
<color name="success">#4CAF50</color> <color name="success">#2E7D32</color>
<color name="on_success">#FFFFFF</color> <color name="on_success">#FFFFFF</color>
<color name="success_container">#D9F6DA</color> <color name="success_container">#D9F6DA</color>
<color name="on_success_container">#1F5E22</color> <color name="on_success_container">#1F5E22</color>
<color name="warning">#FF9800</color> <color name="warning">#E65100</color>
<color name="on_warning">#FFFFFF</color> <color name="on_warning">#FFFFFF</color>
<color name="warning_container">#FFE3B6</color> <color name="warning_container">#FFE3B6</color>
<color name="on_warning_container">#693B00</color> <color name="on_warning_container">#693B00</color>
<color name="error">#F44336</color> <color name="error">#C62828</color>
<color name="on_error">#FFFFFF</color> <color name="on_error">#FFFFFF</color>
<color name="error_container">#F9DEDC</color> <color name="error_container">#F9DEDC</color>
<color name="on_error_container">#410E0B</color> <color name="on_error_container">#410E0B</color>
<color name="info">#2196F3</color> <color name="info">#1565C0</color>
<color name="on_info">#FFFFFF</color> <color name="on_info">#FFFFFF</color>
<color name="info_container">#D4EAFB</color> <color name="info_container">#D4EAFB</color>
<color name="on_info_container">#0B4576</color> <color name="on_info_container">#0B4576</color>
@ -59,20 +61,19 @@
<!-- Inverse surface --> <!-- Inverse surface -->
<color name="inverse_surface">#2D3131</color> <color name="inverse_surface">#2D3131</color>
<color name="inverse_on_surface">#DFE4E4</color> <color name="inverse_on_surface">#DFE4E4</color>
<color name="inverse_primary">#4DD9DB</color> <color name="inverse_primary">#80DADC</color>
<!-- Status indicator palettes (kept for programmatic tint compatibility) --> <!-- Status indicator palettes -->
<color name="status_healthy">@color/success</color> <color name="status_healthy">@color/success</color>
<color name="status_degraded">@color/warning</color> <color name="status_degraded">@color/warning</color>
<color name="status_unhealthy">@color/error</color> <color name="status_unhealthy">@color/error</color>
<!-- Legacy aliases to avoid breaking any remaining references during migration --> <!-- Legacy aliases -->
<color name="purple_200">@color/primary_container</color> <color name="purple_200">@color/primary_container</color>
<color name="purple_500">@color/primary</color> <color name="purple_500">@color/primary</color>
<color name="purple_700">@color/on_primary_container</color> <color name="purple_700">@color/on_primary_container</color>
<color name="teal_200">@color/primary_container</color> <color name="teal_200">@color/primary_container</color>
<color name="teal_700">@color/primary</color> <color name="teal_700">@color/primary</color>
<!-- Legacy neutral aliases kept for programmatic compatibility in unchanged Kotlin code -->
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>

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

@ -6,6 +6,14 @@
<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="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_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>
@ -15,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>
@ -28,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>
@ -46,6 +56,9 @@
<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 --> <!-- Accessibility labels -->
<string name="cd_app_logo">resBuilder app logo</string> <string name="cd_app_logo">resBuilder app logo</string>

View File

@ -6,6 +6,8 @@
<item name="colorPrimaryContainer">@color/primary_container</item> <item name="colorPrimaryContainer">@color/primary_container</item>
<item name="colorOnPrimaryContainer">@color/on_primary_container</item> <item name="colorOnPrimaryContainer">@color/on_primary_container</item>
<item name="colorPrimaryInverse">@color/inverse_primary</item> <item name="colorPrimaryInverse">@color/inverse_primary</item>
<item name="colorPrimaryFixed">@color/primary_fixed</item>
<item name="colorOnPrimaryFixed">@color/on_primary_fixed</item>
<!-- Secondary color roles --> <!-- Secondary color roles -->
<item name="colorSecondary">@color/secondary</item> <item name="colorSecondary">@color/secondary</item>
@ -64,7 +66,7 @@
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item> <item name="android:windowLightStatusBar">true</item>
<!-- M3 Typography and shape defaults inherited from Material3 parent --> <!-- M3 Typography -->
<item name="textAppearanceDisplayLarge">@style/TextAppearance.Material3.DisplayLarge</item> <item name="textAppearanceDisplayLarge">@style/TextAppearance.Material3.DisplayLarge</item>
<item name="textAppearanceDisplayMedium">@style/TextAppearance.Material3.DisplayMedium</item> <item name="textAppearanceDisplayMedium">@style/TextAppearance.Material3.DisplayMedium</item>
<item name="textAppearanceDisplaySmall">@style/TextAppearance.Material3.DisplaySmall</item> <item name="textAppearanceDisplaySmall">@style/TextAppearance.Material3.DisplaySmall</item>
@ -81,11 +83,10 @@
<item name="textAppearanceLabelMedium">@style/TextAppearance.Material3.LabelMedium</item> <item name="textAppearanceLabelMedium">@style/TextAppearance.Material3.LabelMedium</item>
<item name="textAppearanceLabelSmall">@style/TextAppearance.Material3.LabelSmall</item> <item name="textAppearanceLabelSmall">@style/TextAppearance.Material3.LabelSmall</item>
<!-- Default M3 component styles inherited from Material3 parent; explicit overrides kept to a minimum --> <!-- Default M3 component styles -->
<item name="materialButtonStyle">@style/Widget.Material3.Button</item> <item name="materialButtonStyle">@style/Widget.Material3.Button</item>
<item name="materialButtonOutlinedStyle">@style/Widget.Material3.Button.OutlinedButton</item> <item name="materialButtonOutlinedStyle">@style/Widget.Material3.Button.OutlinedButton</item>
<!-- Window background uses surface so app surfaces are always theme-aware -->
<item name="android:windowBackground">?attr/colorSurface</item> <item name="android:windowBackground">?attr/colorSurface</item>
</style> </style>

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

@ -13,9 +13,15 @@ googlePlayServicesAuth = "20.7.0"
credentialManager = "1.5.0-beta01" credentialManager = "1.5.0-beta01"
googleid = "1.1.1" googleid = "1.1.1"
kotlinxCoroutines = "1.9.0" 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" }