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 2d958e9..b93b5f8 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 @@ -47,6 +47,14 @@ interface CalendarDataSource { fun instances(beginMillis: Long, endMillis: Long): List fun eventDetail(eventId: Long): 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. + */ + fun searchEvents(query: String): List + /** * The event-colour palette the calendar's account publishes * (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the @@ -298,6 +306,32 @@ class AndroidCalendarDataSource @Inject constructor( )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() } + 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 selection = "($match) AND " + + "${CalendarContract.Events.DELETED} = 0 AND " + + "${CalendarContract.Events.ORIGINAL_ID} IS NULL" + return resolver.query( + CalendarContract.Events.CONTENT_URI, + SearchProjection.COLUMNS, + selection, + arrayOf(like, like, like), + CalendarContract.Events.DTSTART + " DESC", + )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toSearchResult() } } ?: emptyList() + } + override fun eventDetail(eventId: Long): EventDetail? { val attendees = queryAttendees(eventId) val reminders = queryReminders(eventId) 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 4dcaac1..27c20ce 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 @@ -16,6 +16,13 @@ interface CalendarRepository { fun instances(range: ClosedRange): Flow> 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]). + */ + suspend fun searchEvents(query: String): List + /** * The event-colour palette a calendar's account publishes; empty when it * exposes none (see [CalendarDataSource.eventColorPalette]). 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 749c429..c7949ec 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 @@ -80,6 +80,13 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) } + override suspend fun searchEvents(query: String): List = withContext(io) { + if (query.isBlank()) return@withContext emptyList() + val hidden = prefs.hiddenCalendarIds.first() + dataSource.searchEvents(query) + .let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } } + } + override suspend fun eventColorPalette(calendarId: Long): List = withContext(io) { dataSource.eventColorPalette(calendarId) } 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 0c190f7..619ec7c 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 @@ -139,6 +139,38 @@ internal object EventExportProjection { const val IDX_CALENDAR_ID = 13 } +/** + * Master/one-off Events rows matched by a full-text search. Like + * [EventExportProjection] it reads the Events table directly (so the search is + * unbounded in time), carrying DURATION for recurring rows that have no DTEND. + * Colour folds the calendar fallback like [InstanceProjection]. + */ +internal object SearchProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.CALENDAR_ID, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.DURATION, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.EVENT_COLOR, + CalendarContract.Events.CALENDAR_COLOR, + CalendarContract.Events.EVENT_LOCATION, + ) + + const val IDX_ID = 0 + const val IDX_CALENDAR_ID = 1 + const val IDX_TITLE = 2 + const val IDX_DTSTART = 3 + const val IDX_DTEND = 4 + const val IDX_DURATION = 5 + const val IDX_ALL_DAY = 6 + const val IDX_EVENT_COLOR = 7 + const val IDX_CALENDAR_COLOR = 8 + const val IDX_LOCATION = 9 +} + internal object AttendeeProjection { val COLUMNS: Array = arrayOf( CalendarContract.Attendees.ATTENDEE_NAME, 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 new file mode 100644 index 0000000..4c09781 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis + +/** + * Map an Events-table row (a search hit) to an [EventInstance]. Unlike the + * Instances query this reads the series master, so there is no instance id (the + * event id stands in as the list key) and recurring rows carry DURATION instead + * of DTEND — reconstruct the end the same way the `.ics` export does. + */ +internal fun ColumnReader.toSearchResult(): EventInstance? { + val dtStart = getLong(SearchProjection.IDX_DTSTART) + if (dtStart < 0L) return null + val end = when { + !isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) + else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION)) + }.coerceAtLeast(dtStart) + + val rawTitle = getString(SearchProjection.IDX_TITLE) + val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle + val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) { + getInt(SearchProjection.IDX_CALENDAR_COLOR) + } else { + getInt(SearchProjection.IDX_EVENT_COLOR) + } + val eventId = getLong(SearchProjection.IDX_ID) + + return EventInstance( + instanceId = eventId, + eventId = eventId, + calendarId = getLong(SearchProjection.IDX_CALENDAR_ID), + title = title, + start = dtStart.toKotlinInstantFromEpochMillis(), + end = end.toKotlinInstantFromEpochMillis(), + isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0, + color = color, + location = getString(SearchProjection.IDX_LOCATION), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 941f80f..9ccfdd7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -26,6 +26,7 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.imports.ImportScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen +import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.week.WeekScreen import kotlinx.datetime.LocalDate @@ -97,6 +98,12 @@ fun CalendarHost( var showSettings by rememberSaveable { mutableStateOf(false) } val onOpenSettings = { showSettings = true } + // Full-text search — its own overlay, opened from each calendar screen's + // top bar. Sits below the detail/edit overlays so tapping a result reveals + // the detail on top and backing out returns to the results. + var showSearch by rememberSaveable { mutableStateOf(false) } + val onOpenSearch = { showSearch = true } + // Calendar manager (reached from Settings) — its own overlay so it slides // over Settings and survives view switches. var showCalendars by rememberSaveable { mutableStateOf(false) } @@ -168,6 +175,7 @@ fun CalendarHost( onSelectView = onSelectView, onEventClick = onEventClick, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, ) CalendarView.Day -> DayScreen( @@ -175,6 +183,7 @@ fun CalendarHost( onSelectView = onSelectView, onEventClick = onEventClick, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, initialDateIso = pendingDayIso, ) @@ -183,6 +192,7 @@ fun CalendarHost( onSelectView = onSelectView, onOpenDay = onOpenDay, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, ) CalendarView.Agenda -> AgendaScreen( @@ -190,10 +200,24 @@ fun CalendarHost( onSelectView = onSelectView, onEventClick = onEventClick, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, ) } + // Search overlay — below detail/edit in the Box so a tapped result's + // detail screen draws on top, and closing it returns to the results. + AnimatedVisibility( + visible = showSearch, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + SearchScreen( + onBack = { showSearch = false }, + onEventClick = onEventClick, + ) + } + // Prefer the live key; fall back to the held one only while sliding out. val activeKey = detailKey ?: heldKey AnimatedVisibility( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 83d56a9..ef4acbb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -75,6 +76,7 @@ fun AgendaScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), @@ -119,6 +121,7 @@ fun AgendaScreen( selectedView = selectedView, onCycleView = { onSelectView(selectedView.next()) }, onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, ) }, @@ -274,6 +277,7 @@ private fun AgendaTopBar( selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, ) { TopAppBar( @@ -292,6 +296,12 @@ private fun AgendaTopBar( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } ViewSwitcherPill( current = selectedView, onCycle = onCycleView, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 9e7ef84..abc41d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue @@ -105,6 +106,7 @@ fun DayScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, initialDateIso: String? = null, @@ -188,6 +190,7 @@ fun DayScreen( selectedView = selectedView, onCycleView = { onSelectView(selectedView.next()) }, onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, ) }, @@ -340,6 +343,7 @@ private fun DayTopBar( selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { TopAppBar( @@ -358,6 +362,12 @@ private fun DayTopBar( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } ViewSwitcherPill( current = selectedView, onCycle = onCycleView, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 9210063..f2d5b28 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -86,6 +87,7 @@ fun MonthScreen( onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), @@ -161,6 +163,7 @@ fun MonthScreen( selectedView = selectedView, onCycleView = { onSelectView(selectedView.next()) }, onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, ) }, @@ -261,6 +264,7 @@ private fun MonthTopBar( selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { TopAppBar( @@ -279,6 +283,12 @@ private fun MonthTopBar( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } ViewSwitcherPill( current = selectedView, onCycle = onCycleView, 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 new file mode 100644 index 0000000..737ca31 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -0,0 +1,244 @@ +package de.jeanlucmakiola.calendula.ui.search + +import androidx.activity.compose.BackHandler +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +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.SearchOff +import androidx.compose.material3.ExperimentalMaterial3Api +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.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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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.ui.common.GroupedRow +import de.jeanlucmakiola.calendula.ui.common.InlineTextField +import de.jeanlucmakiola.calendula.ui.common.Position +import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.pastelize +import de.jeanlucmakiola.calendula.ui.common.positionOf +import java.time.Instant as JavaInstant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** + * 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]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + onBack: () -> Unit, + onEventClick: (EventInstance) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), +) { + val query by viewModel.query.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + // Open straight into typing — request focus and raise the keyboard once. + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboard?.show() + } + BackHandler(onBack = onBack) + + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + TopAppBar( + title = { + InlineTextField( + value = query, + onValueChange = viewModel::setQuery, + placeholder = stringResource(R.string.search_hint), + 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, + ), + ) + }, + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when (val s = state) { + SearchUiState.Idle -> SearchMessage( + icon = null, + text = stringResource(R.string.search_idle_hint), + ) + is SearchUiState.Empty -> SearchMessage( + icon = Icons.Default.SearchOff, + text = stringResource(R.string.search_empty, s.query), + ) + is SearchUiState.Results -> SearchResults( + events = s.events, + onEventClick = onEventClick, + ) + } + } + } +} + +@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), + onClick = { onEventClick(event) }, + ) + } + } +} + +@Composable +private fun SearchResultRow( + event: EventInstance, + position: Position, + onClick: () -> Unit, +) { + val dark = isSystemInDarkTheme() + GroupedRow( + title = event.title, + summary = searchSummary(event), + position = position, + minHeight = 64.dp, + leading = { + Box( + modifier = Modifier + .size(width = 6.dp, height = 36.dp) + .clip(RoundedCornerShape(3.dp)) + .background(pastelize(event.color, dark)), + ) + }, + onClick = onClick, + ) +} + +/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */ +@Composable +private fun searchSummary(event: EventInstance): String { + val locale = currentLocale() + val zone = remember { ZoneId.systemDefault() } + val start = remember(event.start, zone) { + JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone) + } + val dateText = remember(locale) { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + }.format(start) + val timeText = if (event.isAllDay) { + stringResource(R.string.event_detail_all_day) + } else { + remember(locale) { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + }.format(start) + } + val base = "$dateText · $timeText" + return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base +} + +@Composable +private fun SearchMessage( + icon: ImageVector?, + text: String, +) { + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(16.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} 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 new file mode 100644 index 0000000..7bb8ed9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt @@ -0,0 +1,77 @@ +package de.jeanlucmakiola.calendula.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlin.time.Clock +import javax.inject.Inject + +/** Shortest query that triggers a search; one character matches almost everything. */ +private const val MIN_QUERY_LENGTH = 2 + +sealed interface SearchUiState { + /** No query yet, or one too short to search — show the prompt. */ + data object Idle : 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 +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@HiltViewModel +class SearchViewModel @Inject constructor( + private val repository: CalendarRepository, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + val state: StateFlow = _query + .debounce(250L) + .map { it.trim() } + .distinctUntilChanged() + .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)) + } + } + .catch { emit(SearchUiState.Idle) } + .flowOn(io) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle) + + fun setQuery(value: String) { + _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 } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 3e3660f..e905ca5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue @@ -113,6 +114,7 @@ fun WeekScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), @@ -193,6 +195,7 @@ fun WeekScreen( selectedView = selectedView, onCycleView = { onSelectView(selectedView.next()) }, onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, ) }, @@ -351,6 +354,7 @@ private fun WeekTopBar( selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { TopAppBar( @@ -369,6 +373,12 @@ private fun WeekTopBar( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } ViewSwitcherPill( current = selectedView, onCycle = onCycleView, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 037c717..84c3801 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -232,6 +232,14 @@ Nichts geplant Anstehende Termine erscheinen hier. + + Suchen + Termine suchen + Zurück + Löschen + Durchsuche deine Termine nach Titel, Ort oder Notizen. + Keine Termine passen zu „%1$s“. + Anstehend Calendula Agenda diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21e31be..fb14991 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -233,6 +233,14 @@ Nothing scheduled Upcoming events will show up here. + + Search + Search events + Back + Clear + Search your events by title, location or notes. + No events match “%1$s”. + Upcoming Calendula agenda 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 f6a447b..900c9bc 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 @@ -16,6 +16,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { var calendarsResult: List = emptyList() var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() } + var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() @@ -51,6 +52,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun calendars(): List = calendarsResult override fun instances(beginMillis: Long, endMillis: Long): List = instancesResult(beginMillis, endMillis) + override fun searchEvents(query: String): List = searchResult(query) override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = eventColorPaletteResult(calendarId)