Select search results and delete them together (#80)

Long-press a result to enter selection mode; the search field swaps for a
contextual bar with the count, select-all and delete. Search hits now carry
whether their event recurs, so a batch holding one asks the usual scope
question once and applies it to the recurring hits only. Results in
read-only calendars stay out of the selection rather than failing at write
time, and the list re-runs its query after the batch.
This commit is contained in:
2026-08-02 20:56:16 +02:00
parent 4e38866dfd
commit 7ab8fcb5f5
9 changed files with 686 additions and 49 deletions

View File

@@ -616,9 +616,7 @@ class AndroidCalendarDataSource @Inject constructor(
// A recurring master's DTSTART is the series start; show its
// nearest occurrence instead so the date is the one the user
// actually cares about (and sorting reflects it).
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
out += if (recurring) {
out += if (base.isRecurring) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy(
start = begin.toKotlinInstantFromEpochMillis(),

View File

@@ -40,5 +40,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
)
}

View File

@@ -62,6 +62,12 @@ data class EventInstance(
val isAllDay: Boolean,
val color: Int,
val location: String?,
/**
* Whether this row's event carries a recurrence rule. Only search results
* (which read the series master) fill this in — the Instances query already
* yields one row per occurrence, so it stays false there.
*/
val isRecurring: Boolean = false,
)
/**

View File

@@ -1,5 +1,10 @@
package de.jeanlucmakiola.calendula.ui.search
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -11,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -19,7 +25,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.SearchOff
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -27,41 +37,53 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.positionOf
import kotlinx.coroutines.delay
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant as JavaInstant
@@ -69,10 +91,14 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** How long the delete confirmation chip stays up, matching a short snackbar. */
private const val CHIP_MILLIS = 4_000L
/**
* Full-text event search (top-bar entry). Type a query → matching events
* (title / location / description) across the whole calendar, newest-relevant
* first; tap a result to open its detail. A full-screen overlay hosted by
* first; tap a result to open its detail. Long-press a result to enter selection
* mode and delete several at once (#80). A full-screen overlay hosted by
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
*/
@OptIn(ExperimentalMaterial3Api::class)
@@ -85,8 +111,13 @@ fun SearchScreen(
) {
val query by viewModel.query.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val selection by viewModel.selection.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
val context = LocalContext.current
val inSelection = selection.isNotEmpty()
var showDeleteDialog by remember { mutableStateOf(false) }
// Each fresh open starts blank and straight into typing. The ViewModel is
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
@@ -97,46 +128,83 @@ fun SearchScreen(
focusRequester.requestFocus()
keyboard?.show()
}
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
// holds only READ_CALENDAR, and granting continues into the held delete.
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val requireWrite: (() -> Unit) -> Unit = { action ->
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
action()
} else {
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
// Back leaves selection mode before it leaves the screen — and without the
// predictive-back scale-out, which would read as closing search only to have
// it snap back. The two handlers are mutually exclusive on [inSelection].
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
Scaffold(
modifier = modifier.predictiveBack(onBack = onBack),
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
title = {
InlineTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = stringResource(R.string.search_hint),
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.search_back),
if (inSelection) {
SelectionTopBar(
count = selection.size,
onClose = viewModel::clearSelection,
onSelectAll = viewModel::selectAll,
onDelete = { requireWrite { showDeleteDialog = true } },
)
} else {
TopAppBar(
title = {
InlineTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = stringResource(R.string.search_hint),
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
)
}
},
actions = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_clear),
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.search_back),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
actions = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_clear),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
},
) { padding ->
// imePadding shrinks the content by the keyboard, so the centered
@@ -158,19 +226,113 @@ fun SearchScreen(
text = stringResource(R.string.search_empty, s.query),
)
is SearchUiState.Results -> SearchResults(
events = s.events,
results = s,
selection = selection,
inSelection = inSelection,
onEventClick = onEventClick,
onToggle = viewModel::toggleSelection,
)
}
DeleteOutcomeChip(
deleteState = deleteState,
onConsume = viewModel::consumeDeleteResult,
modifier = Modifier.align(Alignment.BottomStart),
)
}
}
if (showDeleteDialog) {
val count = selection.size
// One decision for the whole batch: the scope question is only asked when
// a recurring event is in it, and one-offs ignore whatever comes back.
if (viewModel.selectionHasRecurring()) {
RecurringScopeDialog(
title = stringResource(R.string.event_delete_recurring_title),
onSelect = { scope ->
showDeleteDialog = false
viewModel.deleteSelected(scope)
},
onDismiss = { showDeleteDialog = false },
)
} else {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = {
Text(pluralStringResource(R.plurals.search_delete_title, count, count))
},
text = { Text(stringResource(R.string.event_delete_body)) },
confirmButton = {
TextButton(
onClick = {
showDeleteDialog = false
viewModel.deleteSelected(RecurringWriteScope.AllEvents)
},
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
}
}
/** Contextual bar replacing the search field while results are picked. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionTopBar(
count: Int,
onClose: () -> Unit,
onSelectAll: () -> Unit,
onDelete: () -> Unit,
) {
TopAppBar(
title = { Text(pluralStringResource(R.plurals.search_selected_count, count, count)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.search_selection_close),
)
}
},
actions = {
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.search_select_all),
)
}
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.search_delete_selected),
tint = MaterialTheme.colorScheme.error,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
)
}
@Composable
private fun SearchResults(
events: List<EventInstance>,
results: SearchUiState.Results,
selection: Set<Long>,
inSelection: Boolean,
onEventClick: (EventInstance) -> Unit,
onToggle: (Long) -> Unit,
) {
val events = results.events
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
@@ -179,11 +341,22 @@ private fun SearchResults(
items = events,
key = { _, event -> event.eventId },
) { index, event ->
val deletable = results.isDeletable(event)
SearchResultRow(
event = event,
position = positionOf(index, events.size),
modifier = animateItemMotion(),
onClick = { onEventClick(event) },
selected = event.eventId in selection,
// A read-only calendar's row can't join a batch, so in selection
// mode it goes quiet rather than offering a checkbox that fails.
inSelection = inSelection,
selectable = deletable,
onClick = when {
inSelection && deletable -> ({ onToggle(event.eventId) })
inSelection -> null
else -> ({ onEventClick(event) })
},
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
)
}
}
@@ -194,7 +367,11 @@ private fun SearchResultRow(
event: EventInstance,
position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit,
selected: Boolean = false,
inSelection: Boolean = false,
selectable: Boolean = true,
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)? = null,
) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
@@ -204,6 +381,8 @@ private fun SearchResultRow(
summary = searchSummary(event),
position = position,
minHeight = 64.dp,
selected = selected,
dimmed = inSelection && !selectable,
leading = {
Box(
modifier = Modifier
@@ -212,10 +391,69 @@ private fun SearchResultRow(
.background(eventAccent(event.color, dark, soften)),
)
},
trailing = if (inSelection && selectable) {
{
Checkbox(
checked = selected,
onCheckedChange = null,
)
}
} else {
null
},
onClick = onClick,
onLongClick = onLongClick,
)
}
/**
* The batch's receipt: how many events went, and whether any refused. Sits on
* the FAB band so the shortened list stays visible behind it. Deletes aren't
* reversible through the provider, so this carries no Undo.
*/
@Composable
private fun DeleteOutcomeChip(
deleteState: BulkDeleteUiState,
onConsume: () -> Unit,
modifier: Modifier = Modifier,
) {
val message = when (deleteState) {
is BulkDeleteUiState.Done -> when {
deleteState.failed > 0 -> stringResource(
R.string.search_delete_partial,
deleteState.deleted,
deleteState.failed,
)
else -> pluralStringResource(
R.plurals.search_delete_done,
deleteState.deleted,
deleteState.deleted,
)
}
BulkDeleteUiState.NeedsPermission -> stringResource(R.string.event_delete_write_denied)
else -> null
}
// Held past the state being consumed so the chip has something to draw while
// it springs back out.
val shown = remember { mutableStateOf("") }
if (message != null && shown.value != message) shown.value = message
LaunchedEffect(deleteState) {
if (message == null) return@LaunchedEffect
delay(CHIP_MILLIS)
onConsume()
}
Box(
modifier = modifier
.navigationBarsPadding()
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
) {
SnackChip(visible = message != null, message = shown.value)
}
}
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
@Composable
private fun searchSummary(event: EventInstance): String {

View File

@@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@@ -14,12 +16,16 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Clock
import javax.inject.Inject
@@ -34,7 +40,31 @@ sealed interface SearchUiState {
data class Empty(val query: String) : SearchUiState
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
data class Results(val events: List<EventInstance>) : SearchUiState
data class Results(
val events: List<EventInstance>,
/**
* Calendars among the results that can't be written to (WebCal, birthday
* mirrors, …). Their rows are excluded from selection rather than failing
* at delete time.
*/
val readOnlyCalendarIds: Set<Long> = emptySet(),
) : SearchUiState {
fun isDeletable(event: EventInstance): Boolean =
event.calendarId !in readOnlyCalendarIds
}
}
/** Outcome of deleting the selected results (#80). */
sealed interface BulkDeleteUiState {
data object Idle : BulkDeleteUiState
data object Deleting : BulkDeleteUiState
/** Terminal: [deleted] events went, [failed] wouldn't. */
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
/** WRITE_CALENDAR was revoked mid-flight; nothing further was attempted. */
data object NeedsPermission : BulkDeleteUiState
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -47,17 +77,34 @@ class SearchViewModel @Inject constructor(
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
val state: StateFlow<SearchUiState> = _query
.debounce(250L)
.map { it.trim() }
.distinctUntilChanged()
/** Bumped after a delete so the same query re-runs against the changed provider. */
private val _reload = MutableStateFlow(0)
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
/** Event ids picked in selection mode; empty means the mode is off. */
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
val state: StateFlow<SearchUiState> = combine(
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
_reload,
) { q, _ -> q }
.mapLatest { q ->
if (q.length < MIN_QUERY_LENGTH) {
SearchUiState.Idle
} else {
val results = repository.searchEvents(q)
if (results.isEmpty()) SearchUiState.Empty(q)
else SearchUiState.Results(sortNearestFirst(results))
if (results.isEmpty()) {
SearchUiState.Empty(q)
} else {
SearchUiState.Results(
events = sortNearestFirst(results),
readOnlyCalendarIds = readOnlyCalendarIds(),
)
}
}
}
.catch { emit(SearchUiState.Idle) }
@@ -65,9 +112,101 @@ class SearchViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
fun setQuery(value: String) {
if (value != _query.value) _selection.value = emptySet()
_query.value = value
}
/** Toggle one result; the first pick is what turns selection mode on. */
fun toggleSelection(eventId: Long) {
val current = _selection.value
_selection.value = if (eventId in current) current - eventId else current + eventId
}
/** Pick every result that lives in a writable calendar. */
fun selectAll() {
val results = state.value as? SearchUiState.Results ?: return
_selection.value = results.events.filter(results::isDeletable).map { it.eventId }.toSet()
}
/** Leave selection mode. */
fun clearSelection() {
_selection.value = emptySet()
}
/**
* Whether the current selection contains a recurring event, i.e. whether the
* batch needs a [RecurringWriteScope] decision before it can run.
*/
fun selectionHasRecurring(): Boolean {
val results = state.value as? SearchUiState.Results ?: return false
val picked = _selection.value
return results.events.any { it.eventId in picked && it.isRecurring }
}
/**
* Delete every selected event. [scope] applies to the recurring ones only —
* a one-off is always removed whole, whatever the batch decision was. Runs
* one event at a time so a single failure doesn't take the rest with it;
* the tally lands in [deleteState].
*/
fun deleteSelected(scope: RecurringWriteScope) {
if (_deleteState.value == BulkDeleteUiState.Deleting) return
val results = state.value as? SearchUiState.Results ?: return
val picked = _selection.value
val targets = results.events.filter { it.eventId in picked && results.isDeletable(it) }
if (targets.isEmpty()) return
viewModelScope.launch {
_deleteState.value = BulkDeleteUiState.Deleting
var deleted = 0
var failed = 0
var denied = false
for (event in targets) {
try {
withContext(io) { deleteOne(event, scope) }
deleted++
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
denied = true
break
} catch (e: Exception) {
failed++
}
}
_selection.value = emptySet()
_reload.value += 1
_deleteState.value = if (denied) {
BulkDeleteUiState.NeedsPermission
} else {
BulkDeleteUiState.Done(deleted = deleted, failed = failed)
}
}
}
/** Reset [deleteState] after the screen showed the outcome. */
fun consumeDeleteResult() {
_deleteState.value = BulkDeleteUiState.Idle
}
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
val begin = event.start.toEpochMilliseconds()
when {
!event.isRecurring -> repository.deleteEvent(event.eventId)
scope == RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(event.eventId, begin)
scope == RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(event.eventId, begin)
else -> repository.deleteEvent(event.eventId)
}
}
private suspend fun readOnlyCalendarIds(): Set<Long> =
repository.calendars().first()
.filterNot { it.canModifyContents }
.map { it.id }
.toSet()
/** Soonest upcoming (and ongoing) first, then the most recent past. */
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
val now = Clock.System.now()

View File

@@ -318,6 +318,24 @@
<string name="search_idle_hint">Search your events by title, location or notes.</string>
<string name="search_empty">No events match “%1$s”.</string>
<!-- Selecting search results to delete several at once (#80) -->
<plurals name="search_selected_count">
<item quantity="one">%d selected</item>
<item quantity="other">%d selected</item>
</plurals>
<string name="search_selection_close">Cancel selection</string>
<string name="search_select_all">Select all</string>
<string name="search_delete_selected">Delete selected</string>
<plurals name="search_delete_title">
<item quantity="one">Delete %d event?</item>
<item quantity="other">Delete %d events?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d event deleted</item>
<item quantity="other">%d events deleted</item>
</plurals>
<string name="search_delete_partial">%1$d deleted, %2$d couldn\'t be</string>
<!-- Home-screen widgets -->
<string name="widget_agenda_title">Upcoming</string>
<string name="widget_agenda_label">Calendula agenda</string>

View File

@@ -16,7 +16,11 @@ class SearchMapperTest {
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null,
rrule: String? = null,
rdate: String? = null,
): MapColumnReader = MapColumnReader(
SearchProjection.IDX_RRULE to rrule,
SearchProjection.IDX_RDATE to rdate,
SearchProjection.IDX_ID to id,
SearchProjection.IDX_CALENDAR_ID to calendarId,
SearchProjection.IDX_TITLE to title,
@@ -42,4 +46,16 @@ class SearchMapperTest {
fun `absent dtstart drops the search hit`() {
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
}
@Test
fun `a rule or an rdate marks the hit recurring (issue #80)`() {
assertThat(searchReader().toSearchResult()!!.isRecurring).isFalse()
assertThat(searchReader(rrule = "").toSearchResult()!!.isRecurring).isFalse()
assertThat(
searchReader(rrule = "FREQ=WEEKLY").toSearchResult()!!.isRecurring,
).isTrue()
assertThat(
searchReader(rdate = "20260101T000000Z").toSearchResult()!!.isRecurring,
).isTrue()
}
}

View File

@@ -0,0 +1,220 @@
package de.jeanlucmakiola.calendula.ui.search
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
/**
* Selecting search results and deleting the batch (#80): which repository call
* each kind of hit routes to, and what a read-only calendar is allowed to join.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
private val begin = 1_800_000_000_000L
private fun cal(id: Long, canModify: Boolean = true) = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify,
)
private fun hit(
id: Long,
calendarId: Long = 1L,
recurring: Boolean = false,
startMillis: Long = begin,
) = EventInstance(
instanceId = id, eventId = id, calendarId = calendarId, title = "Standup $id",
start = Instant.fromEpochMilliseconds(startMillis),
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
isAllDay = false, color = 0xFF000000.toInt(), location = null, isRecurring = recurring,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): SearchViewModel {
val prefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("search_prefs.preferences_pb").toFile() },
),
)
val settings = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("search_settings.preferences_pb").toFile() },
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
return SearchViewModel(repo, dispatcher)
}
private fun CoroutineScope.activate(vm: SearchViewModel): Job = launch { vm.state.collect {} }
@Test
fun `a batch of one-offs deletes each event whole`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L), hit(2L), hit(3L)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.toggleSelection(1L)
vm.toggleSelection(3L)
assertThat(vm.selectionHasRecurring()).isFalse()
vm.deleteSelected(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.deletedEventIds).containsExactly(1L, 3L)
assertThat(fake.deletedOccurrences).isEmpty()
assertThat(vm.selection.value).isEmpty()
assertThat(vm.deleteState.value).isEqualTo(BulkDeleteUiState.Done(deleted = 2, failed = 0))
job.cancel()
}
@Test
fun `the batch scope reaches the recurring hits only`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L), hit(2L, recurring = true)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.selectAll()
assertThat(vm.selectionHasRecurring()).isTrue()
vm.deleteSelected(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
// The one-off goes whole whatever the batch decided; only the series
// sees the scope, cancelling the occurrence the result row stands for.
assertThat(fake.deletedEventIds).containsExactly(1L)
assertThat(fake.deletedOccurrences).containsExactly(2L to begin)
job.cancel()
}
@Test
fun `this-and-following truncates the recurring hit from its shown occurrence`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(9L, recurring = true)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.toggleSelection(9L)
vm.deleteSelected(RecurringWriteScope.ThisAndFollowing)
advanceUntilIdle()
assertThat(fake.deletedFromOccurrences).containsExactly(9L to begin)
job.cancel()
}
@Test
fun `a read-only calendar's hit cannot be selected or deleted`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, canModify = false))
searchResult = { listOf(hit(1L, calendarId = 1L), hit(2L, calendarId = 2L)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
val results = vm.state.value as SearchUiState.Results
assertThat(results.isDeletable(results.events.single { it.eventId == 2L })).isFalse()
vm.selectAll()
assertThat(vm.selection.value).containsExactly(1L)
// Even a hand-toggled read-only row is dropped before the write.
vm.toggleSelection(2L)
vm.deleteSelected(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(fake.deletedEventIds).containsExactly(1L)
job.cancel()
}
@Test
fun `a failing delete leaves the rest of the batch alone`(@TempDir tempDir: Path) =
runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L), hit(2L)) }
writeError = IllegalStateException("provider said no")
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.selectAll()
vm.deleteSelected(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(vm.deleteState.value)
.isEqualTo(BulkDeleteUiState.Done(deleted = 0, failed = 2))
job.cancel()
}
@Test
fun `changing the query drops the selection`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
searchResult = { listOf(hit(1L)) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.setQuery("standup")
advanceUntilIdle()
vm.toggleSelection(1L)
assertThat(vm.selection.value).isNotEmpty()
vm.setQuery("retro")
assertThat(vm.selection.value).isEmpty()
job.cancel()
}
}