From 007e8ab8f6ffe30b854e1cdbf6eda3f168f5561f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 19:36:22 +0200 Subject: [PATCH] Add actions to search results (#80) (#167) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/167 --- .../data/calendar/CalendarDataSource.kt | 97 ++- .../data/calendar/CalendarRepository.kt | 9 +- .../data/calendar/CalendarRepositoryImpl.kt | 5 +- .../calendula/data/calendar/Projections.kt | 3 + .../calendula/data/calendar/SearchMapper.kt | 2 + .../calendula/domain/EventSearch.kt | 256 ++++++++ .../jeanlucmakiola/calendula/domain/Models.kt | 5 + .../calendula/ui/search/SearchScreen.kt | 570 +++++++++++++++--- .../calendula/ui/search/SearchViewModel.kt | 167 ++++- app/src/main/res/values/strings.xml | 20 + .../calendar/CalendarRepositoryImplTest.kt | 9 +- .../data/calendar/FakeCalendarDataSource.kt | 11 +- .../data/calendar/SearchMapperTest.kt | 16 + .../calendula/domain/EventSearchTest.kt | 315 ++++++++++ .../ui/search/SearchViewModelTest.kt | 247 ++++++++ floret-kit | 2 +- 16 files changed, 1601 insertions(+), 133 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt 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..1b8507f 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 @@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.curatedForPicker import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventSearch import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.Reminder +import de.jeanlucmakiola.calendula.domain.SearchCandidate import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.ics.IcsEvent @@ -62,12 +64,14 @@ interface CalendarDataSource { fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? /** - * Master/one-off events whose title, description or location contains - * [query] (case-insensitive), across all calendars, newest first. Reads the - * Events table directly so the search is unbounded in time; exception rows - * are excluded (see [SearchProjection]). [query] is assumed non-blank. + * Master/one-off events that may match [query], across all calendars: every + * whitespace-separated token has to appear in the title, description or + * location. Reads the Events table directly so the search is unbounded in + * time; exception rows are excluded (see [SearchProjection]). A deliberate + * superset — [EventSearch] makes the final call and orders the hits. + * [query] is assumed non-blank. */ - fun searchEvents(query: String): List + fun searchEvents(query: String): List /** * The event-colour palette the calendar's account publishes @@ -585,20 +589,23 @@ class AndroidCalendarDataSource @Inject constructor( )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() } - override fun searchEvents(query: String): List { + override fun searchEvents(query: String): List { ensureObserversRegistered() - val trimmed = query.trim() - if (trimmed.isEmpty()) return emptyList() - // Escape the SQL LIKE wildcards so a literal % or _ in the query matches - // itself instead of acting as a wildcard. - val escaped = trimmed - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_") - val like = "%$escaped%" - val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " + - "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + - "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'" + val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS) + if (tokens.isEmpty()) return emptyList() + // Only a pre-filter: EventSearch re-checks every token, including the + // ones the cap above dropped. + val patterns = tokens.map { likePatterns(it) } + val match = patterns.joinToString(" AND ") { variants -> + variants.joinToString(" OR ", prefix = "(", postfix = ")") { + "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " + + "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + + "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'" + } + } + val args = patterns + .flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } } + .toTypedArray() val selection = "($match) AND " + "${CalendarContract.Events.DELETED} = 0 AND " + "${CalendarContract.Events.ORIGINAL_ID} IS NULL" @@ -606,19 +613,17 @@ class AndroidCalendarDataSource @Inject constructor( CalendarContract.Events.CONTENT_URI, SearchProjection.COLUMNS, selection, - arrayOf(like, like, like), + args, CalendarContract.Events.DTSTART + " DESC", )?.use { c -> val reader = CursorColumnReader(c) - val out = ArrayList(c.count) + val out = ArrayList(c.count) while (c.moveToNext()) { + val description = reader.getString(SearchProjection.IDX_DESCRIPTION) val base = reader.toSearchResult() ?: continue - // 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) { + // A recurring master's DTSTART is the series start; date the row + // by its nearest occurrence instead. + val event = if (base.isRecurring) { nearestOccurrenceMillis(base.eventId)?.let { (begin, end) -> base.copy( start = begin.toKotlinInstantFromEpochMillis(), @@ -628,11 +633,44 @@ class AndroidCalendarDataSource @Inject constructor( } else { base } + // The raw title, so matching can't catch on the placeholder an + // untitled event is drawn with. + out += SearchCandidate( + event = event, + description = description, + title = reader.getString(SearchProjection.IDX_TITLE), + ) } out } ?: emptyList() } + /** + * One token as the `LIKE` patterns to OR together. SQLite folds case for + * ASCII only, so a token with cased non-ASCII letters is also queried lower-, + * upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding + * those letters away instead would make "москва" `%______%` — a full scan. + */ + private fun likePatterns(token: String): List { + if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) { + return listOf(likePattern(token)) + } + val lower = token.lowercase() + return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar)) + .distinct() + .map(::likePattern) + } + + private fun likePattern(token: String): String { + val sb = StringBuilder("%") + for (c in token) { + // A literal wildcard from the query matches itself. + if (c == '%' || c == '_' || c == '\\') sb.append('\\') + sb.append(c) + } + return sb.append('%').toString() + } + /** * The occurrence of [eventId] nearest to now: the soonest upcoming one * within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one @@ -1593,5 +1631,12 @@ class AndroidCalendarDataSource @Inject constructor( * next fires beyond it falls back to its series-start date. */ const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000 + + /** + * Tokens the SQL pre-filter is built from. Bounds the statement for a + * pasted paragraph; the ranker still requires every token, so a capped + * search returns fewer rows to check, never more hits. + */ + const val MAX_SEARCH_TOKENS = 8 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index f8c6212..b47c83f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.SearchCandidate import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent @@ -17,11 +18,11 @@ interface CalendarRepository { suspend fun eventDetail(eventId: Long): EventDetail /** - * Events whose title, description or location contains [query], with hidden - * calendars removed and newest first. Empty when [query] is blank. Searches - * the whole history/future (see [CalendarDataSource.searchEvents]). + * Candidate matches for [query] with hidden calendars removed; empty when + * [query] is blank. Searches the whole history/future and leaves matching + * and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch]. */ - suspend fun searchEvents(query: String): List + suspend fun searchEvents(query: String): List /** * The event-colour palette a calendar's account publishes; empty when it diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 8d64cab..f7b677a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.SearchCandidate import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import kotlinx.coroutines.CoroutineDispatcher @@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor( ?: throw NoSuchEventException(eventId) } - override suspend fun searchEvents(query: String): List = withContext(io) { + override suspend fun searchEvents(query: String): List = withContext(io) { if (query.isBlank()) return@withContext emptyList() val excluded = prefs.hiddenCalendarIds.first() + prefs.pendingDisabledCalendarIds.first() + invisibleCalendarIds() dataSource.searchEvents(query) - .let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } } + .let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } } } override suspend fun eventColorPalette(calendarId: Long): List = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index ac3e138..ef58672 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -180,6 +180,8 @@ internal object SearchProjection { // display its nearest occurrence, not the series-start DTSTART. CalendarContract.Events.RRULE, CalendarContract.Events.RDATE, + // Excerpted, not just filtered on: a hit has to show what it matched. + CalendarContract.Events.DESCRIPTION, ) const val IDX_ID = 0 @@ -194,6 +196,7 @@ internal object SearchProjection { const val IDX_LOCATION = 9 const val IDX_RRULE = 10 const val IDX_RDATE = 11 + const val IDX_DESCRIPTION = 12 } /** 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/EventSearch.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt new file mode 100644 index 0000000..963fd82 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt @@ -0,0 +1,256 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.number +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +/** An event with the free text the search may have matched it on. */ +data class SearchCandidate( + val event: EventInstance, + val description: String? = null, + /** Null when the event has none: [EventInstance.title] is then a display + * placeholder, which the query must not catch on. */ + val title: String? = event.title, +) + +/** A matched run inside one of a hit's texts, for highlighting. */ +data class MatchSpan(val start: Int, val end: Int) + +/** The part of a description a token matched, elided to what fits a row. */ +data class DescriptionSnippet( + val text: String, + val spans: List, +) + +/** One result: the event, plus where the query matched it. */ +data class SearchHit( + val event: EventInstance, + val titleSpans: List = emptyList(), + val locationSpans: List = emptyList(), + val descriptionSnippet: DescriptionSnippet? = null, + /** Already over, so the row can say so. Finished-today counts. */ + val isPast: Boolean = false, +) + +/** One month's worth of hits, as the results list draws them under a header. */ +data class SearchMonth( + val year: Int, + /** 1–12, so the UI can build the month's first day for a localized label. */ + val monthNumber: Int, + val hits: List, +) + +/** A finished search: a calendar of hits, plus the description-only ones. */ +data class SearchResults( + val months: List, + val inDescriptions: List, +) { + val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty() + + /** Every hit, whichever section it sits in — for select-all and delete. */ + val allHits: List get() = months.flatMap { it.hits } + inDescriptions +} + +/** + * Turning a typed query into the results list. + * + * An event has to carry *every* whitespace-separated token, each in any of + * title / location / description; case is folded in Kotlin rather than by SQL's + * ASCII-only `LIKE`, so "ärzte" finds "Ärzte". + * + * Results are a calendar, not a ranking: months from the current one, then + * forwards, then backwards, and inside a month what is still to come before + * what has passed. The day is the boundary, not the moment. A hit the query + * only reached through its description is held back in + * [SearchResults.inDescriptions] rather than dated among the real ones. + */ +object EventSearch { + + /** Characters of description kept around the match in a row's snippet. */ + private const val SNIPPET_LENGTH = 96 + + /** Characters of lead-in shown before the match, when there is room. */ + private const val SNIPPET_LEAD = 24 + + private val WHITESPACE = Regex("\\s+") + + /** Shared with the data layer so its SQL pre-filter tokenises identically. */ + fun tokenize(query: String): List = + query.trim().split(WHITESPACE).filter { it.isNotEmpty() } + + /** + * Search [candidates] for [query], dropping any that don't carry every token. + * [now] marks a hit as over, [todayStart] is where it stops being current. + * [zone] places each event on the calendar through the span rule, so an + * all-day event keeps its own day whichever side of UTC we are on (#82). + */ + fun search( + candidates: List, + query: String, + now: Instant, + todayStart: Instant, + zone: TimeZone, + ): SearchResults { + val tokens = tokenize(query) + if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList()) + + val today = todayStart.toLocalDateTime(zone).date + val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) } + val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today } + val order = compareBy { if (upcoming(it)) 0 else 1 } + .thenComparator { a, b -> + // What is left reads forwards; what is behind us reads backwards + // from today, nearest first. + if (upcoming(a)) { + a.event.dayStart(zone).compareTo(b.event.dayStart(zone)) + } else { + b.event.dayStart(zone).compareTo(a.event.dayStart(zone)) + } + } + + val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly } + return SearchResults( + months = groupByMonth(dated, zone, todayStart), + inDescriptions = inDescriptions.map { it.hit }, + ) + } + + /** Months from the current one, then forwards, then backwards. */ + private fun groupByMonth( + dated: List, + zone: TimeZone, + todayStart: Instant, + ): List { + val currentKey = monthKey(todayStart.toLocalDateTime(zone).date) + return dated + .groupBy { monthKey(it.event.spanFirstDay(zone)) } + .entries + .sortedWith( + compareBy>> { + when { + it.key == currentKey -> 0 + it.key > currentKey -> 1 + else -> 2 + } + }.thenComparator { a, b -> + if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key) + }, + ) + .map { (key, entries) -> + SearchMonth( + year = key / 12, + monthNumber = key % 12 + 1, + hits = entries.map { it.hit }, + ) + } + } + + /** Year and month as one comparable number. */ + private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1) + + private fun match( + candidate: SearchCandidate, + tokens: List, + now: Instant, + zone: TimeZone, + ): Matched? { + val title = candidate.title?.takeIf { it.isNotBlank() } + val location = candidate.event.location?.takeIf { it.isNotBlank() } + // Collapsed so a multi-line description matches the way it is drawn. + val description = candidate.description + ?.replace(WHITESPACE, " ") + ?.trim() + ?.takeIf { it.isNotEmpty() } + + // Mid-word counts: "termin" has to keep finding "Zahnarzttermin". + val present = { token: String -> + title?.contains(token, ignoreCase = true) == true || + location?.contains(token, ignoreCase = true) == true || + description?.contains(token, ignoreCase = true) == true + } + if (!tokens.all(present)) return null + + val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty() + val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty() + val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty() + return Matched( + hit = SearchHit( + event = candidate.event, + titleSpans = titleSpans, + locationSpans = locationSpans, + descriptionSnippet = description + ?.takeIf { descriptionSpans.isNotEmpty() } + ?.let { snippet(it, descriptionSpans) }, + isPast = candidate.event.dayEnd(zone) < now, + ), + // Nothing in the name or the place caught the query: it is only here + // for something in its notes. + descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(), + ) + } + + /** Every occurrence of every token in [text], merged where they overlap. */ + private fun spansIn(text: String, tokens: List): List { + val raw = mutableListOf() + for (token in tokens) { + var index = text.indexOf(token, startIndex = 0, ignoreCase = true) + while (index >= 0) { + raw += MatchSpan(index, index + token.length) + index = text.indexOf(token, startIndex = index + 1, ignoreCase = true) + } + } + if (raw.isEmpty()) return emptyList() + val sorted = raw.sortedBy { it.start } + val merged = mutableListOf(sorted.first()) + for (span in sorted.drop(1)) { + val last = merged.last() + if (span.start <= last.end) { + merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end)) + } else { + merged += span + } + } + return merged + } + + /** + * A window of [description] around its first match, elided at both ends, + * with the spans rebased onto it and any falling outside dropped. + */ + private fun snippet(description: String, spans: List): DescriptionSnippet { + if (description.length <= SNIPPET_LENGTH) { + return DescriptionSnippet(description, spans) + } + val first = spans.first().start + val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH)) + val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length) + val prefix = if (start > 0) "…" else "" + val suffix = if (end < description.length) "…" else "" + val text = prefix + description.substring(start, end) + suffix + val shift = prefix.length - start + val rebased = spans + .filter { it.start >= start && it.end <= end } + .map { MatchSpan(it.start + shift, it.end + shift) } + return DescriptionSnippet(text, rebased) + } + + private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) { + val event: EventInstance get() = hit.event + } + + /** + * The event's first day as an instant in [zone]. An all-day event sits at + * UTC midnight, so its raw start would rank it on a day it isn't drawn on. + */ + private fun EventInstance.dayStart(zone: TimeZone): Instant = + if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start + + /** The moment the event's last day is over in [zone], exclusive. */ + private fun EventInstance.dayEnd(zone: TimeZone): Instant = + if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end +} 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..4021109 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,11 @@ data class EventInstance( val isAllDay: Boolean, val color: Int, val location: String?, + /** + * Only search results, which read the series master, fill this in — the + * Instances query already yields one row per occurrence. + */ + 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..508dcbe 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,67 +1,108 @@ 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.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed 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.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.mutableIntStateOf +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.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color 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.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight 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.MatchSpan +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.SearchHit +import de.jeanlucmakiola.calendula.domain.SearchMonth import de.jeanlucmakiola.floret.identity.animateItemMotion +import de.jeanlucmakiola.floret.identity.fadeThrough 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.floret.locale.localizedDateFormatter +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha 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,11 +110,13 @@ 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 - * [de.jeanlucmakiola.calendula.ui.CalendarHost]. + * Full-text event search over title / location / description: tap a result to + * open its detail, long-press to select several and delete them at once (#80). + * A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -85,63 +128,79 @@ 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 - // resets a previous search. Peeking a result doesn't re-run this (the screen - // stays composed under the detail), so backing out keeps the query. + // The ViewModel is activity-scoped, so a re-enter is what resets the last + // search, its selection and any delete receipt still up. Peeking a result + // doesn't re-run this — the screen stays composed under the detail. LaunchedEffect(Unit) { viewModel.setQuery("") + viewModel.clearSelection() + viewModel.consumeDeleteResult() + } + // The field only exists out of selection mode, so a recreation that lands + // mid-selection has to wait for the clear above before it can be focused. + var focusPending by remember { mutableStateOf(true) } + LaunchedEffect(inSelection) { + if (inSelection || !focusPending) return@LaunchedEffect + focusPending = false 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 — that would read as search closing and snapping + // 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), - ) - } - }, - 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, - ), + SearchTopBar( + inSelection = inSelection, + query = query, + selectedCount = selection.size, + focusRequester = focusRequester, + onQueryChange = viewModel::setQuery, + onImeAction = { keyboard?.hide() }, + onBack = onBack, + onCloseSelection = viewModel::clearSelection, + onSelectAll = viewModel::selectAll, + onDelete = { requireWrite { showDeleteDialog = true } }, ) }, ) { padding -> - // imePadding shrinks the content by the keyboard, so the centered - // idle/empty message re-centres in the space above it (and the results - // list lifts clear of the keyboard too). + // imePadding so the idle/empty message re-centres above the keyboard and + // the results list lifts clear of it. Box( modifier = Modifier .fillMaxSize() @@ -158,52 +217,314 @@ 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), + ) } } -} -@Composable -private fun SearchResults( - events: List, - onEventClick: (EventInstance) -> Unit, -) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp), - ) { - itemsIndexed( - items = events, - key = { _, event -> event.eventId }, - ) { index, event -> - SearchResultRow( - event = event, - position = positionOf(index, events.size), - modifier = animateItemMotion(), - onClick = { onEventClick(event) }, + 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)) + } + }, ) } } } +/** + * The search field and the contextual selection bar as one bar: the bar stays + * in place across the swap, easing its colour and fading its slots through. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SearchTopBar( + inSelection: Boolean, + query: String, + selectedCount: Int, + focusRequester: FocusRequester, + onQueryChange: (String) -> Unit, + onImeAction: () -> Unit, + onBack: () -> Unit, + onCloseSelection: () -> Unit, + onSelectAll: () -> Unit, + onDelete: () -> Unit, +) { + val container by animateColorAsState( + targetValue = if (inSelection) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + MaterialTheme.colorScheme.surface + }, + animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), + label = "search-top-bar-color", + ) + // Held past the selection emptying, so the count has something to draw while + // it fades out. + var lastCount by remember { mutableIntStateOf(0) } + if (selectedCount > 0) lastCount = selectedCount + + // Read here so the non-composable transitionSpec lambdas can capture it. + val slotSwap = fadeThrough() + + TopAppBar( + title = { + AnimatedContent( + targetState = inSelection, + transitionSpec = { slotSwap }, + label = "search-top-bar-title", + ) { selecting -> + if (selecting) { + Text( + pluralStringResource( + R.plurals.search_selected_count, + lastCount, + lastCount, + ), + ) + } else { + InlineTextField( + value = query, + onValueChange = onQueryChange, + placeholder = stringResource(R.string.search_hint), + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Search, + onImeAction = onImeAction, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + } + } + }, + navigationIcon = { + AnimatedContent( + targetState = inSelection, + transitionSpec = { slotSwap }, + label = "search-top-bar-navigation", + ) { selecting -> + if (selecting) { + IconButton(onClick = onCloseSelection) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.search_selection_close), + ) + } + } else { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.search_back), + ) + } + } + } + }, + actions = { + AnimatedContent( + targetState = inSelection, + transitionSpec = { slotSwap }, + label = "search-top-bar-actions", + ) { selecting -> + Row { + if (selecting) { + 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, + ) + } + } else if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear), + ) + } + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = container), + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SearchResults( + results: SearchUiState.Results, + selection: Set, + inSelection: Boolean, + onEventClick: (EventInstance) -> Unit, + onToggle: (Long) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + // No horizontal inset: GroupedRow already insets itself. + contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), + ) { + results.results.months.forEach { month -> + stickyHeader(key = "month-${month.year}-${month.monthNumber}") { + SearchSectionHeader(text = monthLabel(month)) + } + hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle) + } + // Held back from the calendar above: matched only in the notes. + if (results.results.inDescriptions.isNotEmpty()) { + stickyHeader(key = "in-descriptions") { + SearchSectionHeader(text = stringResource(R.string.search_in_descriptions)) + } + hitRows( + results.results.inDescriptions, + results, + selection, + inSelection, + onEventClick, + onToggle, + ) + } + } +} + +/** One section's rows, sharing a grouped-card run. */ +private fun LazyListScope.hitRows( + hits: List, + results: SearchUiState.Results, + selection: Set, + inSelection: Boolean, + onEventClick: (EventInstance) -> Unit, + onToggle: (Long) -> Unit, +) { + itemsIndexed( + items = hits, + key = { _, hit -> hit.event.eventId }, + ) { index, hit -> + val event = hit.event + val deletable = results.isDeletable(event) + SearchResultRow( + hit = hit, + position = positionOf(index, hits.size), + modifier = animateItemMotion(), + 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 }, + ) + } +} + +/** Pinned section label, styled like the agenda's day headers. */ +@Composable +private fun SearchSectionHeader(text: String) { + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), + ) + } +} + +/** "August 2026" in the locale's own field order. */ +@Composable +private fun monthLabel(month: SearchMonth): String { + val locale = currentLocale() + return remember(month.year, month.monthNumber, locale) { + localizedDateFormatter(locale, "LLLLy") + .format(java.time.LocalDate.of(month.year, month.monthNumber, 1)) + } +} + @Composable private fun SearchResultRow( - event: EventInstance, + hit: SearchHit, position: Position, modifier: Modifier = Modifier, - onClick: () -> Unit, + selected: Boolean = false, + inSelection: Boolean = false, + selectable: Boolean = true, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)? = null, ) { + val event = hit.event val dark = isSystemInDarkTheme() val soften = LocalSoftenColors.current + // On a picked row the headline is already recoloured for the secondary + // container, so the match is carried by weight alone there. + val highlight = SpanStyle( + fontWeight = FontWeight.Bold, + color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary, + ) GroupedRow( - modifier = modifier, - title = event.title, - summary = searchSummary(event), + // Faded like a past event anywhere else in the app — search reaches back + // through the whole history. + modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, + title = marked(event.title, hit.titleSpans, highlight), + summary = searchSummary(hit, highlight), position = position, minHeight = 64.dp, + selected = selected, + dimmed = inSelection && !selectable, leading = { Box( modifier = Modifier @@ -212,21 +533,97 @@ private fun SearchResultRow( .background(eventAccent(event.color, dark, soften)), ) }, + trailing = if (inSelection && selectable) { + { + Checkbox( + checked = selected, + onCheckedChange = null, + ) + } + } else { + null + }, onClick = onClick, + onLongClick = onLongClick, ) } -/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */ +/** + * The batch's receipt: how many went, and whether any refused. No Undo — the + * provider can't put a deleted event back. + */ @Composable -private fun searchSummary(event: EventInstance): String { +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, + ) + } + is BulkDeleteUiState.NeedsPermission -> if (deleteState.deleted > 0) { + stringResource(R.string.search_delete_denied_partial, deleteState.deleted) + } else { + 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) + } +} + +/** [text] with every matched run emphasised. */ +private fun marked(text: String, spans: List, style: SpanStyle): AnnotatedString = + if (spans.isEmpty()) { + AnnotatedString(text) + } else { + buildAnnotatedString { + append(text) + spans.forEach { addStyle(style, it.start, it.end) } + } + } + +/** + * "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the + * description when that is where the query matched. + */ +@Composable +private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString { + val event = hit.event val locale = currentLocale() val zone = remember { ZoneId.systemDefault() } val start = remember(event.start, zone) { JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone) } - // From the shared span rule, not [start]: an all-day event sits at UTC - // midnight and would name the day before west of UTC (#82). The clock time - // below stays device-zone — it is only rendered for timed events. + // From the span rule, not [start]: an all-day event sits at UTC midnight and + // would name the day before west of UTC (#82). val dateText = remember(event.start, event.end, event.isAllDay, locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) .format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate()) @@ -237,8 +634,23 @@ private fun searchSummary(event: EventInstance): String { } else { remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) } - val base = "$dateText · $timeText" - return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base + return buildAnnotatedString { + append("$dateText · $timeText") + event.location?.takeIf { it.isNotBlank() }?.let { location -> + append(" · ") + val offset = length + append(location) + hit.locationSpans.forEach { + addStyle(highlight, offset + it.start, offset + it.end) + } + } + hit.descriptionSnippet?.let { snippet -> + append("\n") + val offset = length + append(snippet.text) + snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) } + } + } } @Composable 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..d410f74 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,10 @@ 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.EventSearch +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.SearchResults +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -14,12 +18,19 @@ 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 kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import javax.inject.Inject @@ -33,8 +44,30 @@ sealed interface SearchUiState { /** A query ran but matched nothing. */ 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 + /** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */ + data class Results( + val results: SearchResults, + /** Rows from these are kept out of selection rather than failing later. */ + val readOnlyCalendarIds: Set = emptySet(), + ) : SearchUiState { + val events: List get() = results.allHits.map { it.event } + + 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 after [deleted] went; the rest wasn't tried. */ + data class NeedsPermission(val deleted: Int) : BulkDeleteUiState } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -47,17 +80,42 @@ 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)) + val now = Clock.System.now() + val zone = TimeZone.currentSystemDefault() + val results = EventSearch.search( + candidates = repository.searchEvents(q), + query = q, + now = now, + todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone), + zone = zone, + ) + if (results.isEmpty) { + SearchUiState.Empty(q) + } else { + SearchUiState.Results( + results = results, + readOnlyCalendarIds = readOnlyCalendarIds(), + ) + } } } .catch { emit(SearchUiState.Idle) } @@ -65,13 +123,94 @@ 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 } - /** Soonest upcoming (and ongoing) first, then the most recent past. */ - private fun sortNearestFirst(events: List): List { - val now = Clock.System.now() - val (upcoming, past) = events.partition { it.end >= now } - return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start } + /** 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 batch needs a [RecurringWriteScope] decision before it runs. */ + 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] reaches the recurring ones only; a + * one-off always goes whole. One 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(deleted = deleted) + } 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() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae5a2ec..66c5fae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -318,6 +318,26 @@ Search your events by title, location or notes. No events match “%1$s”. + + + %d selected + %d selected + + Found in descriptions + 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 + %1$d deleted, then write access was withdrawn + Upcoming Calendula agenda diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 575b4ec..1e022a6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.SearchCandidate import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.datetime.LocalDate @@ -451,15 +452,15 @@ class CalendarRepositoryImplTest { calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L)) searchResult = { listOf( - makeEvent(10L, "Shown", calendarId = 1L), - makeEvent(11L, "Switched off", calendarId = 2L), - makeEvent(12L, "Hidden", calendarId = 3L), + SearchCandidate(makeEvent(10L, "Shown", calendarId = 1L)), + SearchCandidate(makeEvent(11L, "Switched off", calendarId = 2L)), + SearchCandidate(makeEvent(12L, "Hidden", calendarId = 3L)), ) } } val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined) - assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown") + assertThat(repo.searchEvents("e").map { it.event.title }).containsExactly("Shown") } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index 89f96ae..a66fd40 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.SearchCandidate import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent @@ -19,7 +20,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { var calendarsResult: List = emptyList() var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() } - var searchResult: (String) -> List = { _ -> emptyList() } + var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() @@ -29,6 +30,10 @@ internal class FakeCalendarDataSource : CalendarDataSource { var existingUidsResult: Set = emptySet() /** Set to make the next write call throw. */ var writeError: Exception? = null + + /** Deletes let through before [writeError] applies, for part-way failures. */ + var deletesBeforeError: Int = 0 + private var deletes = 0 /** Id returned by the next [insertEvent]. */ var nextInsertId: Long = 100L @@ -71,7 +76,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { } override fun instances(beginMillis: Long, endMillis: Long): List = instancesResult(beginMillis, endMillis) - override fun searchEvents(query: String): List = searchResult(query) + override fun searchEvents(query: String): List = searchResult(query) override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? = eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = @@ -196,7 +201,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { } override fun deleteEvent(eventId: Long) { - writeError?.let { throw it } + writeError?.let { if (deletes++ >= deletesBeforeError) throw it } deletedEventIds += eventId managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } } } 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/domain/EventSearchTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt new file mode 100644 index 0000000..710bab7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchTest.kt @@ -0,0 +1,315 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +/** + * Search matching and how the results are laid out (#80 follow-up): a calendar + * of months rather than a ranking, with description-only hits held back. + */ +class EventSearchTest { + + private val zone = TimeZone.UTC + + /** 2027-01-15T08:00Z — mid-month and mid-day, so both sides of each are testable. */ + private val now = Instant.parse("2027-01-15T08:00:00Z") + private val todayStart = Instant.parse("2027-01-15T00:00:00Z") + + private var nextId = 1L + + private fun candidate( + title: String, + start: String = "2027-01-20T09:00:00Z", + location: String? = null, + description: String? = null, + ): SearchCandidate { + val begin = Instant.parse(start) + return SearchCandidate( + event = EventInstance( + instanceId = nextId, + eventId = nextId++, + calendarId = 1L, + title = title, + start = begin, + end = begin.plus(kotlin.time.Duration.parse("1h")), + isAllDay = false, + color = 0, + location = location, + ), + description = description, + ) + } + + /** [day] is the event's own date; all-day events sit at UTC midnight. */ + private fun allDayCandidate(title: String, day: String): SearchCandidate { + val begin = Instant.parse("${day}T00:00:00Z") + return SearchCandidate( + event = EventInstance( + instanceId = nextId, + eventId = nextId++, + calendarId = 1L, + title = title, + start = begin, + end = begin.plus(kotlin.time.Duration.parse("24h")), + isAllDay = true, + color = 0, + location = null, + ), + ) + } + + private fun search(vararg candidates: SearchCandidate, query: String): SearchResults = + EventSearch.search(candidates.toList(), query, now, todayStart, zone) + + private fun datedTitles(results: SearchResults): List = + results.months.flatMap { month -> month.hits.map { it.event.title } } + + @Test + fun `hits are grouped into months, this one first, then ahead, then back`() { + val thisMonth = candidate("Test A", start = "2027-01-20T09:00:00Z") + val nextMonth = candidate("Test B", start = "2027-02-03T09:00:00Z") + val lastMonth = candidate("Test C", start = "2026-12-10T09:00:00Z") + val yearBefore = candidate("Test D", start = "2026-03-10T09:00:00Z") + + val results = search(yearBefore, nextMonth, lastMonth, thisMonth, query = "test") + + assertThat(results.months.map { it.year to it.monthNumber }) + .containsExactly(2027 to 1, 2027 to 2, 2026 to 12, 2026 to 3) + .inOrder() + assertThat(datedTitles(results)).containsExactly("Test A", "Test B", "Test C", "Test D") + .inOrder() + } + + @Test + fun `inside a month what is still to come comes before what has passed`() { + // Both sides of today share one January card rather than two headers. + val earlierThisMonth = candidate("Test past", start = "2027-01-05T09:00:00Z") + val laterThisMonth = candidate("Test soon", start = "2027-01-20T09:00:00Z") + + val results = search(earlierThisMonth, laterThisMonth, query = "test") + + assertThat(results.months).hasSize(1) + assertThat(datedTitles(results)).containsExactly("Test soon", "Test past").inOrder() + } + + @Test + fun `an event still running today keeps its place among the upcoming ones`() { + // Reported: "test" at 19:00 having passed must not fall behind "test2" at + // 23:00 — the day is the boundary, not the moment. + val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z") + val laterToday = candidate("test2", start = "2027-01-15T20:00:00Z") + + val results = search(laterToday, earlierToday, query = "test") + + assertThat(datedTitles(results)).containsExactly("test", "test2").inOrder() + } + + @Test + fun `a match tomorrow comes before one from yesterday`() { + val yesterday = candidate("test", start = "2027-01-14T09:00:00Z") + val tomorrow = candidate("test2", start = "2027-01-16T09:00:00Z") + + val results = search(yesterday, tomorrow, query = "test") + + assertThat(datedTitles(results)).containsExactly("test2", "test").inOrder() + } + + @Test + fun `a description-only hit is held back from the dated months`() { + // The original complaint: "protestantischer" in a holiday's notes landed + // among the events actually named Test. + val holiday = candidate( + "Reformationstag", + start = "2027-01-16T09:00:00Z", + description = "Feiertag der protestantischer Kirchen", + ) + val real = candidate("Test", start = "2027-06-01T09:00:00Z") + + val results = search(holiday, real, query = "test") + + assertThat(datedTitles(results)).containsExactly("Test") + assertThat(results.inDescriptions.map { it.event.title }).containsExactly("Reformationstag") + } + + @Test + fun `a location match stays in the dated months`() { + val located = candidate("Frühstück", location = "Büro") + + val results = search(located, query = "büro") + + assertThat(datedTitles(results)).containsExactly("Frühstück") + assertThat(results.inDescriptions).isEmpty() + } + + @Test + fun `an event named for one token stays dated even if another is in the notes`() { + val mixed = candidate("Zahnarzt", description = "Termin bestätigen") + + val results = search(mixed, query = "zahnarzt termin") + + assertThat(datedTitles(results)).containsExactly("Zahnarzt") + assertThat(results.inDescriptions).isEmpty() + } + + @Test + fun `every token has to match, in any field and any order`() { + val both = candidate("Zahnarzt", description = "Termin bestätigen") + val onlyOne = candidate("Zahnarzt") + + val results = search(both, onlyOne, query = "termin zahnarzt") + + assertThat(results.allHits.map { it.event.eventId }).containsExactly(both.event.eventId) + } + + @Test + fun `a mid-word match is still found, so compounds keep working`() { + val compound = candidate("Zahnarzttermin") + + assertThat(datedTitles(search(compound, query = "termin"))).containsExactly("Zahnarzttermin") + } + + @Test + fun `case folds beyond ASCII`() { + assertThat(datedTitles(search(candidate("ÄRZTE Termin"), query = "ärzte"))) + .containsExactly("ÄRZTE Termin") + assertThat(datedTitles(search(candidate("ärzte"), query = "ÄRZTE"))) + .containsExactly("ärzte") + } + + @Test + fun `an all-day event is filed by its own day, not by UTC`() { + // Behind UTC an all-day event sits at midnight UTC, which is the previous + // month locally (#82) — the month has to come from the span's first day. + val newYear = SearchCandidate( + event = EventInstance( + instanceId = 99L, + eventId = 99L, + calendarId = 1L, + title = "Test holiday", + start = Instant.parse("2027-02-01T00:00:00Z"), + end = Instant.parse("2027-02-02T00:00:00Z"), + isAllDay = true, + color = 0, + location = null, + ), + ) + + val results = EventSearch.search( + listOf(newYear), + "test", + now, + todayStart, + TimeZone.of("America/New_York"), + ) + + assertThat(results.months.single().let { it.year to it.monthNumber }).isEqualTo(2027 to 2) + } + + @Test + fun `an all-day event that has passed does not rank as still to come`() { + // Ahead of UTC its UTC-midnight end lands after the local day started, so + // comparing raw instants floated yesterday's holiday above today's events. + val berlin = TimeZone.of("Europe/Berlin") + val yesterday = allDayCandidate("Test holiday", "2027-01-14") + val tomorrow = candidate("Test soon", start = "2027-01-16T09:00:00Z") + + val results = EventSearch.search( + listOf(yesterday, tomorrow), + "test", + Instant.parse("2027-01-15T07:00:00Z"), + Instant.parse("2027-01-14T23:00:00Z"), + berlin, + ) + + assertThat(datedTitles(results)).containsExactly("Test soon", "Test holiday").inOrder() + assertThat(results.allHits.last().isPast).isTrue() + } + + @Test + fun `an all-day event is not past until its own day is over`() { + // 21:00 in Los Angeles, where the event's UTC-midnight end is already gone. + val results = EventSearch.search( + listOf(allDayCandidate("Test holiday", "2027-01-15")), + "test", + Instant.parse("2027-01-16T05:00:00Z"), + Instant.parse("2027-01-15T08:00:00Z"), + TimeZone.of("America/Los_Angeles"), + ) + + assertThat(results.allHits.single().isPast).isFalse() + } + + @Test + fun `an untitled event's placeholder is not something the query can match`() { + val untitled = candidate("(Ohne Titel)", description = "Titel folgt noch").copy(title = null) + + val results = search(untitled, query = "titel") + + assertThat(datedTitles(results)).isEmpty() + assertThat(results.inDescriptions.single().titleSpans).isEmpty() + } + + @Test + fun `a hit that has already finished is marked past, including earlier today`() { + val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z") + val laterToday = candidate("test", start = "2027-01-15T20:00:00Z") + + val hits = search(earlierToday, laterToday, query = "test") + .allHits + .associateBy { it.event.eventId } + + assertThat(hits.getValue(earlierToday.event.eventId).isPast).isTrue() + assertThat(hits.getValue(laterToday.event.eventId).isPast).isFalse() + } + + @Test + fun `the title carries the spans to highlight, merged where they overlap`() { + val hit = search(candidate("Test the tester"), query = "test").allHits.single() + + assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder() + } + + @Test + fun `a description snippet is excerpted around its match`() { + val long = "x".repeat(300) + " geheimwort " + "y".repeat(300) + + val snippet = search(candidate("Notiz", description = long), query = "geheimwort") + .allHits + .single() + .descriptionSnippet + + requireNotNull(snippet) + assertThat(snippet.text).contains("geheimwort") + assertThat(snippet.text.length).isLessThan(long.length) + assertThat(snippet.text).startsWith("…") + val span = snippet.spans.single() + assertThat(snippet.text.substring(span.start, span.end)).isEqualTo("geheimwort") + } + + @Test + fun `a title-only match carries no description snippet`() { + val hit = search(candidate("Test", description = "nothing relevant here"), query = "test") + .allHits + .single() + + assertThat(hit.descriptionSnippet).isNull() + } + + @Test + fun `a multi-line description matches and snippets as one line`() { + val snippet = search( + candidate("Notiz", description = "erste Zeile\n\n zweite Zeile"), + query = "zweite", + ).allHits.single().descriptionSnippet + + requireNotNull(snippet) + assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile") + } + + @Test + fun `a blank query matches nothing`() { + assertThat(search(candidate("Test"), query = " ").isEmpty).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..f3dff60 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModelTest.kt @@ -0,0 +1,247 @@ +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 de.jeanlucmakiola.calendula.domain.SearchCandidate +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, + ) = SearchCandidate( + 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 `a permission revoked mid-batch still reports what already went`(@TempDir tempDir: Path) = + runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L)) + searchResult = { listOf(hit(1L), hit(2L), hit(3L)) } + writeError = SecurityException("revoked") + deletesBeforeError = 1 + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.setQuery("standup") + advanceUntilIdle() + vm.selectAll() + vm.deleteSelected(RecurringWriteScope.AllEvents) + advanceUntilIdle() + + assertThat(fake.deletedEventIds).hasSize(1) + assertThat(vm.deleteState.value) + .isEqualTo(BulkDeleteUiState.NeedsPermission(deleted = 1)) + 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..ea860ed 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 71a4f371b6dba4f32fc68bac254226c45696c283 +Subproject commit ea860ed7811fce08e0202cbc3a75fed0e8c3d166