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