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
andmockDateUtilsRepository
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 theTeamLeadersViewModel
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.
- Mock setup: Mocks are created for
nhlRepository
andmockDateUtilsRepository
to control their behavior. - Data preparation: Sample player data, season, team ID, and expected club stats are created.
- Mock interactions: The mocked repositories are set up to return expected data.
- Function call:
enrichPlayersWithTeamData
is called with sample data. - 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)
}
}
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 usingLaunchedEffect
andcollectAsState
. - 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
andTeamLeadersListCard
.
- 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
, andConstraintLayout
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