Let's create notification reminder app in Jetpack Compose.

rocqjones

Jones Mbindyo

Posted on May 14, 2023

Let's create notification reminder app in Jetpack Compose.

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Step 1: Project setup.

  • Open your Android Studio and tap Create Project which 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.
    Templates wizard

  • Click Next and 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 on Help me choose link below the Minimum SDK drop-down.
    Project Name

  • Once you are done you can click Finish and your project will build and generate our starter code.

If you run into trouble with Gradle build, you can access File > settings of 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 accessing VSC menu on the top-bar menu and click Share Project on GitHub. Follow the dialog prompts, add, commit, and push your initial code (Android Studio will automatically create the repository for you).

VCS

Step 3: Configuring CI/CD Using GitHub Actions.

  • Go to your GitHub repo created above and click Actions.
  • Search for Android CI and from results as shown below click Configure. Follow prompts and either commit directly to your main branch or checkout to a new branch which is a better practice. Do not edit android.yml file generated (For now we will go with the default generated configurations).

CI

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.

Example output.
CI output

Step 4: Let's get coding.

Coding meme

  • 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 inside onCreate with ListItems(). Your onCreate should 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()
                }
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode
  • 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 a for-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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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) }
}
Enter fullscreen mode Exit fullscreen mode

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
            )
       )
    ) { //... }
Enter fullscreen mode Exit fullscreen mode
  • 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)
        }
    )
}
Enter fullscreen mode Exit fullscreen mode
  • 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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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 WorkManager offers 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'
}
Enter fullscreen mode Exit fullscreen mode
  • For plugins have:
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt'
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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.kt initially 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 an object DataSource { } which holds listOf( ComposeRandomItem(//...)).
  • The ComposeRandomItem() data class structure.
data class ComposeRandomItem(
    val name: String,
    val schedule: String,
    val type: String,
    val description: String
)
Enter fullscreen mode Exit fullscreen mode

For this section, I prepared the DataSource which can be found here

  • Replace names list from fun ListItems with data: 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
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Let's modify our ComposeCard parameters
@Composable
fun ComposeCard(name: String, type: String, description: String) { }
Enter fullscreen mode Exit fullscreen mode

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()
                        }
                    )
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • @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>
Enter fullscreen mode Exit fullscreen mode
  • 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 })
    }
}
Enter fullscreen mode Exit fullscreen mode
  • In this function, dialogState is a piece of state that is used to track whether the ReminderDialog component should be displayed or not. The state is updated in response to a click event on the Card component, and the dialog is conditionally displayed based on the state of dialogState.

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 ReminderViewModel class which extends the ViewModel class and provides a function scheduleReminder(). This function takes in a duration, unit (TimeUnit), and plant name as input parameters. It will create a OneTimeWorkRequest with the ReminderWorker class 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. The ReminderViewModelFactory is a factory class that creates instances of the ReminderViewModel class. 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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Before creating our ReminderWorker, we will create our BaseApplication class which is a custom implementation of the Application class that we will used to create and register a notification channel for displaying reminders. The onCreate() method is overridden to create a notification channel with the specified name, description, and importance level. The NotificationManager.IMPORTANCE_DEFAULT indicates that the notifications from this channel will have medium importance and will make a sound. The channel is registered with the system by calling the createNotificationChannel() method of the NotificationManager class. The CHANNEL_ID constant 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"
    }
}
Enter fullscreen mode Exit fullscreen mode

Manifest.

<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />

<application
        android:name=".base.BaseApplication">
</application>
Enter fullscreen mode Exit fullscreen mode
  • We will build our ReminderWorker class, which extends the Worker class and overrides the doWork method 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's MainActivity when the user clicks on the notification.
  • The BaseApplication.CHANNEL_ID constant is used to identify the notification channel, and the notificationId field will assign a unique ID number to each notification. Finally, the notification will be displayed to the user by calling the notify method from NotificationManagerCompat.
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"
    }
}
Enter fullscreen mode Exit fullscreen mode
  • To link our ReminderViewModel with our ReminderDialog, we use the viewModel function to create an instance of ReminderViewModel. We also pass an instance of ReminderViewModelFactory to create the view model. Then, we set up a clickable modifier on the composable element, which calls the scheduleReminder method on the viewModel instance when the user clicks on a specific item in the dialog. The scheduleReminder method 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, the onDismiss callback 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()
}
Enter fullscreen mode Exit fullscreen mode

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.

πŸ’– πŸ’ͺ πŸ™… 🚩
rocqjones
Jones Mbindyo

Posted on May 14, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related