What I Built
Two features on top of the existing CalendarContract integration:
- Tap-to-view task detail โ tapping a DayItemCard in CalendarScreen opens a
ModalBottomSheetshowing the task's notes, exact time slot, and reminder setting. - 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
AlarmManageralarm 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?
| Scenario | Path |
|---|---|
| User has Google account + calendar permission | CalendarContract.Reminders โ calendar app notification |
| No calendar account (null eventId from ContentResolver) | AlarmManager โ Just3 notification |
| User dismisses POST_NOTIFICATIONS | AlarmReceiver silently skips; no crash |
| User doesn't grant SCHEDULE_EXACT_ALARM | Falls 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.