Add actions to search results (#80) (#167)

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/167
This commit is contained in:
Jean-Luc Makiola
2026-08-08 19:36:22 +02:00
parent 4e38866dfd
commit 007e8ab8f6
16 changed files with 1601 additions and 133 deletions

View File

@@ -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<EventInstance>
fun searchEvents(query: String): List<SearchCandidate>
/**
* 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<EventInstance> {
override fun searchEvents(query: String): List<SearchCandidate> {
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 " +
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<EventInstance>(c.count)
val out = ArrayList<SearchCandidate>(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<String> {
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
}
}

View File

@@ -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<EventInstance>
suspend fun searchEvents(query: String): List<SearchCandidate>
/**
* The event-colour palette a calendar's account publishes; empty when it

View File

@@ -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<EventInstance> = withContext(io) {
override suspend fun searchEvents(query: String): List<SearchCandidate> = 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<EventColorOption> =

View File

@@ -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
}
/**

View File

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

View File

@@ -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<MatchSpan>,
)
/** One result: the event, plus where the query matched it. */
data class SearchHit(
val event: EventInstance,
val titleSpans: List<MatchSpan> = emptyList(),
val locationSpans: List<MatchSpan> = 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,
/** 112, so the UI can build the month's first day for a localized label. */
val monthNumber: Int,
val hits: List<SearchHit>,
)
/** A finished search: a calendar of hits, plus the description-only ones. */
data class SearchResults(
val months: List<SearchMonth>,
val inDescriptions: List<SearchHit>,
) {
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
/** Every hit, whichever section it sits in — for select-all and delete. */
val allHits: List<SearchHit> 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<String> =
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<SearchCandidate>,
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<Matched> { 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<Matched>,
zone: TimeZone,
todayStart: Instant,
): List<SearchMonth> {
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
return dated
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
.entries
.sortedWith(
compareBy<Map.Entry<Int, List<Matched>>> {
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<String>,
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<String>): List<MatchSpan> {
val raw = mutableListOf<MatchSpan>()
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<MatchSpan>): 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
}

View File

@@ -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,
)
/**

View File

@@ -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,
SearchTopBar(
inSelection = inSelection,
query = query,
selectedCount = selection.size,
focusRequester = focusRequester,
onQueryChange = viewModel::setQuery,
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,
),
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),
)
}
}
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(
events: List<EventInstance>,
results: SearchUiState.Results,
selection: Set<Long>,
inSelection: Boolean,
onEventClick: (EventInstance) -> Unit,
onToggle: (Long) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
// No horizontal inset: GroupedRow already insets itself.
contentPadding = PaddingValues(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) },
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<SearchHit>,
results: SearchUiState.Results,
selection: Set<Long>,
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<MatchSpan>, 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

View File

@@ -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<EventInstance>) : 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<Long> = emptySet(),
) : SearchUiState {
val events: List<EventInstance> 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<String> = _query.asStateFlow()
val state: StateFlow<SearchUiState> = _query
.debounce(250L)
.map { it.trim() }
.distinctUntilChanged()
/** Bumped after a delete so the same query re-runs against the changed provider. */
private val _reload = MutableStateFlow(0)
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
/** Event ids picked in selection mode; empty means the mode is off. */
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
val state: StateFlow<SearchUiState> = combine(
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
_reload,
) { q, _ -> q }
.mapLatest { q ->
if (q.length < MIN_QUERY_LENGTH) {
SearchUiState.Idle
} else {
val results = repository.searchEvents(q)
if (results.isEmpty()) SearchUiState.Empty(q)
else SearchUiState.Results(sortNearestFirst(results))
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<EventInstance>): List<EventInstance> {
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<Long> =
repository.calendars().first()
.filterNot { it.canModifyContents }
.map { it.id }
.toSet()
}

View File

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

View File

@@ -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

View File

@@ -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<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
@@ -29,6 +30,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var existingUidsResult: Set<String> = 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<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
override fun searchEvents(query: String): List<SearchCandidate> = searchResult(query)
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
@@ -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 } }
}

View File

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

View File

@@ -0,0 +1,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<String> =
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()
}
}

View File

@@ -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()
}
}