Project 8: Posts Reader with Images and Pull to Refresh

Android Development with Kotlin: 21 Real Projects · Advanced Apps and Release · 10 min read · Updated

Feed, images and detail

  1. Retrofit loads the posts from the server.
  2. The ViewModel keeps the list and the search text, and exposes the filtered list.
  3. The list screen draws rows. Coil downloads and caches each picture.
  4. Tapping a row navigates to post/{id}, and the detail screen shows that post.

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.com gives fake posts and picsum.photos gives random pictures, both without keys. Check that they are still available before you build.

Create the project

  1. New Empty Activity project Posts, package com.example.posts.
  2. Add Retrofit, serialization, Coil, Navigation Compose and lifecycle libraries from the snippet, then sync. Add the INTERNET permission.
  3. 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

  1. Build the app, scroll the list, search for a word, open a post and go back.
  2. Add a loading placeholder and an error image to AsyncImage using the placeholder and error options.
  3. Add a Favourite star on the detail screen that stores IDs in a mutableStateListOf.
  4. 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.

Frequently asked questions

Why use Coil instead of loading images myself?

Image loading needs caching, resizing, cancelling when rows scroll away and error handling. Coil does all of this and works directly with Compose.

Should the search filter run in the ViewModel or the screen?

In the ViewModel. It keeps the screen simple and the logic testable, and it survives rotation.

What is produceState?

It starts a coroutine tied to a composable and exposes the result as state. It suits small, one-off loads where a ViewModel would be too much.