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