What I Built
Three interconnected features shipped in one session:
- Multi-alert per task โ up to 3 notification reminders (soft) + 3 alarms (loud, AlarmClock) per task, each with a custom minute offset (0 = fires at start time).
- Alerts configurable in three places โ BrainDump detail sheet (defaults), Time Box screen (per task before scheduling), Calendar task detail (edit after scheduling).
- Keyboard fix in ModalBottomSheet โ adjustResize + verticalScroll + imePadding.
Architecture
Data layer
TimeBlock already had reminderMinutes: Int and hasStartAlarm: Boolean (legacy). I added two new fields and kept the old ones for schema safety:
// TimeBlock entity (Room v5 โ v6... actually still v5 at TimeBlock level)
val reminderTimes: String = "10" // comma-separated minutes, e.g. "10,30"
val alarmTimes: String = "" // e.g. "0,15"BrainDumpItem got two new default fields (Room migration 5 โ 6):
val defaultReminderTimes: String = "10"
val defaultAlarmTimes: String = ""When a user enters Time Box, Big3ViewModel reads item.defaultReminderTimes and item.defaultAlarmTimes to pre-populate ItemTimeConfig.reminderTimes / alarmTimes. This means defaults set in BrainDump flow forward automatically.
Notification scheduling
NotificationScheduler was rewritten from single-value to list-based:
fun scheduleReminders(timeBlockId: Long, title: String, startMillis: Long, reminderTimes: List<Int>)
fun scheduleAlarms(timeBlockId: Long, title: String, startMillis: Long, alarmTimes: List<Int>)
fun cancelAll(timeBlockId: Long)PendingIntent request codes are namespaced to avoid collisions:
timeBlockId ร 10 + 0 โ reminder slot 0
timeBlockId ร 10 + 1 โ reminder slot 1
timeBlockId ร 10 + 2 โ reminder slot 2
timeBlockId ร 10 + 3 โ alarm slot 0
timeBlockId ร 10 + 4 โ alarm slot 1
timeBlockId ร 10 + 5 โ alarm slot 2
Before this, I had timeBlockId ร 2 and timeBlockId ร 2 + 1. The multiplication factor had to increase to accommodate 6 slots per task.
Each slot is cancelled individually (FLAG_NO_CREATE to avoid creating a PendingIntent just to cancel it), then rescheduled from a fresh list.
Two alarm types use different AlarmManager APIs:
| Type | API | Effect |
|---|---|---|
| Notification reminder | setExactAndAllowWhileIdle | Fires a high-priority notification |
| Alarm | setAlarmClock | Appears in status bar, survives Doze |
UI
Shared composable: AlertTimesSection
Rather than duplicating the list UI in three places, I made two composables internal so they're reusable across the module:
// Big3Screen.kt
internal fun AlertTimesSection(icon, label, times, addLabel, onAdd, onRemove)
internal fun AlertMinutePickerDialog(onConfirm, onDismiss)AlertTimesSection renders:
- Section header with icon + label
- Each existing alert time as a row with a delete
IconButton - A
+ Add XTextButton(hidden when 3 entries already exist) - When "Add" is tapped:
AlertMinutePickerDialogopens (number field, 0โ480 minutes)
The dialog validates on the fly โ OK button disabled until parsed != null && parsed in 0..480.
State management in CalendarScreen task detail
The sheet's alert state is stored as comma-separated Strings in rememberSaveable (survives config changes; no custom Saver needed since String is auto-parcelable):
var editReminderTimes by rememberSaveable(item.timeBlock.id) { mutableStateOf(item.timeBlock.reminderTimes) }
var editAlarmTimes by rememberSaveable(item.timeBlock.id) { mutableStateOf(item.timeBlock.alarmTimes) }When modifying the list:
// Add:
editReminderTimes = (parseAlertTimes(editReminderTimes) + mins).joinToString(",")
// Remove:
val list = parseAlertTimes(editReminderTimes).toMutableList().also { it.removeAt(index) }
editReminderTimes = list.joinToString(",")Keyboard Fix
The bug
When the user tapped a text field in a ModalBottomSheet, the keyboard appeared on top of the sheet content without pushing it up. On some devices the entire sheet got squashed or the focused field was hidden below the keyboard.
The fix
Two parts:
1. AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">adjustResize makes the window resize when the IME appears, so the Compose layout system knows about the available space.
2. Content modifier in ModalBottomSheet
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()) // scroll if content overflows
.navigationBarsPadding()
.imePadding() // push content above keyboard
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp),imePadding() adds bottom padding equal to the current IME height. Combined with verticalScroll, the content shifts up when the keyboard appears and the user can scroll to any field.
Note: the M3 ModalBottomSheet in this project's version doesn't have a windowInsets parameter, so the sheet manages its own insets. Using adjustResize at the window level + imePadding() inside the content is the reliable alternative.
Auto Calendar Sync (no more Import/Export buttons)
Why CalendarContract is all you need
I originally added Import and Export buttons to the Calendar screen. Import read external events, Export pushed Just3 tasks to the device calendar. This was wrong โ it created unnecessary friction and a misleading mental model.
CalendarContract is the OS's calendar database. It's a ContentProvider built into Android. Every calendar app โ Google Calendar, Samsung Calendar, Exchange clients, anything โ reads and writes to the same underlying store. When Just3 calls resolver.insert(CalendarContract.Events.CONTENT_URI, values), the event appears immediately in the user's calendar app. There is no separate "push" step.
The Export button was redundant because saveToCalendar() in Big3ViewModel was already writing to CalendarContract. The Import button was partially useful (showing events from other apps) but should be automatic, not manual.
What changed
Added updateEvent() to CalendarRepository:
suspend fun updateEvent(eventId: Long, title: String, startMillis: Long, endMillis: Long): BooleanImplemented with ContentResolver.update() โ updates the existing event in place, preserving its ID. This is better than delete+create because the event isn't briefly missing and the same event ID is stable.
Big3ViewModel.saveToCalendar() โ update instead of delete+create:
val calEventId: Long
val existingCalId = existing?.calendarEventId
if (existingCalId != null) {
val updated = calendarRepository.updateEvent(existingCalId, title, startMillis, endMillis)
calEventId = if (updated) existingCalId else {
// Event was deleted externally; recreate
calendarRepository.createEvent(title, startMillis, endMillis) ?: throw ...
}
} else {
calEventId = calendarRepository.createEvent(title, startMillis, endMillis) ?: throw ...
}First time: creates event. Every subsequent "Save to Calendar": updates the existing event. No duplicates.
CalendarViewModel.updateTaskDetail() โ CalendarContract stays in sync:
When a user edits a task's title, notes, or alert times in the Calendar detail sheet, the CalendarContract event is updated immediately:
val savedBlock = if (calEventId != null) {
val updated = calendarRepository.updateEvent(calEventId, updatedItem.text, ...)
if (updated) updatedTimeBlock
else updatedTimeBlock.copy(calendarEventId = calendarRepository.createEvent(...))
} else {
updatedTimeBlock.copy(calendarEventId = calendarRepository.createEvent(...))
}CalendarScreen โ removed Import/Export buttons entirely. External events from other apps still appear automatically (loaded via ON_RESUME lifecycle observer). No buttons needed.
What I Learned
FLAG_NO_CREATE on PendingIntent.getBroadcast avoids creating a new intent just to cancel it. Returns null if none exists โ nothing to cancel, nothing to do.
internal in Kotlin is module-wide, not package-wide. Marking a composable internal in Big3Screen.kt makes it importable from BrainDumpScreen.kt even in a different package, as long as they share the same Gradle module. No need to move it to a shared file.
rememberSaveable with String needs zero boilerplate. String is auto-parcelable, so comma-separated times like "10,30" survive config changes and process death with plain mutableStateOf.
CalendarContract โ Google Calendar. It's the OS-level calendar ContentProvider. All calendar apps share it. Writing to it is the sync โ no OAuth, no REST API, no manual export step needed.
Check which parameters actually exist in the M3 version you're on. skipPartiallyExpanded is available; windowInsets isn't in this project's version โ which is why the manual adjustResize + imePadding() approach was needed. Docs show the latest API, your Gradle dependency might not have it yet.