Young Devs Bin
๐Ÿ”ง Dev Log

Just3: CalendarContract and Swipe-to-Delete

ยท6 min readยท#android#kotlin#compose#just3#calendarcontract

Where We Left Off

Just3's brain-dump screen was mostly working. You could add tasks, mark your Top 3, and the swipe-to-delete animation looked right. Tap "Add to Calendar" on the Time Box screen and... nothing happened. Today I fixed both of those silent failures and added tests for the calendar save flow.


Bug 1: Add to Calendar Silently Succeeded

The "Add to Calendar" button called saveToCalendar() in Big3ViewModel. Inside, it called calendarRepository.createEvent() which returned a nullable Long. The original code didn't check for null:

// Before
_uiState.value.configs.forEach { config ->
    val eventId = calendarRepository.createEvent(...)
    timeBlockRepository.save(
        TimeBlock(..., calendarEventId = eventId)  // null slips through
    )
}
_events.emit(Event.SaveComplete)  // always reached โ€” even when eventId was null

If createEvent() returned null (no calendar found), the time block saved with calendarEventId = null and the screen navigated to Review as if everything worked. No error. No indication that nothing actually wrote to the calendar.

The Fix

Switched from forEach to a for loop so return@launch works, and checked for null immediately:

for (config in _uiState.value.configs) {
    val eventId = calendarRepository.createEvent(
        title = config.item.text,
        startMillis = config.startMillis,
        endMillis = config.endMillis,
    )
    if (eventId == null) {
        _uiState.update { it.copy(isSaving = false) }
        _events.emit(Event.Error("No calendar found. Please add a calendar account in Settings."))
        return@launch
    }
    timeBlockRepository.save(TimeBlock(..., calendarEventId = eventId))
}

Why findPrimaryCalendarId() Was Returning Null

After that fix, the error started appearing on device โ€” so createEvent() was indeed returning null. The root cause was in findPrimaryCalendarId():

// single query, no fallback
resolver.query(
    CalendarContract.Calendars.CONTENT_URI,
    arrayOf(CalendarContract.Calendars._ID),
    "${CalendarContract.Calendars.VISIBLE} = 1",
    null,
    "${CalendarContract.Calendars.IS_PRIMARY} DESC",
)?.use { if (it.moveToFirst()) it.getLong(0) else null }

Two problems:

  1. IS_PRIMARY is not universally available โ€” some ROMs throw an exception on that column, which wasn't caught, so the whole query crashed silently.
  2. VISIBLE = 1 excludes hidden calendars, including a local device calendar with no Google account attached.

The fix adds fallback levels โ€” drop IS_PRIMARY if the first attempt fails, then drop the VISIBLE filter entirely:

fun findPrimaryCalendarId(): Long? {
    // Try 1: prefer visible + IS_PRIMARY
    runCatching {
        resolver.query(CONTENT_URI, arrayOf(_ID), "VISIBLE = 1", null, "IS_PRIMARY DESC")
            ?.use { if (it.moveToFirst()) return it.getLong(0) }
    }
    // Try 2: any visible calendar
    runCatching {
        resolver.query(CONTENT_URI, arrayOf(_ID), "VISIBLE = 1", null, null)
            ?.use { if (it.moveToFirst()) return it.getLong(0) }
    }
    // Try 3: any calendar at all
    return runCatching {
        resolver.query(CONTENT_URI, arrayOf(_ID), null, null, null)
            ?.use { if (it.moveToFirst()) it.getLong(0) else null }
    }.getOrNull()
}
 
---
 
## Bug 2: Swipe-to-Delete Icon Never Received Clicks
 
The swipe UI looked correct โ€” swiping left revealed a red background with a trash icon. But tapping the trash icon did nothing.
 
The implementation used M3's `SwipeToDismissBox` with `backgroundContent`:
 
```kotlin
val dismissState = rememberSwipeToDismissBoxState(
    confirmValueChange = { it == SwipeToDismissBoxValue.EndToStart },
)
 
SwipeToDismissBox(
    state = dismissState,
    backgroundContent = {
        Box(...) {
            IconButton(onClick = { showDeleteDialog = true }) {  // never fires
                Icon(Icons.Default.Delete, ...)
            }
        }
    },
) { /* foreground row */ }

The problem: SwipeToDismissBox wraps the entire composable in a pointer-input handler that consumes horizontal drag gestures. Even when the foreground is at the EndToStart anchor and the background is visually exposed, the gesture detector is still active and intercepts taps before they reach the IconButton in backgroundContent.

confirmValueChange = { it == EndToStart } returning true keeps the item in the swiped position, but the component is still "listening" for drags โ€” and that listener eats the tap.

The Fix

Return false from confirmValueChange for all states, but trigger the dialog before returning:

val dismissState = rememberSwipeToDismissBoxState(
    confirmValueChange = { newValue ->
        if (newValue == SwipeToDismissBoxValue.EndToStart) showDeleteDialog = true
        false  // always snap back โ€” dialog handles the actual action
    },
)

With false, the item always snaps back to its resting position immediately. The dialog appears as a direct result of the completed swipe โ€” no need to tap the background element at all. The trash icon is still useful as a visual hint during the swipe animation; it just isn't interactive.

The dialog cancel path also simplified โ€” no more scope.launch { dismissState.reset() } since the state never leaves Settled:

AlertDialog(
    onDismissRequest = { showDeleteDialog = false },
    confirmButton = {
        TextButton(onClick = { showDeleteDialog = false; onDelete() }) {
            Text("Delete", color = MaterialTheme.colorScheme.error)
        }
    },
    dismissButton = {
        TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
    },
)

Tests for the Calendar Save Flow

Before fixing the bugs, I added Big3ViewModelTest to catch exactly this class of failure:

@Test
fun saveToCalendar_noCalendar_emitsError() = runTest {
    setupThreeBig3Items()
    calendarRepo.calendarId = null  // simulate no calendar found
    advanceUntilIdle()
 
    val events = mutableListOf<Big3ViewModel.Event>()
    val job = launch(UnconfinedTestDispatcher()) { viewModel.events.toList(events) }
 
    viewModel.saveToCalendar()
    advanceUntilIdle()
 
    assertTrue(events.any { it is Big3ViewModel.Event.Error })
    job.cancel()
}
 
@Test
fun saveToCalendar_noCalendar_doesNotSaveAnyTimeBlocks() = runTest {
    setupThreeBig3Items()
    calendarRepo.calendarId = null
    advanceUntilIdle()
 
    viewModel.saveToCalendar()
    advanceUntilIdle()
 
    assertTrue(timeBlockRepo.getAllBlocks().isEmpty())
}

FakeCalendarRepository.calendarId = null simulates no calendar on the device. The second test is the key one: before the fix, it would have found 3 time blocks with null calendarEventId โ€” proof that the save was completing silently even with no calendar.

Also fixed CalendarViewModelTest โ€” it was missing the third constructor argument (CalendarRepository) after that dependency was added, which would have failed to compile on the next test run.


What I Learned

CalendarContract.IS_PRIMARY is not guaranteed. It's documented but not universally available across ROMs. Always wrap CalendarContract queries in broad try/catch and have a fallback without that column.

SwipeToDismissBox is for dismissal, not reveal. The component is designed to remove an item from the list. Trying to hold it in a swiped-open state and make the background interactive fights against how it handles touches. The correct pattern for "swipe to reveal action" is to trigger the action from confirmValueChange itself, then let the item snap back.

Test the null path explicitly. The calendar bug existed because the success and failure paths converged โ€” both emitted SaveComplete. A test that asserts no time blocks are saved when calendarId = null would have caught this immediately.