Month calendar using Jetpack Glance

AndroidAI
9 min readSep 18, 2022

--

Recently the Android team released the alpha version of Jetpack Glance. I wrote an article about

Please read these articles

In this article, I am going to tell you how to create an month calendar app widget using Jetpack glance. Let’s start…

Initial Setup

If you want to try Jetpack glance there are some steps. First of all, we should add the Glance dependencies into our build.gradle file.

dependencies {
implementation "androidx.glance:glance-appwidget:1.0.0-alpha04"
}

Since Glance is based on the compose framework, compose will need to be enabled for the project.

android {
buildFeatures {
compose true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.2.0-alpha01"
kotlinCompilerVersion '1.6.10'
}
}

That’s all in the dependency part. Let us jump into the coding part.

Create widget

Widget info

If you want to create a widget we should create an XML definition for our widgets which contains all the basic information about the widgets. To do that in the project, you should create a new resource directory named “xml”, which will contain it. Then within “xml” you should create a new file called “month_calendar_widget_info.xml”. That contains

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:description="@string/widget_description"
android:initialLayout="@layout/widget_initial_layout"
android:minWidth="@dimen/dp_126"
android:minHeight="@dimen/dp_100"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen|searchbox|keyguard"
tools:targetApi="jelly_bean_mr1">
</appwidget-provider>

The appwidget-provider provides android with attributes about the widget.

  • The name of the widget will be “BasicGlanceWidget”)
  • Its minimum sizing information in DP
  • We also provide an initial layout.
  • Resize mode: How the widget can be resized by the user
  • The category, which defines the type of widget; can be Home Screen, KeyGuard, or Search Box
  • updatePeriodMillis -> set as 0 means don’t update

Initial Layout

We should provide Glance initial layout so that it can be used by Glance to show to the user till the composable view are ready to show its like a splash screen or placeholder. Glance does not support Jetpack compose to create an initial layout. Unfortunately, these layouts will need to be written in xml Since this part of the code is still based on RemoteView, one of the supported xml layouts will be required:

  • FrameLayout
  • LinearLayout
  • RelativeLayout
  • GridLayout

ConstraintLayout will not work for the widget’s initial or preview layout.

So in the layout section create a xml called “widget_initial_layout.xml”. That contains

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_widget">

<ProgressBar
android:id="@+id/loader"
style="?android:attr/progressBarStyle"
android:layout_above="@id/widgetLoadingState"
android:layout_width="@dimen/dp_20"
android:layout_height="@dimen/dp_20"
android:layout_centerInParent="true" />

<TextView
android:id="@+id/widgetLoadingState"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_centerInParent="true"
android:paddingVertical="@dimen/dp_8"
android:text="@string/loading_your_widget"
android:textColor="@color/label_text_color"
android:textSize="@dimen/sp_12" />

</RelativeLayout>

“bg_widget.xml” contains

<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/dp_10" />
<solid android:color="@color/white" />
</shape>

that’s for our initial layout.

With this basic information, this XML info can now be included in the manifest.

Android Manifest

To properly declare the widget, we should create a receiver for our widget which will be used to update our widget. First, two files will be created called “MonthCalendarGlanceWidgetReceiver.kt” and “MonthCalendarGlanceWidget.kt”

class MonthCalendarGlanceWidget : GlanceAppWidget() {

@Composable
override fun Content() {
}
}class MonthCalendarGlanceWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthCalendarGlanceWidget()
}

The MonthCalendarGlanceWidgetReceiver extends the GlanceAppWidgetReceiver which acts as a provider for the widget. This provided widget, MonthCalendarGlanceWidget, contains a composable function Content() that will contain the widget layout.

Once these classes are defined, the receiver can be added to the manifest.

<receiver
android:name=".widget.MonthCalendarGlanceWidgetReceiver"
android:exported="false"
android:label="@string/widget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/month_calendar_widget_info" />
</receiver>

The receiver contains

name -> name of the class which act as a provider for the widget.

label -> Label of the widget. This will be shown in the widget preview when the user tries to add the widget

meta tag -> here we should specify the widget info

That’s all now let us jump into the composable UI and try to create month calendar

Now you can add your widget to the home screen but it will be transparent as we didn’t start our widget UI. Let us start to create a widget UI.

The above methods and procedure is same for all widget creations

Creating the Month Calendar

Before we go into the UI part you should know some basic views, layout modifiers and text style please read my previous articles to know the basics.

You should also know some variables of GlanceWidget class which are

LocalContext.current //To get the context
LocalGlanceId.current //To get the GlanceId which is widget Id.
LocalSize.current //To get the size of the widget

First we will create a function contains parent view. Inside that we will call separate composable function to create header, day of week row and dates

@Composable
fun MonthCalendarView(context: Context, calendar: Calendar) {
val firstDayOfMonth = Calendar.SUNDAY
Column
(modifier = GlanceModifier.fillMaxSize().background(ImageProvider(R.drawable.bg_widget))
.appWidgetBackground()) {
MonthCalendarHeaderView(context = context, calendar)
DaysOfWeek(firstDayOfWeek = firstDayOfMonth)
Date(firstDayOfMonth = firstDayOfMonth, calendar = calendar)
}
}

Here appWidgetBackground() is used for smoother transition. We will set a drawable for our background

Next will create header function

@Composable
private fun MonthCalendarHeaderView(context: Context, calendar: Calendar) {
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = R.dimen.dp_8)) {
Row(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment
.CenterVertically) {
Image(modifier = getHeaderImageModifier(getImageActionCallback(TYPE_PREVIOUS_MONTH)),
provider = ImageProvider(R.drawable.ic_widget_arrow_left),
contentDescription = "Previous month")
Text(text = getFormatString(calendar.timeInMillis, MONTH_YEAR_FORMAT),
style = TextStyle(fontSize = 14.sp, textAlign = TextAlign
.Center, color = ColorProvider(
Color(0xFF202124),Color(0xFFFFFFFF)
)))
Image(modifier = getHeaderImageModifier(getImageActionCallback(TYPE_NEXT_MONTH)),
provider = ImageProvider(R.drawable.ic_widget_arrow_right),
contentDescription = "Next month")
}
}
}

Some methods used in the above function

private fun getImageActionCallback(clickType: Int): Action {
return actionRunCallback<OnMonthChangedActionCallback(actionParametersOf(
ActionParameters.Key("button_click_key") to clickType))
}

Header modifier

private fun getHeaderImageModifier(onClick: Action): GlanceModifier {
return GlanceModifier.size(R.dimen.dp_32)
.clickable(onClick).padding(vertical = R.dimen.dp_4, horizontal = R.dimen.dp_4)
}

Format method

private fun getFormatString(timeInMillis: Long, requiredFormat: String): String {
//Here we will use simple date format to convert calendar to specific format
}

Next we will create a day of week composable function

@Composable
fun DaysOfWeek(firstDayOfWeek: Int) {
val weekDayCalendar = Calendar.getInstance()
weekDayCalendar.set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
Row(modifier = GlanceModifier.fillMaxWidth()) {
for (i in 0 until COLUMN_COUNT) {
Text(modifier = GlanceModifier.defaultWeight(), text = getFormatString(
weekDayCalendar.timeInMillis, WEEK_DAY_FORMAT
), style = TextStyle(
color = ColorProvider(
Color(0xFF202124),Color(0xFFFFFFFF)
), fontSize = 13.sp,
fontWeight = FontWeight.Medium, textAlign = TextAlign.Center
)
)

weekDayCalendar.add(Calendar.DAY_OF_WEEK, 1)
}
}
}

Next we will create a composable method to set date

@Composable
private fun Date(firstDayOfMonth: Int, calendar: Calendar) {
val todayCalendar = CalendarUtils.getCalendar()
val dateCalendar = calendar.clone() as Calendar
dateCalendar.set(Calendar.DAY_OF_MONTH, 1)
CalendarUtils.setTimeToBeginningOfDay(dateCalendar)

val currentMonth = dateCalendar.get(Calendar.MONTH)
val futureCalendar = dateCalendar.clone() as Calendar
futureCalendar.add(Calendar.MONTH, 1)
val futureMonth = futureCalendar.get(Calendar.MONTH)

val firstDayIndex = getMonthFirstDayWeekIndex(dateCalendar, firstDayOfMonth)

dateCalendar.add(Calendar.MONTH, -1)
val prevMonthDays = dateCalendar.getActualMaximum(Calendar.DAY_OF_MONTH)

val value = prevMonthDays - firstDayIndex + 1
dateCalendar.set(Calendar.DAY_OF_MONTH, value)

Column(modifier = GlanceModifier.fillMaxSize().padding(top = R.dimen.dp_10)) {
for (row in 0 until ROW_COUNT) {
if (futureMonth == dateCalendar.get(Calendar.MONTH)) {
continue
}
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
for (column in 0 until COLUMN_COUNT) {
var dateString = ""
if (currentMonth == dateCalendar.get(Calendar.MONTH)) {
dateString = getFormatString(
dateCalendar.timeInMillis, DATE_FORMAT
)
}

DateTextView(
dateString = dateString,
isToday = CalendarUtils.isSameDay(dateCalendar, todayCalendar),
onClick = actionRunCallback<OnDateSelectedActionCallback>(
actionParametersOf(
ActionParameters.Key("date_click_key")
to dateCalendar.timeInMillis
)
)
)

dateCalendar.add(Calendar.DAY_OF_MONTH, 1)
}
}
}
}
}

I will explain the logic behind this

val todayCalendar = CalendarUtils.getCalendar()
val dateCalendar = calendar.clone() as Calendar
dateCalendar.set(Calendar.DAY_OF_MONTH, 1)

In the above piece of code, we are getting the current date calendar and setting the date to start of the month

val currentMonthInt = dateCalendar.get(Calendar.MONTH)
val futureCalendar = dateCalendar.clone() as Calendar
futureCalendar.add(Calendar.MONTH, 1)
val futureMonthInt = futureCalendar.get(Calendar.MONTH)

Here we are getting the current month and the future month

val firstDayIndex = getMonthFirstDayWeekIndex(dateCalendar, firstDayOfMonth)private fun getMonthFirstDayIndex(calendar: Calendar, firstDayOfMonth: Int): Int {
val day = calendar.clone() as Calendar
day.set(Calendar.DAY_OF_WEEK, firstDayOfMonth)
var weekDayIndex = 0
val currentWeekDayIndex = calendar[Calendar.DAY_OF_WEEK]
for (i in 0 until 7) {
if (day[Calendar.DAY_OF_WEEK] == currentWeekDayIndex) {
weekDayIndex = i
break
}
day.add(Calendar.DAY_OF_WEEK, 1)
}
return weekDayIndex
}

In the above we are getting the current month starting date week day index

dateCalendar.add(Calendar.MONTH, -1)
val prevMonthDays = dateCalendar.getActualMaximum(Calendar.DAY_OF_MONTH)

val value = prevMonthDays - firstDayIndex + 1
dateCalendar.set(Calendar.DAY_OF_MONTH, value)

Here we are getting the last date of previous month and setting that date to calendar for iteration. That is the starting point

Inside the main Date function we are checkign and setting the date as empty string for previous month and future month only current month is date is shown

Now we are going to create composable function for date text view

@Composable
private fun RowScope.DateTextView(
dateString: String, isToday: Boolean, onClick: Action?
) {
val isCurrentMonthDate = dateString != ""

Column(
modifier = getDateTextParentModifier(isCurrentMonthDate, onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.Vertical.CenterVertically
) {
Text(
modifier = getDateTextChildModifier(isToday, ImageProvider(R
.drawable.bg_widget_today_circle))
.padding(R.dimen.dp_2), text = dateString, style = TextStyle(
color = getLabelTextColor(),
fontSize = 12.sp, textAlign = TextAlign.Center
), maxLines = 1
)
}
}

Used methods in the above code

private fun RowScope.getDateTextParentModifier(isCurrentMonth: Boolean, onClick: Action?):
GlanceModifier {
return if (isCurrentMonth) {
GlanceModifier.defaultWeight().clickable(onClick = onClick!!)
} else {
GlanceModifier.defaultWeight()
}
}

private fun getDateTextChildModifier(isCurrentMonth: Boolean, dateBackground: ImageProvider):
GlanceModifier {
return if (isCurrentMonth) {
GlanceModifier.wrapContentSize().background(dateBackground)
} else {
GlanceModifier.wrapContentSize()
}
}

And at last the callback class

class OnMonthChangedActionCallback : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val clickType = requireNotNull(parameters[AppWidgetUtils.getButtonClickActionParameterKey()])
updateAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) {
val calendar: Calendar = CalendarUtils.getCalendar(
it[longPreferencesKey(
AppWidgetUtils.EXTRAS_CURRENT_CALENDAR_MILLISECOND
)]
)
when (clickType) {
AppWidgetUtils.TYPE_PREVIOUS_MONTH -> {
calendar.add(Calendar.MONTH, -1)
}
AppWidgetUtils.TYPE_NEXT_MONTH -> {
calendar.add(Calendar.MONTH, 1)
}
else -> {

}
}
it.toMutablePreferences().apply {
this[longPreferencesKey(AppWidgetUtils.EXTRAS_CURRENT_CALENDAR_MILLISECOND)] =
calendar.timeInMillis
}
}
MonthCalendarGlanceWidget().update(context, glanceId)
}

}

Here we will check and update the calendar to previous or next

class OnDateSelectedActionCallback : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val timeInMillis = requireNotNull(parameters[AppWidgetUtils
.getDateClickActionParameterKey()])
val date = CalendarUtils.formatDateTime(timeInMillis,
requiredFormat = AppWidgetUtils.MONTH_DATE_FORMAT)
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(context, date, Toast.LENGTH_LONG).show()
}
}
}

At last lets call the parent view in content function

@Composable
override fun Content() {
val prefs = currentState<Preferences>()
val calendar: Calendar =
CalendarUtils.getCalendar(prefs[longPreferencesKey(AppWidgetUtils
.EXTRAS_CURRENT_CALENDAR_MILLISECOND)])
MonthCalendarView(context = LocalContext.current,
calendar = calendar)
}

Here we will get the millis second from pref and call parent view

Combining all together

class MonthCalendarGlanceWidget : GlanceAppWidget() {

@Composable
override fun Content() {
val prefs = currentState<Preferences>()
val calendar: Calendar =
CalendarUtils.getCalendar(prefs[longPreferencesKey(AppWidgetUtils
.EXTRAS_CURRENT_CALENDAR_MILLISECOND)])
MonthCalendarView(context = LocalContext.current,
calendar = calendar)
}

@Composable
fun MonthCalendarView(context: Context, calendar: Calendar) {
val firstDayOfMonth = Calendar.SUNDAY
Column
(modifier = GlanceModifier.fillMaxSize().background(ImageProvider(R.drawable
.bg_widget))
.appWidgetBackground()) {
MonthCalendarHeaderView(context = context, calendar)
DaysOfWeek(firstDayOfWeek = firstDayOfMonth)
Date(firstDayOfMonth = firstDayOfMonth, calendar = calendar)
}
}

@Composable
private fun MonthCalendarHeaderView(context: Context, calendar: Calendar) {
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = R.dimen.dp_8)) {
Row(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment
.CenterVertically) {
Image(modifier = getHeaderImageModifier(getImageActionCallback(TYPE_PREVIOUS_MONTH)),
provider = ImageProvider(R.drawable.ic_widget_arrow_left),
contentDescription = getResString(context, R.string.previous_month))
Text(text = getFormatString(calendar.timeInMillis, MONTH_YEAR_FORMAT),
style = TextStyle(fontSize = 14.sp, textAlign = TextAlign
.Center, color = getWidgetHeaderTextColor()))
Image(modifier = getHeaderImageModifier(getImageActionCallback(TYPE_NEXT_MONTH)),
provider = ImageProvider(R.drawable.ic_widget_arrow_right),
contentDescription = getResString(context, R.string.next_month))
}
}
}

@Composable
fun DaysOfWeek(firstDayOfWeek: Int) {
val weekDayCalendar = CalendarUtils.getCalendar()
weekDayCalendar.set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
Row(modifier = GlanceModifier.fillMaxWidth()) {
for (i in 0 until COLUMN_COUNT) {
Text(modifier = GlanceModifier.defaultWeight(), text = getFormatString(
weekDayCalendar.timeInMillis, WEEK_DAY_FORMAT
), style = TextStyle(
color = getLabelTextColor(), fontSize = 13.sp,
fontWeight = FontWeight.Medium, textAlign = TextAlign.Center
)
)

weekDayCalendar.add(Calendar.DAY_OF_WEEK, 1)
}
}
}

@Composable
private fun Date(firstDayOfMonth: Int, calendar: Calendar) {
val todayCalendar = CalendarUtils.getCalendar()
val dateCalendar = calendar.clone() as Calendar
dateCalendar.set(Calendar.DAY_OF_MONTH, 1)
CalendarUtils.setTimeToBeginningOfDay(dateCalendar)

val currentMonth = dateCalendar.get(Calendar.MONTH)
val futureCalendar = dateCalendar.clone() as Calendar
futureCalendar.add(Calendar.MONTH, 1)
val futureMonth = futureCalendar.get(Calendar.MONTH)

val firstDayIndex = getMonthFirstDayWeekIndex(dateCalendar, firstDayOfMonth)

dateCalendar.add(Calendar.MONTH, -1)
val prevMonthDays = dateCalendar.getActualMaximum(Calendar.DAY_OF_MONTH)

val value = prevMonthDays - firstDayIndex + 1
dateCalendar.set(Calendar.DAY_OF_MONTH, value)

Column(modifier = GlanceModifier.fillMaxSize().padding(top = R.dimen.dp_10)) {
for (row in 0 until ROW_COUNT) {
if (futureMonth == dateCalendar.get(Calendar.MONTH)) {
continue
}
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
for (column in 0 until COLUMN_COUNT) {
var dateString = ""
if (currentMonth == dateCalendar.get(Calendar.MONTH)) {
dateString = getFormatString(
dateCalendar.timeInMillis, DATE_FORMAT
)
}

DateTextView(
dateString = dateString,
isToday = CalendarUtils.isSameDay(dateCalendar, todayCalendar),
onClick = actionRunCallback<OnDateSelectedActionCallback>(
actionParametersOf(
getDateClickActionParameterKey()
to dateCalendar.timeInMillis
)
)
)

dateCalendar.add(Calendar.DAY_OF_MONTH, 1)
}
}
}
}
}

@Composable
private fun RowScope.DateTextView(
dateString: String, isToday: Boolean, onClick: Action?
) {
val isCurrentMonthDate = dateString != ""

Column(
modifier = getDateTextParentModifier(isCurrentMonthDate, onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.Vertical.CenterVertically
) {
Text(
modifier = getDateTextChildModifier(isToday, ImageProvider(R
.drawable.bg_widget_today_circle))
.padding(R.dimen.dp_2), text = dateString, style = TextStyle(
color = getLabelTextColor(),
fontSize = 12.sp, textAlign = TextAlign.Center
), maxLines = 1
)
}
}

private fun getMonthFirstDayWeekIndex(calendar: Calendar, firstDayOfMonth: Int): Int {
val day = calendar.clone() as Calendar
day.set(Calendar.DAY_OF_WEEK, firstDayOfMonth)
var weekDayIndex = 0
val currentWeekDayIndex = calendar[Calendar.DAY_OF_WEEK]
for (i in 0 until 7) {
if (day[Calendar.DAY_OF_WEEK] == currentWeekDayIndex) {
weekDayIndex = i
break
}
day.add(Calendar.DAY_OF_WEEK, 1)
}
return weekDayIndex
}

private fun getResString(context: Context, stringResId: Int): String {
return context.resources.getString(stringResId)
}

private fun getFormatString(timeInMillis: Long, requiredFormat: String): String {
return CalendarUtils.formatDateTime(
timeInMillis = timeInMillis, requiredFormat = requiredFormat
)
}

private fun RowScope.getDateTextParentModifier(isCurrentMonth: Boolean, onClick: Action?):
GlanceModifier {
return if (isCurrentMonth) {
GlanceModifier.defaultWeight().clickable(onClick = onClick!!)
} else {
GlanceModifier.defaultWeight()
}
}

private fun getDateTextChildModifier(isCurrentMonth: Boolean, dateBackground: ImageProvider):
GlanceModifier {
return if (isCurrentMonth) {
GlanceModifier.wrapContentSize().background(dateBackground)
} else {
GlanceModifier.wrapContentSize()
}
}

private fun getHeaderImageModifier(onClick: Action): GlanceModifier {
return GlanceModifier.size(R.dimen.widget_image_size).clickable(onClick).padding(
vertical = R.dimen.dp_4, horizontal = R.dimen.dp_4)
}

private fun getImageActionCallback(clickType: Int): Action {
return actionRunCallback<OnMonthChangedActionCallback>(actionParametersOf(
getButtonClickActionParameterKey() to clickType))
}

private fun getLabelTextColor() = ColorProvider(
Colors.labelTextColorLight,
Colors.labelTextColorDark
)

private fun getWidgetHeaderTextColor(): ColorProvider {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
getLabelTextColor()
} else {
ColorProvider(R.color.widget_header_text_color)
}
}
}

Thats all the code now you can run and experience the month calendar in widget

Below is how its look like

Thanks for watching. Please follow me for more blogs. Once again THANK YOU

Will also attach the github link of the project in future. Stay tuned

--

--