Young Devs Bin
๐Ÿ”ง Dev Log

Just3: Calendar Detail Sheet and Reminder Alarms

ยท5 min readยท#android#kotlin#compose#room#alarmmanager#just3

What I Built

Two features on top of the existing CalendarContract integration:

  1. Tap-to-view task detail โ€” tapping a DayItemCard in CalendarScreen opens a ModalBottomSheet showing the task's notes, exact time slot, and reminder setting.
  2. Reminder alarm system โ€” users configure a reminder (None / 5 / 10 / 15 / 30 min / 1 hour) per task in the Time Box screen before "Add to Calendar". The reminder fires through CalendarContract.Reminders when a device calendar event exists, or through a local AlarmManager alarm when there's no calendar account.

Architecture overview

Big3Screen (Time Box)
  โ””โ”€โ”€ TimeConfigCard (per task)
        โ”œโ”€โ”€ duration chips (unchanged)
        โ””โ”€โ”€ reminder chips (new) โ†’ onReminderChange โ†’ Big3ViewModel

Big3ViewModel.saveToCalendar()
  โ”œโ”€โ”€ CalendarRepository.createEvent(reminderMinutes=N)
  โ”‚     โ””โ”€โ”€ CalendarContract.Reminders insert  โ† calendar app handles notification
  โ”œโ”€โ”€ TimeBlockRepository.save/update(reminderMinutes=N)  โ† Room
  โ””โ”€โ”€ NotificationScheduler.schedule/cancel()  โ† local AlarmManager fallback

CalendarScreen
  โ””โ”€โ”€ DayItemCard (clickable)
        โ””โ”€โ”€ ModalBottomSheet โ†’ TaskDetailSheet
              โ”œโ”€โ”€ title + priority badge
              โ”œโ”€โ”€ time range + duration
              โ”œโ”€โ”€ reminder label
              โ”œโ”€โ”€ notes (or "No notes")
              โ””โ”€โ”€ complete/incomplete button

Room migration: version 2 โ†’ 3

Added reminderMinutes column to time_blocks:

// AppDatabase.kt
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE time_blocks ADD COLUMN reminderMinutes INTEGER NOT NULL DEFAULT 10")
    }
}

Default is 10 minutes so existing rows get a sensible reminder without user action.

// TimeBlock.kt
data class TimeBlock(
    ...
    val reminderMinutes: Int = 10,
)

CalendarContract.Reminders โ€” guarded insert

Previously the implementation always inserted a reminder row even when reminderMinutes == 0. Fixed with a guard:

if (reminderMinutes > 0) {
    val reminderValues = ContentValues().apply {
        put(CalendarContract.Reminders.EVENT_ID, eventId)
        put(CalendarContract.Reminders.MINUTES, reminderMinutes)
        put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
    }
    resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminderValues)
}

Local AlarmManager fallback

When createEvent() returns null (no calendar account or permission denied), the app falls back to AlarmManager:

// Big3ViewModel.saveToCalendar()
if (newEventId == null) {
    notificationScheduler.schedule(timeBlockId, config.item.text, config.startMillis, config.reminderMinutes)
} else {
    notificationScheduler.cancel(timeBlockId)  // calendar app handles it; cancel any leftover local alarm
}

NotificationScheduler

Injectable @Singleton that wraps AlarmManager. On Android 12+ checks canScheduleExactAlarms() and falls back to an inexact alarm if the user hasn't granted exact alarm access (going to settings is not required โ€” it degrades gracefully):

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
    alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
} else {
    alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}

AlarmReceiver

BroadcastReceiver that shows the notification on alarm fire. Skips if POST_NOTIFICATIONS is not granted (Android 13+):

class AlarmReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
            ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
                != PackageManager.PERMISSION_GRANTED) return
 
        val notification = NotificationCompat.Builder(context, NotificationScheduler.CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(intent.getStringExtra(EXTRA_TITLE))
            .setContentText(context.getString(R.string.notification_reminder_body))
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setCategory(NotificationCompat.CATEGORY_REMINDER)
            .setAutoCancel(true)
            .build()
 
        context.getSystemService(NotificationManager::class.java)?.notify(id, notification)
    }
}

Notification channel

Created in Just3Application.onCreate():

val channel = NotificationChannel(
    NotificationScheduler.CHANNEL_ID,
    getString(R.string.notification_channel_name),
    NotificationManager.IMPORTANCE_HIGH,
)
getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)

Manifest additions

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
 
<receiver
    android:name=".data.receiver.AlarmReceiver"
    android:exported="false" />

POST_NOTIFICATIONS is requested at runtime alongside calendar permissions when the user taps "Add to Calendar" (Android 13+ only โ€” the build list is built conditionally with Build.VERSION.SDK_INT >= TIRAMISU).


Reminder chips UI (Big3Screen)

Added a reminder row below the duration chips in TimeConfigCard:

[ ๐Ÿ”” Reminder ]
[ None ] [ 5 min ] [ 10 min ] [ 15 min ] [ 30 min ] [ 1 hour ]

Options: 0, 5, 10, 15, 30, 60 minutes before start. Default: 10. Labels format: "%d min before" / "1 hour before" / "None".


Task detail sheet (CalendarScreen)

Tapping any DayItemCard now sets selectedItem state and shows a ModalBottomSheet:

var selectedItem by remember { mutableStateOf<CalendarViewModel.DayItem?>(null) }
 
DayItemCard(
    item = item,
    onClick = { selectedItem = item },
    ...
)
 
if (selectedItem != null) {
    ModalBottomSheet(onDismissRequest = { selectedItem = null }, ...) {
        TaskDetailSheet(item = selectedItem!!, ...)
    }
}

The TaskDetailSheet composable displays:

  • Priority badge + task title
  • Time range and duration
  • Reminder label (reads timeBlock.reminderMinutes)
  • Notes from brainDumpItem.notes (or "No notes" placeholder)
  • Mark complete / incomplete button (closes sheet after toggle)

Why two notification paths?

ScenarioPath
User has Google account + calendar permissionCalendarContract.Reminders โ†’ calendar app notification
No calendar account (null eventId from ContentResolver)AlarmManager โ†’ Just3 notification
User dismisses POST_NOTIFICATIONSAlarmReceiver silently skips; no crash
User doesn't grant SCHEDULE_EXACT_ALARMFalls back to inexact AlarmManager.set()

The paths are mutually exclusive: when eventId != null, I cancel any existing local alarm so users never get double notifications.


What I Learned

Design the AlarmManager path as a peer, not a fallback. It's easy to treat it as an afterthought, but users without a Google account rely on it entirely. It needs its own cancel logic, its own graceful degradation (canScheduleExactAlarms()), and its own permission handling โ€” same rigor as the CalendarContract path.

Check canScheduleExactAlarms() before calling setExactAndAllowWhileIdle() on Android 12+. Users can revoke exact alarm permission from Settings at any time โ€” calling without checking throws a SecurityException.

Room migration defaults matter for existing users. ALTER TABLE with NOT NULL requires a DEFAULT. Picking 10 minutes instead of 0 means existing users silently get a reminder, which is better than silently getting none.

Notification paths must be mutually exclusive. When a device calendar event exists, CalendarContract.Reminders handles the notification โ€” the local AlarmManager alarm has to be explicitly cancelled, or the user gets fired twice.