Get yourself a cup of coffee before we dive into this interesting tutorial.
@Composable
fun Coffee(enough: Int, caffeine: Int) {
when (enough) {
in (caffeine + 1) downTo caffeine -> {
drinkCoffee()
}
else -> {
drinkCoffee()
}
}
}
What we will cover.
- Applying CI/CD Using GitHub Actions for Android.
- Creating notification reminders using compose.
- Schedule notification using Work Manager.
Nice to have.
- Prior knowledge coding in Kotlin.
- Familiarity with Android Studio tools & usage.
- Skills in Version Control (Git).
- Basic understanding of Jetpack Compose.
- Scheduling tasks with WorkManager
Step 1: Project setup.
- Open your Android Studio and tap
Create Projectwhich will take you to Templates wizard.
In my case I am using...
Android Studio Electric Eel | 2022.1.1 Patch 2 Current Desktop: ubuntu:GNOME
We will use Empty Compose Activity (Material3) template which will generate for us a starter project.

Click
Nextand in this stage we will give our project a nice name. In this app we will set our Minimum SDK to API level 23 which is equivalent to Android Version 6.0(Marshmallow). If you want to learn more about which Minimum API Level to choose depending on your app needs you can tap onHelp me chooselink below the Minimum SDK drop-down.

Once you are done you can click
Finishand your project will build and generate our starter code.
If you run into trouble with Gradle build, you can access
File > settingsof your project and manually update Gradle JDK version by pointing to the right local directory (I'm using version 11.0.15 at the time of this tutorial)
Step 2: Setup GitHub Repository.
If you're following this tutorial it's nice to have a GitHub account to complete this section. If you don't have one, you can do that here.
- To easily complete this section you can connect your Android Studio to your GitHub account from
File > Settings > Version Control > GitHub. If you're already connected we can share our project by accessingVSCmenu on the top-bar menu and clickShare Project on GitHub. Follow the dialog prompts, add, commit, and push your initial code (Android Studio will automatically create the repository for you).
Step 3: Configuring CI/CD Using GitHub Actions.
- Go to your GitHub repo created above and click
Actions. - Search for
Android CIand from results as shown below clickConfigure. Follow prompts and either commit directly to your main branch or checkout to a new branch which is a better practice. Do not editandroid.ymlfile generated (For now we will go with the default generated configurations).
GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline.
Setup Branch protection rules
From your repo settings got to Branches and create some rules. For Branch name pattern write the name of your default branch and check the following rules:
- Require a pull request before merging.
- Require status checks to pass before merging.
- Require branches to be up to date before merging.
- Do not allow bypassing the above settings.
Step 4: Let's get coding.
- We will start by creating some animated collapsible cards with some random data. Since this is not the main area of focus we will keep it simple.
- From our generated code we replace the
Greeting("Android")method insideonCreatewithListItems(). YouronCreateshould now look like:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ReminderAppTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ListItems()
}
}
}
}
- In our
ListItems()function we will create a list of 30 cards. To optimize our program's performance, we will use a lazy list instead of afor-loop. Lazy lists allow us to efficiently process large datasets by evaluating only the elements that are needed, on demand. This reduces memory consumption and speeds up our program's execution time, especially when working with large datasets.
@Composable
fun ListItems(
modifier: Modifier = Modifier,
names: List<String> = List(30) { "$it" }
) {
LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
items(items = names) { n ->
ComposeCard(name = n)
}
}
}
Modularity and Reusability: We will break down the program's logic into different functions to improve the code's readability, maintainability, ease testing, and performance on multi-core processors.
-
ComposeCard()will define our Composable card and it's content.
@Composable
fun ComposeCard(name: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primary
),
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
CardContent(name)
}
}
- Since we want to handle animations within our card, we will create other functions (
CardContent()) that handle the card content and compose its state. Additionally, by isolating the animation logic in its own function, we can optimize its performance and ensure smooth, responsive animations.
@Composable
fun CardContent(name: String) {
val expanded = remember { mutableStateOf(false) }
}
We use the remember function in Android Jetpack Compose to store and manage state within a composable function. This function creates a MutableState object instance, which we can use to store and update state values. By initializing the expanded variable using remember, we can update its value within our composable function's scope, and any changes will trigger recompositions of the function.
- Let's add a spring-based animation to our card make it feel more natural and engaging when clicked.
Row(modifier = Modifier
.padding(12.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
) { //... }
- To add content to our animated card, we can include a single column layout and an icon button with an onClick listener that handles the expanded state, as explained above.
Column(modifier = Modifier.weight(1f).padding(12.dp)) {
Text(text = "Hello")
Text(text = "$name.",
style = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.ExtraBold
)
)
if (expanded.value) {
// Some random text here.
Text(
text = ("Jetpack Compose is a modern UI toolkit designed to simplify UI development.").repeat(2)
)
}
}
IconButton(onClick = { expanded.value = !expanded.value }) {
Icon(
painter = if (expanded.value) painterResource(id = R.drawable.baseline_expand_less_24) else painterResource(id = R.drawable.baseline_expand_more_24),
contentDescription = if (expanded.value) {
stringResource(R.string.show_less)
} else {
stringResource(R.string.show_more)
}
)
}
- At this point we already have a working collapsible list of items. We can override a dark theme on our app as follows:
@Preview(
showBackground = true,
widthDp = 320,
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "DefaultPreviewDark"
)
@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun DefaultPreview() {
ReminderAppTheme {
ListItems()
}
}
Notifications in Compose using Work Manager.
In order to achieve our goal, we'll be utilizing Android Jetpack WorkManager. This powerful framework handles various types of persistent work, such as Immediate, Long Running, and Deferrable tasks. For the purposes of this post, we'll be focusing on Immediate tasks.
Using
WorkManageroffers numerous benefits to developers. Firstly, it ensures that background tasks are reliably executed, even if the app is closed or the device is rebooted. Secondly, it provides a flexible API for scheduling work that can be tailored to specific app requirements. Thirdly, it optimizes battery usage by intelligently deferring work until system resources become available, ensuring that the app does not consume excessive power. Furthermore, it supports chaining of tasks, which allows for the creation of complex workflows with minimal overhead. Lastly, it simplifies the management of scheduled tasks by providing a single, centralized location for monitoring and controlling their execution.Modify our
build.gradle(:app)dependencies tree to have the following.
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.activity:activity-compose:1.7.1'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.compose.material3:material3:1.0.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
// work manager
implementation("androidx.work:work-runtime-ktx:$work_version")
// coroutines
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"
// Lifecycle components
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$rootProject.lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycleVersion"
// Lifecycle utilities for Compose
implementation "androidx.lifecycle:lifecycle-runtime-compose:$rootProject.lifecycleVersion"
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.compose.material:material:1.4.3'
}
- For plugins have:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
}
- Modify our
build.gradle(Project:)as follows
buildscript {
ext {
compose_version = '1.4.3'
work_version = "2.8.1"
lifecycleVersion = '2.6.1'
coroutines = '1.6.4'
}
}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.4.2' apply false
id 'com.android.library' version '7.4.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.20' apply false
}
If you're experiencing build issues related to compatibility issues, check out this Compose to Kotlin Compatibility documentation and Compose Compiler Stable Version. At the time of writing this post the above should work.
- To schedule reminders we're going to make some changes to our
MainActivity.ktinitially we we're using random data for our collapsible list. We're going to replace the list of 30 items we generate with real data from our local data sources. Create anobject DataSource { }which holdslistOf( ComposeRandomItem(//...)). - The
ComposeRandomItem()data class structure.
data class ComposeRandomItem(
val name: String,
val schedule: String,
val type: String,
val description: String
)
For this section, I prepared the
DataSourcewhich can be found here
- Replace
nameslist fromfun ListItemswithdata: List<ComposeRandomItem> = DataSource.plants.map { it }. The updated function should be as follows.
@Composable
fun ListItems(
modifier: Modifier = Modifier,
data: List<ComposeRandomItem> = DataSource.plants.map { it }
) {
LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
items(items = data.toMutableList()) { n ->
ComposeCard(
name = n.name,
type = n.type,
description = n.description
)
}
}
}
- Let's modify our
ComposeCardparameters
@Composable
fun ComposeCard(name: String, type: String, description: String) { }
Custom reminder dialog.
Our custom ReminderDialog composable function takes two parameters: name, a string that represents the reminder's name, and onDismiss, a function that's invoked when the dialog is dismissed.
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ReminderDialog(name: String, onDismiss: () -> Unit) {
val schedules = listOf(
R.string.schedule_5_seconds to 5000L,
R.string.schedule_8_minutes to 8 * 60 * 1000L,
R.string.schedule_1_day to 24 * 60 * 60 * 1000L,
R.string.schedule_1_week to 7 * 24 * 60 * 60 * 1000L
)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
Surface(
shape = RoundedCornerShape(16.dp),
modifier = Modifier.padding(16.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.title_reminder),
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 16.dp).fillMaxWidth()
)
schedules.forEach { (scheduleTextId, delayMillis) ->
ListItem(
text = { Text(text = stringResource(scheduleTextId)) },
modifier = Modifier.clickable {
// event
onDismiss()
}
)
}
}
}
}
}
@OptIn(ExperimentalMaterialApi::class)is an annotation used in Kotlin to indicate that the annotated element is using experimental Material Design components or APIs that are subject to change in future versions.The string resources used.
<string name="title_reminder">Remind me in…</string>
<string name="channel_name">reminder_channel</string>
<string name="channel_description">reminder_reminder</string>
<string name="schedule_5_seconds">5 seconds</string>
<string name="schedule_8_minutes">8 minutes</string>
<string name="schedule_1_day">1 day</string>
<string name="schedule_1_week">1 week</string>
- To demonstrate the use of state in managing dynamic UI elements within our composable function, we will modify our
ComposeCard()function as follows:
@Composable
fun ComposeCard(name: String, type: String, description: String) {
val dialogState = remember { mutableStateOf(false) }
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primary
),
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp),
onClick = { dialogState.value = true }
) {
CardContent(name, type, description)
}
if (dialogState.value) {
ReminderDialog(name = name, onDismiss = { dialogState.value = false })
}
}
- In this function,
dialogStateis a piece of state that is used to track whether theReminderDialogcomponent should be displayed or not. The state is updated in response to a click event on theCardcomponent, and the dialog is conditionally displayed based on the state ofdialogState.
Scheduling reminders using WorkManager.
- By using the Android Jetpack WorkManager API, we can schedule a one-time work request to display a reminder. This is achieved through the
ReminderViewModelclass which extends theViewModelclass and provides a functionscheduleReminder(). This function takes in a duration, unit (TimeUnit), and plant name as input parameters. It will create aOneTimeWorkRequestwith theReminderWorkerclass as the work to be done, sets the input data for the work request using the plant name and description obtained from a list of items, and schedules the work request using the WorkManager instance. TheReminderViewModelFactoryis a factory class that creates instances of theReminderViewModelclass. This approach allows for separation of concerns, making it easier to manage dependencies and testability in the application.
class ReminderViewModel(application: Application): ViewModel() {
private val itemsList = DataSource.plants
private val workManager = WorkManager.getInstance(application)
internal fun scheduleReminder(
duration: Long,
unit: TimeUnit,
plantName: String
) {
// create a Data instance with the plantName passed to it
val myWorkRequestBuilder = OneTimeWorkRequestBuilder<ReminderWorker>()
for (items in itemsList.toMutableList()) {
if (items.name == plantName) {
myWorkRequestBuilder.setInputData(
workDataOf(
"NAME" to items.name,
"MESSAGE" to items.description
)
)
}
}
myWorkRequestBuilder.setInitialDelay(duration, unit)
workManager.enqueue(myWorkRequestBuilder.build())
}
}
class ReminderViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return if (modelClass.isAssignableFrom(ReminderViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
ReminderViewModel(application) as T
} else {
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
- Before creating our
ReminderWorker, we will create ourBaseApplicationclass which is a custom implementation of theApplicationclass that we will used to create and register a notification channel for displaying reminders. TheonCreate()method is overridden to create a notification channel with the specified name, description, and importance level. TheNotificationManager.IMPORTANCE_DEFAULTindicates that the notifications from this channel will have medium importance and will make a sound. The channel is registered with the system by calling thecreateNotificationChannel()method of theNotificationManagerclass. TheCHANNEL_IDconstant is used to uniquely identify the notification channel and is made available through the companion object of the class.
class BaseApplication : Application() {
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = getString(R.string.channel_name)
val descriptionText = getString(R.string.channel_description)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
description = descriptionText
}
// Register the channel with the system
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
companion object {
const val CHANNEL_ID = "reminder_id"
}
}
Manifest.
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<application
android:name=".base.BaseApplication">
</application>
- We will build our
ReminderWorkerclass, which extends theWorkerclass and overrides thedoWorkmethod responsible for creating and displaying notifications to the user. The notification content will include the name of the plant and a reminder message. It will also set up a pending intent to launch the app'sMainActivitywhen the user clicks on the notification. - The
BaseApplication.CHANNEL_IDconstant is used to identify the notification channel, and thenotificationIdfield will assign a unique ID number to each notification. Finally, the notification will be displayed to the user by calling thenotifymethod fromNotificationManagerCompat.
class ReminderWorker(
context: Context,
workerParams: WorkerParameters
) : Worker(context, workerParams) {
// Arbitrary id number
private val notificationId = 17
@SuppressLint("MissingPermission")
override fun doWork(): Result {
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE
)
val plantName = inputData.getString(nameKey)
val body = "Hello, It's time to water your $plantName and spray pesticides to avoid powdery mildew."
val builder = NotificationCompat.Builder(applicationContext, BaseApplication.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_android_black_24dp)
.setContentTitle("Reminder App.")
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
with(NotificationManagerCompat.from(applicationContext)) {
notify(notificationId, builder.build())
}
return Result.success()
}
companion object {
const val nameKey = "NAME"
}
}
- To link our
ReminderViewModelwith ourReminderDialog, we use theviewModelfunction to create an instance ofReminderViewModel. We also pass an instance ofReminderViewModelFactoryto create the view model. Then, we set up aclickablemodifier on the composable element, which calls thescheduleRemindermethod on theviewModelinstance when the user clicks on a specific item in the dialog. ThescheduleRemindermethod takes the delay time, time unit, and name of the plant as its parameters, and uses these to create a work request to send a notification to the system. Finally, theonDismisscallback is called to dismiss the dialog.
val viewModel: ReminderViewModel = viewModel(
factory = ReminderViewModelFactory(
LocalContext.current.applicationContext as Application
)
)
//... Our previous code...
modifier = Modifier.clickable {
viewModel.scheduleReminder(delayMillis, TimeUnit.MILLISECONDS, name)
onDismiss()
}
Conclusion
In this blog, we have discussed the process of creating a reminder app in Android using Kotlin and Jetpack. We started by setting up the basic UI of the app and then implemented the ViewModel and Repository classes to manage the app's data.
Next, we explored the use of WorkManager to schedule notifications for each plant in the app, and created a ReminderWorker class to handle the creation and display of notifications.
Throughout this process, we emphasized the importance of writing clean and maintainable code, and used best practices such as using dependency injection and following the single responsibility principle.
By following these steps, we were able to create a functional reminder app that can help users keep track of their plant care routines.
References.
- The complete source code of this series is located in this GitHub Repository. You can fork to have your copy and create some more cooler features based on what we have learned.
- Jetpack Compose documentation.
- WorkManager.
- Notifications.
- ViewModel overview.
- Compose Material 3.



Top comments (1)
nicely written!