This project joins what you know into the shape of a typical content app: a feed of items with pictures, a way to refresh it, a search box and a detail screen. News apps, social apps and shop catalogues all work this way. New parts are image loading with Coil, pull to refresh and sharing one ViewModel between two screens.
Concepts in this project
- Coil: a small image-loading library for Compose.
AsyncImage(model = url, ...)downloads, caches and shows a picture, with placeholders and error handling. - Pull to refresh: the user drags the list down to reload. Material 3 provides
PullToRefreshBox. - Search: the ViewModel keeps the full list and the query, and exposes a filtered list.
- List and detail: tapping a row navigates to
post/{id}, and the detail screen finds the item by ID. - Test APIs:
jsonplaceholder.typicode.comgives fake posts andpicsum.photosgives random pictures, both without keys. Check that they are still available before you build.
Create the project
- New Empty Activity project
Posts, packagecom.example.posts. - Add Retrofit, serialization, Coil, Navigation Compose and lifecycle libraries from the snippet, then sync. Add the INTERNET permission.
- Create four files.
`Gradle additions for projects 8 and 9`
// Project 8 (posts reader), on top of the Retrofit and serialization lines from project 6:
implementation("io.coil-kt:coil-compose:2.7.0") // AsyncImage
implementation("androidx.navigation:navigation-compose:2.8.5")
// Project 9 (habit tracker):
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// plugins block: id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
Posts/app/src/main/java/com/example/posts/
PostApi.kt DTOs + Retrofit
PostViewModel.kt list, query, refresh
PostsApp.kt NavHost, list screen, detail screen
MainActivity.kt
The code
`app/src/main/java/com/example/posts/PostApi.kt`
package com.example.posts
import kotlinx.serialization.Serializable
import retrofit2.http.GET
import retrofit2.http.Path
// jsonplaceholder.typicode.com is a free practice API. The posts are fake text.
@Serializable
data class Post(val id: Int, val userId: Int, val title: String, val body: String)
interface PostApi {
@GET("posts")
suspend fun posts(): List<Post>
@GET("posts/{id}")
suspend fun post(@Path("id") id: Int): Post
}
`app/src/main/java/com/example/posts/PostViewModel.kt`
package com.example.posts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
data class PostsState(
val loading: Boolean = false,
val refreshing: Boolean = false,
val posts: List<Post> = emptyList(),
val error: String? = null
)
class PostViewModel : ViewModel() {
private val api: PostApi = Retrofit.Builder()
.baseUrl("https://jsonplaceholder.typicode.com/")
.addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
.build()
.create(PostApi::class.java)
private val _state = MutableStateFlow(PostsState(loading = true))
private val _query = MutableStateFlow("")
val query = _query.asStateFlow()
// What the screen shows: the loaded posts filtered by the search text.
val visible: StateFlow<PostsState> = combine(_state, _query) { s, q ->
if (q.isBlank()) s else s.copy(posts = s.posts.filter { it.title.contains(q, ignoreCase = true) })
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PostsState(loading = true))
init { load(first = true) }
fun setQuery(text: String) { _query.value = text }
fun load(first: Boolean = false) {
_state.update { it.copy(loading = first, refreshing = !first, error = null) }
viewModelScope.launch {
try {
val list = api.posts()
_state.update { it.copy(loading = false, refreshing = false, posts = list) }
} catch (e: Exception) {
_state.update { it.copy(loading = false, refreshing = false, error = "Could not load posts. Pull down to try again.") }
}
}
}
suspend fun find(id: Int): Post? =
_state.value.posts.firstOrNull { it.id == id } ?: runCatching { api.post(id) }.getOrNull()
}
`app/src/main/java/com/example/posts/PostsApp.kt`
package com.example.posts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import coil.compose.AsyncImage
@Composable
fun PostsApp(vm: PostViewModel = viewModel()) {
val nav = rememberNavController()
NavHost(nav, startDestination = "list") {
composable("list") { ListScreen(vm, onOpen = { nav.navigate("detail/$it") }) }
composable("detail/{id}", arguments = listOf(navArgument("id") { type = NavType.IntType })) { entry ->
DetailScreen(vm, entry.arguments?.getInt("id") ?: 0, onBack = { nav.popBackStack() })
}
}
}
// Free random pictures. The same seed always gives the same picture, so each post keeps its image.
private fun imageFor(id: Int) = "https://picsum.photos/seed/post$id/400/240"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ListScreen(vm: PostViewModel, onOpen: (Int) -> Unit) {
val state by vm.visible.collectAsStateWithLifecycle()
val query by vm.query.collectAsStateWithLifecycle()
Scaffold(topBar = { TopAppBar(title = { Text("Posts") }) }) { padding ->
Column(Modifier.padding(padding).padding(horizontal = 12.dp)) {
OutlinedTextField(query, vm::setQuery, label = { Text("Search titles") }, singleLine = true, modifier = Modifier.fillMaxWidth())
when {
state.loading -> Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() }
else -> PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = { vm.load() }, modifier = Modifier.fillMaxSize()) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxSize()) {
state.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error) } }
if (state.posts.isEmpty() && state.error == null) item { Text("No posts match.") }
items(state.posts, key = { it.id }) { post ->
Card(Modifier.fillMaxWidth().clickable { onOpen(post.id) }) {
AsyncImage(
model = imageFor(post.id), contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(140.dp)
)
Column(Modifier.padding(12.dp)) {
Text(post.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(post.body, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DetailScreen(vm: PostViewModel, id: Int, onBack: () -> Unit) {
// produceState runs the suspend call once for this id and exposes the result as State.
val post by produceState<Post?>(initialValue = null, id) { value = vm.find(id) }
Scaffold(topBar = {
TopAppBar(title = { Text("Post $id") }, navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
})
}) { padding ->
val p = post
if (p == null) Box(Modifier.padding(padding).fillMaxSize(), Alignment.Center) { CircularProgressIndicator() }
else Column(Modifier.padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
AsyncImage(imageFor(p.id), null, Modifier.fillMaxWidth().height(200.dp).clip(MaterialTheme.shapes.medium), contentScale = ContentScale.Crop)
Text(p.title, style = MaterialTheme.typography.headlineSmall)
Text(p.body)
}
}
}
The detail screen uses produceState, which starts a coroutine for a composable and gives back the result as state. It is a short way to load one item when a full ViewModel would be too much.
`app/src/main/java/com/example/posts/MainActivity.kt`
package com.example.posts
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MaterialTheme { PostsApp() } }
}
}
Expected output
The first screen shows a search bar and a list of posts, each with a small picture, a title and a short body. While loading, a progress indicator shows. Drag the list down: a refresh spinner shows and the list reloads. Type a word in the search bar and the list is filtered by title. Tap a post to open the detail screen with a large picture and the full text. Back returns to the list with your search text still in place.
Your task
- Build the app, scroll the list, search for a word, open a post and go back.
- Add a loading placeholder and an error image to
AsyncImageusing theplaceholderanderroroptions. - Add a Favourite star on the detail screen that stores IDs in a
mutableStateListOf. - Test the offline case. What does the user see? Add an Error state with a Retry button.
How to check it worked
- Pictures load and are cached when you scroll back up.
- Pull to refresh reloads the data.
- Going back from the detail screen keeps the search text.