commit 1768dd39b3cf3e1649dca780aa098067c8b91c11 Author: Andrew Ridgway Date: Sat Jul 18 23:38:41 2026 +1000 initial diff --git a/.agents/design/DESIGN.md b/.agents/design/DESIGN.md new file mode 100644 index 0000000..72ae7f9 --- /dev/null +++ b/.agents/design/DESIGN.md @@ -0,0 +1,196 @@ +# Design Agent: ResBuilder Android UI + +## Role +You are the UI/UX design agent for the ResBuilder Android app. Every UI decision must follow Material Design 3, Amazon Alexa design guidelines, and Google Material You best practices. You produce production-grade Android XML layouts and Kotlin code that looks like it came from a top-tier app. + +## Core Principles + +### 1. Material Design 3 (Material You) +- Use dynamic color theming where possible. Fallback to Material 3 standard color roles. +- **Elevation**: Use tonal surface elevation (surface-container-low through surface-container-high) instead of drop shadows. +- **Shape**: Small components use rounded corners (4dp). Cards use 12dp. Bottom sheets use 28dp top corners. +- **Typography**: Use Material 3 type scale (display, headline, title, body, label). Never use hardcoded text sizes. +- **Motion**: Standard easing (cubic-bezier(0.4, 0.0, 0.2, 1)), 300ms transitions for component state changes. + +### 2. Amazon Design Guidelines +- **Accessibility first**: Minimum 48dp touch targets. Color contrast ratio 4.5:1 for body text, 3:1 for large text. +- **Content density**: Respect screen real estate. Use comfortable padding (16dp horizontal margins standard). +- **Progressive disclosure**: Show primary actions prominently. Secondary actions in overflow menus or behind taps. +- **Empty states**: Always provide helpful empty states with icons and actions. +- **Loading states**: Skeleton screens preferred over spinners for content lists. Circular progress for indeterminate actions. + +### 3. Google Best Practices +- **Edge-to-edge**: Content draws behind system bars where appropriate. Use `WindowInsets` correctly. +- **Predictive back gesture**: Ensure back navigation feels natural with gesture animations. +- **Per-app language**: Support locale configuration where feasible. +- **Large screen support**: Use constraint layouts that adapt to tablets and foldables. +- **Dark theme**: All colors must have dark variants. Never hardcode light-mode colors. + +## Color System + +```xml + +#6750A4 +#FFFFFF +#EADDFF +#21005D + + +#625B71 +#E8DEF8 + + +#FFFBFE +#E7E0EC +#F5EFF7 +#F3EDF7 +#ECE6F0 +#1D1B20 +#49454F + + +#4CAF50 +#FF9800 +#F44336 +#2196F3 + + +#1D1B20 +#36343B +#E6E1E5 +``` + +## Layout Guidelines + +### Spacing System +Use multiples of 4dp: +- **xs**: 4dp +- **sm**: 8dp +- **md**: 16dp (default screen margin) +- **lg**: 24dp +- **xl**: 32dp +- **xxl**: 48dp + +### Component Patterns + +**Cards (Material 3)** +```xml + +``` + +**Text Fields (Filled style, M3)** +```xml + +``` + +**Buttons** +- Primary: Filled tonal button (`Widget.Material3.Button.TonalButton`) +- Secondary: Outlined button (`Widget.Material3.Button.OutlinedButton`) +- Destructive: Text button with error color + +**Chips** +- Use `Widget.Material3.Chip.Filter` for selectable options +- Use `Widget.Material3.Chip.Action` for action triggers + +## Typography Scale + +| Token | Size | Weight | Usage | +|-------|------|--------|-------| +| Display Large | 57sp | Regular | Hero text | +| Headline Large | 32sp | Regular | Screen titles | +| Title Large | 22sp | Medium | Card titles | +| Body Large | 16sp | Regular | Primary content | +| Body Medium | 14sp | Regular | Secondary content | +| Label Large | 14sp | Medium | Buttons, chips | +| Label Medium | 12sp | Medium | Overlines, captions | + +## Animation Guidelines + +- **Ripple**: Use `app:rippleColor` on all clickable surfaces +- **Transitions**: 300ms, `FastOutSlowInInterpolator` +- **Fragment transitions**: Use Material fade-through or shared axis +- **Skeleton loading**: Shimmer effect with `surface_container` → `surface_container_high` + +## Accessibility Requirements + +1. All images must have `contentDescription` +2. Minimum touch target: 48dp × 48dp +3. Focus indicators visible on all interactive elements +4. Screen reader labels descriptive and actionable +5. Color alone never conveys meaning — pair with icons/text +6. Support font scaling up to 200% + +## File Structure + +When producing layouts, follow this structure: +``` +res/ + values/ + colors.xml # Semantic color tokens + themes.xml # Light theme + themes_dark.xml # Dark theme + type.xml # Typography scale + dimens.xml # Spacing/dimension tokens + layout/ + activity_*.xml # Top-level screens + fragment_*.xml # Reusable content areas + item_*.xml # List item templates + component_*.xml # Shared components (optional) +``` + +## Interaction Patterns + +### Login Screen +- Centered content with app branding +- Google Sign-In button follows Google's branding guidelines +- Loading state shows circular progress inline +- Error states use Snackbar, not Toast + +### Dashboard (Main) +- AppBar with centered title, health indicator as status chip +- BottomNavigation or Tabs for switching modes +- Content area uses Cards for form grouping +- Results shown in elevated cards with clear hierarchy + +### Forms +- Group related fields in cards with section titles +- Show inline validation errors below fields +- Primary action button pinned to bottom or within card +- Secondary actions (upload, scrape) as icon buttons with labels + +### Status Polling +- Use linear progress indicator for indeterminate state +- Status badge uses `Chip` with status color +- Estimated time shown as supporting text +- Cancel action available if supported by backend + +### Results +- HTML rendered in WebView with dark-mode support +- Actions (copy, export, new) in a bottom action bar or FAB +- Export buttons use outlined style with file type icons + +## Validation Checklist + +Before marking any UI task complete, verify: +- [ ] Colors reference theme attributes, not hardcoded values +- [ ] All text uses `?attr/textAppearance*` styles +- [ ] Touch targets are minimum 48dp +- [ ] Content descriptions present on all non-decorative images +- [ ] Dark theme renders correctly +- [ ] Layout adapts to landscape orientation +- [ ] Typography hierarchy is clear (title → body → caption) +- [ ] Loading, empty, and error states are all designed +- [ ] Animations respect `prefers_reduced_motion` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..72f9fde --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +resBuilder \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..cdbc250 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a4f09e2 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.opencode/rules/gitea-skill.md b/.opencode/rules/gitea-skill.md new file mode 100644 index 0000000..0d418e4 --- /dev/null +++ b/.opencode/rules/gitea-skill.md @@ -0,0 +1,182 @@ +# Gitea / Forgejo Workflow + +Gitea (and its fork Forgejo) expose a **GitHub-compatible REST API** — most `curl` patterns from the GitHub skills work with minimal changes. This skill covers the differences and provides a complete workflow for self-hosted instances. + +## When to Use This Skill + +- The git remote points to a non-GitHub host (e.g. `gitea@host:owner/repo.git`) +- `gh` CLI is not available or doesn't support the platform +- You need to create PRs, check CI, or manage repos on a self-hosted Gitea instance + +## Auth Detection + +```bash +# Extract owner/repo from the SSH remote +REMOTE_URL=$(git remote get-url origin) +OWNER_REPO=$(echo "$REMOTE_URL" | sed 's|.*:||; s|\.git$||') +OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1) +REPO=$(echo "$OWNER_REPO" | cut -d/ -f2) + +# Determine the Gitea API base from the remote +GITEA_HOST=$(echo "$REMOTE_URL" | sed 's|.*@||; s|:.*||') +GITEA_API="http://${GITEA_HOST}:3000/api/v1" # default port, adjust if different + +# Try to find a token +if [ -n "$GITEA_TOKEN" ]; then + TOKEN="$GITEA_TOKEN" +elif [ -f "$HOME/.gitea_token" ]; then + TOKEN=$(cat "$HOME/.gitea_token") +else + echo "No GITEA_TOKEN found — API calls will fail for write operations" + echo "Create a token at: https:///user/settings/applications" +fi +``` + +## Creating a PR + +```bash +BRANCH=$(git branch --show-current) + +curl -s -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + "${GITEA_API}/repos/${OWNER}/${REPO}/pulls" \ + -d "{ + \"title\": \"feat: add user authentication\", + \"body\": \"## Summary\\nAdds login and register API endpoints.\", + \"head\": \"$BRANCH\", + \"base\": \"master\" + }" +``` + +**Note:** Gitea defaults to `master` not `main` for the base branch. + +## Key Differences from GitHub + +| Aspect | GitHub | Gitea | +|--------|--------|-------| +| API base URL | `https://api.github.com` | `https:///api/v1` | +| Auth header | `Authorization: token ` | Same format | +| SSH remote | `git@github.com:o/r.git` | `git@:o/r.git` | +| `gh` CLI | Works | Not supported | +| Default branch | `main` | `master` | +| Auto-merge | Supported via GraphQL | Not supported | + +## Posting Comments on a PR + +Comments on a PR use the **issues/comments** endpoint (Gitea treats PRs as issues for comments): + +```bash +curl -s -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + "${GITEA_API}/repos/${OWNER}/${REPO}/issues/${PR_NUMBER}/comments" \ + -d '{"body": "Your comment text here"}' +``` + +## Checking PR Status + +```bash +# Get PR details (state, mergeable, comment/review counts) +curl -s \ + -H "Authorization: token $TOKEN" \ + "${GITEA_API}/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}" \ + | jq '{state, mergeable, comments, review_comments}' + +# List comments on a PR +curl -s \ + -H "Authorization: token $TOKEN" \ + "${GITEA_API}/repos/${OWNER}/${REPO}/issues/${PR_NUMBER}/comments" \ + | jq -r '.[] | "\(.id): \(.user.login) — \(.body[0:120])..."' +``` + +## Monitoring CI Build Status + +Gitea Actions exposes build status via the **commit status API**: + +```bash +# Combined status (state: pending/success/failure/error) +curl -s -H "Authorization: token $TOKEN" \ + "${GITEA_API}/repos/${OWNER}/${REPO}/commits/${SHA}/status" \ + | jq '{state, sha, total_count, statuses: [.statuses[] | {context, status, description, target_url}]}' +``` + +The response shape: +```json +{ + "state": "pending", + "sha": "a5342300...", + "total_count": 1, + "statuses": [{ + "context": "Build and Push Image / Build and push image (push)", + "status": "pending", + "description": "Waiting to run", + "target_url": "/armistace/resbuilder_ai/actions/runs/25/jobs/0" + }] +} +``` + +### CRITICAL: Commit status API can return empty + +The commit status API can return `{"state":"","sha":"","total_count":0,"statuses":null}` even when a build is actively running. This happens when: +- The commit was pushed but the runner hasn't picked it up yet (push is still in progress — can take 30-60+ min for large images) +- The runner is slow to report status back to Gitea +- The build is running but hasn't updated the commit status yet + +**Do not treat an empty status response as "build complete" or "no build needed".** Always cross-reference with runner logs to confirm. + +## Reading Raw File Content from a Branch + +For **public repos**, the raw endpoint works directly: + +```bash +curl -s "https:///$OWNER/$REPO/raw/branch/$BRANCH/$FILE_PATH" +``` + +For **private repos**, use the API contents endpoint: + +```bash +curl -s \ + -H "Authorization: token $TOKEN" \ + "${GITEA_API}/repos/${OWNER}/${REPO}/contents/${FILE_PATH}?ref=${BRANCH}" \ + | python3 -c "import sys,json,base64; raw=json.load(sys.stdin); print(base64.b64decode(raw['content']).decode())" +``` + +**Important:** The `/contents` endpoint resolves to the branch you specify in `?ref=`. For PR head branches, use the branch name directly (e.g. `?ref=frontend-and-fixes`) — using `ref=pulls/9/head` may return empty content for modified files because it resolves to the base branch's version of those files. + +## Merging a PR + +```bash +PR_NUMBER= + +# Merge the PR via API (squash) +curl -s -X POST \ + -H "Authorization: token $TOKEN" \ + "${GITEA_API}/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge" \ + -d '{"Do": "squash"}' + +# Delete the remote branch after merge +BRANCH=$(git branch --show-current) +git push origin --delete $BRANCH +git checkout master && git pull origin master +git branch -d $BRANCH +``` + +## What Doesn't Work + +- **`gh` CLI** — no Gitea backend. All operations use `git` + `curl`. +- **Gitea Actions REST API** (`/actions/runs`) — returns 404 for non-admin users. Use the commit status API instead. +- **Gitea Actions web UI** (`/actions`) — also returns 404 from bot tokens. Only the repo owner can see it via the browser. +- **Auto-merge** — no GraphQL endpoint available. + +## Pitfalls + +- **Gitea returns 404 for API calls without auth** — even for public repos. Always include the token. +- **Default branch is `master`** not `main` — adjust all `base` parameters. +- **HTTP vs HTTPS** — many self-hosted instances run on plain HTTP. Match the protocol. +- **Token creation** — at `User Settings → Applications → Generate New Token`. The `repo` scope covers everything. +- **PR comments use the issues endpoint** — Gitea doesn't have a separate PR comment endpoint. Use `/issues/{id}/comments`. +- **Pushing to an existing PR branch** — after pushing new commits, the PR updates automatically. No need to recreate it. +- **The `raw` endpoint** — use `/raw/branch/{branch}/{path}` not `/contents/{path}` for direct file content. +- **Contents API with PR ref** — using `?ref=pulls/N/head` on the `/contents` endpoint returns the **base branch version** of modified files, not the PR head version. Always use the branch name directly. +- **Old statuses accumulate** — `GET /commits/{sha}/statuses` returns ALL statuses ever set for that commit. Filter by `created_at` to find the latest. Use `GET /commits/{sha}/status` (singular) for the combined/current state. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e403a8d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "com.example.resbuilder" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.example.resbuilder" + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + optimization { + enable = false + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.core.ktx) + implementation(libs.material) + implementation(libs.okhttp) + implementation(libs.gson) + implementation(libs.browser) + implementation(libs.google.play.services.auth) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/resbuilder/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/resbuilder/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..a766a63 --- /dev/null +++ b/app/src/androidTest/java/com/example/resbuilder/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.resbuilder + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.resbuilder", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..caf2a3e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 0000000..d2bf3ab --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/data/model/Models.kt @@ -0,0 +1,64 @@ +package com.example.resbuilder.data.model + +data class User( + val authenticated: Boolean = false, + val email: String? = null, + val name: String? = null, + val picture: String? = null, + val is_admin: Boolean = false, + val usage_count: Int = 0, + val free_limit: Int = 2, + val can_submit: Boolean = false +) + +data class BuildRequest( + val job_title: String, + val job_text: String, + val resume_text: String? = null, + val cover_letter_text: String? = null +) + +data class BuildResponse( + val job_id: String +) + +data class Job( + val job_id: String? = null, + val type: String? = null, + val status: String = "queued", + val created_at: String? = null, + val updated_at: String? = null, + val html_content: String? = null, + val raw_content: String? = null, + val error: String? = null +) + +data class UploadResponse( + val text: String, + val filename: String, + val size: Int +) + +data class ScrapeResponse( + val text: String, + val url: String, + val length: Int +) + +data class HealthResponse( + val status: String, + val timestamp: String, + val dependencies: Map? = null +) + +data class AdminUser( + val email: String, + val name: String? = null, + val usage_count: Int = 0, + val blocked: Boolean = false, + val is_admin: Boolean = false +) + +data class AdminUsersResponse( + val users: 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 new file mode 100644 index 0000000..a808b87 --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/data/remote/ApiClient.kt @@ -0,0 +1,294 @@ +package com.example.resbuilder.data.remote + +import com.example.resbuilder.data.model.AdminUsersResponse +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.ScrapeResponse +import com.example.resbuilder.data.model.UploadResponse +import com.example.resbuilder.data.model.User +import com.google.gson.Gson +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +object ApiClient { + + internal const val API_BASE = "https://resume.aridgwayweb.com" + + private val gson = Gson() + private val cookieStore = mutableListOf() + private var bearerToken: String? = null + + private val client = OkHttpClient.Builder() + .cookieJar(object : CookieJar { + override fun saveFromResponse(url: HttpUrl, cookies: List) { + cookieStore.removeAll { existing -> + cookies.any { it.name == existing.name && it.domain == existing.domain } + } + cookieStore.addAll(cookies) + } + + override fun loadForRequest(url: HttpUrl): List { + return cookieStore.filter { it.matches(url) } + } + }) + .addInterceptor { chain -> + val req = chain.request() + val builder = req.newBuilder() + bearerToken?.let { builder.header("Authorization", "Bearer $it") } + chain.proceed(builder.build()) + } + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + fun setToken(token: String) { + bearerToken = token + } + + fun setCookies(cookies: List) { + cookieStore.clear() + cookieStore.addAll(cookies) + } + + fun clearAuth() { + bearerToken = null + cookieStore.clear() + } + + @Throws(IOException::class) + fun exchangeGoogleToken(idToken: String): String { + val json = gson.toJson(mapOf("id_token" to idToken)) + val req = Request.Builder() + .url("$API_BASE/auth/google-token") + .post(json.toRequestBody("application/json".toMediaType())) + .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 ?: "Google token exchange failed") + } + val body = res.body?.string() ?: throw IOException("Empty response") + val resp = gson.fromJson(body, Map::class.java) + return resp["token"] as? String ?: throw IOException("No token in response") + } + } + + @Throws(IOException::class) + fun healthCheck(): HealthResponse? { + val req = Request.Builder() + .url("$API_BASE/health") + .build() + client.newCall(req).execute().use { res -> + if (!res.isSuccessful) return null + val body = res.body?.string() ?: return null + return gson.fromJson(body, HealthResponse::class.java) + } + } + + @Throws(IOException::class) + fun getMe(): User { + val req = Request.Builder() + .url("$API_BASE/auth/me") + .build() + client.newCall(req).execute().use { res -> + if (!res.isSuccessful) return User(authenticated = false) + val body = res.body?.string() ?: return User(authenticated = false) + return gson.fromJson(body, User::class.java) + } + } + + fun getLoginUrl(): String = "$API_BASE/auth/login" + fun getLogoutUrl(): String = "$API_BASE/auth/logout" + + @Throws(IOException::class) + fun buildResume(jobTitle: String, jobText: String, resumeText: String): BuildResponse { + val payload = BuildRequest( + job_title = jobTitle, + job_text = jobText, + resume_text = resumeText + ) + val json = gson.toJson(payload) + val req = Request.Builder() + .url("$API_BASE/build_resume") + .post(json.toRequestBody("application/json".toMediaType())) + .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 } + when (res.code) { + 401 -> throw IOException("Please log in first") + 403 -> throw IOException(detail ?: "You have reached your free limit") + else -> throw IOException(detail ?: "Failed to submit resume job") + } + } + val body = res.body?.string() ?: throw IOException("Empty response") + return gson.fromJson(body, BuildResponse::class.java) + } + } + + @Throws(IOException::class) + fun buildCoverLetter(jobTitle: String, jobText: String, coverLetterText: String): BuildResponse { + val payload = BuildRequest( + job_title = jobTitle, + job_text = jobText, + cover_letter_text = coverLetterText + ) + val json = gson.toJson(payload) + val req = Request.Builder() + .url("$API_BASE/build_cover_letter") + .post(json.toRequestBody("application/json".toMediaType())) + .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 } + when (res.code) { + 401 -> throw IOException("Please log in first") + 403 -> throw IOException(detail ?: "You have reached your free limit") + else -> throw IOException(detail ?: "Failed to submit cover letter job") + } + } + val body = res.body?.string() ?: throw IOException("Empty response") + return gson.fromJson(body, BuildResponse::class.java) + } + } + + @Throws(IOException::class) + fun getJob(jobId: String): Job? { + val req = Request.Builder() + .url("$API_BASE/job/$jobId") + .build() + client.newCall(req).execute().use { res -> + if (res.code == 404) return null + if (!res.isSuccessful) throw IOException("Failed to fetch job status") + val body = res.body?.string() ?: return null + return gson.fromJson(body, Job::class.java) + } + } + + @Throws(IOException::class) + fun uploadResume(file: File): UploadResponse { + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", file.name, file.asRequestBody("application/octet-stream".toMediaType())) + .build() + val req = Request.Builder() + .url("$API_BASE/upload/resume") + .post(body) + .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 ?: "Upload failed") + } + val respBody = res.body?.string() ?: throw IOException("Empty response") + return gson.fromJson(respBody, UploadResponse::class.java) + } + } + + @Throws(IOException::class) + fun uploadCoverLetter(file: File): UploadResponse { + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("file", file.name, file.asRequestBody("application/octet-stream".toMediaType())) + .build() + val req = Request.Builder() + .url("$API_BASE/upload/cover_letter") + .post(body) + .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 ?: "Upload failed") + } + val respBody = res.body?.string() ?: throw IOException("Empty response") + return gson.fromJson(respBody, UploadResponse::class.java) + } + } + + @Throws(IOException::class) + fun scrapeJobUrl(url: String): ScrapeResponse { + val json = gson.toJson(mapOf("url" to url)) + val req = Request.Builder() + .url("$API_BASE/scrape-job") + .post(json.toRequestBody("application/json".toMediaType())) + .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 scrape URL") + } + val body = res.body?.string() ?: throw IOException("Empty response") + return gson.fromJson(body, ScrapeResponse::class.java) + } + } + + @Throws(IOException::class) + fun adminGetUsers(): AdminUsersResponse { + val req = Request.Builder() + .url("$API_BASE/admin/users") + .build() + client.newCall(req).execute().use { res -> + if (!res.isSuccessful) throw IOException("Failed to fetch users") + val body = res.body?.string() ?: throw IOException("Empty response") + return gson.fromJson(body, AdminUsersResponse::class.java) + } + } + + @Throws(IOException::class) + fun adminReleaseUser(email: String): Boolean { + val req = Request.Builder() + .url("$API_BASE/admin/users/${java.net.URLEncoder.encode(email, "UTF-8")}/release") + .post(FormBody.Builder().build()) + .build() + client.newCall(req).execute().use { res -> + return res.isSuccessful + } + } + + @Throws(IOException::class) + fun adminBlockUser(email: String): Boolean { + val req = Request.Builder() + .url("$API_BASE/admin/users/${java.net.URLEncoder.encode(email, "UTF-8")}/block") + .post(FormBody.Builder().build()) + .build() + client.newCall(req).execute().use { res -> + return res.isSuccessful + } + } + + fun getExportPdfUrl(jobId: String): String = "$API_BASE/export/$jobId/pdf" + fun getExportDocxUrl(jobId: String): String = "$API_BASE/export/$jobId/docx" +} \ No newline at end of file diff --git a/app/src/main/java/com/example/resbuilder/ui/AdminActivity.kt b/app/src/main/java/com/example/resbuilder/ui/AdminActivity.kt new file mode 100644 index 0000000..9c8b713 --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/AdminActivity.kt @@ -0,0 +1,85 @@ +package com.example.resbuilder.ui + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.resbuilder.R +import com.example.resbuilder.data.model.AdminUser +import com.example.resbuilder.data.remote.ApiClient +import com.google.android.material.appbar.MaterialToolbar + +class AdminActivity : AppCompatActivity() { + + private lateinit var toolbar: MaterialToolbar + private lateinit var recyclerView: RecyclerView + private lateinit var adapter: AdminUserAdapter + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_admin) + + toolbar = findViewById(R.id.adminToolbar) + recyclerView = findViewById(R.id.adminRecyclerView) + + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + adapter = AdminUserAdapter { user, block -> + onUserAction(user, block) + } + recyclerView.layoutManager = LinearLayoutManager(this) + recyclerView.adapter = adapter + + loadUsers() + } + + private fun loadUsers() { + Thread { + try { + val response = ApiClient.adminGetUsers() + handler.post { + adapter.submitList(response.users) + } + } catch (e: Exception) { + handler.post { + Toast.makeText(this, e.message ?: "Failed to load users", Toast.LENGTH_LONG).show() + } + } + }.start() + } + + private fun onUserAction(user: AdminUser, block: Boolean) { + Thread { + val success = try { + if (block) ApiClient.adminBlockUser(user.email) else ApiClient.adminReleaseUser(user.email) + } catch (e: Exception) { + false + } + handler.post { + Toast.makeText( + this, + if (success) R.string.admin_action_success else R.string.admin_action_failed, + Toast.LENGTH_SHORT + ).show() + if (success) loadUsers() + } + }.start() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } +} diff --git a/app/src/main/java/com/example/resbuilder/ui/AdminUserAdapter.kt b/app/src/main/java/com/example/resbuilder/ui/AdminUserAdapter.kt new file mode 100644 index 0000000..ef4c6b6 --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/AdminUserAdapter.kt @@ -0,0 +1,61 @@ +package com.example.resbuilder.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.resbuilder.R +import com.example.resbuilder.data.model.AdminUser +import com.google.android.material.button.MaterialButton + +class AdminUserAdapter( + private val onAction: (AdminUser, Boolean) -> Unit +) : RecyclerView.Adapter() { + + private val users = mutableListOf() + + fun submitList(list: List) { + users.clear() + users.addAll(list) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_admin_user, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(users[position]) + } + + override fun getItemCount(): Int = users.size + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val email: TextView = itemView.findViewById(R.id.userEmail) + private val name: TextView = itemView.findViewById(R.id.userName) + private val usage: TextView = itemView.findViewById(R.id.userUsage) + private val actionButton: MaterialButton = itemView.findViewById(R.id.actionButton) + + fun bind(user: AdminUser) { + email.text = user.email + name.text = user.name ?: "" + name.visibility = if (user.name.isNullOrEmpty()) View.GONE else View.VISIBLE + usage.text = itemView.context.getString(R.string.usage_format, user.usage_count, 0) + + if (user.is_admin) { + actionButton.visibility = View.GONE + } else { + actionButton.visibility = View.VISIBLE + if (user.blocked) { + actionButton.text = itemView.context.getString(R.string.release_user) + actionButton.setOnClickListener { onAction(user, false) } + } else { + actionButton.text = itemView.context.getString(R.string.block_user) + actionButton.setOnClickListener { onAction(user, true) } + } + } + } + } +} diff --git a/app/src/main/java/com/example/resbuilder/ui/JobFormFragment.kt b/app/src/main/java/com/example/resbuilder/ui/JobFormFragment.kt new file mode 100644 index 0000000..cf464df --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/JobFormFragment.kt @@ -0,0 +1,256 @@ +package com.example.resbuilder.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.OpenableColumns +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import com.example.resbuilder.R +import com.example.resbuilder.data.model.UploadResponse +import com.example.resbuilder.data.model.User +import com.example.resbuilder.data.remote.ApiClient +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import java.io.File +import java.io.FileOutputStream + +class JobFormFragment : Fragment() { + + companion object { + const val TYPE_RESUME = "resume" + const val TYPE_COVER_LETTER = "cover_letter" + private const val ARG_TYPE = "type" + + fun newInstance(type: String): JobFormFragment { + return JobFormFragment().apply { + arguments = Bundle().apply { putString(ARG_TYPE, type) } + } + } + } + + interface JobFormListener { + fun onJobSubmitted(jobId: String, type: String) + } + + private var listener: JobFormListener? = null + private val type: String by lazy { arguments?.getString(ARG_TYPE) ?: TYPE_RESUME } + + private lateinit var jobTitleInput: TextInputEditText + private lateinit var seekUrlInput: TextInputEditText + private lateinit var scrapeButton: MaterialButton + private lateinit var jobDescriptionInput: TextInputEditText + private lateinit var uploadButton: MaterialButton + private lateinit var docInputLayout: TextInputLayout + private lateinit var docInput: TextInputEditText + private lateinit var submitButton: MaterialButton + private lateinit var progressBar: ProgressBar + + private val handler = Handler(Looper.getMainLooper()) + private var user: User = User() + + private lateinit var filePickerLauncher: ActivityResultLauncher + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = context as? JobFormListener + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + filePickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + uploadFile(uri) + } + } + } + loadUser() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_job_form, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + jobTitleInput = view.findViewById(R.id.jobTitleInput) + seekUrlInput = view.findViewById(R.id.seekUrlInput) + scrapeButton = view.findViewById(R.id.scrapeButton) + jobDescriptionInput = view.findViewById(R.id.jobDescriptionInput) + uploadButton = view.findViewById(R.id.uploadButton) + docInputLayout = view.findViewById(R.id.docInputLayout) + docInput = view.findViewById(R.id.docInput) + submitButton = view.findViewById(R.id.submitButton) + progressBar = view.findViewById(R.id.progressBar) + + docInputLayout.hint = getString(if (type == TYPE_COVER_LETTER) R.string.hint_your_cover_letter else R.string.hint_your_resume) + submitButton.text = getString(if (type == TYPE_COVER_LETTER) R.string.generate_cover_letter else R.string.generate_resume) + + scrapeButton.setOnClickListener { scrapeUrl() } + uploadButton.setOnClickListener { pickFile() } + submitButton.setOnClickListener { submitJob() } + + jobTitleInput.doAfterTextChanged { validateForm() } + jobDescriptionInput.doAfterTextChanged { validateForm() } + docInput.doAfterTextChanged { validateForm() } + } + + private fun loadUser() { + Thread { + try { + user = ApiClient.getMe() + handler.post { validateForm() } + } catch (e: Exception) { + user = User(authenticated = false) + handler.post { validateForm() } + } + }.start() + } + + private fun showLoading(loading: Boolean) { + progressBar.visibility = if (loading) View.VISIBLE else View.GONE + scrapeButton.isEnabled = !loading + uploadButton.isEnabled = !loading + submitButton.isEnabled = !loading + } + + private fun validateForm() { + val authenticated = user.authenticated + val overLimit = !user.is_admin && !user.can_submit + val hasTitle = jobTitleInput.text?.toString()?.isNotBlank() == true + val hasJob = jobDescriptionInput.text?.toString()?.isNotBlank() == true + val hasDoc = docInput.text?.toString()?.isNotBlank() == true + + submitButton.isEnabled = authenticated && !overLimit && hasTitle && hasJob && hasDoc + submitButton.text = when { + !authenticated -> getString(R.string.please_log_in) + overLimit -> getString(R.string.usage_limit_banner) + type == TYPE_COVER_LETTER -> getString(R.string.generate_cover_letter) + else -> getString(R.string.generate_resume) + } + } + + private fun scrapeUrl() { + val url = seekUrlInput.text?.toString()?.trim() ?: "" + if (url.isEmpty()) { + seekUrlInput.error = "Enter a URL" + return + } + showLoading(true) + Thread { + try { + val resp = ApiClient.scrapeJobUrl(url) + handler.post { + showLoading(false) + jobDescriptionInput.setText(resp.text) + } + } catch (e: Exception) { + handler.post { + showLoading(false) + Toast.makeText(context, e.message ?: "Scrape failed", Toast.LENGTH_LONG).show() + } + } + }.start() + } + + private fun pickFile() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/msword")) + } + filePickerLauncher.launch(intent) + } + + private fun uploadFile(uri: Uri) { + val context = requireContext() + showLoading(true) + Thread { + try { + val file = uriToFile(context, uri) + val resp: UploadResponse = if (type == TYPE_COVER_LETTER) ApiClient.uploadCoverLetter(file) else ApiClient.uploadResume(file) + file.delete() + handler.post { + showLoading(false) + docInput.setText(resp.text) + } + } catch (e: Exception) { + handler.post { + showLoading(false) + Toast.makeText(context, e.message ?: "Upload failed", Toast.LENGTH_LONG).show() + } + } + }.start() + } + + private fun uriToFile(context: Context, uri: Uri): File { + val fileName = getFileName(context, uri) ?: "document.pdf" + val tempFile = File(context.cacheDir, fileName) + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + return tempFile + } + + private fun getFileName(context: Context, uri: Uri): String? { + if (uri.scheme == "content") { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx >= 0) return cursor.getString(idx) + } + } + } + return uri.lastPathSegment + } + + private fun submitJob() { + val title = jobTitleInput.text?.toString()?.trim() ?: "" + val jobText = jobDescriptionInput.text?.toString()?.trim() ?: "" + val docText = docInput.text?.toString()?.trim() ?: "" + if (title.isEmpty() || jobText.isEmpty() || docText.isEmpty()) { + Toast.makeText(context, R.string.fields_required, Toast.LENGTH_SHORT).show() + return + } + showLoading(true) + Thread { + try { + val resp = if (type == TYPE_COVER_LETTER) { + ApiClient.buildCoverLetter(title, jobText, docText) + } else { + ApiClient.buildResume(title, jobText, docText) + } + handler.post { + showLoading(false) + listener?.onJobSubmitted(resp.job_id, type) + } + } catch (e: Exception) { + handler.post { + showLoading(false) + Toast.makeText(context, e.message ?: "Submit failed", Toast.LENGTH_LONG).show() + } + } + }.start() + } + + override fun onDestroy() { + super.onDestroy() + handler.removeCallbacksAndMessages(null) + } +} diff --git a/app/src/main/java/com/example/resbuilder/ui/JobStatusFragment.kt b/app/src/main/java/com/example/resbuilder/ui/JobStatusFragment.kt new file mode 100644 index 0000000..e337276 --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/JobStatusFragment.kt @@ -0,0 +1,173 @@ +package com.example.resbuilder.ui + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +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 + +class JobStatusFragment : Fragment() { + + companion object { + private const val ARG_JOB_ID = "job_id" + private const val ARG_TYPE = "type" + private const val POLL_INTERVAL_MS = 2000L + private const val TIMEOUT_MS = 600000L + + fun newInstance(jobId: String, type: String): JobStatusFragment { + return JobStatusFragment().apply { + arguments = Bundle().apply { + putString(ARG_JOB_ID, jobId) + putString(ARG_TYPE, type) + } + } + } + } + + private lateinit var statusBadge: TextView + private lateinit var statusProgress: ProgressBar + private lateinit var statusMessage: TextView + private lateinit var statusDetail: TextView + private lateinit var tryAgainButton: MaterialButton + + private val handler = Handler(Looper.getMainLooper()) + private var pollRunnable: Runnable? = null + private var startTime = 0L + private var stopped = false + + private val jobId: String by lazy { arguments?.getString(ARG_JOB_ID) ?: "" } + private val type: String by lazy { arguments?.getString(ARG_TYPE) ?: JobFormFragment.TYPE_RESUME } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_job_status, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + statusBadge = view.findViewById(R.id.statusBadge) + statusProgress = view.findViewById(R.id.statusProgress) + statusMessage = view.findViewById(R.id.statusMessage) + statusDetail = view.findViewById(R.id.statusDetail) + tryAgainButton = view.findViewById(R.id.tryAgainButton) + + tryAgainButton.setOnClickListener { + requireActivity().supportFragmentManager.popBackStackImmediate() + } + + startPolling() + } + + private fun startPolling() { + startTime = System.currentTimeMillis() + val r = object : Runnable { + override fun run() { + if (stopped) return + pollJob() + } + } + pollRunnable = r + handler.post(r) + } + + private fun pollJob() { + Thread { + try { + val job = ApiClient.getJob(jobId) + handler.post { + if (stopped) return@post + if (job == null) { + onFailed(getString(R.string.job_failed_message)) + return@post + } + updateUI(job) + when (job.status.uppercase()) { + "COMPLETED" -> onCompleted(job) + "FAILED" -> onFailed(job.error ?: getString(R.string.job_failed_message)) + else -> scheduleNextPoll() + } + } + } catch (e: Exception) { + handler.post { + if (stopped) return@post + scheduleNextPoll() + } + } + }.start() + } + + private fun scheduleNextPoll() { + if (System.currentTimeMillis() - startTime > TIMEOUT_MS) { + onFailed(getString(R.string.job_timeout_message)) + return + } + pollRunnable?.let { handler.postDelayed(it, POLL_INTERVAL_MS) } + } + + private fun updateUI(job: Job) { + val status = job.status.uppercase() + statusBadge.text = status + val (bgColor, textColor) = when (status) { + "QUEUED" -> Pair(R.color.status_badge_queued, R.color.white) + "RUNNING" -> Pair(R.color.status_badge_running, R.color.white) + "COMPLETED" -> Pair(R.color.status_badge_completed, R.color.white) + "FAILED" -> Pair(R.color.status_badge_failed, R.color.white) + else -> Pair(R.color.status_badge_queued, R.color.black) + } + statusBadge.setBackgroundColor(ContextCompat.getColor(requireContext(), bgColor)) + statusBadge.setTextColor(ContextCompat.getColor(requireContext(), textColor)) + if (!job.error.isNullOrEmpty()) { + statusDetail.text = job.error + statusDetail.visibility = View.VISIBLE + } + } + + private fun onCompleted(job: Job) { + statusProgress.visibility = View.GONE + statusMessage.text = getString(R.string.status_completed) + tryAgainButton.visibility = View.GONE + + val resultFragment = ResultFragment.newInstance(job, type) + requireActivity().supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, resultFragment) + .addToBackStack(null) + .commitAllowingStateLoss() + } + + private fun onFailed(message: String) { + statusProgress.visibility = View.GONE + statusMessage.text = message + statusBadge.text = getString(R.string.status_failed) + statusBadge.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.status_badge_failed)) + statusBadge.setTextColor(ContextCompat.getColor(requireContext(), R.color.white)) + tryAgainButton.visibility = View.VISIBLE + statusDetail.visibility = View.GONE + } + + override fun onPause() { + super.onPause() + stopped = true + pollRunnable?.let { handler.removeCallbacks(it) } + } + + override fun onResume() { + super.onResume() + stopped = false + } + + override fun onDestroy() { + super.onDestroy() + stopped = true + pollRunnable?.let { handler.removeCallbacks(it) } + handler.removeCallbacksAndMessages(null) + } +} diff --git a/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt b/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt new file mode 100644 index 0000000..b6dae09 --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/LoginActivity.kt @@ -0,0 +1,155 @@ +package com.example.resbuilder.ui + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.ProgressBar +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +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 + +class LoginActivity : AppCompatActivity() { + + private lateinit var googleSignInButton: SignInButton + private lateinit var progress: ProgressBar + + private val handler = Handler(Looper.getMainLooper()) + + private lateinit var googleSignInLauncher: ActivityResultLauncher + + // Same client ID registered in Google Cloud Console for the backend OAuth + private val webClientId = "416332591622-godddvcplbobkhv6uvabg94pvtukp61u.apps.googleusercontent.com" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_login) + + googleSignInButton = findViewById(R.id.googleSignInButton) + progress = findViewById(R.id.loginProgress) + + googleSignInButton.setOnClickListener { launchGoogleSignIn() } + + googleSignInLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { 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(true) + checkExistingAuth() + } + + private fun showLoading(loading: Boolean) { + progress.visibility = if (loading) View.VISIBLE else View.GONE + googleSignInButton.isEnabled = !loading + } + + private fun checkExistingAuth() { + Thread { + try { + val user = ApiClient.getMe() + handler.post { + showLoading(false) + if (user.authenticated) { + launchMain() + } + } + } catch (_: Exception) { + handler.post { showLoading(false) } + } + }.start() + } + + private fun launchGoogleSignIn() { + showLoading(true) + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(webClientId) + .requestEmail() + .build() + val client = GoogleSignIn.getClient(this, gso) + // Clear any previous sign-in to force account picker + client.signOut().addOnCompleteListener { + val signInIntent = client.signInIntent + googleSignInLauncher.launch(signInIntent) + } + } + + 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() + } + } + + private fun exchangeToken(idToken: String) { + Thread { + 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() + } + } + } catch (e: Exception) { + handler.post { + showLoading(false) + Toast.makeText( + this, + e.message ?: "Token exchange failed", + Toast.LENGTH_LONG + ).show() + } + } + }.start() + } + + private fun launchMain() { + startActivity(Intent(this, MainActivity::class.java)) + finish() + } + + override fun onDestroy() { + super.onDestroy() + handler.removeCallbacksAndMessages(null) + } +} diff --git a/app/src/main/java/com/example/resbuilder/ui/MainActivity.kt b/app/src/main/java/com/example/resbuilder/ui/MainActivity.kt new file mode 100644 index 0000000..d36a827 --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/MainActivity.kt @@ -0,0 +1,192 @@ +package com.example.resbuilder.ui + +import android.content.Intent +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 +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +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 + +class MainActivity : AppCompatActivity(), JobFormFragment.JobFormListener, ResultFragment.OnNewListener { + + private lateinit var toolbar: MaterialToolbar + private lateinit var tabLayout: TabLayout + private lateinit var healthIndicator: ImageView + private lateinit var userName: TextView + private lateinit var usageBanner: TextView + + private val handler = Handler(Looper.getMainLooper()) + private var user: User = User() + private var healthRunnable: Runnable? = null + + companion object { + private const val TAB_RESUME = 0 + private const val TAB_COVER = 1 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + toolbar = findViewById(R.id.toolbar) + tabLayout = findViewById(R.id.tabLayout) + healthIndicator = findViewById(R.id.healthIndicator) + userName = findViewById(R.id.userName) + usageBanner = findViewById(R.id.usageBanner) + + setSupportActionBar(toolbar) + + 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) + } + }) + + loadUser() + startHealthPolling() + if (savedInstanceState == null) { + switchFormTab(TAB_RESUME) + } + } + + private fun loadUser() { + Thread { + try { + user = ApiClient.getMe() + runOnUiThread { updateUserUI() } + } catch (e: Exception) { + runOnUiThread { + user = User(authenticated = false) + updateUserUI() + Toast.makeText(this, e.message ?: "Failed to load user", Toast.LENGTH_SHORT).show() + } + } + }.start() + } + + 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 + } else { + usageBanner.visibility = 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() + } + + private fun startHealthPolling() { + val r = object : Runnable { + override fun run() { + checkHealth() + handler.postDelayed(this, 30000) + } + } + healthRunnable = r + handler.post(r) + } + + private fun checkHealth() { + Thread { + val colorRes = try { + val health = ApiClient.healthCheck() + when (health?.status?.lowercase()) { + "ok", "healthy", "up" -> R.color.status_healthy + else -> R.color.status_degraded + } + } catch (e: Exception) { + R.color.status_unhealthy + } + runOnUiThread { + healthIndicator.setColorFilter(ContextCompat.getColor(this, colorRes), PorterDuff.Mode.SRC_IN) + } + }.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) + } + } + + private fun signOut() { + Thread { + try { + ApiClient.clearAuth() + } catch (_: Exception) {} + runOnUiThread { + startActivity(Intent(this, LoginActivity::class.java)) + finish() + } + }.start() + } + + override fun onJobSubmitted(jobId: String, type: String) { + val statusFragment = JobStatusFragment.newInstance(jobId, type) + supportFragmentManager.beginTransaction() + .replace(R.id.fragmentContainer, statusFragment) + .addToBackStack(null) + .commitAllowingStateLoss() + } + + fun onResultNewJob() { + supportFragmentManager.popBackStackImmediate() + tabLayout.getTabAt(TAB_RESUME)?.select() + switchFormTab(TAB_RESUME) + } + + override fun onNewJob() { + onResultNewJob() + } + + override fun onDestroy() { + super.onDestroy() + healthRunnable?.let { handler.removeCallbacks(it) } + handler.removeCallbacksAndMessages(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 new file mode 100644 index 0000000..5c39cec --- /dev/null +++ b/app/src/main/java/com/example/resbuilder/ui/ResultFragment.kt @@ -0,0 +1,116 @@ +package com.example.resbuilder.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +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 + +class ResultFragment : Fragment() { + + companion object { + private const val ARG_JOB_ID = "job_id" + private const val ARG_HTML = "html_content" + private const val ARG_RAW = "raw_content" + private const val ARG_TYPE = "type" + + fun newInstance(job: Job, type: String): ResultFragment { + return ResultFragment().apply { + arguments = Bundle().apply { + putString(ARG_JOB_ID, job.job_id) + putString(ARG_HTML, job.html_content) + putString(ARG_RAW, job.raw_content) + putString(ARG_TYPE, type) + } + } + } + } + + interface OnNewListener { + fun onNewJob() + } + + private var listener: OnNewListener? = null + + private lateinit var resultBadge: TextView + private lateinit var resultWebView: WebView + private lateinit var copyButton: MaterialButton + private lateinit var newButton: MaterialButton + private lateinit var pdfButton: MaterialButton + private lateinit var docxButton: MaterialButton + + private val jobId: String? by lazy { arguments?.getString(ARG_JOB_ID) } + private val htmlContent: String? by lazy { arguments?.getString(ARG_HTML) } + private val rawContent: String? by lazy { arguments?.getString(ARG_RAW) } + private val type: String by lazy { arguments?.getString(ARG_TYPE) ?: JobFormFragment.TYPE_RESUME } + + override fun onAttach(context: Context) { + super.onAttach(context) + listener = (parentFragment as? OnNewListener) ?: (context as? OnNewListener) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_result, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + resultBadge = view.findViewById(R.id.resultBadge) + resultWebView = view.findViewById(R.id.resultWebView) + copyButton = view.findViewById(R.id.copyButton) + newButton = view.findViewById(R.id.newButton) + pdfButton = view.findViewById(R.id.pdfButton) + docxButton = view.findViewById(R.id.docxButton) + + resultBadge.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.status_badge_completed)) + resultBadge.setTextColor(ContextCompat.getColor(requireContext(), R.color.white)) + + resultWebView.settings.javaScriptEnabled = false + val html = htmlContent ?: "

No content available.

" + resultWebView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) + + copyButton.setOnClickListener { copyText() } + newButton.setOnClickListener { listener?.onNewJob() } + + jobId?.let { id -> + pdfButton.setOnClickListener { openUrl(ApiClient.getExportPdfUrl(id)) } + docxButton.setOnClickListener { openUrl(ApiClient.getExportDocxUrl(id)) } + } ?: run { + pdfButton.isEnabled = false + docxButton.isEnabled = false + } + } + + private fun copyText() { + val raw = rawContent + if (raw.isNullOrEmpty()) { + Toast.makeText(context, R.string.nothing_to_copy, Toast.LENGTH_SHORT).show() + return + } + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Generated document", raw)) + 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/keepRules/rules.keep b/app/src/main/keepRules/rules.keep new file mode 100644 index 0000000..d7e081a --- /dev/null +++ b/app/src/main/keepRules/rules.keep @@ -0,0 +1,12 @@ +# Add project specific R8 rules here. +# AGP will combine all keep rule files in src/main/keepRules to pass to R8 +# +# For more details, see +# https://d.android.com/r/tools/r8/keep-rules + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_banner_warning.xml b/app/src/main/res/drawable/bg_banner_warning.xml new file mode 100644 index 0000000..24271aa --- /dev/null +++ b/app/src/main/res/drawable/bg_banner_warning.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/circle_status.xml b/app/src/main/res/drawable/circle_status.xml new file mode 100644 index 0000000..52a072a --- /dev/null +++ b/app/src/main/res/drawable/circle_status.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_circle_status.xml b/app/src/main/res/drawable/ic_circle_status.xml new file mode 100644 index 0000000..7d3a35f --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_status.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_admin.xml b/app/src/main/res/layout/activity_admin.xml new file mode 100644 index 0000000..8d6f461 --- /dev/null +++ b/app/src/main/res/layout/activity_admin.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..369520e --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,42 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..274b5b5 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_job_form.xml b/app/src/main/res/layout/fragment_job_form.xml new file mode 100644 index 0000000..031e6d0 --- /dev/null +++ b/app/src/main/res/layout/fragment_job_form.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_job_status.xml b/app/src/main/res/layout/fragment_job_status.xml new file mode 100644 index 0000000..6fe9dbc --- /dev/null +++ b/app/src/main/res/layout/fragment_job_status.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml new file mode 100644 index 0000000..b991da0 --- /dev/null +++ b/app/src/main/res/layout/fragment_result.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_admin_user.xml b/app/src/main/res/layout/item_admin_user.xml new file mode 100644 index 0000000..0d8061e --- /dev/null +++ b/app/src/main/res/layout/item_admin_user.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml new file mode 100644 index 0000000..69dd90e --- /dev/null +++ b/app/src/main/res/menu/menu_main.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..9d9df44 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..beaeb61 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,20 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + + #FF4CAF50 + #FFFFC107 + #FFF44336 + #FFFFF3E0 + #FFE65100 + #FF9E9E9E + #FF2196F3 + #FF4CAF50 + #FFF44336 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b86b09c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,55 @@ + + resBuilder + + + Sign in with Google + Checking authentication… + + + resBuilder + Build Resume + Build Cover Letter + Sign Out + Admin Panel + You have reached your free limit. Please contact support to upgrade. + Unknown user + + + Job Title + SEEK job URL + Scrape + Job Description + Upload + Your Resume + Your Cover Letter + Generate Resume + Generate Cover Letter + Please log in to continue + Please fill in all fields + + + Queued + Running + Completed + Failed + This usually takes 1–5 minutes. + Job failed + Job timed out. Please try again. + Try again + + + Copy Text + New + PDF + DOCX + Copied to clipboard + Nothing to copy + + + Admin Panel + Block + Release + Action completed + Action failed + %1$d / %2$d + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bb472dc --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4df9255 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/example/resbuilder/ExampleUnitTest.kt b/app/src/test/java/com/example/resbuilder/ExampleUnitTest.kt new file mode 100644 index 0000000..da3f5d3 --- /dev/null +++ b/app/src/test/java/com/example/resbuilder/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.example.resbuilder + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3756278 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..99fc155 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# When enabled, the Configuration Cache allows Gradle to skip the configuration +# phase entirely if nothing that affects the build configuration (such as build scripts) +# has changed. Additionally, Gradle applies performance optimizations to task execution. +org.gradle.configuration-cache=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..2119376 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,28 @@ +[versions] +agp = "9.2.1" +coreKtx = "1.10.1" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +appcompat = "1.6.1" +material = "1.10.0" +okhttp = "4.12.0" +gson = "2.10.1" +browser = "1.7.0" +googlePlayServicesAuth = "20.7.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +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" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +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" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7581889 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +#Sat Jul 18 22:29:21 AEST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..39653f8 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "resBuilder" +include(":app")