A job portal is a good last project because it joins list screens, filtering, forms, file handling and backend calls. It is also close to this platform's own purpose. The app has two main screens: a searchable, filterable list of jobs, and a detail screen where the user attaches a resume and applies.
The app needs a backend that you provide: GET /jobs returns a list and POST /jobs/{id}/apply accepts a multipart request with a cover note and a file. Authentication is left out here to keep the lesson focused. A real portal would use the sign-in pattern from projects 13 and 18 and attach the token to every request.
Concepts in this project
- Filtering in plain Kotlin:
List<Job>.filtered(JobFilter)combines a text search, a remote-only switch and a job type. It is a pure function with unit tests. - Derived list:
JobsState.shownis computed from the full list and the filter, so the list and filters can never disagree. - Filter chips:
FilterChipfor remote and job types, toggled on and off. - Storage Access Framework:
ActivityResultContracts.OpenDocumentlets the user pick a file without any storage permission. - File checks before upload:
resumeProblemrejects the wrong type, an empty file and a file over 5 MB. The server must check again. - Multipart upload: a text part for the cover note and a file part for the resume, sent with Retrofit.
Create the project
- New Empty Activity project
Jobs, packagecom.example.jobs. - Add Retrofit and serialization from the Project 21 block and the INTERNET permission.
- Prepare a backend (or a mock server) that returns the JSON for
Joband accepts the upload. - Create the files below.
`Gradle dependencies for projects 11 to 21 (use the block for this project)`
// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")
// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.
// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.
// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.
// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")
// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Jobs/app/src/main/java/com/example/jobs/
JobModels.kt Job, filter logic, resume rules
JobApi.kt Retrofit interface (list, apply)
JobViewModel.kt state, filters, upload
JobScreens.kt list with chips, detail with upload
MainActivity.kt
app/src/test/.../JobTest.kt
The code
`app/src/main/java/com/example/jobs/JobModels.kt`
package com.example.jobs
import kotlinx.serialization.Serializable
@Serializable
data class Job(
val id: String,
val title: String,
val company: String,
val location: String,
val type: String = "Full-time", // Full-time, Part-time, Internship, Contract
val remote: Boolean = false,
val description: String = "",
)
@Serializable
data class ApplyResult(val ok: Boolean, val message: String = "")
data class JobFilter(
val query: String = "",
val remoteOnly: Boolean = false,
val type: String? = null,
)
// Filtering is done in plain Kotlin, so it is easy to test. A real backend would also filter on the server.
fun List<Job>.filtered(f: JobFilter): List<Job> {
val q = f.query.trim().lowercase()
return filter { job ->
(q.isEmpty() || job.title.lowercase().contains(q) || job.company.lowercase().contains(q) || job.location.lowercase().contains(q)) &&
(!f.remoteOnly || job.remote) &&
(f.type == null || job.type == f.type)
}
}
// Accept only resume files of reasonable size and type before uploading.
fun resumeProblem(mime: String?, sizeBytes: Long): String? {
val allowed = setOf(
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return when {
mime == null || mime !in allowed -> "Choose a PDF or Word file."
sizeBytes <= 0 -> "The file is empty."
sizeBytes > 5L * 1024 * 1024 -> "The file must be 5 MB or smaller."
else -> null
}
}
`app/src/main/java/com/example/jobs/JobApi.kt`
package com.example.jobs
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
// The contract your own backend provides. The job list and applications are stored on the server.
interface JobApi {
@GET("jobs")
suspend fun jobs(@Query("page") page: Int): List<Job>
@Multipart
@POST("jobs/{id}/apply")
suspend fun apply(
@Path("id") id: String,
@Part("cover") cover: RequestBody,
@Part resume: MultipartBody.Part,
): ApplyResult
}
`app/src/main/java/com/example/jobs/JobViewModel.kt`
package com.example.jobs
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
data class JobsState(
val all: List<Job> = emptyList(),
val loading: Boolean = true,
val error: String? = null,
val filter: JobFilter = JobFilter(),
val applyMessage: String? = null,
val applying: Boolean = false,
) {
val shown: List<Job> get() = all.filtered(filter)
}
class JobViewModel(private val api: JobApi) : ViewModel() {
private val _state = MutableStateFlow(JobsState())
val state: StateFlow<JobsState> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
try {
val jobs = api.jobs(page = 1)
_state.update { it.copy(all = jobs, loading = false) }
} catch (e: Exception) {
_state.update { it.copy(loading = false, error = "Could not load jobs. Pull to try again.") }
}
}
}
fun setFilter(f: JobFilter) {
_state.update { it.copy(filter = f) }
}
fun apply(resolver: ContentResolver, jobId: String, cover: String, resume: Uri) {
viewModelScope.launch {
val mime = resolver.getType(resume)
val size = withContext(Dispatchers.IO) {
resolver.query(resume, null, null, null, null)?.use { c ->
val col = c.getColumnIndex(OpenableColumns.SIZE)
if (c.moveToFirst() && col >= 0) c.getLong(col) else 0L
} ?: 0L
}
val problem = resumeProblem(mime, size)
if (problem != null) {
_state.update { it.copy(applyMessage = problem) }
return@launch
}
_state.update { it.copy(applying = true, applyMessage = null) }
try {
val bytes = withContext(Dispatchers.IO) { resolver.openInputStream(resume)!!.use { it.readBytes() } }
val part = MultipartBody.Part.createFormData(
"resume",
"resume",
bytes.toRequestBody(mime!!.toMediaType()),
)
val result = api.apply(jobId, cover.take(2000).toRequestBody("text/plain".toMediaType()), part)
_state.update { it.copy(applying = false, applyMessage = if (result.ok) "Application sent" else result.message) }
} catch (e: Exception) {
_state.update { it.copy(applying = false, applyMessage = "Could not send the application. Try again.") }
}
}
}
fun messageShown() {
_state.update { it.copy(applyMessage = null) }
}
}
`app/src/main/java/com/example/jobs/JobScreens.kt`
package com.example.jobs
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@Composable
fun JobApp(vm: JobViewModel) {
val s by vm.state.collectAsState()
var selected by remember { mutableStateOf<Job?>(null) }
val job = selected
if (job == null) {
JobList(vm, s, onOpen = { selected = it })
} else {
JobDetail(vm, s, job, onBack = {
selected = null
vm.messageShown()
})
}
}
@Composable
private fun JobList(vm: JobViewModel, s: JobsState, onOpen: (Job) -> Unit) {
Column(Modifier.fillMaxSize().padding(12.dp)) {
OutlinedTextField(
value = s.filter.query,
onValueChange = { vm.setFilter(s.filter.copy(query = it)) },
label = { Text("Search title, company or city") },
modifier = Modifier.fillMaxWidth(),
)
Row(Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = s.filter.remoteOnly,
onClick = { vm.setFilter(s.filter.copy(remoteOnly = !s.filter.remoteOnly)) },
label = { Text("Remote") },
)
listOf("Full-time", "Internship", "Contract").forEach { t ->
FilterChip(
selected = s.filter.type == t,
onClick = { vm.setFilter(s.filter.copy(type = if (s.filter.type == t) null else t)) },
label = { Text(t) },
)
}
}
when {
s.loading -> CircularProgressIndicator()
s.error != null -> Column {
Text(s.error, color = MaterialTheme.colorScheme.error)
OutlinedButton(onClick = vm::load) { Text("Try again") }
}
s.shown.isEmpty() -> Text("No jobs match your filters.")
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(s.shown, key = { it.id }) { j ->
Card(Modifier.fillMaxWidth().clickable { onOpen(j) }) {
Column(Modifier.padding(12.dp)) {
Text(j.title, style = MaterialTheme.typography.titleMedium)
Text(j.company + " · " + j.location + if (j.remote) " · Remote" else "")
Text(j.type, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
@Composable
private fun JobDetail(vm: JobViewModel, s: JobsState, job: Job, onBack: () -> Unit) {
val context = LocalContext.current
var cover by remember { mutableStateOf("") }
var resume by remember { mutableStateOf<Uri?>(null) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { resume = it }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onBack) { Text("Back") }
Text(job.title, style = MaterialTheme.typography.headlineSmall)
Text(job.company + " · " + job.location)
Text(job.description)
OutlinedTextField(cover, { cover = it }, label = { Text("Cover note (optional)") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
picker.launch(arrayOf("application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
}) { Text(if (resume == null) "Choose resume" else "Resume chosen") }
Button(
onClick = { resume?.let { vm.apply(context.contentResolver, job.id, cover, it) } },
enabled = resume != null && !s.applying,
) { Text(if (s.applying) "Sending..." else "Apply") }
}
s.applyMessage?.let { Text(it) }
}
}
`app/src/main/java/com/example/jobs/MainActivity.kt`
package com.example.jobs
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val api = Retrofit.Builder()
.baseUrl("https://your-jobs-backend.example.com/") // replace with your own server
.addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
.build()
.create(JobApi::class.java)
setContent {
MaterialTheme {
Surface(Modifier.systemBarsPadding()) {
val vm: JobViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return JobViewModel(api) as T
}
},
)
JobApp(vm)
}
}
}
}
}
`app/src/test/java/com/example/jobs/JobTest.kt`
package com.example.jobs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class JobTest {
private val jobs = listOf(
Job("1", "Android Developer", "Acme", "Pune", "Full-time", remote = false),
Job("2", "Backend Intern", "Globex", "Remote", "Internship", remote = true),
Job("3", "Kotlin Engineer", "Initech", "Delhi", "Contract", remote = true),
)
@Test
fun searchMatchesTitleCompanyAndCity() {
assertEquals(1, jobs.filtered(JobFilter(query = "acme")).size)
assertEquals(1, jobs.filtered(JobFilter(query = "delhi")).size)
assertEquals(3, jobs.filtered(JobFilter(query = "e")).size)
}
@Test
fun remoteAndTypeFiltersCombine() {
val r = jobs.filtered(JobFilter(remoteOnly = true, type = "Contract"))
assertEquals(listOf("3"), r.map { it.id })
}
@Test
fun resumeRules() {
assertNull(resumeProblem("application/pdf", 1000))
assertNotNull(resumeProblem("image/png", 1000))
assertNotNull(resumeProblem("application/pdf", 6L * 1024 * 1024))
assertNotNull(resumeProblem("application/pdf", 0))
}
}
How it works
- The ViewModel loads the first page of jobs and keeps the filter in state. Every change to the filter recalculates
shown. - The list screen shows a spinner while loading, an error with Try again on failure, a message when nothing matches, and the cards otherwise.
- The detail screen opens the system file picker for PDF and Word files. The Apply button is disabled until a file is chosen and while sending.
- Before upload, the ViewModel reads the file's type and size, applies
resumeProblem, and shows a clear message if it fails. Otherwise it reads the bytes and sends a multipart request.
Expected output
The list shows a search box, chips for Remote, Full-time, Internship and Contract, and a card for each job with title, company, place and type. Type "kotlin": the list narrows to matching jobs. Turn on Remote and Contract together: only jobs that are both remain. Open a job: you see the description, a cover note box, Choose resume and Apply. Choose a PNG file and press Apply: the message "Choose a PDF or Word file." appears. Choose a PDF under 5 MB: Apply sends it and shows "Application sent". Without a backend, the list shows "Could not load jobs. Pull to try again." with a Try again button.
Your task
- Build the app and test the filters with sample data from a mock server.
- Add sign-in and send the token in an interceptor.
- Add paging with Paging 3 and a page parameter.
- Save jobs for later in Room and add a Saved tab.
How to check it worked
- Filters combine correctly.
- Wrong file types and big files are refused before upload.
- You can explain why the server must validate the resume again.