androidengineers.Book a session

Case Study: Design Instagram

Exercise: Feed + Stories POC

exercise60 minHard

Build a mini Instagram-like app with a paginated feed and a stories row. Stories must preload the next story's first image while the current story is playing. Likes must be optimistic.

Goal

  • Home screen: stories row + paginated feed
  • Stories: full-screen with progress bar, auto-advance, tap left/right navigation
  • Feed: Paging 3 with cursor, Room cache, like button with optimistic update
  • Preloading: Coil preloads adjacent story images

Step 1: Data Layer

// Domain models
data class StoryGroup(
    val userId: String,
    val userName: String,
    val avatarUrl: String,
    val items: List<StoryItem>,
    val hasSeen: Boolean
)

data class FeedPost(
    val id: String,
    val authorName: String,
    val authorAvatarUrl: String,
    val imageUrl: String,
    val caption: String,
    val likeCount: Int,
    val commentCount: Int,
    val isLikedByMe: Boolean,
    val createdAt: Long
)

// APIs
interface StoryApi {
    suspend fun getStories(): List<StoryGroup>
}

interface FeedApi {
    suspend fun getFeed(cursor: String?, limit: Int): FeedPage
    suspend fun likePost(postId: String)
    suspend fun unlikePost(postId: String)
}

data class FeedPage(
    val posts: List<FeedPost>,
    val nextCursor: String?
)

Step 2: Feed with Paging 3

class FeedPagingSource(private val api: FeedApi) : PagingSource<String, FeedPost>() {
    override fun getRefreshKey(state: PagingState<String, FeedPost>): String? = null

    override suspend fun load(params: LoadParams<String>): LoadResult<String, FeedPost> {
        return try {
            val page = api.getFeed(cursor = params.key, limit = params.loadSize)
            LoadResult.Page(
                data = page.posts,
                prevKey = null,
                nextKey = page.nextCursor
            )
        } catch (e: IOException) {
            LoadResult.Error(e)
        }
    }
}

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val storyApi: StoryApi,
    private val feedApi: FeedApi
) : ViewModel() {

    private val _stories = MutableStateFlow<List<StoryGroup>>(emptyList())
    val stories: StateFlow<List<StoryGroup>> = _stories

    val feed: Flow<PagingData<FeedPost>> = Pager(
        config = PagingConfig(pageSize = 20, prefetchDistance = 5),
        pagingSourceFactory = { FeedPagingSource(feedApi) }
    ).flow.cachedIn(viewModelScope)

    private val pendingLikes = ConcurrentHashMap<String, Job>()

    init {
        viewModelScope.launch {
            _stories.value = storyApi.getStories()
        }
    }

    fun onLikeTapped(post: FeedPost) {
        pendingLikes[post.id]?.cancel()

        // Optimistic: toggle in current paging data
        // (In real app, update Room; here we track a local override map)
        _likeOverrides[post.id] = !post.isLikedByMe

        pendingLikes[post.id] = viewModelScope.launch {
            try {
                if (post.isLikedByMe) feedApi.unlikePost(post.id)
                else feedApi.likePost(post.id)
            } catch (e: Exception) {
                _likeOverrides.remove(post.id)  // rollback
                _effects.emit(UiEffect.ShowError("Couldn't update like"))
            }
        }
    }

    private val _likeOverrides = mutableMapOf<String, Boolean>()
    fun isLikedOverride(postId: String): Boolean? = _likeOverrides[postId]
}

Step 3: Stories ViewModel

@HiltViewModel
class StoryViewModel @Inject constructor() : ViewModel() {
    private val _currentItemIndex = MutableStateFlow(0)
    val currentItemIndex: StateFlow<Int> = _currentItemIndex

    private val _progress = MutableStateFlow(0f)
    val progress: StateFlow<Float> = _progress

    private var timerJob: Job? = null
    private var currentItems: List<StoryItem> = emptyList()

    fun setStory(storyGroup: StoryGroup) {
        currentItems = storyGroup.items
        _currentItemIndex.value = storyGroup.items.indexOfFirst { !it.seen }.takeIf { it >= 0 } ?: 0
        startTimer()
    }

    private fun startTimer() {
        timerJob?.cancel()
        _progress.value = 0f
        val duration = currentItems.getOrNull(_currentItemIndex.value)?.duration ?: 5000

        timerJob = viewModelScope.launch {
            val start = System.currentTimeMillis()
            while (isActive) {
                val elapsed = System.currentTimeMillis() - start
                _progress.value = (elapsed.toFloat() / duration).coerceIn(0f, 1f)
                if (elapsed >= duration) {
                    advanceToNext()
                    break
                }
                delay(16)
            }
        }
    }

    fun advanceToNext() {
        if (_currentItemIndex.value + 1 < currentItems.size) {
            _currentItemIndex.value++
            startTimer()
        } else {
            // Signal: end of this story group
            viewModelScope.launch { _effects.emit(StoryEffect.NextUser) }
        }
    }

    fun goToPrevious() {
        if (_currentItemIndex.value > 0) {
            _currentItemIndex.value--
            startTimer()
        } else {
            viewModelScope.launch { _effects.emit(StoryEffect.PreviousUser) }
        }
    }
}

Step 4: Home Screen UI

@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val stories by viewModel.stories.collectAsStateWithLifecycle()
    val feedPagingData = viewModel.feed.collectAsLazyPagingItems()

    LazyColumn {
        // Stories row
        item {
            StoriesRow(
                stories = stories,
                onStoryTapped = { /* navigate to story viewer */ }
            )
        }

        // Feed items
        items(
            count = feedPagingData.itemCount,
            key = feedPagingData.itemKey { it.id }
        ) { index ->
            val post = feedPagingData[index] ?: return@items
            val isLiked = viewModel.isLikedOverride(post.id) ?: post.isLikedByMe

            FeedPostCard(
                post = post.copy(isLikedByMe = isLiked),
                onLike = { viewModel.onLikeTapped(post) }
            )
        }

        // Loading and error states
        when {
            feedPagingData.loadState.refresh is LoadState.Loading ->
                item { CircularProgressIndicator(Modifier.fillMaxWidth().wrapContentWidth()) }
            feedPagingData.loadState.append is LoadState.Loading ->
                item { LinearProgressIndicator(Modifier.fillMaxWidth()) }
            feedPagingData.loadState.refresh is LoadState.Error ->
                item { RetryButton(onClick = { feedPagingData.retry() }) }
        }
    }
}

Verification Checklist

[ ] Stories row appears at top; unseen stories have colored ring
[ ] Tap story → full screen; progress bar segments match item count
[ ] Auto-advance → next item after duration; next user after last item
[ ] Tap right third → advance; tap left third → go back
[ ] Like button → immediate color change + count update
[ ] Kill app during like → on relaunch, like state persists (from Room or server)
[ ] Feed scrolls to bottom → page 2 loads automatically
[ ] Pull-to-refresh → feed resets to first page
[ ] Adjacent story image preloaded → no spinner on transition

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Feed + Stories POC | Android System Design | Android Engineers