Blog Infos
Author
Published
Topics
, , , ,
Published

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: SearchInputSelectable 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
)
}
view raw GenericText.kt hosted with ❤ by GitHub

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
)
}
view raw TextLabels.kt hosted with ❤ by GitHub

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()
view raw searchQuery.kt hosted with ❤ by GitHub

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: PrayerTypeNovenaType 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
)
)
}
view raw TagText.kt hosted with ❤ by GitHub

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
}
view raw PrayerState.kt hosted with ❤ by GitHub

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
)
}
view raw PrayerItems.kt hosted with ❤ by GitHub

The only difference between them is the attributes that are being passed to the CardWithImage component, and of course the type of Prayer.

ChapletCardItem — NovenaCardItem — PrayerCardItem

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 titletag, 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

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.

Menu