If you read the first part, you are ready to continue with me in this compose migration journey.
This part will be focused in the design of the implementation using compose in a scalable way. We will think through all the components and make them reusable, and eventually release the first feature with Jetpack Compose in production.
PrayersScreen:
This is the feature/screen that I have migrated from xml to compose in the part 1, as there I just developed a temporary solution, now I will show how implement it in a more scalable way.
I have 3 main things in this screen: SearchInput, Selectable Filter and Prayers List.

SanctusSearchBar:
I implemented it inside sanctus-components, to make it a reusable component, although it is probably the only case, for now, that has a search input.
@Composable | |
fun SanctusSearchBar( | |
query: String, | |
onSearch: (String) -> Unit, | |
modifier: Modifier = Modifier, | |
placeHolder: String = LocalContext.current.getString(R.string.search_place_holder) | |
) { | |
SearchBar( | |
modifier = modifier | |
.fillMaxWidth() | |
.focusable(false), | |
shape = SanctusAppShapes.medium, | |
expanded = false, | |
onExpandedChange = {}, | |
colors = SearchBarDefaults.colors( | |
containerColor = MaterialTheme.colorScheme.surface | |
), | |
shadowElevation = SanctusDimensions.elevation, | |
inputField = { | |
SearchBarDefaults.InputField( | |
modifier = Modifier.focusable(false), | |
onSearch = {}, | |
expanded = false, | |
onExpandedChange = {}, | |
placeholder = { | |
BodyLargeText(placeHolder) | |
}, | |
leadingIcon = { | |
Icon( | |
Icons.Default.Search, | |
contentDescription = null, | |
tint = MaterialTheme.colorScheme.primary | |
) | |
}, | |
trailingIcon = { | |
if (query.isNotEmpty()) { | |
IconButton( | |
onClick = { | |
onSearch("") | |
} | |
) { | |
Icon( | |
Icons.Default.Delete, | |
contentDescription = null, | |
tint = MaterialTheme.colorScheme.primary | |
) | |
} | |
} | |
}, | |
query = query, | |
onQueryChange = { | |
onSearch(it) | |
} | |
) | |
}, | |
content = { } | |
) | |
} |
A simple wrapper around the SearchBar from the Material 3 Compose library.
Here are some things worth paying attention to:
BodyLargeText used as the component for the placeholder component, but where does it come from?
I have created all Text components based on the SanctusAppTypography, and a GenericText component used in all text labels.
GenericText:
@Composable | |
fun GenericText( | |
text: String, | |
modifier: Modifier = Modifier, | |
style: TextStyle = SanctusAppTypography.bodyMedium, | |
color: Color = Color.Unspecified, | |
fontSize: TextUnit = TextUnit.Unspecified, | |
fontWeight: FontWeight? = null, | |
textAlign: TextAlign? = null, | |
textDecoration: TextDecoration? = null, | |
maxLines: Int = Int.MAX_VALUE, | |
overflow: TextOverflow = TextOverflow.Clip | |
) { | |
Text( | |
text = text, | |
modifier = modifier, | |
style = style, | |
color = color, | |
fontSize = fontSize, | |
fontWeight = fontWeight, | |
textAlign = textAlign, | |
textDecoration = textDecoration, | |
maxLines = maxLines, | |
overflow = overflow | |
) | |
} |
It is just a wrapper for Text component from compose library to concentrate any other changes I want to make.
TextLabels:
@Composable | |
fun DisplayLargeText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.displayLarge, | |
color = color, | |
textAlign = textAlign | |
) | |
} | |
@Composable | |
fun DisplayMediumText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.displayMedium, | |
color = color, | |
textAlign = textAlign | |
) | |
} | |
@Composable | |
fun HeadlineLargeText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.headlineLarge, | |
color = color, | |
textAlign = textAlign | |
) | |
} | |
@Composable | |
fun HeadlineMediumText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.headlineMedium, | |
color = color, | |
textAlign = textAlign | |
) | |
} | |
@Composable | |
fun TitleLargeText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.titleLarge, | |
color = color, | |
textAlign = textAlign | |
) | |
} | |
@Composable | |
fun BodyLargeText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.bodyLarge, | |
color = color, | |
textAlign = textAlign | |
) | |
} | |
@Composable | |
fun BodyMediumText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.bodyMedium, | |
color = color, | |
textAlign = textAlign | |
) | |
} | |
@Composable | |
fun LabelLargeText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.labelLarge, | |
color = color, | |
textAlign = textAlign | |
) | |
} | |
@Composable | |
fun LabelMediumText( | |
text: String, | |
modifier: Modifier = Modifier, | |
color: Color = Color.Unspecified, | |
textAlign: TextAlign? = null | |
) { | |
GenericText( | |
text = text, | |
modifier = modifier, | |
style = SanctusAppTypography.labelMedium, | |
color = color, | |
textAlign = textAlign | |
) | |
} |
You can observe that for the style I’m using the SanctusAppTypography to define how the labels looks like.
Coming back to SanctusSearchBar, a few things are worth mentioning:
leadingIcon: I have used a default Search icon from the material library, and for the tint color MaterialTheme.colorScheme.primary, so it will always reflect the SanctusAppTheme that was defined and explained in the first article.
trailingIcon: this icon only appears if there is a text in the search input, and if the icon is clicked it clears the input and triggers the onSearch listener to reset the search state.
To finish this component, I used 4 parameters, but only one is mandatory: query. It represents the text typed by the user and is used for the search. The responsibility for handling it stays outside the component — in this case, it’s managed in the ViewModel as a StateFlow.
private val _searchQuery = MutableStateFlow("") | |
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() |
Since the query is managed in the ViewModel using StateFlow, the data will survive configuration changes and persists until the ViewModel is destroyed.
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() | |
SanctusSearchBar( | |
onSearch = { | |
viewModel.search(it) | |
}, | |
modifier = Modifier | |
.padding(horizontal = SanctusDimensions.paddingMedium), | |
query = searchQuery | |
) |
PrayersScreen using SanctusSearchBar
Why did I use collectAsStateWithLifecycle?
Calling the selectedFilters StateFlow from the ViewModel using this extension function makes it a compose state, meaning it is now a lifecycle aware, so it will only collect the selectedFilters when the composable is an active lifecycle, preventing memory leaks.
PrayersFilterComponent:
The filters, which are selectable items, will reflect which types of prayers will be displayed in the list.
I have created 2 main components in the sanctus-components module that represent the filter:
SelectableItem:
@Composable | |
fun SelectableItem( | |
modifier: Modifier = Modifier, | |
item: SelectableItemData, | |
selectedColor: Color = MaterialTheme.colorScheme.primary, | |
unselectedColor: Color = MaterialTheme.colorScheme.surface, | |
onItemSelected: (SelectableItemData) -> Unit | |
) { | |
val backgroundColor = if (item.isSelected) { | |
selectedColor | |
} else { | |
unselectedColor | |
} | |
TagText( | |
modifier = modifier | |
.clickable { onItemSelected(item) }, | |
tag = item.name, | |
backgroundColor = backgroundColor | |
) | |
} | |
interface SelectableType | |
data class SelectableItemData( | |
val name: String, | |
val isSelected: Boolean, | |
val selectedColor: Color, | |
val unselectedColor: Color, | |
val type: SelectableType | |
) |
I have set the colors using the theme of the app, but if in the future I want to use others, I can simply pass them in the parameters.
I created the SelectableItemData to be used as a selectable item in the component. Each item holds a SelectableType, which is a generic interface. This allows different parts of the app to implement their own type like: PrayerType, NovenaType and ChapletType, making this component completely independent of the feature that implements it.
TagText: a text component with a tag style:
@Composable | |
fun TagText( | |
modifier: Modifier = Modifier, | |
tag: String, | |
contentColor: Color = MaterialTheme.colorScheme.onSurface, | |
backgroundColor: Color = MaterialTheme.colorScheme.surface, | |
) { | |
BodyMediumText( | |
text = tag, | |
color = contentColor.copy(alpha = 0.8f), | |
modifier = modifier | |
.padding( | |
top = 8.dp | |
) | |
.background( | |
color = backgroundColor, | |
shape = SanctusAppShapes.large | |
) | |
.border( | |
width = 1.dp, | |
color = contentColor.copy(alpha = 0.5f), | |
shape = SanctusAppShapes.large | |
) | |
.padding( | |
vertical = 4.dp, | |
horizontal = 6.dp | |
) | |
) | |
} |

TagText
As you can see, I used another already created text component, to keep the app consistent.
SelectableItemList:
@Composable | |
fun SelectableItemList( | |
modifier: Modifier = Modifier, | |
selectedItems: List<SelectableItemData>, | |
onItemSelected: (SelectableItemData) -> Unit, | |
) { | |
LazyRow ( | |
modifier = modifier | |
.fillMaxWidth() | |
.padding(horizontal = SanctusDimensions.paddingMedium), | |
horizontalArrangement = Arrangement.spacedBy(SanctusDimensions.paddingSmall) | |
) { | |
items(selectedItems){ item -> | |
SelectableItem( | |
item = item, | |
selectedColor = item.selectedColor, | |
unselectedColor = item.unselectedColor, | |
onItemSelected = onItemSelected, | |
) | |
} | |
} | |
} |
This is the list that contains all the selectable items and displays them inside a LazyRow. I am using dimensions from the application definition.
PrayersFilterComponent:
@Composable | |
fun PrayersFilterComponent( | |
selectedFilters: Set<SelectableFilterType>, | |
onFilterSelected: (SelectableFilterType) -> Unit | |
) { | |
val context = LocalContext.current | |
val items = remember(selectedFilters) { | |
listOf( | |
SelectableItemData( | |
name = context.getString(R.string.prayer_tag), | |
isSelected = selectedFilters.any { it is SelectableFilterType.PrayerType }, | |
selectedColor = ColorPrayer, | |
unselectedColor = White, | |
type = SelectableFilterType.PrayerType() | |
), | |
SelectableItemData( | |
name = context.getString(R.string.novena_tag), | |
isSelected = selectedFilters.any { it is SelectableFilterType.NovenaType }, | |
selectedColor = ColorNovena, | |
unselectedColor = White, | |
type = SelectableFilterType.NovenaType() | |
), | |
SelectableItemData( | |
name = context.getString(R.string.terco_tag), | |
isSelected = selectedFilters.any { it is SelectableFilterType.ChapletType }, | |
selectedColor = ColorTerco, | |
unselectedColor = White, | |
type = SelectableFilterType.ChapletType() | |
) | |
) | |
} | |
SelectableItemList( | |
selectedItems = items, | |
onItemSelected = { selectedItem -> | |
onFilterSelected(selectedItem.type as SelectableFilterType) | |
} | |
) | |
} | |
sealed class SelectableFilterType: SelectableType { | |
data class PrayerType(val prayerId: Int = 1) : SelectableFilterType() | |
data class NovenaType(val novenaId: Int = 2) : SelectableFilterType() | |
data class ChapletType(val chapletId: Int = 3) : SelectableFilterType() | |
} | |
I created a SelectableFilterType that implements the SelectableType, which was mentioned in the SelectableItem. Three types were created and used in the component together with others specific attributes, like colors and text.
It is important to observe that PrayersFilterComponent has 2 parameters: selectedFitlers, which comes from the viewModel and is the current filter state, and also the listener of each filter, which is also toggles the selected option in the viewModel.
private val _selectedFilters = MutableStateFlow( | |
setOf( | |
SelectableFilterType.PrayerType(), | |
SelectableFilterType.NovenaType(), | |
SelectableFilterType.ChapletType() | |
) | |
) | |
val selectedFilters: StateFlow<Set<SelectableFilterType>> = _selectedFilters.asStateFlow() | |
fun toggleFilter(filterType: SelectableFilterType) { | |
_selectedFilters.update { currentFilters -> | |
if (currentFilters.contains(filterType)) { | |
currentFilters - filterType | |
} else { | |
currentFilters + filterType | |
} | |
} | |
applyFilters() | |
} |
This is how the PrayersScreen calls this component in the end:
val selectedFilters by viewModel.selectedFilters.collectAsStateWithLifecycle() | |
PrayersFilterComponent( | |
selectedFilters = selectedFilters, | |
onFilterSelected = { filterType -> | |
viewModel.toggleFilter(filterType) | |
} | |
) |
Prayers List:
I’m using LazyColumn for it, but here I will go further and start explaining the PrayerStates, which are all the states that can be displayed in the screen. It can be the list of prayers, but also Error, Empty or Loading
val prayerState by viewModel.prayersState.collectAsStateWithLifecycle() | |
val context = LocalContext.current | |
Box( | |
modifier = Modifier.fillMaxSize() | |
) { | |
when (prayerState) { | |
PrayerState.Empty -> { | |
EmptyStateScreen( | |
searchQuery = searchQuery, | |
title = context.getString(R.string.empty_screen_title), | |
subtitle = context.getString(R.string.empty_screen_subtitle) | |
) | |
} | |
is PrayerState.Error -> { | |
ErrorStateScreen( | |
onRetry = { prayerState.retryAction() } | |
) | |
} | |
PrayerState.Loading -> { | |
LoadingStateScreen() | |
} | |
is PrayerState.Success -> { | |
is PrayerState.Success -> { | |
PrayersListScreen( | |
prayers = prayerState.prayers, | |
onPrayerClicked = onPrayerClicked, | |
onNovenaClicked = onNovenaClicked, | |
onChapletClicked = onChapletClicked | |
) | |
} | |
} |
PrayerState:
sealed interface PrayerState { | |
data object Loading : PrayerState | |
data object Empty : PrayerState | |
data class Success(val prayers: List<ViewType>) : PrayerState | |
data class Error(val throwable: Throwable, val retryAction: () -> Unit) : PrayerState | |
} |
These are all the states that can come from the PrayerViewModel and read by the PrayersScreen.
private val _prayersState = MutableStateFlow<PrayerState>(PrayerState.Loading) | |
val prayersState: StateFlow<PrayerState> = _prayersState.asStateFlow() |
PrayerState.Empty:
This shows the empty state, which is a generic component:
@Composable | |
fun EmptyStateScreen( | |
modifier: Modifier = Modifier, | |
title: String, | |
subtitle: String? = null, | |
searchQuery: String = "", | |
actionText: String? = null, | |
onActionClick: (() -> Unit)? = null, | |
contentPadding: PaddingValues = PaddingValues(SanctusDimensions.paddingMedium), | |
icon: @Composable (() -> Unit)? = null | |
) { | |
val context = LocalContext.current | |
Column( | |
modifier = modifier | |
.fillMaxSize() | |
.padding(contentPadding), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
icon?.invoke() | |
TitleLargeText( | |
modifier = Modifier.padding(top = SanctusDimensions.paddingMedium), | |
text = title, | |
textAlign = TextAlign.Center | |
) | |
subtitle?.let { | |
BodyMediumText( | |
modifier = Modifier.padding(top = SanctusDimensions.paddingSmall), | |
text = subtitle, | |
textAlign = TextAlign.Center | |
) | |
} | |
if (searchQuery.isNotEmpty()) { | |
BodyMediumText( | |
modifier = Modifier.padding(top = SanctusDimensions.paddingMedium), | |
text = context.getString(R.string.search_query_text, searchQuery), | |
textAlign = TextAlign.Center, | |
color = MaterialTheme.colorScheme.onSurfaceVariant | |
) | |
} | |
if (actionText != null && onActionClick != null) { | |
SanctusPrimaryButton( | |
modifier = Modifier.padding(top = SanctusDimensions.paddingLarge), | |
text = actionText, | |
onClick = onActionClick | |
) | |
} | |
} | |
} |

EmptyScreen Preview
What It Shows: An optional icon, a title (always visible), an optional subtitle, Optional “Search: query” if search is enabled and an action button if a action is presented. Action button is present when an action is needed which is defined by the presence of actionText e onActionClick.
PrayerState.Error:
@Composable | |
fun ErrorStateScreen( | |
modifier: Modifier = Modifier, | |
title: String = LocalContext.current.getString(R.string.title), | |
subtitle: String? = LocalContext.current.getString(R.string.subtitle), | |
retryText: String = LocalContext.current.getString(R.string.retryText), | |
errorMessage: String? = null, | |
onRetry: (() -> Unit)? = null, | |
contentPadding: PaddingValues = PaddingValues(SanctusDimensions.paddingMedium), | |
icon: @Composable (() -> Unit)? = { | |
Icon( | |
imageVector = Icons.Default.Close, | |
contentDescription = null, | |
tint = MaterialTheme.colorScheme.error, | |
modifier = Modifier.size(SanctusDimensions.paddingExtraLarge) | |
) | |
} | |
) { | |
Column( | |
modifier = modifier | |
.fillMaxSize() | |
.padding(contentPadding), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
icon?.invoke() | |
TitleLargeText( | |
modifier = Modifier | |
.padding(top = SanctusDimensions.paddingMedium), | |
text = title, | |
textAlign = TextAlign.Center, | |
color = MaterialTheme.colorScheme.error | |
) | |
subtitle?.let { | |
BodyMediumText( | |
modifier = Modifier | |
.padding(top = SanctusDimensions.paddingSmall), | |
text = subtitle, | |
textAlign = TextAlign.Center | |
) | |
} | |
errorMessage?.let { | |
BodyMediumText( | |
modifier = Modifier | |
.padding(top = SanctusDimensions.paddingSmall), | |
text = errorMessage, | |
textAlign = TextAlign.Center, | |
color = MaterialTheme.colorScheme.error | |
) | |
} | |
onRetry?.let { | |
SanctusPrimaryButton( | |
modifier = Modifier | |
.padding(top = SanctusDimensions.paddingLarge), | |
text = retryText, | |
onClick = onRetry | |
) | |
} | |
} | |
} |

ErrorStateScreen preview
What It Shows: an X icon that can be different if passed in the parameter, a title (always visible), subTitle, retryText and errorMessage when present, and if onRetry parameter is passed, shows a button to handle the function invocation.
PrayerState.Loading:
@Composable | |
fun LoadingStateScreen( | |
modifier: Modifier = Modifier, | |
message: String? = null, | |
contentPadding: PaddingValues = PaddingValues(SanctusDimensions.paddingMedium), | |
showProgressIndicator: Boolean = true | |
) { | |
val context = LocalContext.current | |
Column( | |
modifier = modifier | |
.fillMaxSize() | |
.padding(contentPadding), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
if (showProgressIndicator) { | |
CircularProgressIndicator( | |
modifier = Modifier.padding(bottom = SanctusDimensions.paddingMedium) | |
) | |
} | |
BodyLargeText( | |
text = message?: context.getString(R.string.loading_message), | |
textAlign = TextAlign.Center | |
) | |
} | |
} |

LoadingStateScreen Preview
What It Shows: a default loader and a loading text message.
PrayerState.Success:
@Composable | |
fun PrayersListScreen( | |
prayers: List<ViewType>, | |
onPrayerClicked: (PrayerSearch) -> Unit, | |
onNovenaClicked: (NovenaSearch) -> Unit, | |
onChapletClicked: (ChapletSearch) -> Unit | |
) { | |
LazyColumn( | |
modifier = Modifier | |
.fillMaxSize(), | |
contentPadding = PaddingValues( | |
top = SanctusDimensions.paddingMedium, | |
bottom = SanctusDimensions.searchBarPlusMargin, | |
start = SanctusDimensions.paddingMedium, | |
end = SanctusDimensions.paddingMedium | |
), | |
verticalArrangement = Arrangement.spacedBy( | |
space = SanctusDimensions.paddingMedium | |
) | |
) { | |
items(prayers) { prayer -> | |
when (prayer) { | |
is PrayerSearch -> { | |
PrayerCardItem( | |
prayer = prayer, | |
onClick = { | |
onPrayerClicked(prayer) | |
} | |
) | |
} | |
is NovenaSearch -> { | |
NovenaCardItem( | |
novena = prayer, | |
onClick = { | |
onNovenaClicked(prayer) | |
} | |
) | |
} | |
is ChapletSearch -> { | |
ChapletCardItem( | |
chaplet = prayer, | |
onClick = { | |
onChapletClicked(prayer) | |
} | |
) | |
} | |
} | |
} | |
} | |
} |
What It Shows: the list of prayers is using a LazyColumn to avoid loading all items at once in the UI, as it is behaves similar to the old RecyclerView, so it only loads what is currently displayed on the screen.
I have implemented 3 Items, but they are all using a common compose component inside:
@Composable | |
fun PrayerCardItem( | |
prayer: PrayerSearch, | |
onClick: () -> Unit | |
) { | |
val context = LocalContext.current | |
CardWithImage( | |
title = prayer.prayer.name, | |
tag = context.getString(R.string.prayer_tag), | |
imageResId = com.evangelhododiacatolico.tools.R.drawable.icon_pray_card, | |
backgroundColor = ColorPrayer, | |
onClick = onClick | |
) | |
} | |
@Composable | |
fun NovenaCardItem( | |
novena: NovenaSearch, | |
onClick: () -> Unit | |
) { | |
val context = LocalContext.current | |
CardWithImage( | |
title = novena.novena.name, | |
tag = context.getString(R.string.novena_tag), | |
imageResId = com.evangelhododiacatolico.tools.R.drawable.icon_novena_card, | |
backgroundColor = ColorNovena, | |
onClick = onClick | |
) | |
} | |
@Composable | |
fun ChapletCardItem( | |
chaplet: ChapletSearch, | |
onClick: () -> Unit | |
) { | |
val context = LocalContext.current | |
CardWithImage( | |
title = chaplet.terco.name, | |
tag = context.getString(R.string.terco_tag), | |
imageResId = com.evangelhododiacatolico.tools.R.drawable.icon_terco_card, | |
backgroundColor = ColorTerco, | |
onClick = onClick | |
) | |
} |
The only difference between them is the attributes that are being passed to the CardWithImage component, and of course the type of Prayer.


CardWithImage:
@Composable | |
fun CardWithImage( | |
title: String, | |
tag: String, | |
imageRes: ImageResource, | |
modifier: Modifier = Modifier, | |
backgroundColor: Color = MaterialTheme.colorScheme.surface, | |
contentColor: Color = MaterialTheme.colorScheme.onSurface, | |
onClick: () -> Unit, | |
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } | |
) { | |
Card( | |
modifier = modifier | |
.fillMaxWidth() | |
.clickable( | |
interactionSource = interactionSource, | |
indication = LocalIndication.current, | |
onClick = onClick | |
), | |
shape = SanctusAppShapes.medium, | |
colors = CardDefaults.cardColors( | |
containerColor = backgroundColor, | |
contentColor = contentColor | |
), | |
elevation = CardDefaults.cardElevation( | |
defaultElevation = SanctusDimensions.elevation, | |
pressedElevation = SanctusDimensions.buttonElevation | |
) | |
) { | |
Row( | |
modifier = Modifier | |
.padding(all = SanctusDimensions.paddingMedium) | |
.heightIn(min = SanctusDimensions.rowExtraLarge), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Column( | |
modifier = Modifier | |
.weight(weight = 1f) | |
.padding(end = SanctusDimensions.paddingSmall) | |
) { | |
TitleLargeText( | |
text = title, | |
color = contentColor, | |
) | |
Spacer(modifier = Modifier.height(height = SanctusDimensions.paddingExtraSmall)) | |
TagText( | |
tag = tag, | |
contentColor = contentColor, | |
backgroundColor = backgroundColor | |
) | |
} | |
when (imageRes) { | |
is ImageResource.Drawable -> { | |
Image( | |
painter = painterResource(id = imageRes.id), | |
contentDescription = title, | |
modifier = Modifier | |
.size(size = SanctusDimensions.iconSizeExtraLarge) | |
.clip(shape = SanctusAppShapes.small), | |
contentScale = ContentScale.Crop | |
) | |
} | |
is ImageResource.Vector -> { | |
Icon( | |
imageVector = imageRes.vector, | |
contentDescription = title, | |
modifier = Modifier.size(size = SanctusDimensions.iconSizeExtraLarge), | |
tint = contentColor | |
) | |
} | |
} | |
} | |
} | |
} | |
sealed class ImageResource { | |
data class Drawable(@DrawableRes val id: Int) : ImageResource() | |
data class Vector(val vector: ImageVector) : ImageResource() | |
} |
This CardWithImage composable displays a clickable card with a title, tag, and image. It supports both drawable and vector assets. The layout uses a row with text content on the left and an image on the right, with consistent spacing from a design system. It’s fully accessible and handles different image types through a sealed class.
Finally the combination of all those things in the PrayersScreen
@Composable | |
fun PrayersScreen( | |
viewModel: PrayerViewModel = koinViewModel(), | |
onPrayerClicked: (PrayerSearch) -> Unit, | |
onNovenaClicked: (NovenaSearch) -> Unit, | |
onChapletClicked: (ChapletSearch) -> Unit | |
) { | |
val prayerState by viewModel.prayersState.collectAsStateWithLifecycle() | |
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() | |
PrayerScaffold( | |
searchBar = { PrayerSearchBar(viewModel) }, | |
filters = { PrayerFilters(viewModel) }, | |
content = { | |
PrayerContent( | |
prayerState = prayerState, | |
searchQuery = searchQuery, | |
onPrayerClicked = onPrayerClicked, | |
onNovenaClicked = onNovenaClicked, | |
onChapletClicked = onChapletClicked | |
) | |
} | |
) | |
} | |
@Composable | |
private fun PrayerSearchBar(viewModel: PrayerViewModel) { | |
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() | |
SanctusSearchBar( | |
onSearch = { viewModel.search(it) }, | |
modifier = Modifier.padding(horizontal = SanctusDimensions.paddingMedium), | |
query = searchQuery | |
) | |
} | |
@Composable | |
private fun PrayerFilters(viewModel: PrayerViewModel) { | |
val selectedFilters by viewModel.selectedFilters.collectAsStateWithLifecycle() | |
PrayersFilterComponent( | |
selectedFilters = selectedFilters, | |
onFilterSelected = { viewModel.toggleFilter(it) } | |
) | |
} | |
@Composable | |
private fun PrayerContent( | |
prayerState: PrayerState, | |
searchQuery: String, | |
onPrayerClicked: (PrayerSearch) -> Unit, | |
onNovenaClicked: (NovenaSearch) -> Unit, | |
onChapletClicked: (ChapletSearch) -> Unit | |
) { | |
val context = LocalContext.current | |
when(prayerState) { | |
PrayerState.Empty -> { | |
EmptyStateScreen( | |
searchQuery = searchQuery, | |
title = context.getString(R.string.empty_screen_title), | |
subtitle = context.getString(R.string.empty_screen_subtitle) | |
) | |
} | |
is PrayerState.Error -> { | |
ErrorStateScreen( | |
onRetry = { prayerState.retryAction() } | |
) | |
} | |
PrayerState.Loading -> { | |
LoadingStateScreen() | |
} | |
is PrayerState.Success -> { | |
PrayersListScreen( | |
prayers = prayerState.prayers, | |
onPrayerClicked = onPrayerClicked, | |
onNovenaClicked = onNovenaClicked, | |
onChapletClicked = onChapletClicked | |
) | |
} | |
} | |
} | |
@Composable | |
private fun PrayerScaffold( | |
searchBar: @Composable () -> Unit, | |
filters: @Composable () -> Unit, | |
content: @Composable () -> Unit | |
) { | |
Scaffold( | |
modifier = Modifier.fillMaxSize(), | |
topBar = { | |
Column { | |
searchBar() | |
filters() | |
} | |
} | |
) { padding -> | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(padding) | |
) { | |
content() | |
} | |
} | |
} |
Job Offers
Finally the result of all components together, but you can obeserve that I developed 4 internal components: PrayerSearchBar PrayerFilters, PrayerContent and PrayerScaffold, in the same file of the PrayerScreen. Why? Because I want to apply the Slot API pattern, and for that I created the PrayerScaffold and passed all the compose components to it, making the screen very well structures and easy to read.
One important detail is the usage of the koinViewModel to get the viewModel instance for the screen. Good architecture decisions in the past are now being paid off with the migration to compose.
I already release the new version of the app with compose, and so far, so good.
The app is running with compose and old xml for other screens and it is fine. I’m very happy with the result and will continue to upgrade the app to meet 100% of compose.

Check out the Sanctus App in the PlayStore:
https://play.google.com/store/apps/details?id=com.evangelhododiacatolico
This article was previously published on proandroiddev.com.