Realays Logo Realays
← Back to Blog
DevLog 12/17/2025

[Dalendar DevLog 7] Advanced Features - Widgets and Notifications

Implementing home screen widgets and smart notification system for enhanced user engagement.

[Dalendar DevLog 7] Advanced Features - Widgets and Notifications

Episode 7: Advanced Features - Widgets and Notifications

Introduction

A great calendar app extends beyond the main app interface. This episode covers implementing home screen widgets for quick access and a smart notification system to keep users informed.

1. Home Screen Widget Design

1.1. Widget Types

We implement three widget variants:

  • Month Widget: Full calendar view
  • Agenda Widget: Upcoming schedule list
  • Mini Widget: Today’s date with schedule count

1.2. Month Widget Implementation

class CalendarWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            MonthWidgetContent()
        }
    }
}

@Composable
fun MonthWidgetContent() {
    val context = LocalContext.current
    val repository = remember { getRepository(context) }
    val currentRataDie = remember { EAFCalculator.todayRataDie() }
    val schedules by repository.getSchedulesForMonth(/* current month */)
        .collectAsState(initial = emptyList())

    Column(
        modifier = GlanceModifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.surface)
            .padding(8.dp)
    ) {
        MonthHeader()
        DayGrid(schedules = schedules)
    }
}

1.3. Efficient Widget Updates

class WidgetUpdateWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        // Only update if schedules changed
        val lastUpdate = preferences.getLastWidgetUpdate()
        val hasChanges = repository.hasChangesRataDie(lastUpdate)

        if (hasChanges) {
            GlanceAppWidgetManager(applicationContext)
                .updateAll(CalendarWidget::class.java)
            preferences.setLastWidgetUpdate(System.currentTimeMillis())
        }

        return Result.success()
    }
}

// Schedule periodic updates
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "widget_update",
    ExistingPeriodicWorkPolicy.KEEP,
    PeriodicWorkRequestBuilder<WidgetUpdateWorker>(15, TimeUnit.MINUTES).build()
)

1.4. Widget Interactions

// Click on date opens app to that day
Text(
    text = day.toString(),
    modifier = GlanceModifier.clickable {
        actionStartActivity(
            MainActivity::class.java,
            parameters = actionParametersOf(
                KEY_SELECTED_DATE to rataDie
            )
        )
    }
)

2. Notification System

2.1. Notification Channel Setup

fun createNotificationChannels(context: Context) {
    val channels = listOf(
        NotificationChannel(
            CHANNEL_REMINDERS,
            "Schedule Reminders",
            NotificationManager.IMPORTANCE_HIGH
        ),
        NotificationChannel(
            CHANNEL_SUMMARIES,
            "Daily Summaries",
            NotificationManager.IMPORTANCE_DEFAULT
        )
    )

    val manager = context.getSystemService(NotificationManager::class.java)
    channels.forEach { manager.createNotificationChannel(it) }
}

2.2. Schedule Reminder Notifications

fun scheduleReminder(context: Context, schedule: Schedule, minutesBefore: Int) {
    val alarmManager = context.getSystemService(AlarmManager::class.java)

    // Calculate reminder time
    val scheduleDateTimeMillis = rataDieToMillis(schedule.startRataDie) +
        parseTimeToMillis(schedule.startTime)
    val reminderTime = scheduleDateTimeMillis - (minutesBefore * 60 * 1000)

    val intent = Intent(context, ReminderReceiver::class.java).apply {
        putExtra(KEY_SCHEDULE_ID, schedule.id)
        putExtra(KEY_SCHEDULE_TITLE, schedule.title)
    }

    val pendingIntent = PendingIntent.getBroadcast(
        context,
        schedule.id.toInt(),
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )

    alarmManager.setExactAndAllowWhileIdle(
        AlarmManager.RTC_WAKEUP,
        reminderTime,
        pendingIntent
    )
}

2.3. Notification Builder

class ReminderReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val scheduleId = intent.getLongExtra(KEY_SCHEDULE_ID, -1)
        val title = intent.getStringExtra(KEY_SCHEDULE_TITLE) ?: return

        val notification = NotificationCompat.Builder(context, CHANNEL_REMINDERS)
            .setSmallIcon(R.drawable.ic_calendar)
            .setContentTitle(title)
            .setContentText("Starting soon")
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setAutoCancel(true)
            .setContentIntent(createOpenAppIntent(context, scheduleId))
            .addAction(
                R.drawable.ic_snooze,
                "Snooze 10 min",
                createSnoozeIntent(context, scheduleId)
            )
            .build()

        NotificationManagerCompat.from(context)
            .notify(scheduleId.toInt(), notification)
    }
}

2.4. Daily Summary Notifications

class DailySummaryWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val tomorrow = EAFCalculator.todayRataDie() + 1
        val schedules = repository.getSchedulesForDay(tomorrow)

        if (schedules.isEmpty()) return Result.success()

        val notification = NotificationCompat.Builder(applicationContext, CHANNEL_SUMMARIES)
            .setSmallIcon(R.drawable.ic_calendar)
            .setContentTitle("Tomorrow's Schedule")
            .setContentText("You have ${schedules.size} event(s)")
            .setStyle(NotificationCompat.InboxStyle().apply {
                schedules.take(5).forEach { schedule ->
                    addLine("${schedule.startTime} - ${schedule.title}")
                }
                if (schedules.size > 5) {
                    addLine("and ${schedules.size - 5} more...")
                }
            })
            .build()

        NotificationManagerCompat.from(applicationContext)
            .notify(NOTIFICATION_DAILY_SUMMARY, notification)

        return Result.success()
    }
}

// Schedule daily at 8 PM
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "daily_summary",
    ExistingPeriodicWorkPolicy.KEEP,
    PeriodicWorkRequestBuilder<DailySummaryWorker>(1, TimeUnit.DAYS)
        .setInitialDelay(calculateDelayUntil20_00(), TimeUnit.MILLISECONDS)
        .build()
)

3. Smart Notification Features

3.1. Adaptive Timing

fun calculateOptimalReminderTime(schedule: Schedule): Long {
    val userPreferences = getUserPreferences()
    val scheduleType = classifySchedule(schedule)

    return when (scheduleType) {
        ScheduleType.MEETING -> userPreferences.meetingReminderMinutes
        ScheduleType.TASK -> userPreferences.taskReminderMinutes
        ScheduleType.EVENT -> userPreferences.eventReminderMinutes
        else -> 15 // Default 15 minutes
    } * 60 * 1000
}

3.2. Notification Grouping

fun createGroupedNotification(schedules: List<Schedule>) {
    // Individual notifications
    schedules.forEach { schedule ->
        val notification = createScheduleNotification(schedule)
            .setGroup(GROUP_SCHEDULE_REMINDERS)
            .build()

        notify(schedule.id.toInt(), notification)
    }

    // Summary notification
    val summaryNotification = NotificationCompat.Builder(context, CHANNEL_REMINDERS)
        .setSmallIcon(R.drawable.ic_calendar)
        .setContentTitle("${schedules.size} upcoming events")
        .setGroup(GROUP_SCHEDULE_REMINDERS)
        .setGroupSummary(true)
        .build()

    notify(NOTIFICATION_GROUP_SUMMARY, summaryNotification)
}

4. Widget Performance Optimization

4.1. Lazy Loading

@Composable
fun DayGrid(schedules: List<Schedule>) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(7),
        modifier = GlanceModifier.fillMaxSize()
    ) {
        items(42) { index ->  // 6 weeks × 7 days
            val rataDie = calculateRataDieForGridPosition(index)
            DayCell(
                rataDie = rataDie,
                hasSchedules = schedules.any { it.startRataDie == rataDie }
            )
        }
    }
}

4.2. Minimal Data Transfer

// Don't transfer full schedule objects to widget
data class WidgetScheduleData(
    val rataDie: Int,
    val hasSchedule: Boolean,
    val scheduleCount: Int
)

// In repository
suspend fun getWidgetData(startRataDie: Int, endRataDie: Int): List<WidgetScheduleData> {
    return dao.getScheduleCountsByDay(startRataDie, endRataDie)
}

// Optimized query
@Query("""
    SELECT startRataDie as rataDie, COUNT(*) as count
    FROM schedules
    WHERE startRataDie >= :start AND startRataDie <= :end
    GROUP BY startRataDie
""")
suspend fun getScheduleCountsByDay(start: Int, end: Int): List<DayScheduleCount>

5. Permission Handling

5.1. Notification Permissions (Android 13+)

fun requestNotificationPermission(activity: Activity) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        if (ContextCompat.checkSelfPermission(
                activity,
                Manifest.permission.POST_NOTIFICATIONS
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            ActivityCompat.requestPermissions(
                activity,
                arrayOf(Manifest.permission.POST_NOTIFICATIONS),
                REQUEST_NOTIFICATION_PERMISSION
            )
        }
    }
}

5.2. Exact Alarm Permission

fun requestExactAlarmPermission(context: Context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        val alarmManager = context.getSystemService(AlarmManager::class.java)
        if (!alarmManager.canScheduleExactAlarms()) {
            Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).also {
                context.startActivity(it)
            }
        }
    }
}

6. Testing Widgets and Notifications

6.1. Widget Preview

@Preview(widthDp = 200, heightDp = 200)
@Composable
fun MonthWidgetPreview() {
    MonthWidgetContent()
}

6.2. Notification Testing

@Test
fun testReminderScheduling() {
    val schedule = Schedule(
        id = 1,
        title = "Meeting",
        startRataDie = EAFCalculator.todayRataDie() + 1,
        startTime = "14:00"
    )

    scheduleReminder(context, schedule, minutesBefore = 15)

    // Verify alarm was scheduled
    // (Would use ShadowAlarmManager in Robolectric test)
}

7. User Preferences

7.1. Notification Settings

@Composable
fun NotificationSettingsScreen() {
    var enableReminders by rememberPreference(KEY_ENABLE_REMINDERS, true)
    var defaultReminderTime by rememberPreference(KEY_DEFAULT_REMINDER, 15)
    var enableDailySummary by rememberPreference(KEY_ENABLE_SUMMARY, true)

    Column {
        SwitchPreference(
            title = "Enable Reminders",
            checked = enableReminders,
            onCheckedChange = { enableReminders = it }
        )

        SliderPreference(
            title = "Default Reminder Time",
            value = defaultReminderTime,
            valueRange = 5f..60f,
            steps = 11,
            onValueChange = { defaultReminderTime = it.toInt() },
            valueLabel = "$defaultReminderTime minutes before"
        )

        SwitchPreference(
            title = "Daily Summary",
            summary = "Get tomorrow's schedule at 8 PM",
            checked = enableDailySummary,
            onCheckedChange = { enableDailySummary = it }
        )
    }
}

Conclusion

Advanced features implemented:

✅ Three widget variants (Month, Agenda, Mini) ✅ Efficient widget update system ✅ Smart reminder notifications ✅ Daily summary notifications ✅ Notification grouping ✅ Permission handling ✅ User preference controls

These features keep users engaged even when the app isn’t open!

Next Episode Preview:

  • Final polish and release preparation
  • Play Store listing optimization
  • Analytics integration
  • Post-launch monitoring

Making the calendar truly ubiquitous!

Related Posts