Created
October 15, 2025 15:27
-
-
Save stevdza-san/9be91ff7651090fc39a519c7223a2d24 to your computer and use it in GitHub Desktop.
Kotlin Flows - Day 2
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.Button | |
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.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.channels.Channel | |
import kotlinx.coroutines.delay | |
import kotlinx.coroutines.flow.consumeAsFlow | |
import kotlinx.coroutines.flow.receiveAsFlow | |
import kotlinx.coroutines.launch | |
class DownloadViewModel : ViewModel() { | |
private val _downloadProgressFlow = Channel<Int>(capacity = 3) | |
val downloadProgressFlow = _downloadProgressFlow.receiveAsFlow() | |
private val _downloadProgressFlow2 = Channel<Int>(capacity = Channel.BUFFERED) | |
val downloadProgressFlow2 = _downloadProgressFlow.consumeAsFlow() | |
fun startDownload() { | |
viewModelScope.launch { | |
for (progress in 0..100 step 5) { | |
delay(200) | |
_downloadProgressFlow.send(progress) | |
_downloadProgressFlow2.send(progress) | |
} | |
} | |
} | |
} | |
@Composable | |
fun DownloadScreen() { | |
val viewModel: DownloadViewModel = viewModel() | |
var progress by remember { mutableStateOf(0) } | |
LaunchedEffect(Unit) { | |
viewModel.downloadProgressFlow.collect { | |
progress = it | |
println("Collector 1: Download progress ${it}%") | |
} | |
// viewModel.downloadProgressFlow2.collect { | |
// progress = it | |
// println("Collector 1: Download progress ${it}%") | |
// } | |
} | |
LaunchedEffect(Unit) { | |
viewModel.downloadProgressFlow.collect { | |
progress = it | |
println("Collector 2: Download progress ${it}%") | |
} | |
// viewModel.downloadProgressFlow2.collect { | |
// progress = it | |
// println("Collector 2: Download progress ${it}%") | |
// } | |
} | |
LaunchedEffect(Unit) { | |
viewModel.downloadProgressFlow.collect { | |
progress = it | |
println("Collector 3: Download progress ${it}%") | |
} | |
// viewModel.downloadProgressFlow2.collect { | |
// progress = it | |
// println("Collector 2: Download progress ${it}%") | |
// } | |
} | |
Column( | |
modifier = Modifier.fillMaxSize().padding(16.dp), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Button(onClick = { viewModel.startDownload() }) { | |
Text("Start Download") | |
} | |
Spacer(modifier = Modifier.height(24.dp)) | |
Text("Download Progress: $progress%") | |
} | |
} |
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 kotlinx.coroutines.flow.MutableStateFlow | |
import kotlinx.coroutines.flow.StateFlow | |
class MyViewModel : ViewModel() { | |
private val _count = MutableStateFlow(0) | |
val count: StateFlow<Int> get() = _count | |
val user: StateFlow<String> | |
field = MutableStateFlow("John Doe") | |
} | |
// build.gradle experimental opt-in | |
// sourceSets { | |
// sourceSets.all { | |
// languageSettings.enableLanguageFeature("ExplicitBackingFields") | |
// } | |
// } |
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.ui.test.* | |
import androidx.compose.ui.test.junit4.createComposeRule | |
import androidx.test.ext.junit.runners.AndroidJUnit4 | |
import com.stevdza_san.demo.flow.LoginTestTags | |
import com.stevdza_san.demo.flow.LoginTestingScreen | |
import org.junit.Before | |
import org.junit.Rule | |
import org.junit.Test | |
import org.junit.runner.RunWith | |
// DEPENDENCIES | |
// androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.9.3") | |
// debugImplementation("androidx.compose.ui:ui-test-manifest:1.9.3") | |
@RunWith(AndroidJUnit4::class) | |
class LoginScreenTest { | |
@get:Rule | |
val composeTestRule = | |
createComposeRule() // Provides testing environment for Compose UI components | |
@Before | |
fun setUp() { | |
composeTestRule.setContent { | |
LoginTestingScreen() | |
} | |
} | |
@Test | |
fun loginScreen_displaysAllUIComponents() { | |
// Assert - Verify all components are displayed | |
composeTestRule.onNodeWithTag(LoginTestTags.SCREEN_TITLE).assertIsDisplayed() | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD).assertIsDisplayed() | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD).assertIsDisplayed() | |
composeTestRule.onNodeWithTag(LoginTestTags.REMEMBER_ME_LABEL).assertIsDisplayed() | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON).assertIsDisplayed() | |
} | |
@Test | |
fun loginScreen_titleDisplaysCorrectText() { | |
// Assert | |
composeTestRule.onNodeWithTag(LoginTestTags.SCREEN_TITLE) | |
.assertTextEquals("Login Screen") | |
} | |
@Test | |
fun loginScreen_canEnterTextInUsernameField() { | |
// Act | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("testuser") | |
// Assert | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.assertTextContains("testuser") | |
} | |
@Test | |
fun loginScreen_canEnterTextInPasswordField() { | |
// Act | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.performTextInput("testpassword") | |
// Assert | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.assertTextContains("testpassword") | |
} | |
@Test | |
fun loginScreen_showsErrorMessage_whenUsernameIsEmpty() { | |
// Act - Enter password but leave username empty, then click login | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.performTextInput("password123") | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON) | |
.performClick() | |
// Assert | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertIsDisplayed() | |
.assertTextEquals("Username is required") | |
} | |
@Test | |
fun loginScreen_showsErrorMessage_whenPasswordIsEmpty() { | |
// Act - Enter username but leave password empty, then click login | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("testuser") | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON) | |
.performClick() | |
// Assert | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertIsDisplayed() | |
.assertTextEquals("Password is required") | |
} | |
@Test | |
fun loginScreen_showsErrorMessage_whenPasswordIsTooShort() { | |
// Act - Enter username and short password, then click login | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("testuser") | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.performTextInput("123") | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON) | |
.performClick() | |
// Assert | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertIsDisplayed() | |
.assertTextEquals("Password must be at least 6 characters") | |
} | |
@Test | |
fun loginScreen_showsErrorMessage_whenCredentialsAreInvalid() { | |
// Act - Enter invalid credentials and click login | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("wronguser") | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.performTextInput("wrongpassword") | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON) | |
.performClick() | |
// Assert | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertIsDisplayed() | |
.assertTextEquals("Invalid credentials") | |
} | |
@Test | |
fun loginScreen_showsSuccessMessage_whenCredentialsAreValid() { | |
// Act - Enter valid credentials and click login | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("admin") | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.performTextInput("password123") | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON) | |
.performClick() | |
// Assert | |
composeTestRule.onNodeWithTag(LoginTestTags.SUCCESS_MESSAGE) | |
.assertIsDisplayed() | |
.assertTextEquals("Login successful! Welcome admin") | |
// Assert that error message is not displayed | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertDoesNotExist() | |
} | |
@Test | |
fun loginScreen_errorMessageClearsWhenTypingUsername() { | |
// Act - First create an error by clicking login without username | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.performTextInput("password123") | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON) | |
.performClick() | |
// Assert error is displayed | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertIsDisplayed() | |
// Act - Start typing in username field | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("a") | |
// Assert error message disappears | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertDoesNotExist() | |
} | |
@Test | |
fun loginScreen_errorMessageClearsWhenTypingPassword() { | |
// Act - First create an error by clicking login without password | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("testuser") | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON) | |
.performClick() | |
// Assert error is displayed | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertIsDisplayed() | |
// Act - Start typing in password field | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.performTextInput("a") | |
// Assert error message disappears | |
composeTestRule.onNodeWithTag(LoginTestTags.ERROR_MESSAGE) | |
.assertDoesNotExist() | |
} | |
@Test | |
fun loginScreen_successMessageClearsWhenTyping() { | |
// Act - First create a success message with valid login | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("admin") | |
composeTestRule.onNodeWithTag(LoginTestTags.PASSWORD_FIELD) | |
.performTextInput("password123") | |
composeTestRule.onNodeWithTag(LoginTestTags.LOGIN_BUTTON) | |
.performClick() | |
// Assert success message is displayed | |
composeTestRule.onNodeWithTag(LoginTestTags.SUCCESS_MESSAGE) | |
.assertIsDisplayed() | |
// Act - Start typing in username field | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextClearance() | |
composeTestRule.onNodeWithTag(LoginTestTags.USERNAME_FIELD) | |
.performTextInput("newuser") | |
// Assert success message disappears | |
composeTestRule.onNodeWithTag(LoginTestTags.SUCCESS_MESSAGE) | |
.assertDoesNotExist() | |
} | |
} |
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.delay | |
import kotlinx.coroutines.flow.Flow | |
import kotlinx.coroutines.flow.SharingStarted | |
import kotlinx.coroutines.flow.StateFlow | |
import kotlinx.coroutines.flow.flow | |
import kotlinx.coroutines.flow.stateIn | |
import androidx.compose.runtime.Composable | |
import androidx.navigation.compose.NavHost | |
import androidx.navigation.compose.composable | |
import androidx.navigation.compose.rememberNavController | |
import kotlinx.serialization.Serializable | |
import androidx.compose.foundation.BorderStroke | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.layout.* | |
import androidx.compose.foundation.rememberScrollState | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.verticalScroll | |
import androidx.compose.material3.* | |
import androidx.compose.runtime.* | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.text.font.FontStyle | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.unit.dp | |
import androidx.lifecycle.compose.collectAsStateWithLifecycle | |
import androidx.lifecycle.viewmodel.compose.viewModel | |
import com.stevdza_san.demo.RequestState | |
class Repository { | |
fun fetchLazilyData(): Flow<RequestState<Int>> = flow { | |
println("📡 Repository (Lazily): FLOW STARTED!") | |
emit(RequestState.Loading) | |
delay(1000) | |
var counter = 0 | |
while (true) { | |
counter++ | |
println("📡 Repository (Lazily): Emitting #$counter") | |
emit(RequestState.Success(counter)) | |
delay(3000) // Emit every 3 seconds | |
} | |
} | |
fun fetchEagerlyData(): Flow<RequestState<Int>> = flow { | |
println("📡 Repository (Eagerly): FLOW STARTED!") | |
emit(RequestState.Loading) | |
delay(1000) | |
var counter = 0 | |
while (true) { | |
counter++ | |
println("📡 Repository (Eagerly): Emitting #$counter") | |
emit(RequestState.Success(counter)) | |
delay(3000) // Emit every 3 seconds | |
} | |
} | |
fun fetchWhileSubscribedData(): Flow<RequestState<Int>> = flow { | |
println("📡 Repository (WhileSubscribed): FLOW STARTED!") | |
emit(RequestState.Loading) | |
delay(1000) | |
var counter = 0 | |
while (true) { | |
counter++ | |
println("📡 Repository (WhileSubscribed): Emitting #$counter") | |
emit(RequestState.Success(counter)) | |
delay(3000) // Emit every 3 seconds | |
} | |
} | |
} | |
class MyViewModel() : ViewModel() { | |
val repository = Repository() | |
val whileSubscribedUiState: StateFlow<RequestState<Int>> = repository | |
.fetchWhileSubscribedData() | |
.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.WhileSubscribed(5000), // Stops 5 seconds after last subscriber | |
initialValue = RequestState.Idle | |
) | |
val eagerlyUiState: StateFlow<RequestState<Int>> = repository | |
.fetchEagerlyData() | |
.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.Eagerly, // Starts immediately, never stops | |
initialValue = RequestState.Idle | |
) | |
val lazilyUiState: StateFlow<RequestState<Int>> = repository | |
.fetchLazilyData() | |
.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.Lazily, // Starts on first subscription, never stops | |
initialValue = RequestState.Idle | |
) | |
} | |
@Composable | |
fun SharingStrategyNavGraph() { | |
val navController = rememberNavController() | |
NavHost( | |
navController = navController, | |
startDestination = SharingStrategyScreen.Home | |
) { | |
composable<SharingStrategyScreen.Home> { | |
SharingStrategiesHome( | |
navigateToDetails = { navController.navigate(SharingStrategyScreen.Details) } | |
) | |
} | |
composable<SharingStrategyScreen.Details> { | |
SharingStrategiesDetails() | |
} | |
} | |
} | |
@Serializable | |
sealed class SharingStrategyScreen { | |
@Serializable | |
object Home : SharingStrategyScreen() | |
@Serializable | |
object Details : SharingStrategyScreen() | |
} | |
@Composable | |
fun SharingStrategiesDetails() { | |
Box( | |
modifier = Modifier.fillMaxSize(), | |
contentAlignment = Alignment.Center | |
) { | |
Text("Details about Sharing Strategy") | |
} | |
} | |
@Composable | |
fun SharingStrategiesHome(navigateToDetails: () -> Unit) { | |
val socialMediaViewModel: MyViewModel = viewModel() | |
val whileSubscribedUiState by socialMediaViewModel.whileSubscribedUiState.collectAsStateWithLifecycle() | |
val eagerlyUiState by socialMediaViewModel.eagerlyUiState.collectAsState() | |
val lazilyUiState by socialMediaViewModel.lazilyUiState.collectAsState() | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(16.dp) | |
.safeDrawingPadding() | |
.verticalScroll(rememberScrollState()), | |
verticalArrangement = Arrangement.spacedBy(16.dp) | |
) { | |
// Header | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.clickable { navigateToDetails() }, | |
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) | |
) { | |
Column( | |
modifier = Modifier.padding(16.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Text( | |
text = "Real-World Sharing Strategies", | |
style = MaterialTheme.typography.headlineSmall, | |
fontWeight = FontWeight.Bold, | |
color = MaterialTheme.colorScheme.onPrimaryContainer, | |
textAlign = TextAlign.Center | |
) | |
Spacer(modifier = Modifier.height(8.dp)) | |
Text( | |
text = "Social Media App Example\nStates collected at top level, different strategies for different data", | |
style = MaterialTheme.typography.bodyMedium, | |
color = MaterialTheme.colorScheme.onPrimaryContainer, | |
textAlign = TextAlign.Center | |
) | |
} | |
} | |
// User Profile - Eagerly | |
FeatureCard( | |
title = "User Profile (Eagerly)", | |
description = "Critical data that should load immediately when app starts", | |
state = eagerlyUiState, | |
cardColor = MaterialTheme.colorScheme.primaryContainer, | |
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, | |
strategy = "Eagerly", | |
useCase = "Profile data is essential and should be ready immediately when user opens the app. Flow starts as soon as ViewModel is created." | |
) | |
// Live Notifications - WhileSubscribed | |
FeatureCard( | |
title = "Live Notifications (WhileSubscribed)", | |
description = "Real-time updates that pause when app is backgrounded", | |
state = whileSubscribedUiState, | |
cardColor = MaterialTheme.colorScheme.secondaryContainer, | |
contentColor = MaterialTheme.colorScheme.onSecondaryContainer, | |
strategy = "WhileSubscribed", | |
useCase = "Notifications should only update when user is actively using the app. Saves battery by stopping when app goes to background." | |
) | |
// Chat Messages - Lazily | |
FeatureCard( | |
title = "Chat Messages (Lazily)", | |
description = "Loaded when user opens chat, continues running in background", | |
state = lazilyUiState, | |
cardColor = MaterialTheme.colorScheme.tertiaryContainer, | |
contentColor = MaterialTheme.colorScheme.onTertiaryContainer, | |
strategy = "Lazily", | |
useCase = "Messages start loading when user first opens chat. Continues updating in background so messages stay fresh even if user switches tabs." | |
) | |
} | |
} | |
@Composable | |
private fun FeatureCard( | |
title: String, | |
description: String, | |
state: RequestState<Int>, | |
cardColor: Color, | |
contentColor: Color, | |
strategy: String, | |
useCase: String, | |
) { | |
Card( | |
modifier = Modifier.fillMaxWidth(), | |
colors = CardDefaults.cardColors(containerColor = cardColor) | |
) { | |
Column( | |
modifier = Modifier.padding(16.dp), | |
verticalArrangement = Arrangement.spacedBy(12.dp) | |
) { | |
// Title | |
Text( | |
text = title, | |
style = MaterialTheme.typography.titleMedium, | |
fontWeight = FontWeight.Bold, | |
color = contentColor | |
) | |
// Description | |
Text( | |
text = description, | |
style = MaterialTheme.typography.bodySmall, | |
color = contentColor | |
) | |
// Use case explanation | |
Text( | |
text = "💡 $useCase", | |
style = MaterialTheme.typography.bodySmall, | |
color = contentColor, | |
fontStyle = FontStyle.Italic | |
) | |
// Current state display | |
Card( | |
modifier = Modifier.fillMaxWidth(), | |
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), | |
border = BorderStroke( | |
1.dp, | |
MaterialTheme.colorScheme.outline | |
) | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background( | |
color = when (state) { | |
is RequestState.Idle -> Color.Gray.copy(alpha = 0.2f) | |
is RequestState.Loading -> Color.Blue.copy(alpha = 0.2f) | |
is RequestState.Success -> Color.Green.copy(alpha = 0.2f) | |
is RequestState.Error -> Color.Red.copy(alpha = 0.2f) | |
}, | |
shape = RoundedCornerShape(8.dp) | |
) | |
.padding(16.dp), | |
contentAlignment = Alignment.Center | |
) { | |
Column( | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Text( | |
text = "[$strategy Strategy]", | |
style = MaterialTheme.typography.labelSmall, | |
fontWeight = FontWeight.Bold | |
) | |
Spacer(modifier = Modifier.height(8.dp)) | |
when (state) { | |
is RequestState.Idle -> { | |
Text( | |
text = "Waiting to start...", | |
style = MaterialTheme.typography.bodyMedium, | |
) | |
} | |
is RequestState.Loading -> { | |
CircularProgressIndicator( | |
modifier = Modifier.size(20.dp), | |
strokeWidth = 2.dp, | |
) | |
Spacer(modifier = Modifier.height(8.dp)) | |
Text( | |
text = "Loading data...", | |
style = MaterialTheme.typography.bodyMedium, | |
) | |
} | |
is RequestState.Success -> { | |
Text( | |
text = "✅ Data loaded", | |
style = MaterialTheme.typography.bodyMedium, | |
fontWeight = FontWeight.Bold | |
) | |
Text( | |
text = "${state.data}", | |
style = MaterialTheme.typography.titleMedium, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center | |
) | |
} | |
is RequestState.Error -> { | |
Text( | |
text = "❌ Error occurred", | |
style = MaterialTheme.typography.bodyMedium, | |
fontWeight = FontWeight.Bold | |
) | |
Text( | |
text = state.message, | |
style = MaterialTheme.typography.bodySmall, | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} |
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.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
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.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.platform.testTag | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.text.input.KeyboardType | |
import androidx.compose.ui.unit.dp | |
import androidx.lifecycle.ViewModel | |
import androidx.lifecycle.viewmodel.compose.viewModel | |
import kotlinx.coroutines.flow.MutableStateFlow | |
import kotlinx.coroutines.flow.StateFlow | |
import kotlinx.coroutines.flow.asStateFlow | |
// Test Tags - Define as constants for reusability | |
object LoginTestTags { | |
const val SCREEN_TITLE = "screen_title" | |
const val USERNAME_FIELD = "username_field" | |
const val PASSWORD_FIELD = "password_field" | |
const val LOGIN_BUTTON = "login_button" | |
const val REMEMBER_ME_LABEL = "remember_me_label" | |
const val ERROR_MESSAGE = "error_message" | |
const val SUCCESS_MESSAGE = "success_message" | |
} | |
// Data class to represent the login UI state | |
data class LoginUiState( | |
val username: String = "", | |
val password: String = "", | |
val errorMessage: String = "", | |
val successMessage: String = "", | |
val isLoading: Boolean = false, | |
) | |
// ViewModel to manage login screen state | |
class LoginViewModel : ViewModel() { | |
private val _uiState = MutableStateFlow(LoginUiState()) | |
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow() | |
fun updateUsername(username: String) { | |
_uiState.value = _uiState.value.copy( | |
username = username, | |
errorMessage = "", // Clear error when user types | |
successMessage = "" // Clear success when user types | |
) | |
} | |
fun updatePassword(password: String) { | |
_uiState.value = _uiState.value.copy( | |
password = password, | |
errorMessage = "", // Clear error when user types | |
successMessage = "" // Clear success when user types | |
) | |
} | |
fun validateAndLogin() { | |
val currentState = _uiState.value | |
// Clear previous messages | |
_uiState.value = currentState.copy( | |
errorMessage = "", | |
successMessage = "", | |
isLoading = true | |
) | |
// Simulate network delay (you can remove this in real app) | |
// In real app, this would be a suspend function with actual API call | |
val errorMessage = when { | |
currentState.username.isEmpty() -> "Username is required" | |
currentState.password.isEmpty() -> "Password is required" | |
currentState.password.length < 6 -> "Password must be at least 6 characters" | |
currentState.username == "admin" && currentState.password == "password123" -> null | |
else -> "Invalid credentials" | |
} | |
_uiState.value = if (errorMessage != null) { | |
currentState.copy( | |
errorMessage = errorMessage, | |
isLoading = false | |
) | |
} else { | |
currentState.copy( | |
successMessage = "Login successful! Welcome ${currentState.username}", | |
isLoading = false | |
) | |
} | |
} | |
} | |
@Composable | |
fun LoginTestingScreen() { | |
val viewModel: LoginViewModel = viewModel() | |
val uiState by viewModel.uiState.collectAsState() | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(16.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.Center | |
) { | |
// Title | |
Text( | |
text = "Login Screen", | |
style = MaterialTheme.typography.headlineMedium, | |
fontWeight = FontWeight.Bold, | |
modifier = Modifier.testTag(LoginTestTags.SCREEN_TITLE) | |
) | |
Spacer(modifier = Modifier.height(32.dp)) | |
// Login Card | |
Card( | |
modifier = Modifier.fillMaxWidth(), | |
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) | |
) { | |
Column( | |
modifier = Modifier.padding(16.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
// Username Field | |
OutlinedTextField( | |
value = uiState.username, | |
onValueChange = { viewModel.updateUsername(it) }, | |
label = { Text("Username") }, | |
singleLine = true, | |
enabled = !uiState.isLoading, | |
modifier = Modifier | |
.fillMaxWidth() | |
.testTag(LoginTestTags.USERNAME_FIELD) | |
) | |
Spacer(modifier = Modifier.height(16.dp)) | |
// Password Field | |
OutlinedTextField( | |
value = uiState.password, | |
onValueChange = { viewModel.updatePassword(it) }, | |
label = { Text("Password") }, | |
singleLine = true, | |
enabled = !uiState.isLoading, | |
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), | |
modifier = Modifier | |
.fillMaxWidth() | |
.testTag(LoginTestTags.PASSWORD_FIELD) | |
) | |
Spacer(modifier = Modifier.height(24.dp)) | |
// Login Button | |
Button( | |
onClick = { viewModel.validateAndLogin() }, | |
enabled = !uiState.isLoading, | |
modifier = Modifier.testTag(LoginTestTags.LOGIN_BUTTON) | |
) { | |
Text(if (uiState.isLoading) "Logging in..." else "Login") | |
} | |
} | |
} | |
Spacer(modifier = Modifier.height(16.dp)) | |
// Error Message | |
if (uiState.errorMessage.isNotEmpty()) { | |
Text( | |
text = uiState.errorMessage, | |
color = MaterialTheme.colorScheme.error, | |
modifier = Modifier.testTag(LoginTestTags.ERROR_MESSAGE) | |
) | |
} | |
// Success Message | |
if (uiState.successMessage.isNotEmpty()) { | |
Text( | |
text = uiState.successMessage, | |
color = MaterialTheme.colorScheme.primary, | |
modifier = Modifier.testTag(LoginTestTags.SUCCESS_MESSAGE) | |
) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment