ylliX - Online Advertising Network
How to smash a Jetpack Compose feature from Product — including testing! 🧪 🧐

How to smash a Jetpack Compose feature from Product — including testing! 🧪 🧐


The Team Leaders feature for the NHL app on Google Play

To create exceptional Jetpack Compose features, start by aligning them with your product goals and user needs. Design, prototype, and develop features that provide value. For this specific feature, Product aims to deliver interactive widgets and comprehensive player data, enhancing the user experience. 🏆🎉🥇💪

  • Define product goals.
  • Identify key features.
  • Leverage Jetpack Compose’s strengths.
  • Design and prototype.
  • Develop with best practices.
  • Test rigorously.
  • Iterate and refine!

The TeamLeadersViewModel is a key component responsible for managing data and state in this Jetpack Compose app displaying team leader information.

  • collects team player data (getPlayers) and enriches it with player stats (enrichPlayersWithTeamData).
  • Manages UI state using a MutableStateFlow that emits different states:
  • Loading: While data is being fetched.
  • Success: When data is retrieved successfully, holding skater and goalie leader lists.
  • Error: If an error occurs during data fetching.
  • Uses dependency injection (@HiltViewModel) to access repositories and dispatchers.
  • Leverages runCatching for concise error handling.
  • Exposes a uiState StateFlow for UI components to observe data changes.
  • Encapsulates player data enrichment logic in a private testable function.
  • Improved separation of concerns by isolating data fetching and state management.
  • Reactive UI updates through StateFlow.
  • Testable code with private functions accessible for unit testing (@VisibleForTesting).
data class EnrichedPlayerData(
val id: Int = 0,
val headshot: String = "",
val sweaterNumber: Int = 0,
val positionCode: String = "",
val firstName: String = "",
val lastName: String = "",
val points: Int = 0,
val goals: Int = 0,
val assists: Int = 0,
val goalsAgainstAverage: Double = 0.0,
val savePercentage: Double = 0.0
)

sealed class TeamLeadersUiState {
data object Loading : TeamLeadersUiState()
data class Success(val skaters: List<EnrichedPlayerData>, val goalies: List<EnrichedPlayerData>) : TeamLeadersUiState()
data class Error(val throwable: Throwable) : TeamLeadersUiState()
}

@HiltViewModel
class TeamLeadersViewModel @Inject constructor(
private val nhlRepository: NhlRepository,
private val dateUtilsRepository: DateUtilsRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {

private val _uiState = MutableStateFlow<TeamLeadersUiState>(TeamLeadersUiState.Loading)
val uiState: StateFlow<TeamLeadersUiState> = _uiState.asStateFlow()

suspend fun getPlayers(teamAbbrevId: String) = withContext(context = ioDispatcher) {
try {
viewModelScope.launch {
nhlRepository.getPlayers(teamAbbrevId).collect { team ->
val players = (team.forwards + team.defensemen + team.goalies).map { player ->
player.copy(teamLogo = team.defensemen[0].teamLogo, fullTeamName = Default(team.defensemen[0].fullTeamName.default))
}
enrichPlayersWithTeamData(teamAbbrevId, players)
}
}
} catch (e: Throwable) {
_uiState.emit(TeamLeadersUiState.Error(Throwable("Oops, something went wrong. Try again.")))
}
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
suspend fun enrichPlayersWithTeamData(teamAbbrevId: String, allPlayers: List<Player>) {
var enrichedSkaters = emptyList<EnrichedPlayerData>()
var enrichedGoalies = emptyList<EnrichedPlayerData>()
runCatching {
nhlRepository.getClubStats(teamAbbrevId, dateUtilsRepository.getCurrentSeasonInYears()).collect { clubStats ->
enrichedSkaters = clubStats.skaters.map { skater ->
val player = allPlayers.find { it.firstName.default == skater.firstName.default } ?: Player(firstName = "N/A")
EnrichedPlayerData(
id = player.id,
headshot = player.headshot,
sweaterNumber = player.sweaterNumber,
positionCode = player.positionCode,
firstName = player.firstName.default,
lastName = player.lastName.default,
points = skater.points,
goals = skater.goals,
assists = skater.assists
)
}
enrichedGoalies = clubStats.goalies.map { goalie ->
val player = allPlayers.find { it.firstName.default == goalie.firstName.default } ?: Player(firstName = "N/A")
EnrichedPlayerData(
id = player.id,
headshot = player.headshot,
sweaterNumber = player.sweaterNumber,
positionCode = player.positionCode,
firstName = player.firstName.default,
lastName = player.lastName.default,
savePercentage = goalie.savePercentage,
goalsAgainstAverage = goalie.goalsAgainstAverage
)
}
}
}.onSuccess {
_uiState.emit(
TeamLeadersUiState.Success(skaters = enrichedSkaters, goalies = enrichedGoalies)
)
}.onFailure { throwable ->
_uiState.emit(TeamLeadersUiState.Error(Throwable(throwable.message)))
}
}
}

This unit test verifies that the enrichPlayersWithTeamData function in the TeamLeadersViewModel correctly transforms player and club-stat data into EnrichedPlayerData objects. By simulating network calls and providing mock data, the test ensures that the function accurately maps player information with relevant team statistics.

  • Mock objects: mockNhlRepository and mockDateUtilsRepository are mocked to control their behavior during testing.
  • Test dispatcher: standardTestDispatcher is used to manage coroutines within the test environment.
  • Test setup: The setUp method initializes the TeamLeadersViewModel with mocked dependencies.
  • Test case: The enrichPlayersWithTeamData() emits players with team stats test case verifies that:
  • The correct data is returned from mocked repositories.
  • The player data is enriched with team statistics.
  • The uiState is updated with the enriched player data.
  1. Mock setup: Mocks are created for nhlRepository and mockDateUtilsRepository to control their behavior.
  2. Data preparation: Sample player data, season, team ID, and expected club stats are created.
  3. Mock interactions: The mocked repositories are set up to return expected data.
  4. Function call: enrichPlayersWithTeamData is called with sample data.
  5. Verification: The emitted uiState is checked to ensure it contains the expected enriched player data.

Overall, this unit test effectively verifies the core functionality of the enrichPlayersWithTeamData function. It tests the interaction with mocked repositories, the enrichment process, and the correct update of the uiState.

  • Edge cases: Consider testing edge cases, such as empty player lists or missing data from the club stats.
  • Error handling: Test how the function handles errors from the repositories.
  • UI state updates: Verify that the uiState is updated correctly in different scenarios (success, error, loading).
@ExperimentalCoroutinesApi
@RunWith(JUnit4::class)
class TeamLeadersViewModelTest {

@MockK
lateinit var mockNhlRepository: NhlRepository

@MockK
lateinit var mockDateUtilsRepository: DateUtilsRepository

private lateinit var viewModel: TeamLeadersViewModel

private val standardTestDispatcher = StandardTestDispatcher()

@Before
fun setUp() {
Dispatchers.setMain(standardTestDispatcher)
MockKAnnotations.init(this)

viewModel = TeamLeadersViewModel(
nhlRepository = mockNhlRepository,
dateUtilsRepository = mockDateUtilsRepository,
ioDispatcher = standardTestDispatcher
)
}

@After
fun tearDown() {
unmockkAll()
}

@Test
fun `enrichPlayersWithTeamData() emits players with team stats`() = runTest {
val mockPlayers = listOf(Player(id = 101, firstName = "ScoobyDoo"))
val mockSeason = "20232024"
val mockTeamAbbrevId = "LAK"
val mockSkater = Skater(id = 101, firstName = "ScoobyDoo", goals = 16)
val mockClubStats = ClubStats(skaters = listOf(mockSkater))

coEvery { mockDateUtilsRepository.getCurrentSeasonInYears() } returns mockSeason
coEvery { mockNhlRepository.getClubStats(mockTeamAbbrevId, mockSeason) } returns flowOf(mockClubStats)
advanceUntilIdle()

viewModel.enrichPlayersWithTeamData(mockTeamAbbrevId, mockPlayers)

val actualSkater = (viewModel.uiState.value as? TeamLeadersUiState.Success)?.topGoalsPlayers.orEmpty()
assertEquals(mockSkater.goals, actualSkater[0].goals)
}

}

Yay, Unit test passed!

By writing comprehensive unit tests, you can ensure the reliability and correctness of your ViewModel’s behavior. More importantly the test is there as a refactoring shield 🛡️ if your refactored code changes and the test breaks, you know to update your viewModel code because it’s not a perfect world and you don’t know the business decision made at the initial time of implementation. 🤷

This section dives into the TeamLeadersScreen composable and its related components used to display team leaders information.

  • Responds to changes in the TeamLeadersViewModel state using LaunchedEffect and collectAsState.
  • Handles different UI states:
  • Error: Navigates back with an error message.
  • Loading: Displays a loading screen.
  • Success (with players): Shows the team leaders screen
  • Manages the overall layout using a Scaffold composable.
  • Implements a parallax scrolling toolbar with dynamic title based on collapsing state.
  • Displays an asynchronous image for the team logo.
  • Shows a bottom app bar with an ad banner (not covered here).
  • Renders leader categories using LazyColumn and TeamLeadersListCard.
  • Represents a card displaying leaders for a specific category (points, goals, assists).
  • Uses a ConstraintLayout for flexible layout of player information:
  • Headshot image using AsyncImage.
  • Player details like name, number, position, and team abbreviation.
  • Text displaying the relevant stat (points, goals, assists).
  • Enables user navigation to the player profile screen.
  • This composable structure promotes separation of concerns by dividing the screen layout, content loading, and individual leader card rendering into separate composables.
  • Effective usage of Jetpack Compose features like LaunchedEffect, collectAsState, Scaffold, LazyColumn, and ConstraintLayout for a dynamic and user-friendly interface.
@Composable
fun TeamLeadersScreen(
teamAbbrevId: String,
viewModel: TeamLeadersViewModel = hiltViewModel(),
navController: NavController
) {
LaunchedEffect(Unit) { viewModel.getPlayers(teamAbbrevId) }
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is TeamLeadersUiState.Error -> {
NavigateBackWithMessageScreen(message = stringResource(id = R.string.sin_bin_no_players_found), navController = navController)
}
is TeamLeadersUiState.Loading -> LoadingScreen()
is TeamLeadersUiState.Success -> {
ShowTeamLeadersScreen(
teamColor = teamColor,
teamAbbrevId = teamAbbrevId,
uiState = uiState,
navController = navController
)
}
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShowTeamLeadersScreen(
teamColor: String,
teamAbbrevId: String,
uiState: TeamLeadersUiState.Succes,
navController: NavController
) {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(coroutineScope) { onDispose { coroutineScope.cancel() } }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val isCollapsed: Boolean by remember {
derivedStateOf { scrollBehavior.state.collapsedFraction == 1f }
}
val toolbarTitle = if (!isCollapsed) "TEAM".plus("\nLEADERS") else "LEADERS"
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
ParallaxToolBar(
scrollBehavior = scrollBehavior,
navController = navController,
title = toolbarTitle,
color = teamColor,
actions = {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://nhl.com/logos/".plus(teamAbbrevId))
.decoderFactory(SvgDecoder.Factory())
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = null
)
}
)
},
bottomBar = { BottomAppBar(Modifier.fillMaxWidth()) { SetAdmobAdaptiveBanner() } }
) { padding ->
LazyColumn(Modifier.padding(padding)) {
item {
TeamLeadersListCard(title = POINTS, teamColor = teamColor, players = uiState.skaters.sortedByDescending { it.points }, navController = navController)
}
item {
TeamLeadersListCard(title = GOALS, teamColor = teamColor, players = uiState.skaters.sortedByDescending { it.goals }, navController = navController)
}
item {
TeamLeadersListCard(title = ASSISTS, teamColor = teamColor, players = uiState.skaters.sortedByDescending { it.assists }, navController = navController)
}
}
}
}

@Composable
fun TeamLeadersListCard(title: String, teamColor: String, players: List<EnrichedPlayerData>, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(coroutineScope) { onDispose { coroutineScope.cancel() } }
Column(Modifier.padding(all = 10.dp).border(1.dp, lightGrayColor, RoundedCornerShape(4))) {
Text(text = title, textAlign = TextAlign.Start)
players.forEachIndexed { index, player ->
val bgColor = if (index == 0) Color(parseColor(teamColor)) else Color.White
val borderColor = if (index == 0) Color.Transparent else lightGrayColor
ConstraintLayout(
Modifier
.border(1.dp, borderColor, RoundedCornerShape(8))
.background(color = bgColor, shape = RoundedCornerShape(8))
.clickable { coroutineScope.launch { navController.navigate(PlayerProfile.createRoute(id = player.id)) } }
.fillMaxSize()
) {
val (headshot, firstName, lastName, playerTeamNumAndPos, number) = createRefs()
val chainRef = createVerticalChain(firstName, lastName, playerTeamNumAndPos, chainStyle = ChainStyle.Packed)
constrain(chainRef) {
top.linkTo(headshot.top)
bottom.linkTo(headshot.bottom)
}
Box(Modifier
.clip(CircleShape)
.background(colorResource(R.color.offWhiteColor))
.border(shape = CircleShape, width = 1.dp, color = colorResource(R.color.whiteSmokeColor))
.constrainAs(headshot) {
start.linkTo(parent.start)
top.linkTo(parent.top)
}
) {
AsyncImage(model = player.headshot, contentDescription = null, modifier = Modifier.fillMaxSize())
}
Text(
text = player.firstName.default,
modifier = Modifier
.constrainAs(firstName) {
start.linkTo(headshot.end)
end.linkTo(number.start)
top.linkTo(headshot.top)
bottom.linkTo(lastName.top)
width = Dimension.fillToConstraints
}
)
Text(
text = player.lastName.default,
textAlign = TextAlign.Start,
modifier = Modifier
.constrainAs(lastName) {
start.linkTo(headshot.end)
end.linkTo(number.start)
top.linkTo(firstName.bottom)
bottom.linkTo(playerTeamNumAndPos.top)
width = Dimension.fillToConstraints
}
)
val regexTeamNameAbbr = Regex("[A-Z]{3}").find(player.headshot)
val teamNameAbbr = regexTeamNameAbbr?.value ?: "NHL"
Text(
text = "$teamNameAbbr • #${player.sweaterNumber} • ${player.positionCode}",
textAlign = TextAlign.Start,
modifier = Modifier
.constrainAs(playerTeamNumAndPos) {
start.linkTo(headshot.end)
end.linkTo(number.start)
top.linkTo(lastName.bottom)
bottom.linkTo(headshot.bottom)
width = Dimension.fillToConstraints
}
)
val numberString = when (title) {
POINTS -> player.points.toString()
GOALS -> player.goals.toString()
ASSISTS -> player.assists.toString()
else -> "N/A"
}
Text(
text = numberString,
textAlign = TextAlign.Center,
modifier = Modifier
.constrainAs(number) {
start.linkTo(playerTeamNumAndPos.end)
end.linkTo(parent.end)
top.linkTo(firstName.top)
bottom.linkTo(playerTeamNumAndPos.bottom)
width = Dimension.fillToConstraints
}
)
}
}
}
}

This blog post provides a comprehensive guide to building Jetpack Compose features from a product-centric perspective. It covers key steps such as understanding product goals, designing and prototyping, developing with best practices, testing, and iterating for improvement. By following these guidelines, you can create exceptional mobile app experiences using Jetpack Compose.

To download the sample app and this feature, visit the following link on Google Play: https://play.google.com/store/apps/details?id=com.brickyard.nhl

Best,
RC



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *