Back in 2017, I started my first Android app using Kotlin. At the time, it was just to explore the language, but later I ended up presenting it to my former colleagues to show how we could use Kotlin in our projects since they were all written in Java.

GitHub commit screenshot
The idea of the app was straightforward, helping me on my prayer journey.
Why did I decide to create an app like this?
Well, I had no better idea, so I just picked the first thing that came to mind to start writing code quickly. What mattered to me back then was learning how to use Kotlin in Android projects.
In the beginning, I was exploring what I could use to build a solid app ready for production, and I ended up using the following libraries:
apply plugin: 'com.android.application' | |
apply plugin: 'kotlin-android' | |
apply plugin: 'com.neenbedankt.android-apt' | |
apply plugin: 'kotlin-android-extensions' | |
apply plugin: 'kotlin-kapt' | |
apply plugin: 'io.fabric' | |
repositories { | |
mavenCentral() | |
} | |
buildscript { | |
ext.kotlin_version = '1.1.1' | |
repositories { | |
jcenter() | |
mavenCentral() | |
maven { url 'https://maven.fabric.io/public' } | |
} | |
dependencies { | |
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.1.1' | |
classpath "org.jetbrains.kotlin:kotlin-android-extensions:1.1.1" | |
classpath 'io.fabric.tools:gradle:1.+' | |
} | |
} | |
android { | |
compileSdkVersion 25 | |
buildToolsVersion "25.0.2" | |
defaultConfig { | |
applicationId "com.evangelhododia" | |
minSdkVersion 16 | |
targetSdkVersion 25 | |
versionCode 1 | |
versionName "1.0" | |
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" | |
} | |
buildTypes { | |
release { | |
minifyEnabled false | |
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' | |
} | |
} | |
sourceSets { | |
main.java.srcDirs += 'src/main/kotlin' | |
} | |
packagingOptions { | |
exclude 'META-INF/rxjava.properties' | |
} | |
} | |
dependencies { | |
compile fileTree(dir: 'libs', include: ['*.jar']) | |
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { | |
exclude group: 'com.android.support', module: 'support-annotations' | |
}) | |
compile 'com.android.support:appcompat-v7:25.3.1' | |
compile 'com.android.support:design:25.3.1' | |
compile 'com.android.support.constraint:constraint-layout:1.0.2' | |
compile 'org.jetbrains.kotlin:kotlin-stdlib-jre7:1.1.1' | |
compile 'org.jetbrains.anko:anko-support-v4:0.9' | |
compile 'com.google.dagger:dagger:2.10' | |
compile 'com.squareup.retrofit2:retrofit:2.2.0' | |
compile 'com.squareup.retrofit2:converter-gson:2.2.0' | |
compile 'com.squareup.retrofit2:converter-scalars:2.2.0' | |
compile 'com.squareup.retrofit2:adapter-rxjava:2.2.0' | |
compile 'com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0' | |
compile 'io.reactivex.rxjava2:rxandroid:2.0.1' | |
compile 'io.reactivex.rxjava2:rxjava:2.0.1' | |
compile 'com.squareup.okhttp3:okhttp:3.2.0' | |
compile 'com.squareup.okio:okio:1.7.0' | |
compile 'com.facebook.stetho:stetho:1.4.1' | |
compile 'com.facebook.stetho:stetho-okhttp3:1.4.1' | |
compile 'com.aurelhubert:ahbottomnavigation:2.0.6' | |
compile 'com.jakewharton:butterknife:8.4.0' | |
compile 'com.google.firebase:firebase-database:10.0.1' | |
testCompile 'junit:junit:4.12' | |
apt 'com.jakewharton:butterknife-compiler:8.4.0' | |
kapt 'com.google.dagger:dagger-compiler:2.10' | |
provided 'org.glassfish:javax.annotation:10.0-b28' | |
} | |
kapt { | |
generateStubs = true | |
} | |
apply plugin: 'com.google.gms.google-services' |

What is interest in on this build gradle file?
I was already using dependency injection with Dagger 2, firebase real time data base as my backend and RxJava in order to do async operations, and a lot of other libraries that were very popular back then and some that are still used.
In a month or so, I had an app ready to publish, and I did it, shipped out my first personal project to production on Google Play store.
It was my first personal project in production, and it felt so good, because I finally could have my own app and use any libraries that I wanted.
The reality is very simple, it is not easy to simply add new libraries to existing projects, or even change everything in a stable code base, because with client products we need to be very careful not to break everything. In order to bring a new library or approach to an application, we need to have a solid reason for it, but in the other hand for personal projects, we can do whatever we want, because we are the owners.
I was working on large projects with millions of users, so I could not change everything at once, so I thought this would be the best way to keep myself in the loop for new technologies, and to show up that we could start upcoming projects using Kotlin and begin to migrate some of the ongoing projects.
And it ended up very well. After 8 years, the project is still relevant for my learnings as a Android developer
8 years ago, and now, what is happening in the Android world?
Jetpack Compose is the hottest topic in the Android world, but how can I update a project that has 8 years to use the latest libraries? So, that is what I will try to summarise in the first post of probably a series of real case of migrating XML views to Jetpack Compose.
First step, let’s check the app structure:
I have implemented basically feature modules:
So, as you can see it is well divided in modules and this was made around 6 years ago (2 years after the first release):



git commits from the first modules creation
And inside of a feature module, the structure is the following:
P.S: prayers feature module, will be the first one to be migrated from XML to Compose, that is why I’m showing it here.
Now as it is a bit more clear about the whole project structure, let’s dive into the UI folder of the prayer module, to understand how we can start the migration to Compose.
But first, I want to show the first screen that will be migrated: CardPrayersFragment.
This is the CardPrayerFragment, which is one of the 4 main screens of the app, as it is part of the bottom tab navigation entry points. So let me explain how this screen was implemented, to understand how complex it will be to migrate to Compose.
Structure of the ui package regarding the Prayers feature:
CardsPrayerFragment:
This the main class of the feature/screen, where the list of prayers and filters are implemented. Let’s take a look at the XML of this screen:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/color_back_nav"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:background="@android:color/white"> <EditText android:id="@+id/edtSearch" android:layout_width="match_parent" android:layout_height="wrap_content" android:drawableStart="@drawable/ic_search" android:drawablePadding="@dimen/margin_view" android:padding="@dimen/little_margin_view" android:layout_marginTop="@dimen/little_margin_view" android:layout_marginStart="@dimen/margin_view" android:layout_marginEnd="@dimen/margin_view" android:layout_marginBottom="@dimen/margin_view" android:maxLines="1" android:inputType="text" android:imeOptions="actionGo" android:background="@drawable/bg_rounded_black_transparent"/> <com.evangelhododiacatolico.prayers.ui.filter.FilterView android:id="@+id/filterView" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="@dimen/margin_view"/> </LinearLayout> <View android:layout_width="match_parent" android:layout_height="2dp" android:background="@color/color_line"/> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent" android:clipToPadding="false" android:paddingTop="@dimen/margin_view"/> </LinearLayout>
edtSearch: is just the search box, to find prayers by typing.

edtSearch
FilterView: it is a custom component created to setup filters, by FilterType, which consist in a horizontal recyclerView that holds the filters options.

FilterView
I have 3 filter types that are defined in the FilterType sealed class:
sealed class FilterType { object TercoMain: FilterType() object NovenaMain: FilterType() sealed class PrayerMain: FilterType() { object Prayer: PrayerMain() object SubCategory: PrayerMain() } }
And then, I custom FilterItem to hold the filter type, and some options to customize the UI:
data class FilterItem( val tag: String, //text that shows in the UI val backgroundDrawable: Int, //backhground when isActive = true val backgroundDrawableDeactive: Int, //backhground when isActive = false var isActive: Boolean = true, // definition if the is On or Off val type: FilterType // the type of the filter )
To start using the FilterView, the screen that is having it in the XML needs to call the setupFilter:
class FilterView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : LinearLayout(context, attrs, defStyleAttr) { private val binding: ViewFilterBinding = ViewFilterBinding.inflate(LayoutInflater.from(context), this, true) private var filterAdapter: FilterAdapter? = null fun setupFilter( filters: List<FilterItem>, listener: (FilterItem) -> Unit ) { filterAdapter = FilterAdapter( filters = filters, onClickListener = { val index = filters.indexOf(it) it.isActive = !it.isActive filterAdapter?.notifyItemChanged(index) onClickListener(it) } ) binding.filterList.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) binding.filterList.adapter = filterAdapter } }
This is a custom view that sets up a horizontal filter list using a RecyclerView. First, it binds the view to access the XML components. Then, the setupFilter function initialises a FilterAdapter to populate the list with filter items. The adapter takes a high-order function to handle item clicks, toggling the isActive state and notifying the adapter to update the UI. Finally, the RecyclerView is set to a horizontal layout, and the adapter is attached.
FilterAdapter: This defines how each filter item is displayed in the list. It binds the data to the UI, updating the tag text and background based on whether the filter is active or not. It also handles click events, passing the clicked item back through a high-order function.
class FilterAdapter( val filters: List<FilterItem>, val onClickListener: (FilterItem) -> Unit ): RecyclerView.Adapter<FilterAdapter.FilterViewHolder>() { inner class FilterViewHolder( private val binding: ItemFilterBinding ) : RecyclerView.ViewHolder(binding.root){ fun bind(filter: FilterItem) = with(binding) { val context = binding.root.context txtTag.text = filter.tag when(filter.isActive) { true -> txtTag.background = ContextCompat.getDrawable(context, filter.backgroundDrawable) else -> txtTag.background = ContextCompat.getDrawable(context, filter.backgroundDrawableDeactive) } binding.root.setOnClickListener { onClickListener.invoke(filter) } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FilterViewHolder { val binding = ItemFilterBinding.inflate(LayoutInflater.from(parent.context), parent, false) return FilterViewHolder(binding) } override fun getItemCount(): Int = filters.size override fun onBindViewHolder(viewHolder: FilterViewHolder, position: Int) = viewHolder.bind(filters[position]) }
Filters are explained, now let’s check the list of prayers implementation.

List of prayers from the Sanctus App
RecyclerView — List of prayers
Here I need to explain a little deeper how lists in general are implemented in the whole app.
You can skip it if you want, but I found it very important to understand how it was and how it will be with Compose, but feel free to move forward.
My approach to handling lists was creating a GenericAdapter using the delegate adapters pattern. Each delegate has a specific type, and the GenericAdapter includes predefined types such as Load, NoResult, and ErrorRetry. These can be reused across different scenarios, making the solution more scalable and preventing the need to re-implement these cases for every list in the app
class GenericAdapter( lifecycleOwner: LifecycleOwner, private var items: ArrayList<ViewType>, private var delegateAdapters : SparseArrayCompat<ViewTypeDelegateAdapter> ): RecyclerView.Adapter<RecyclerView.ViewHolder>() { private val _errorLiveData = MutableLiveData<Throwable>() val errorLiveData: LiveData<Throwable> = _errorLiveData init { val adapters = listOf( Load.TYPE to LoadDelegateAdapter(), NoResults.TYPE to NoResultsDelegateAdapter(), ErrorRetry.TYPE to ErrorDelegateAdapter().apply { errorLiveData.observe(lifecycleOwner) { updateList(listOf(Load())) _errorLiveData.postValue(it) } } ) adapters.forEach { (type, adapter) -> delegateAdapters.put(type, adapter) } items = arrayListOf(Load()) } override fun getItemCount(): Int = items.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val delegateAdapter = delegateAdapters.get(viewType) return delegateAdapter?.onCreateViewHolder(parent) ?: object : RecyclerView.ViewHolder(parent) {} } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val delegateAdapter = delegateAdapters.get(getItemViewType(position)) delegateAdapter?.onBindViewHolder(position, holder, items[position]) } override fun getItemViewType(position: Int) = items[position].getViewType() fun updateList(newList: List<ViewType>, query: String = "") { if (items.firstOrNull() is Load) { items.removeAt(0) notifyItemRemoved(0) } if (newList.isEmpty()) { items.add(NoResults(query = query)) notifyItemInserted(items.size - 1) } else { val diffResult = DiffUtil.calculateDiff(ViewTypeDiffCallback(items, newList)) items.clear() items.addAll(newList) diffResult.dispatchUpdatesTo(this) } } fun resetList( newList: List<ViewType>, query: String = "" ) { if (newList.isEmpty()) { val diffResult = DiffUtil.calculateDiff(ViewTypeDiffCallback(items, listOf(NoResults(query)))) items.clear() items.add(NoResults(query)) diffResult.dispatchUpdatesTo(this) } else { val diffResult = DiffUtil.calculateDiff(ViewTypeDiffCallback(items, newList)) items.clear() items.addAll(newList) diffResult.dispatchUpdatesTo(this) } } fun showError(throwable: Throwable, color: Int = Color.BLACK) { resetList(listOf(ErrorRetry(throwable, color))) } }
Init: I have setup as default 3 delegate types, as explained above, but want to add the ErrorRetry explanation: when it is present there is a liveData, which fires the retry event when the user clicks on retry button, so the view that is implementing the GenericAdapter can benefit of this error handling.
getItemsCount, onCreateViewHolder, and onBindViewHolder, are just recycler view holder methods, and getItemViewType is the key of getting the correct delegate to show, so as you can see it is implemented for all types:

Android studio screenshot — project structure
So the generic adapter rely on the ViewType, which is used for the whole app. Back 6 years ago, it was the way that I have found in order to have a more scalable list and components over the application.
And updateList, resetList and showError are used to update the list with new data and show errors when it is necessary.
It takes too long, but we are getting there. Now we can check the CardPrayerFragment, which uses all the above things that were explained.
First, a diagram to show where the data is coming from, because the idea is not to explain the whole architecture of the app, but an overview is great:

Sanctus App diagram
So basically, the data comes from 3 different places, Novena module which is responsable for Novenas , Terco module which is responsable for the Tercos data, and Prayers data source, is part of the prayers module, after fetching the data, which can be from the backend(firebase) or directly from the database, which for the UI does not matter, as the PrayerViewModel is asking it to PrayersUseCase.
Let’s check how the CardPrayerFragment looks like:
class CardPrayerFragment: BaseFragmentX(R.layout.fragment_prayer) { private val viewModel: PrayerViewModel by viewModel() private val crashLogs: CrashLogs by inject() private lateinit var genericAdapter: GenericAdapter private val unsubscribeOnDestroy = CompositeDisposable() private lateinit var binding: FragmentPrayerBinding companion object { fun newInstance(): CardPrayerFragment { return CardPrayerFragment() } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentPrayerBinding.inflate(layoutInflater) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupViewModel() setupList() setupRecyclerView() setupSearch() } override fun onDestroy() { super.onDestroy() unsubscribeOnDestroy.clear() } private fun setupViewModel() { viewModel.init() viewModel.prayersLiveData.observe(viewLifecycleOwner, Observer { it?.getContentIfNotHandled()?.let { genericAdapter.updateList(it) } }) viewModel.updatePrayersLiveData.observe(viewLifecycleOwner, Observer { it?.getContentIfNotHandled()?.let { genericAdapter.resetList(it, binding.edtSearch.text.toString()) } }) viewModel.errorRetryLiveData.observe(viewLifecycleOwner, Observer { it?.getContentIfNotHandled()?.let { genericAdapter.showError(it) } }) viewModel.filtersLiveData.observe(viewLifecycleOwner, Observer { it?.getContentIfNotHandled()?.let { filters -> binding.filterView.setupFilter(filters) { filter -> viewModel.filter(filter) } } }) } private fun setupSearch() { binding.edtSearch.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_GO) { binding.edtSearch.dismissKeyboard() } true } RxTextView.textChanges(binding.edtSearch) .skipInitialValue() .debounce(300, TimeUnit.MILLISECONDS) .subscribeBy( onNext = { viewModel.search(it.toString()) }, onError = { crashLogs.logException(it) } ) .addTo(unsubscribeOnDestroy) } private fun setupList() { val delegateAdapters = SparseArrayCompat<ViewTypeDelegateAdapter>() val searchChapletDelegateAdapter = SearchChapletDelegateAdapter() searchChapletDelegateAdapter.tercoSearchLiveData.observe(viewLifecycleOwner, Observer { it.getContentIfNotHandled()?.let { startActivity(TercoFlowActivity.newIntent(requireContext(), it)) } }) val searchNovenaDelegateAdapter = SearchNovenaDelegateAdapter() searchNovenaDelegateAdapter.novenaSearchLiveData.observe(viewLifecycleOwner, Observer { it.getContentIfNotHandled()?.let { startActivity(NovenaFlowActivity.newIntent(requireContext(), it)) } }) val searchPrayerDelegateAdapter = SearchPrayerDelegateAdapter() searchPrayerDelegateAdapter.prayerSearchLiveData.observe(viewLifecycleOwner, Observer { it.getContentIfNotHandled()?.let { startActivity(PrayersDetailActivity.newIntent(requireContext(), it)) } }) delegateAdapters.put(PrayerSearch.TYPE, searchPrayerDelegateAdapter) delegateAdapters.put(NovenaSearch.TYPE, searchNovenaDelegateAdapter) delegateAdapters.put(ChapletSearch.TYPE, searchChapletDelegateAdapter) genericAdapter = GenericAdapter( this, arrayListOf(), delegateAdapters ) genericAdapter.errorLiveData.observe(viewLifecycleOwner, Observer { viewModel.init() }) setupRecyclerView() } private fun setupRecyclerView() = with(binding.recyclerView) { layoutManager = LinearLayoutManager(context) val decor = DividerItemDecoration(context, DividerItemDecoration.VERTICAL) decor.setDrawable(context.resources.getDrawable(R.drawable.divider2)) addItemDecoration(decor) adapter = genericAdapter } }
Just want to mention that I’m using Koin as DI. I started with dagger but, later on I have decided to experiment Koin as a learning thing 7 years ago – 1 year after the first release.

GitHub screenshot
Let’s check the 4 methods that makes this screen works:
setupViewModel: start to observe any changes regarding the liveDatas to show the data in the screen.
setupList: here is where we add the delegate adapters for each card that we will show on the screen, and add then to the generic adapter constructor.
The adapter will have 3 new types and will map it to the right delegate, resulting in the end of having: PrayerSearch.TYPE, NovenaSearch.TYPE and ChapletSearch.TYPE together with Load.TYPE, NoResults.TYPE and ErrorRetry.TYPE, that are added by default.
setupSearch: input text field using RxTextView, to observe the changes when the user is typing and send it after 300 ms to the ViewModel to filter the list.
setupRecyclerView: it is the setup of the recyclerView to use the generic adapter and add a divider to the list.
Okay, finally we can start the fun part and really start the Compose implementation, as we have covered the most important part of the screen.
2. Compose Journey
From here I’m going to start explaining my approach to starting the migration.
As Prayers screen is the first to be migrated, I could say “I will create everything inside of prayer module”, but I don’t want to make this mistake, because later I want to update the whole app gradually, and for that I will need to breakdown the work and think in how I can scale the Compose implementation over the app.
To start I have decided to create a new module called: sanctus-theme-Compose. It will hold everything related to the theme of the app. I can just add this module to the prayer dependencies and also to other modules to reuse it across the app.
sanctus-theme-compose structure:
Android studio screenshot — project structure
SanctusAppColors: I have collected all colours over the app, and add it to this file, so it will be easy to reuse.
val ColorPrimary = Color(0xFFFF5757) val ColorAccent = Color(0xFFF82222) val ColorPrimaryDark = Color(0xFFFAF7F2) val PrimaryTextColor = Color(0xFF000000) val WhiteTransparent = Color(0x52FFFFFF) val WhiteTransparent2 = Color(0x0D201E1E) val Yellow = Color(0xFFFF9800) val Gray = Color(0xFF464242) val Background = Color(0xFFF6F9FB) //there are more colors, but it is to big to show all
SanctusColorScheme: this is the definition of the colours that will be used as the app theme, and here we can start to see how amazing Compose is, because it very easy to define light and dark theme colors, and support them directly in the app.
val LightColorScheme = lightColorScheme( primary = ColorPrimary, secondary = ColorAccent, background = Background, surface = White, onPrimary = White, onSecondary = Black, onBackground = PrimaryTextColor, onSurface = PrimaryTextColor, ) val DarkColorScheme = darkColorScheme( primary = ColorPrimary, secondary = ColorAccent, background = Gray, surface = Gray, onPrimary = White, onSecondary = White, onBackground = White, onSurface = White, )
SanctusAppTypography: another amazing thing about Compose is that we can define a typography for the app and just use it on the app theme.
val LatoFontFamily = FontFamily( Font(R.font.lato_light, FontWeight.Light), Font(R.font.lato_regular, FontWeight.Normal), Font(R.font.lato_black, FontWeight.Medium), Font(R.font.lato_bold, FontWeight.Bold), Font(R.font.lato_italic, FontWeight.Normal), Font(R.font.lato_bold_italic, FontWeight.Bold) ) val SanctusAppTypography = Typography( displayLarge = TextStyle( fontFamily = LatoFontFamily, fontWeight = FontWeight.Light, fontSize = 57.sp, lineHeight = 64.sp ), displayMedium = TextStyle( fontFamily = LatoFontFamily, fontWeight = FontWeight.Light, fontSize = 45.sp, lineHeight = 52.sp ), // there are more options, just hide it to not become way to big
SanctusAppShapes: defines three corner radii for UI elements: small (4dp), medium (8dp), and large (16dp), used for rounding corners in the app’s design.
val SanctusAppShapes = Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(8.dp), large = RoundedCornerShape(16.dp) )
SanctusAppTheme: with all the foundation well defined, we can setup our AppTheme Compose to be used across the app, setting up the colours, typography and shapes, and taking in consideration dark mode from the device. It is so nice and easy to support dark mode from the start, without having to think about it any further.
@Composable fun SanctusAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme MaterialTheme( colorScheme = colorScheme, typography = SanctusAppTypography, shapes = SanctusAppShapes, content = content ) }
Now let’s add this module to the prayer module and see how it will work.
Before starting the project, I had to update several things to make the app work again. The last commit was 6 years ago, and the app was even removed from the Play Store last year(03/24). My first priority was to fix the issues and release a workable version again, which I’ve already done. I also took this opportunity to update the Gradle build system from Groovy to Kotlin DSL and began taking advantage of version catalogs.
@Composable fun PrayersScreen( viewModel: PrayerViewModel = koinViewModel(), onPrayerClicked: (PrayerSearch) -> Unit, onNovenaClicked: (NovenaSearch) -> Unit, onChapletClicked: (ChapletSearch) -> Unit ) { val prayers by viewModel.prayersLiveData.observeAsState() prayers?.getContentIfNotHandled()?.let { list -> LazyColumn { items(list) { prayer -> when (prayer) { is PrayerSearch -> PrayerItem(prayer, onClick = { onPrayerClicked(prayer) }) is NovenaSearch -> NovenaItem(prayer, onClick = { onNovenaClicked(prayer) }) is ChapletSearch -> ChapletItem(prayer, onClick = { onChapletClicked(prayer) }) } } } } } @Composable fun PrayerItem(prayer: PrayerSearch, onClick: () -> Unit) { Card(onClick = onClick) { Text( text = prayer.prayer.name, style = MaterialTheme.typography.headlineLarge ) } } @Composable fun NovenaItem(prayer: NovenaSearch, onClick: () -> Unit) { Card(onClick = onClick) { Text( text = prayer.novena.name, style = MaterialTheme.typography.headlineLarge ) } } @Composable fun ChapletItem(prayer: ChapletSearch, onClick: () -> Unit) { Card(onClick = onClick) { Text( text = prayer.terco.name, style = MaterialTheme.typography.headlineLarge ) } }
I have decided to add as api to use the Compose implementation from the the sanctus-theme-compose module:
api(project(":sanctus-theme-compose"))
Step 1: add a Compose view to the XML file for CardPrayerFragment to render the composable into the fragment, and remove the old components:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/color_back_nav"> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" android:layout_width="match_parent" android:layout_height="match_parent"/> </LinearLayout>
Step 2: access the Compose view using the view binding in the CardPrayerFragment.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val composeView = binding.composeView composeView.setContent { SanctusAppTheme { PrayersScreen( onPrayerClicked = { prayer -> startActivity(PrayersDetailActivity.newIntent(requireContext(), prayer.prayer)) }, onNovenaClicked = { novena -> startActivity(NovenaFlowActivity.newIntent(requireContext(), novena.novena)) }, onChapletClicked = { chaplet -> startActivity(TercoFlowActivity.newIntent(requireContext(), chaplet.terco)) } ) } } }
Booomm, we have it, a brand new screen using Compose!!! But, wait, where is the implementation?

gif recorded from the Sacntus App
Very simple, right? YEEESSS.
It was easy to do that, because I have benefit of the koinViewModel.
And I was able to take advantage of everything I was already using in the fragment, without any problems, making my migration even smoother.
@Composable fun PrayersScreen( viewModel: PrayerViewModel = koinViewModel(), onPrayerClicked: (PrayerSearch) -> Unit, onNovenaClicked: (NovenaSearch) -> Unit, onChapletClicked: (ChapletSearch) -> Unit ) { val prayers by viewModel.prayersLiveData.observeAsState() prayers?.getContentIfNotHandled()?.let { list -> LazyColumn { items(list) { prayer -> when (prayer) { is PrayerSearch -> PrayerItem(prayer, onClick = { onPrayerClicked(prayer) }) is NovenaSearch -> NovenaItem(prayer, onClick = { onNovenaClicked(prayer) }) is ChapletSearch -> ChapletItem(prayer, onClick = { onChapletClicked(prayer) }) } } } } } @Composable fun PrayerItem(prayer: PrayerSearch, onClick: () -> Unit) { Card(onClick = onClick) { Text( text = prayer.prayer.name, style = MaterialTheme.typography.headlineLarge ) } } @Composable fun NovenaItem(prayer: NovenaSearch, onClick: () -> Unit) { Card(onClick = onClick) { Text( text = prayer.novena.name, style = MaterialTheme.typography.headlineLarge ) } } @Composable fun ChapletItem(prayer: ChapletSearch, onClick: () -> Unit) { Card(onClick = onClick) { Text( text = prayer.terco.name, style = MaterialTheme.typography.headlineLarge ) } }
Job Offers
This is the first version of this screen, and I can already see some improvements. Of course there’s still a lot to refine, but having a solid architecture makes the transition to Compose much smoother. My next step is to dig deeper, rethink some parts of the app, and start creating reusable components inside the existing sanctus-components module. This way, I can add Compose components that will be used not only in this screen (prayers) but across the app, making future changes much easier.
In the next article, I will detail the process of developing all the necessary components to build the app using Compose and releasing this feature to production.
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.