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 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.
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 onHelp me choose
link below the Minimum SDK drop-down.
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 accessingVSC
menu 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 CI
and 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.yml
file 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 insideonCreate
withListItems()
. YouronCreate
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()
}
}
}
}
- 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
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'
}
- 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.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 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
DataSource
which can be found here
- Replace
names
list fromfun ListItems
withdata: 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
ComposeCard
parameters
@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,
dialogState
is a piece of state that is used to track whether theReminderDialog
component should be displayed or not. The state is updated in response to a click event on theCard
component, 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
ReminderViewModel
class which extends theViewModel
class and provides a functionscheduleReminder()
. This function takes in a duration, unit (TimeUnit), and plant name as input parameters. It will create aOneTimeWorkRequest
with theReminderWorker
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. TheReminderViewModelFactory
is a factory class that creates instances of theReminderViewModel
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")
}
}
}
- Before creating our
ReminderWorker
, we will create ourBaseApplication
class which is a custom implementation of theApplication
class 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_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 thecreateNotificationChannel()
method of theNotificationManager
class. TheCHANNEL_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"
}
}
Manifest.
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<application
android:name=".base.BaseApplication">
</application>
- We will build our
ReminderWorker
class, which extends theWorker
class and overrides thedoWork
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'sMainActivity
when the user clicks on the notification. - The
BaseApplication.CHANNEL_ID
constant is used to identify the notification channel, and thenotificationId
field will assign a unique ID number to each notification. Finally, the notification will be displayed to the user by calling thenotify
method 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
ReminderViewModel
with ourReminderDialog
, we use theviewModel
function to create an instance ofReminderViewModel
. We also pass an instance ofReminderViewModelFactory
to create the view model. Then, we set up aclickable
modifier on the composable element, which calls thescheduleReminder
method on theviewModel
instance when the user clicks on a specific item in the dialog. ThescheduleReminder
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, theonDismiss
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()
}
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!