Skip to content

Instantly share code, notes, and snippets.

@stevdza-san
Created October 14, 2025 16:02
Show Gist options
  • Save stevdza-san/b7008a800f59b6301808b65479514bdf to your computer and use it in GitHub Desktop.
Save stevdza-san/b7008a800f59b6301808b65479514bdf to your computer and use it in GitHub Desktop.
Kotlin Flows - Day 1
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.retry
// Simple Repository
class DataRepository {
private var attempt = 0
fun fetchData(): Flow<String> = flow {
attempt++
println("🔄 Attempt #$attempt")
emit("Loading...")
delay(1000)
// Simulate failure on first 2 attempts
if (attempt <= 3) {
throw Exception("Network error")
}
emit("Data loaded successfully!")
attempt = 0 // Reset for next demo
}
.retry(1) { exception ->
// Don't retry on cancellation
if (exception is CancellationException) {
false
} else {
println("🔄 Retrying...")
true
}
}
.catch { exception ->
// Don't catch CancellationException - let it propagate for structured concurrency
if (exception !is CancellationException) {
println("🛡️ Caught error: ${exception.message}")
// Emit fallback value instead of rethrowing
emit("❌ Failed to load data: ${exception.message}")
} else {
throw exception
}
}
fun longRunningTask(): Flow<String> = flow {
repeat(5) { i ->
println("📊 Step ${i + 1}")
emit("Processing step ${i + 1}...")
delay(1000)
}
emit("Task completed!")
}
.onCompletion { cause ->
when (cause) {
null -> println(" Task completed")
is CancellationException -> throw cause
else -> println(" Task failed")
}
}
}
// ViewModel
class CancellationViewModel : ViewModel() {
private val repository = DataRepository()
suspend fun demoRetryAndCatch() {
repository.fetchData()
.collect { data ->
println(" Received: $data")
}
}
fun demoCancellation() {
viewModelScope.launch {
val job = launch {
try {
repository.longRunningTask()
.collect { status ->
println("📋 $status")
}
} catch (e: CancellationException) {
println("🔴 Cancellation caught in inner job")
throw e // Re-throw to maintain cancellation
}
}
// Cancel after 2.5 seconds
delay(2500)
job.cancel()
println("🛑 Cancelling task...")
}
}
}
@Composable
fun CancellationScreen() {
val viewModel: CancellationViewModel = viewModel()
LaunchedEffect(Unit) {
viewModel.demoRetryAndCatch()
delay(5000)
viewModel.demoCancellation()
}
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
// ============================================================================
// combine - TV Guide with Multiple Data Sources
// Demonstrates combining search filters with user preferences
// ============================================================================
data class TVProgram(
val id: String,
val title: String,
val description: String,
val category: String,
val isLive: Boolean,
val isFavorite: Boolean = false,
)
data class SearchFilters(
val query: String = "",
val category: String = "All",
val liveOnly: Boolean = false,
)
data class UserPreferences(
val favoriteCategories: List<String> = listOf("News", "Sports"),
val showFavoritesFirst: Boolean = true,
)
private val allPrograms = listOf(
TVProgram("1", "Breaking News", "Latest world news", "News", true),
TVProgram("2", "Live Football", "Premier League match", "Sports", true),
TVProgram("3", "Planet Earth", "Wildlife documentary", "Documentary", false),
TVProgram("4", "Game of Thrones", "Fantasy drama series", "Movies", false),
TVProgram("5", "NBA Tonight", "Basketball highlights", "Sports", false),
TVProgram("6", "World Report", "International news", "News", true),
TVProgram("7", "SpongeBob", "Animated comedy", "Kids", true),
TVProgram("8", "Cosmos", "Space exploration", "Documentary", false),
)
// Simulated TV Guide Repository
class TVGuideRepository {
fun getProgramsFlow() = flow { emit(allPrograms) }
fun getUserPreferencesFlow() = flow {
emit(
UserPreferences(
favoriteCategories = listOf("News", "Sports"),
showFavoritesFirst = true
)
)
}
}
// ViewModel - This is where combine belongs!
@OptIn(FlowPreview::class)
class TVGuideViewModel : ViewModel() {
private val repository = TVGuideRepository()
private val allPrograms = repository.getProgramsFlow()
// private val _filters = MutableStateFlow(SearchFilters())
// val filters: StateFlow<SearchFilters> = _filters.asStateFlow()
val filters: StateFlow<SearchFilters>
field = MutableStateFlow(SearchFilters())
val userPreferences = repository.getUserPreferencesFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UserPreferences()
)
val filteredPrograms = combine(
allPrograms,
filters,
userPreferences
) { allPrograms, filters, userPrefs ->
println("📺 combine triggered!")
println(" Filters: $filters")
println(" User Prefs: ${userPrefs.favoriteCategories}")
// Search programs logic moved here from repository
var results = allPrograms
// Apply search query
if (filters.query.isNotBlank()) {
results = results.filter {
it.title.contains(filters.query, ignoreCase = true) ||
it.description.contains(filters.query, ignoreCase = true)
}
}
// Apply category filter
if (filters.category != "All") {
results = results.filter { it.category == filters.category }
}
// Apply live filter
if (filters.liveOnly) {
results = results.filter { it.isLive }
}
// Apply user preferences: Mark favorites and sort by preference
results = results.map { program ->
program.copy(
isFavorite = program.category in userPrefs.favoriteCategories
)
}
// Sort favorites first if user prefers it
if (userPrefs.showFavoritesFirst) {
results = results.sortedWith(
compareByDescending<TVProgram> { it.isFavorite }
.thenBy { it.title }
)
}
results
}
fun updateSearchQuery(query: String) {
filters.value = filters.value.copy(query = query)
}
fun updateCategory(category: String) {
filters.value = filters.value.copy(category = category)
}
fun toggleLiveOnly(enabled: Boolean) {
filters.value = filters.value.copy(liveOnly = enabled)
}
}
@OptIn(FlowPreview::class)
@Composable
fun CombineExample() {
val viewModel: TVGuideViewModel = viewModel()
val filteredPrograms by viewModel.filteredPrograms.collectAsState(initial = listOf())
val userPreferences by viewModel.userPreferences.collectAsState()
val filters by viewModel.filters.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.padding(16.dp)
) {
// Search Controls
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = filters.query,
onValueChange = { viewModel.updateSearchQuery(it) },
label = { Text("Search Programs") },
placeholder = { Text("Try: News, Football, Game") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
// Category Buttons
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
listOf(
"All",
"News",
"Sports",
"Movies",
"Kids",
"Documentary"
)
) { category ->
Button(
onClick = { viewModel.updateCategory(category) }
) {
Text(category, style = MaterialTheme.typography.labelMedium)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Live Only Toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = filters.liveOnly,
onCheckedChange = { viewModel.toggleLiveOnly(it) }
)
Spacer(modifier = Modifier.width(8.dp))
Text("Live Only", style = MaterialTheme.typography.bodyMedium)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// User Preferences Info
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
"👤 User Preferences Applied:",
style = MaterialTheme.typography.titleSmall
)
Text(
"Favorite categories: ${userPreferences.favoriteCategories.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall
)
Text(
"⭐ Favorites shown first",
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(12.dp))
if (filteredPrograms.isNotEmpty()) {
Text(
"Found ${filteredPrograms.size} programs:",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredPrograms) { program ->
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (program.isFavorite) {
Text("⭐ ", style = MaterialTheme.typography.titleSmall)
}
Text(
program.title,
style = MaterialTheme.typography.titleSmall
)
}
Text(
program.description,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
program.category,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
if (program.isLive) {
Text(
"🔴 LIVE",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
}
}
}
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.stevdza_san.demo.homework.compose.models.Channel
import com.stevdza_san.demo.homework.compose.models.CurrentShow
import com.stevdza_san.demo.homework.compose.models.Program
import com.stevdza_san.demo.homework.compose.viewmodel.TvViewModel
import com.stevdza_san.demo.homework.compose.viewmodel.heroImages
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.stevdza_san.demo.homework.compose.models.Channel
import com.stevdza_san.demo.homework.compose.repository.TvRepository
import demo.composeapp.generated.resources.Res
import demo.composeapp.generated.resources.hero1
import demo.composeapp.generated.resources.hero2
import demo.composeapp.generated.resources.hero3
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
data class Channel(
val id: String,
val name: String,
)
data class Program(
val id: String,
val channelId: String,
val name: String,
)
data class CurrentShow(
val title: String,
val description: String,
val timeSlot: String,
val rating: String,
)
data class TvUiState(
val channels: List<Channel> = emptyList(),
val programs: List<Program> = emptyList(),
val selectedChannel: Channel? = null,
val overlayVisible: Boolean = false,
val currentShow: CurrentShow? = null
)
class TvRepository {
fun fetchChannels(): List<Channel> {
return (1..10).map { id ->
Channel(
id = "$id",
name = "Channel $id"
)
}
}
fun fetchPrograms(): List<Program> {
val programNames = listOf(
"Morning News", "Weather Report", "Talk Show", "Comedy Hour", "Drama Series",
"Documentary", "Sports Update", "Cooking Show", "Reality TV", "Game Show",
"Movie Night", "Late Show", "Kids Program", "Educational", "Music Video",
"Travel Guide", "Science Today", "History Channel", "Nature Watch", "Tech Review"
)
return (1..20).flatMap { channelId ->
programNames.shuffled().mapIndexed { index, baseName ->
Program(
id = "${baseName}_${channelId}_${index}",
channelId = "$channelId",
name = "$baseName ${(1..99).random()}"
)
}
}
}
fun fetchCurrentShows(): List<CurrentShow> {
return listOf(
CurrentShow(
"The Crown",
"A biographical drama about Queen Elizabeth II's reign, exploring personal relationships and political events.",
"8:00 PM - 9:00 PM",
"★★★★☆ 4.2/5"
),
CurrentShow(
"Stranger Things",
"Supernatural horror series set in the 1980s following kids in a small town dealing with mysterious events.",
"9:00 PM - 10:00 PM",
"★★★★★ 4.8/5"
),
CurrentShow(
"Breaking Bad",
"A high school chemistry teacher turned methamphetamine manufacturer in this critically acclaimed crime drama.",
"10:00 PM - 11:00 PM",
"★★★★★ 4.9/5"
),
CurrentShow(
"The Office",
"Mockumentary sitcom depicting the everyday work lives of office employees in Scranton, Pennsylvania.",
"7:30 PM - 8:00 PM",
"★★★★☆ 4.4/5"
),
CurrentShow(
"Game of Thrones",
"Epic fantasy series featuring political intrigue, dragons, and the battle for the Iron Throne.",
"9:00 PM - 10:00 PM",
"★★★★☆ 4.1/5"
),
CurrentShow(
"Friends",
"Classic sitcom following six friends living in Manhattan navigating life, love, and careers.",
"8:00 PM - 8:30 PM",
"★★★★☆ 4.5/5"
),
CurrentShow(
"The Mandalorian",
"Star Wars series following a bounty hunter's adventures in the outer reaches of the galaxy.",
"8:00 PM - 9:00 PM",
"★★★★☆ 4.3/5"
),
CurrentShow(
"Squid Game",
"Korean survival thriller where contestants play deadly children's games for a massive cash prize.",
"9:00 PM - 10:00 PM",
"★★★★☆ 4.2/5"
),
CurrentShow(
"The Witcher",
"Fantasy series following Geralt of Rivia, a monster hunter in a world full of magic and political intrigue.",
"8:00 PM - 9:00 PM",
"★★★★☆ 4.0/5"
),
CurrentShow(
"Wednesday",
"Dark comedy following Wednesday Addams as she navigates her years as a student at Nevermore Academy.",
"7:00 PM - 8:00 PM",
"★★★★☆ 4.1/5"
)
)
}
}
val heroImages = listOf(
Res.drawable.hero1,
Res.drawable.hero2,
Res.drawable.hero3
)
class TvViewModel : ViewModel() {
private val repository = TvRepository()
var uiState by mutableStateOf(value = TvUiState())
private set
init { loadChannelsAndPrograms() }
private fun loadChannelsAndPrograms() {
uiState = uiState.copy(
channels = repository.fetchChannels(),
programs = repository.fetchPrograms(),
)
}
fun selectChannel(channel: Channel) {
uiState = uiState.copy(selectedChannel = channel)
}
fun selectProgram() {
uiState = uiState.copy(
currentShow = repository.fetchCurrentShows().shuffled().firstOrNull(),
overlayVisible = true
)
}
fun hideOverlay() {
uiState = uiState.copy(overlayVisible = false)
}
}
@Composable
fun ComposeHomeworkScreen() {
val viewModel: TvViewModel = viewModel()
val uiState = viewModel.uiState
var currentImageIndex by remember { mutableStateOf(0) }
val currentImage by remember { derivedStateOf { heroImages[currentImageIndex] } }
// Banner image change
LaunchedEffect(key1 = Unit) {
while (true) {
delay(5000)
currentImageIndex = (currentImageIndex + 1) % heroImages.size
}
}
// Overlay auto-hide timer
LaunchedEffect(key1 = uiState.overlayVisible) {
if (uiState.overlayVisible) {
delay(3000)
viewModel.hideOverlay()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.background(Color.White),
contentAlignment = Alignment.BottomCenter
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Hero Banner
HeroBanner(
currentImage = currentImage,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Spacer(modifier = Modifier.height(16.dp))
// Content Lists
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp)
) {
// Channel List
ChannelList(
channels = uiState.channels,
selectedChannel = uiState.selectedChannel,
onChannelClick = viewModel::selectChannel,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
)
Spacer(modifier = Modifier.width(16.dp))
// Program List
ProgramList(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
programs = uiState.programs
.filter { it.channelId == uiState.selectedChannel?.id },
onClick = viewModel::selectProgram
)
}
}
// Current Show Overlay
AnimatedVisibility(
visible = uiState.overlayVisible,
enter = OVERLAY_ENTER_ANIMATION,
exit = OVERLAY_EXIT_ANIMATION,
modifier = Modifier.align(Alignment.BottomCenter)
) {
uiState.currentShow?.let { CurrentShowOverlay(currentShow = it) }
}
}
}
@Composable
fun HeroBanner(
currentImage: DrawableResource,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier
.padding(horizontal = 16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Box(
modifier = Modifier.fillMaxSize()
) {
AnimatedContent(
targetState = currentImage,
transitionSpec = { HERO_TRANSITION_SPEC }
) { image ->
Image(
painter = painterResource(image),
contentDescription = "Hero Banner",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
}
}
@Composable
fun ChannelList(
channels: List<Channel>,
selectedChannel: Channel?,
onChannelClick: (Channel) -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
Text(
text = "Channels",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primary)
.padding(16.dp),
color = MaterialTheme.colorScheme.onPrimary,
textAlign = TextAlign.Center
)
AnimatedContent(
targetState = channels,
transitionSpec = { CONTENT_TRANSITION_SPEC }
) { channelList ->
if (channelList.isNotEmpty()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
items = channelList,
key = { it.id }
) { channel ->
ChannelItem(
channel = channel,
isSelected = channel == selectedChannel,
onClick = { onChannelClick(channel) }
)
}
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No channels available",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
@Composable
fun ChannelItem(
channel: Channel,
isSelected: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
}
),
elevation = CardDefaults.cardElevation(
defaultElevation = if (isSelected) 4.dp else 2.dp
)
) {
Text(
text = channel.name,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
fontSize = 14.sp,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
)
}
}
@Composable
fun ProgramList(
modifier: Modifier = Modifier,
programs: List<Program>,
onClick: () -> Unit,
) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
Text(
text = "Programs",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondary)
.padding(16.dp),
color = MaterialTheme.colorScheme.onSecondary,
textAlign = TextAlign.Center
)
AnimatedContent(
targetState = programs,
transitionSpec = { CONTENT_TRANSITION_SPEC }
) { programList ->
if (programList.isNotEmpty()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
items = programList,
key = { program -> program.id }
) { program ->
ProgramItem(
program = program,
onClick = onClick
)
}
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Select a channel to view programs",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
@Composable
fun ProgramItem(
program: Program,
onClick: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Text(
text = program.name,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
fun CurrentShowOverlay(
currentShow: CurrentShow,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.85f)
),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
// Show title
Text(
text = currentShow.title,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(bottom = 8.dp)
)
// Time slot
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Text(
text = "⏰ ",
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.8f)
)
Text(
text = currentShow.timeSlot,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.9f)
)
}
// Rating
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 12.dp)
) {
Text(
text = currentShow.rating,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFFFD700) // Gold color for rating
)
}
// Description
Text(
text = currentShow.description,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.85f),
lineHeight = 20.sp,
textAlign = TextAlign.Justify
)
Spacer(modifier = Modifier.height(8.dp))
// "Now Playing" indicator
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.background(
color = Color.Red,
shape = CircleShape
)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "NOW PLAYING",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color.Red,
letterSpacing = 1.sp
)
}
}
}
}
val OVERLAY_ENTER_ANIMATION = slideInVertically(
animationSpec = tween(400),
initialOffsetY = { it }
) + scaleIn(
animationSpec = tween(400),
initialScale = 0.8f
) + fadeIn(
animationSpec = tween(300)
)
val OVERLAY_EXIT_ANIMATION = slideOutVertically(
animationSpec = tween(300),
targetOffsetY = { it }
) + scaleOut(
animationSpec = tween(300),
targetScale = 0.9f
) + fadeOut(
animationSpec = tween(200)
)
val HERO_TRANSITION_SPEC = fadeIn(animationSpec = tween(800)) togetherWith
fadeOut(animationSpec = tween(800))
val CONTENT_TRANSITION_SPEC = fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
class EmissionCollectionViewModel : ViewModel() {
fun fetchData() = flow {
delay(100)
emit("First value")
delay(100)
emit("Second value")
delay(100)
emit("Third value")
}
}
@Composable
fun EmissionCollectionScreen() {
val viewModel: EmissionCollectionViewModel = viewModel()
LaunchedEffect(Unit) {
viewModel.fetchData().collect { value ->
delay(3000)
println("Collected value: $value")
}
}
}
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
class FlowOnRepository {
fun fetchUsers(): Flow<String> = flow {
println("📥 Repository emitting on: ${currentCoroutineContext()[CoroutineDispatcher]}")
repeat(3) { i ->
delay(500) // Simulate network delay
val user = "User $i"
println("📥 Repository emitting: $user")
emit(user)
}
}.flowOn(Dispatchers.IO)
}
class FlowOnViewModel : ViewModel() {
private val repository = FlowOnRepository()
val usersFlow = repository.fetchUsers()
init {
// viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch {
println("⚙️ ViewModel collecting on: ${this.coroutineContext[CoroutineDispatcher]}")
usersFlow.collect { user ->
println("⚙️ ViewModel received: $user)")
}
}
}
}
@Composable
fun FlowOnScreen() {
val viewModel: FlowOnViewModel = viewModel()
// Collect the flow in UI (LaunchedEffect uses Main dispatcher)
LaunchedEffect(Unit) {
println("🎯 UI collecting on: ${this.coroutineContext[CoroutineDispatcher]}")
viewModel.usersFlow.collect { user ->
println("🎯 UI received: $user")
}
}
}
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stevdza_san.demo.RequestState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlin.random.Random
class LoggingRepository {
fun sendLogToServer() {
println("Sending logs to server...")
}
}
data class Download(
val id: Int,
val fileName: String,
val progress: Int = 0,
)
class DownloadRepository {
fun downloadFile(fileId: Int): Flow<RequestState<Download>> = flow {
val fileName = "File_$fileId.zip"
for (progress in 0..100 step 20) {
delay(200) // Simulate download time
if (progress == 60 && Random.nextBoolean()) {
emit(RequestState.Error("Network error during download"))
} else {
emit(RequestState.Success(Download(fileId, fileName, progress)))
}
}
}
}
class LaunchInDemoViewModel : ViewModel() {
private val downloadRepository = DownloadRepository()
private val loggingRepository = LoggingRepository()
private val _startDownload = MutableSharedFlow<Int>()
@OptIn(ExperimentalCoroutinesApi::class)
private val downloadedFile: StateFlow<RequestState<Download>> = _startDownload
.flatMapLatest { fileId -> downloadRepository.downloadFile(fileId) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
initialValue = RequestState.Loading
)
init {
startErrorMonitoring()
}
private fun startErrorMonitoring() {
downloadedFile
.filterIsInstance<RequestState.Error>()
.distinctUntilChanged { old, new -> old.message == new.message } // Prevent duplicate error logs
.onEach { error -> loggingRepository.sendLogToServer() }
.launchIn(viewModelScope)
}
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
// ViewModel with search functionality
class SearchViewModel : ViewModel() {
fun searchUsers(query: String) {
if (query.isBlank()) return
println("🔍 Making API call to search: '$query'")
// This would be your actual API call
// repository.searchUsers(query)
}
}
@OptIn(FlowPreview::class)
@Composable
fun SnapshotFlowScreen() {
val viewModel: SearchViewModel = viewModel()
var searchText by remember { mutableStateOf("") }
// Without snapshotFlow: you'd call searchUsers() directly in onValueChange
// Problem: API call on EVERY keystroke!
// With snapshotFlow: you can debounce and only search after user stops typing
LaunchedEffect(Unit) {
snapshotFlow(block = { searchText })
.debounce(800) // Wait 800ms after user stops typing
.collect { query ->
viewModel.searchUsers(query)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Search with Debouncing",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Type to search users:",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = searchText,
onValueChange = { searchText = it },
label = { Text("Search users...") },
placeholder = { Text("Try typing: john, alice, bob") }
)
}
}
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.yield
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration.Companion.seconds
fun countdownFlow(start: Int): Flow<Int> = flow {
for (number in start downTo 0) {
emit(number)
delay(1000L)
}
}
fun main(): Unit = runBlocking {
var currentValue = 0
val startValue = 10
withTimeoutOrNull(timeout = 5.seconds) {
launch {
countdownFlow(start = startValue)
.collect { value ->
currentValue = value
println("Time left: $value")
}
}
}
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
enum class GameState {
IDLE, PLAYING, FINISHED
}
data class GameStats(
val wordsTyped: Int = 0,
val correctWords: Int = 0,
val wpm: Int = 0,
val accuracy: Int = 100,
)
class TypingSpeedViewModel : ViewModel() {
// Reactive UI state
var gameState by mutableStateOf(GameState.IDLE)
private set
var currentWord by mutableStateOf("")
private set
var userInput by mutableStateOf("")
private set
var timeLeft by mutableStateOf(60)
private set
var gameStats by mutableStateOf(GameStats())
private set
// Word list for the game
private val words = listOf(
"apple", "banana", "orange", "grape", "cherry", "lemon", "peach", "plum",
"house", "garden", "flower", "river", "mountain", "forest", "ocean", "desert",
"happy", "smile", "laugh", "dance", "music", "friend", "family", "love",
"computer", "keyboard", "mouse", "screen", "internet", "website", "email", "phone",
"book", "story", "chapter", "page", "word", "sentence", "paragraph", "letter"
)
// Cold flow for word generation - only starts when game begins!
private val wordsFlow: Flow<String> = flow {
while (true) {
val randomWord = words.random()
emit(randomWord)
delay(100) // Quick emission for immediate word availability
}
}
// Cold flow for countdown timer - only starts when game begins!
private val timerFlow: Flow<Int> = flow {
for (seconds in 60 downTo 0) {
emit(seconds)
delay(1000) // Emit every second
}
// Game automatically ends when timer reaches 0
}
private var wordJob: Job? = null
private var timerJob: Job? = null
fun startGame() {
if (gameState == GameState.IDLE) {
// Reset game state
gameStats = GameStats()
userInput = ""
timeLeft = 60
gameState = GameState.PLAYING
// Start cold flows - this is the educational part!
startWordGeneration()
startTimer()
}
}
private fun startWordGeneration() {
wordJob = viewModelScope.launch(Dispatchers.IO) {
wordsFlow.collect { word ->
if (gameState == GameState.PLAYING) {
// Only update word if we're still playing
if (currentWord.isEmpty()) {
withContext(Dispatchers.Main) {
currentWord = word
}
}
}
}
}
}
private fun startTimer() {
timerJob = viewModelScope.launch {
timerFlow.collect { seconds ->
timeLeft = seconds
if (seconds == 0) {
endGame()
}
}
}
}
fun updateInput(input: String) {
if (gameState == GameState.PLAYING) {
userInput = input
// Check if word is complete (user pressed space)
if (input.endsWith(" ")) {
checkWord(input.trim())
userInput = ""
generateNextWord()
}
}
}
private fun checkWord(typedWord: String) {
val isCorrect = typedWord == currentWord
val newStats = gameStats.copy(
wordsTyped = gameStats.wordsTyped + 1,
correctWords = if (isCorrect) gameStats.correctWords + 1 else gameStats.correctWords
)
// Calculate WPM and accuracy
val timeElapsed = (60 - timeLeft) / 60.0
val wpm = if (timeElapsed > 0) (newStats.correctWords / timeElapsed).toInt() else 0
val accuracy = if (newStats.wordsTyped > 0)
((newStats.correctWords.toDouble() / newStats.wordsTyped) * 100).toInt()
else 100
gameStats = newStats.copy(wpm = wpm, accuracy = accuracy)
}
private fun generateNextWord() {
currentWord = words.random()
}
private fun endGame() {
gameState = GameState.FINISHED
wordJob?.cancel()
timerJob?.cancel()
}
fun resetGame() {
gameState = GameState.IDLE
currentWord = ""
userInput = ""
timeLeft = 60
gameStats = GameStats()
wordJob?.cancel()
timerJob?.cancel()
}
}
@Composable
fun TypingSpeedGame() {
val viewModel: TypingSpeedViewModel = viewModel()
val state = viewModel.gameState
val currentWord = viewModel.currentWord
val userInput = viewModel.userInput
val timeLeft = viewModel.timeLeft
val stats = viewModel.gameStats
Column(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color(0xFF1a1a2e),
Color(0xFF16213e),
Color(0xFF0f4c75)
)
)
)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Title
Text(
text = "Typing Speed Test",
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Light,
modifier = Modifier.padding(bottom = 32.dp)
)
when (state) {
GameState.IDLE -> {
WelcomeScreen(onStartGame = viewModel::startGame)
}
GameState.PLAYING -> {
GameScreen(
currentWord = currentWord,
userInput = userInput,
timeLeft = timeLeft,
stats = stats,
onInputChange = viewModel::updateInput
)
}
GameState.FINISHED -> {
ResultsScreen(
stats = stats,
onRestart = viewModel::resetGame
)
}
}
}
}
@Composable
private fun WelcomeScreen(onStartGame: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White.copy(alpha = 0.1f)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Test your typing speed!",
color = Color.White,
fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = "You have 60 seconds to type as many words as possible correctly.",
color = Color.White.copy(alpha = 0.8f),
fontSize = 16.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
Button(
onClick = onStartGame,
modifier = Modifier.padding(top = 16.dp)
) {
Text("Start Game", fontSize = 18.sp)
}
}
}
}
@Composable
private fun GameScreen(
currentWord: String,
userInput: String,
timeLeft: Int,
stats: GameStats,
onInputChange: (String) -> Unit,
) {
// Timer and Stats Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
StatCard("Time", "$timeLeft s", Color(0xFFFF6B6B))
StatCard("WPM", stats.wpm.toString(), Color(0xFF4ECDC4))
StatCard("Words", "${stats.correctWords}/${stats.wordsTyped}", Color(0xFF45B7D1))
StatCard("Accuracy", "${stats.accuracy}%", Color(0xFF96CEB4))
}
Spacer(modifier = Modifier.height(32.dp))
// Current Word Display
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White.copy(alpha = 0.1f)
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(48.dp),
contentAlignment = Alignment.Center
) {
Text(
text = currentWord,
color = Color.White,
fontSize = 48.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Input Field
OutlinedTextField(
value = userInput,
onValueChange = onInputChange,
label = { Text("Type the word above", color = Color.White.copy(alpha = 0.7f)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
)
)
}
@Composable
private fun StatCard(label: String, value: String, color: Color) {
Card(
modifier = Modifier.size(80.dp, 60.dp),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.2f)),
border = androidx.compose.foundation.BorderStroke(1.dp, color)
) {
Column(
modifier = Modifier.fillMaxSize().padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = value,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
Text(
text = label,
color = Color.White.copy(alpha = 0.8f),
fontSize = 10.sp
)
}
}
}
@Composable
private fun ResultsScreen(stats: GameStats, onRestart: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color.White.copy(alpha = 0.1f)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Game Over!",
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 24.dp)
)
ResultRow("Words Per Minute", stats.wpm.toString())
ResultRow("Total Words", stats.wordsTyped.toString())
ResultRow("Correct Words", stats.correctWords.toString())
ResultRow("Accuracy", "${stats.accuracy}%")
Button(
onClick = onRestart,
modifier = Modifier.padding(top = 24.dp)
) {
Text("Play Again", fontSize = 18.sp)
}
}
}
}
@Composable
private fun ResultRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = Color.White.copy(alpha = 0.8f),
fontSize = 16.sp
)
Text(
text = value,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// ============================================================================
// Example 2: zip - Combines data from multiple independent sources
// ============================================================================
// Domain Models
data class UserProfile(
val id: String,
val name: String,
val email: String,
val avatar: String,
)
data class UserPreference(
val theme: String,
val notifications: Boolean,
val language: String,
)
data class UserDashboard(
val profile: UserProfile,
val preferences: UserPreference,
)
// UI State
data class DashboardUiState(
val userId: String = "",
val isLoading: Boolean = false,
val dashboard: UserDashboard? = null,
val error: String? = null,
)
// Repository/API Layer (Data Layer)
class MyUserRepository {
private fun getUserProfile(userId: String): UserProfile {
return UserProfile(
id = userId,
name = "John Doe",
email = "[email protected]",
avatar = "https://avatar.example.com/$userId"
)
}
private fun getUserPreferences(userId: String): UserPreference {
return UserPreference(
theme = "Dark",
notifications = true,
language = "English"
)
}
// Repository provides simple flows - no complex orchestration
fun getUserProfileFlow(userId: String) = flow {
val profile = getUserProfile(userId)
emit(profile)
}.flowOn(Dispatchers.IO)
fun getUserPreferencesFlow(userId: String) = flow {
val preferences = getUserPreferences(userId)
emit(preferences)
}.flowOn(Dispatchers.IO)
}
// ViewModel - Presentation Layer (Clean Architecture)
@OptIn(FlowPreview::class)
class UserDashboardViewModel : ViewModel() {
private val repository = MyUserRepository()
private val _userId = MutableStateFlow("")
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
init {
// Set up debounced user ID changes
viewModelScope.launch {
_userId
.debounce(500) // Wait 500ms after user stops typing
.collect { userId ->
_uiState.value = _uiState.value.copy(userId = userId)
loadUserDashboard(userId)
}
}
}
fun updateUserId(userId: String) {
// Only update the userId flow, debounce will handle the rest
_userId.value = userId
// Immediately update UI to show what user is typing
_uiState.value = _uiState.value.copy(userId = userId)
}
private fun loadUserDashboard(userId: String) {
if (userId.isBlank()) {
_uiState.value = _uiState.value.copy(
isLoading = false,
dashboard = null,
error = null
)
return
}
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
try {
repository.getUserProfileFlow(userId)
.zip(repository.getUserPreferencesFlow(userId)) { profile, preferences ->
println("🔗 zip completed: ${profile.name} with ${preferences.theme} theme")
UserDashboard(profile, preferences)
}
.collect { dashboard ->
_uiState.value = _uiState.value.copy(
isLoading = false,
dashboard = dashboard
)
println("✅ Dashboard loaded successfully")
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Unknown error occurred"
)
println("❌ Error loading dashboard: ${e.message}")
}
}
}
}
@Composable
@OptIn(FlowPreview::class)
fun ZipExample() {
val viewModel = viewModel<UserDashboardViewModel>()
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("zip Example (Best Practice)", style = MaterialTheme.typography.headlineSmall)
Text(
"ViewModel handles zip orchestration",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = uiState.userId,
onValueChange = { viewModel.updateUserId(it) },
label = { Text("Enter User ID...") },
placeholder = { Text("Try: user123, admin, guest") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Handle different UI states
when {
uiState.isLoading -> {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text(
"Loading profile and preferences in parallel...",
style = MaterialTheme.typography.bodySmall
)
}
uiState.error != null -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
) {
Text(
text = "Error: ${uiState.error}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp)
)
}
}
uiState.dashboard != null -> {
val dashboard = uiState.dashboard!!
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("User Dashboard", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(16.dp))
// Profile Section
Text("Profile", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
DashboardRow("Name:", dashboard.profile.name)
DashboardRow("Email:", dashboard.profile.email)
DashboardRow("ID:", dashboard.profile.id)
Spacer(modifier = Modifier.height(16.dp))
// Preferences Section
Text("Preferences", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
DashboardRow("Theme:", dashboard.preferences.theme)
DashboardRow(
"Notifications:",
if (dashboard.preferences.notifications) "Enabled" else "Disabled"
)
DashboardRow("Language:", dashboard.preferences.language)
}
}
}
}
}
}
@Composable
private fun DashboardRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment