Created
October 14, 2025 16:02
-
-
Save stevdza-san/b7008a800f59b6301808b65479514bdf to your computer and use it in GitHub Desktop.
Kotlin Flows - Day 1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") } | |
) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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