Skip to content

Instantly share code, notes, and snippets.

@stevdza-san
Created October 15, 2025 15:27
Show Gist options
  • Save stevdza-san/9be91ff7651090fc39a519c7223a2d24 to your computer and use it in GitHub Desktop.
Save stevdza-san/9be91ff7651090fc39a519c7223a2d24 to your computer and use it in GitHub Desktop.
Kotlin Flows - Day 2
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%")
}
}
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")
// }
// }
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()
}
}
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
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.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.serialization.Serializable
class NavigationViewModel(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val id = savedStateHandle.getStateFlow<Int?>("id", null)
val name = savedStateHandle.getStateFlow<String?>("name", null)
val user = id.combine(name) { id, name ->
if (id != null && name != null) User(id, name) else null
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null
)
}
@Composable
fun NavigationHome(navigateToDetails: (Int, String) -> Unit) {
Box(
modifier = Modifier
.clickable { navigateToDetails(1, "Stefan") }
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "HOME",
fontSize = MaterialTheme.typography.titleLarge.fontSize
)
}
}
@Composable
fun NavigationDetails() {
val viewModel: NavigationViewModel = viewModel()
val user by viewModel.user.collectAsState()
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "HOME: ${user?.id} (${user?.name})",
fontSize = MaterialTheme.typography.titleLarge.fontSize
)
}
}
@Composable
fun NavigationNavGraph() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = MyScreen.Home
) {
composable<MyScreen.Home> {
NavigationHome(
navigateToDetails = { id, name ->
navController.navigate(MyScreen.Detail(id, name))
}
)
}
composable<MyScreen.Detail> {
NavigationDetails()
}
}
}
data class User(
val id: Int,
val name: String,
)
@Serializable
sealed class MyScreen {
@Serializable
object Home : MyScreen()
@Serializable
data class Detail(
val id: Int,
val name: String,
) : MyScreen()
}
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,
)
}
}
}
}
}
}
}
}
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