From 7ab8fcb5f508ba780c222a14b2a32f62b0a29281 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 20:56:16 +0200 Subject: [PATCH] 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. --- .../data/calendar/CalendarDataSource.kt | 4 +- .../calendula/data/calendar/SearchMapper.kt | 2 + .../jeanlucmakiola/calendula/domain/Models.kt | 6 + .../calendula/ui/search/SearchScreen.kt | 314 +++++++++++++++--- .../calendula/ui/search/SearchViewModel.kt | 153 ++++++++- app/src/main/res/values/strings.xml | 18 + .../data/calendar/SearchMapperTest.kt | 16 + .../ui/search/SearchViewModelTest.kt | 220 ++++++++++++ floret-kit | 2 +- 9 files changed, 686 insertions(+), 49 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 63fff41..cb33c9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -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(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index af33a74..d3baf96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -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(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 90491de..647233a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -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, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index c424edd..3fe3012 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -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, + results: SearchUiState.Results, + selection: Set, + 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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt index 7bb8ed9..63cc616 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt @@ -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) : SearchUiState + data class Results( + val events: List, + /** + * 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 = 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 = _query.asStateFlow() - val state: StateFlow = _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>(emptySet()) + + /** Event ids picked in selection mode; empty means the mode is off. */ + val selection: StateFlow> = _selection.asStateFlow() + + private val _deleteState = MutableStateFlow(BulkDeleteUiState.Idle) + val deleteState: StateFlow = _deleteState.asStateFlow() + + val state: StateFlow = 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 = + 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): List { val now = Clock.System.now() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae5a2ec..0c8efdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -318,6 +318,24 @@ Search your events by title, location or notes. No events match “%1$s”. + + + %d selected + %d selected + + Cancel selection + Select all + Delete selected + + Delete %d event? + Delete %d events? + + + %d event deleted + %d events deleted + + %1$d deleted, %2$d couldn\'t be + Upcoming Calendula agenda diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt index 9b1bb30..ba5f5f5 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt @@ -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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt new file mode 100644 index 0000000..20194ad --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt @@ -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() + } +} diff --git a/floret-kit b/floret-kit index 71a4f37..b947568 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 71a4f371b6dba4f32fc68bac254226c45696c283 +Subproject commit b9475688a82e7da83296f7781bece8024dd68e11