From a0b4c84df7335278ef74a4c572b4fe562e0afd66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:25:03 +0000 Subject: [PATCH 1/3] Consider the list filter criteria in the widget configuration The widget so far showed every entry of the configured calendar. It now offers the same filter criteria as the list top bar (hide completed tasks, category, status), stored per widget in the Glance state and applied when the entries are loaded. To share the filtering itself instead of reimplementing it for the widget, the per-entry check moved into ListFilterCriteria.matches(), which ListState now uses as well. ListFilterRow reports back a changed ListFilterCriteria rather than a ListAction, so it can be reused outside of the list screen, and its "Hide completed tasks" label became a string resource. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RwavgpN8tKbJS7998Ecn6z --- .../spectacled/widget/SpectacledWidget.kt | 17 ++- .../widget/SpectacledWidgetConfigActivity.kt | 134 ++++++++++++++---- .../widget/WidgetFilterPreferences.kt | 43 ++++++ .../composeResources/values/strings.xml | 2 + .../list/presentation/ListScreenRoot.kt | 2 +- .../screens/list/presentation/ListState.kt | 32 +---- .../presentation/components/ListFilterRow.kt | 20 +-- .../datastructures/ListFilterCriteria.kt | 33 ++++- 8 files changed, 209 insertions(+), 74 deletions(-) create mode 100644 shared/src/androidMain/kotlin/at/techbee/spectacled/widget/WidgetFilterPreferences.kt diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt index 563050a2..48e9b246 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt @@ -65,6 +65,7 @@ import at.techbee.spectacled.screens.core.domain.SyncState import at.techbee.spectacled.screens.core.domain.repository.CalendarRepository import at.techbee.spectacled.screens.core.domain.repository.IcalEntryRepository import at.techbee.spectacled.screens.core.getAndroidLogoResId +import at.techbee.spectacled.screens.list.presentation.datastructures.ListFilterCriteria import at.techbee.spectacled.shared.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -88,18 +89,19 @@ class SpectacledWidget : GlanceAppWidget(), KoinComponent { provideContent { val prefs = currentState() val calendarId = prefs[longPreferencesKey(CALENDAR_ID_KEY)] + val listFilterCriteria = prefs.getListFilterCriteria() val calendar by produceState(initialValue = null, key1 = calendarId) { value = calendarId?.let { calendarRepository.getCalendarById(it) } } - val entries by remember(calendarId) { + val entries by remember(calendarId, listFilterCriteria) { if (calendarId != null) { icalEntryRepository .getIcalEntriesByCalendarFlow(calendarId) .map { list -> - list.filter { !it.syncState.isDeletedState() } + list.filter { !it.syncState.isDeletedState() && it.matchesWidgetFilter(listFilterCriteria) } .sortedByDescending { it.dtStart?.instant?.toEpochMilliseconds() ?: it.created.instant.toEpochMilliseconds() } .groupBy { it.parentUid } } @@ -311,6 +313,17 @@ class SpectacledWidget : GlanceAppWidget(), KoinComponent { } ?: Intent() } + /** + * Like the list screen, the full criteria only apply to top level entries: a subtask is shown + * with its parent as long as it isn't hidden as completed, even if it carries no category or + * status of its own. + */ + private fun IcalEntry.matchesWidgetFilter(listFilterCriteria: ListFilterCriteria): Boolean = + if (parentUid == null) + listFilterCriteria.matches(this) + else + !(listFilterCriteria.hideCompletedTasks && isDone()) + companion object { const val CALENDAR_ID_KEY = "calendar_id" const val ICAL_ENTRY_ID_KEY = "ical_entry_id" diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt index 00c5ecbe..d1efa047 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt @@ -38,10 +38,15 @@ import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition import at.techbee.spectacled.SpectacledVariant import at.techbee.spectacled.screens.core.domain.Calendar +import at.techbee.spectacled.screens.core.domain.CalendarComponent import at.techbee.spectacled.screens.core.domain.HomeCollection +import at.techbee.spectacled.screens.core.domain.IcalEntry import at.techbee.spectacled.screens.core.domain.Principal import at.techbee.spectacled.screens.core.domain.repository.CalendarRepository +import at.techbee.spectacled.screens.core.domain.repository.IcalEntryRepository import at.techbee.spectacled.screens.core.presentation.components.CalendarSelector +import at.techbee.spectacled.screens.list.presentation.components.ListFilterRow +import at.techbee.spectacled.screens.list.presentation.datastructures.ListFilterCriteria import at.techbee.spectacled.theme.AppTheme import at.techbee.spectacled.widget.SpectacledWidget.Companion.CALENDAR_ID_KEY import kotlinx.coroutines.launch @@ -51,10 +56,12 @@ import org.koin.core.component.inject import spectacled.shared.generated.resources.Res import spectacled.shared.generated.resources.done import spectacled.shared.generated.resources.widget_configuration +import spectacled.shared.generated.resources.widget_filter_criteria class SpectacledWidgetConfigActivity : ComponentActivity(), KoinComponent { private val calendarRepository: CalendarRepository by inject() + private val icalEntryRepository: IcalEntryRepository by inject() private val spectacledVariant: SpectacledVariant by inject() private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID @@ -92,25 +99,66 @@ class SpectacledWidgetConfigActivity : ComponentActivity(), KoinComponent { calendarRepository.getAllPrincipalsFlow().collect { value = it } } + var selectedCalendarId by remember { mutableStateOf(null as Long?) } + var listFilterCriteria by remember { mutableStateOf(ListFilterCriteria()) } + var settingsLoaded by remember { mutableStateOf(false) } + + // Restore the configuration of an already placed widget as soon as its state arrives. + LaunchedEffect(prefs) { + val loadedPrefs = prefs ?: return@LaunchedEffect + if (settingsLoaded) return@LaunchedEffect + + selectedCalendarId = loadedPrefs[longPreferencesKey(CALENDAR_ID_KEY)] + listFilterCriteria = loadedPrefs.getListFilterCriteria() + settingsLoaded = true + } + + // The categories offered as filter are the ones actually used in the selected calendar. + val allCategories by produceState(emptyList(), selectedCalendarId) { + val calendarId = selectedCalendarId + if (calendarId == null) { + value = emptyList() + return@produceState + } + icalEntryRepository.getIcalEntriesByCalendarFlow(calendarId).collect { icalEntries -> + value = icalEntries + .flatMap { it.categories } + .filter { it != IcalEntry.PINNED_CATEGORY } + .distinct() + .sorted() + } + } + WidgetConfigContent( - prefs, - calendars, - homeCollections, - principals, - spectacledVariant, + calendars = calendars, + homeCollections = homeCollections, + principals = principals, + selectedCalendarId = selectedCalendarId, + onCalendarIdSelected = { selectedCalendarId = it }, + listFilterCriteria = listFilterCriteria, + onListFilterCriteriaChanged = { listFilterCriteria = it }, + allCategories = allCategories, + calendarComponent = spectacledVariant.mainCalendarComponent, + spectacledVariant = spectacledVariant, onConfirm = { + val calendarId = selectedCalendarId ?: return@WidgetConfigContent scope.launch { - onCalendarSelected(it, glanceId) + onConfigConfirmed(calendarId, listFilterCriteria, glanceId) } } ) } } - private suspend fun onCalendarSelected(calendarId: Long, glanceId: GlanceId) { + private suspend fun onConfigConfirmed( + calendarId: Long, + listFilterCriteria: ListFilterCriteria, + glanceId: GlanceId + ) { try { updateAppWidgetState(this@SpectacledWidgetConfigActivity, glanceId) { prefs -> prefs[longPreferencesKey(CALENDAR_ID_KEY)] = calendarId + prefs.setListFilterCriteria(listFilterCriteria) } SpectacledWidget().update(this@SpectacledWidgetConfigActivity, glanceId) } catch (e: Exception) { @@ -128,28 +176,19 @@ class SpectacledWidgetConfigActivity : ComponentActivity(), KoinComponent { @OptIn(ExperimentalMaterial3Api::class) @Composable fun WidgetConfigContent( - prefs: Preferences?, calendars: List, homeCollections: List, principals: List, + selectedCalendarId: Long?, + onCalendarIdSelected: (Long) -> Unit, + listFilterCriteria: ListFilterCriteria, + onListFilterCriteriaChanged: (ListFilterCriteria) -> Unit, + allCategories: List, + calendarComponent: CalendarComponent, spectacledVariant: SpectacledVariant, - onConfirm: (Long) -> Unit + onConfirm: () -> Unit ) { - - var selectedCalendarId by remember { - mutableStateOf(null as Long?) - } - - LaunchedEffect(prefs, calendars) { - if (selectedCalendarId == null) { - val savedId = prefs?.get(longPreferencesKey(CALENDAR_ID_KEY)) - if (savedId != null) { - selectedCalendarId = savedId - } - } - } - AppTheme(spectacledVariant = spectacledVariant) { Scaffold ( topBar = { @@ -157,9 +196,7 @@ fun WidgetConfigContent( title = { }, actions = { TextButton( - onClick = { - selectedCalendarId?.let { onConfirm(it) } - }, + onClick = onConfirm, enabled = selectedCalendarId != null, ) { Text(stringResource(Res.string.done)) @@ -184,15 +221,29 @@ fun WidgetConfigContent( homeCollections = homeCollections, calendars = calendars, selectedCalendarId = selectedCalendarId, - onCalendarIdSelected = { selectedCalendarId = it }, + onCalendarIdSelected = onCalendarIdSelected, modifier = Modifier.fillMaxWidth().padding (16.dp) ) + Text( + text = stringResource(Res.string.widget_filter_criteria), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) + ) + + ListFilterRow( + listFilterCriteria = listFilterCriteria, + allCategories = allCategories, + calendarComponent = calendarComponent, + onListFilterCriteriaChanged = onListFilterCriteriaChanged, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) + /* Spacer(modifier = Modifier.weight(1f)) Button( - onClick = { selectedCalendarId?.let { onConfirm(it) } }, + onClick = { onConfirm() }, enabled = selectedCalendarId != null, modifier = Modifier.padding(16.dp) ) { @@ -208,11 +259,34 @@ fun WidgetConfigContent( @Composable private fun WidgetConfigContent_Preview() { WidgetConfigContent( - prefs = null, calendars = listOf(Calendar.getCalendarForPreview()), homeCollections = listOf(HomeCollection.getHomeCollectionForPreview()), principals = listOf(Principal.getPrincipalForPreview()), + selectedCalendarId = null, + onCalendarIdSelected = { }, + listFilterCriteria = ListFilterCriteria(), + onListFilterCriteriaChanged = { }, + allCategories = listOf("Category 1", "Category 2"), + calendarComponent = CalendarComponent.VJOURNAL, spectacledVariant = SpectacledVariant.JOURNALS, onConfirm = {} ) -} \ No newline at end of file +} + +@Preview +@Composable +private fun WidgetConfigContent_tasks_filtered_Preview() { + WidgetConfigContent( + calendars = listOf(Calendar.getCalendarForPreview()), + homeCollections = listOf(HomeCollection.getHomeCollectionForPreview()), + principals = listOf(Principal.getPrincipalForPreview()), + selectedCalendarId = 0L, + onCalendarIdSelected = { }, + listFilterCriteria = ListFilterCriteria(searchCategory = "Category 1", hideCompletedTasks = true), + onListFilterCriteriaChanged = { }, + allCategories = listOf("Category 1", "Category 2"), + calendarComponent = CalendarComponent.VTODO, + spectacledVariant = SpectacledVariant.TASKS, + onConfirm = {} + ) +} diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/WidgetFilterPreferences.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/WidgetFilterPreferences.kt new file mode 100644 index 00000000..d7f8d9fa --- /dev/null +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/WidgetFilterPreferences.kt @@ -0,0 +1,43 @@ +package at.techbee.spectacled.widget + +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import at.techbee.spectacled.screens.core.domain.Status +import at.techbee.spectacled.screens.list.presentation.datastructures.ListFilterCriteria + +/* + * Persistence of the widget's filter criteria in the Glance widget state. + * + * Only the criteria offered by the widget configuration are stored; ListFilterCriteria.searchQuery + * belongs to the transient search of the list screen and stays at its default here. + */ + +private val CATEGORY_KEY = stringPreferencesKey("filter_category") +private val STATUS_KEY = stringPreferencesKey("filter_status") +private val HIDE_COMPLETED_TASKS_KEY = booleanPreferencesKey("filter_hide_completed_tasks") + +/** The filter criteria stored for a widget, all defaults for a widget configured before filtering existed. */ +fun Preferences.getListFilterCriteria() = ListFilterCriteria( + searchCategory = this[CATEGORY_KEY], + // An unknown name means the enum changed since the widget was configured: treat it as no filter. + filterStatus = this[STATUS_KEY]?.let { stored -> Status.entries.firstOrNull { it.name == stored } }, + hideCompletedTasks = this[HIDE_COMPLETED_TASKS_KEY] == true +) + +fun MutablePreferences.setListFilterCriteria(listFilterCriteria: ListFilterCriteria) { + val category = listFilterCriteria.searchCategory + if (category.isNullOrBlank()) + remove(CATEGORY_KEY) + else + this[CATEGORY_KEY] = category + + val status = listFilterCriteria.filterStatus + if (status == null) + remove(STATUS_KEY) + else + this[STATUS_KEY] = status.name + + this[HIDE_COMPLETED_TASKS_KEY] = listFilterCriteria.hideCompletedTasks +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 3239a2aa..b64b1e05 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -106,6 +106,7 @@ Search… Filter… + Hide completed tasks Add journal Add note Add task @@ -414,6 +415,7 @@ How to host it myself Widget configuration + Filter criteria %1$s: %2$s diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListScreenRoot.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListScreenRoot.kt index ed769357..738d4985 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListScreenRoot.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListScreenRoot.kt @@ -448,7 +448,7 @@ fun ListScreenRoot( listFilterCriteria = state.listFilterCriteria, allCategories = state.icalEntries.flatMap { it.categories }.distinct(), calendarComponent = state.spectacledVariant.mainCalendarComponent, - onAction = { listViewModel.onAction(it) } + onListFilterCriteriaChanged = { listViewModel.onAction(ListAction.OnListFilterCriteriaChanged(it)) } ) } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListState.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListState.kt index 64d639ae..0af8cd9b 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListState.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListState.kt @@ -158,38 +158,10 @@ data class ListState( .groupBy { it.parentUid!! } private fun getFilteredList(icalEntries: List): List { - val criteria = listFilterCriteria - // Early exit if no filter is active - if (!criteria.anyFilterActive()) return icalEntries - - val query = criteria.searchQuery - val category = criteria.searchCategory - val status = criteria.filterStatus - val hideCompletedTasks = criteria.hideCompletedTasks - - return icalEntries.filter { item -> - // Single pass check for all conditions - val matchesQuery = query.isNullOrBlank() || - item.summary?.contains(query, ignoreCase = true) == true || - item.description?.contains(query, ignoreCase = true) == true - - if (!matchesQuery) return@filter false - - val matchesCategory = category.isNullOrBlank() || - item.categories.any { it.equals(category, ignoreCase = true) } + if (!listFilterCriteria.anyFilterActive()) return icalEntries - if (!matchesCategory) return@filter false - - val matchesStatus = status == null || item.status == status - if(!matchesStatus) - return@filter false - - if(hideCompletedTasks && item.isDone()) - return@filter false - - return@filter true - } + return icalEntries.filter { listFilterCriteria.matches(it) } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/components/ListFilterRow.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/components/ListFilterRow.kt index ee3d6fb7..ddf00937 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/components/ListFilterRow.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/components/ListFilterRow.kt @@ -28,13 +28,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import at.techbee.spectacled.screens.core.domain.CalendarComponent import at.techbee.spectacled.screens.core.domain.Status -import at.techbee.spectacled.screens.list.presentation.ListAction import at.techbee.spectacled.screens.list.presentation.datastructures.ListFilterCriteria import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import spectacled.shared.generated.resources.Res import spectacled.shared.generated.resources.category import spectacled.shared.generated.resources.clear_selection +import spectacled.shared.generated.resources.hide_completed_tasks import spectacled.shared.generated.resources.ic_completed_hidden import spectacled.shared.generated.resources.ic_completed_visible import spectacled.shared.generated.resources.status @@ -45,7 +45,7 @@ fun ListFilterRow( listFilterCriteria: ListFilterCriteria, allCategories: List, calendarComponent: CalendarComponent, - onAction: (ListAction) -> Unit, + onListFilterCriteriaChanged: (ListFilterCriteria) -> Unit, modifier: Modifier = Modifier, ) { @@ -62,7 +62,7 @@ fun ListFilterRow( //Hide completed ElevatedFilterChip( selected = listFilterCriteria.hideCompletedTasks, - onClick = { onAction(ListAction.OnListFilterCriteriaChanged(listFilterCriteria.copy(hideCompletedTasks = !listFilterCriteria.hideCompletedTasks))) }, + onClick = { onListFilterCriteriaChanged(listFilterCriteria.copy(hideCompletedTasks = !listFilterCriteria.hideCompletedTasks)) }, leadingIcon = { Crossfade(listFilterCriteria.hideCompletedTasks) { hidden -> if (hidden) @@ -71,7 +71,7 @@ fun ListFilterRow( Icon(painterResource(Res.drawable.ic_completed_visible), null) } }, - label = { Text("Hide completed tasks") }, + label = { Text(stringResource(Res.string.hide_completed_tasks)) }, modifier = Modifier.padding(horizontal = 4.dp) ) @@ -88,7 +88,7 @@ fun ListFilterRow( if (!listFilterCriteria.searchCategory.isNullOrBlank()) IconButton( onClick = { - onAction(ListAction.OnListFilterCriteriaChanged(listFilterCriteria.copy(searchCategory = null))) + onListFilterCriteriaChanged(listFilterCriteria.copy(searchCategory = null)) }, modifier = Modifier.size(28.dp) ) { @@ -107,7 +107,7 @@ fun ListFilterRow( DropdownMenuItem( text = { Text(category) }, onClick = { - onAction(ListAction.OnListFilterCriteriaChanged(listFilterCriteria.copy(searchCategory = category))) + onListFilterCriteriaChanged(listFilterCriteria.copy(searchCategory = category)) categoryDropdownExpanded = false } ) @@ -129,7 +129,7 @@ fun ListFilterRow( if (listFilterCriteria.filterStatus != null) IconButton( onClick = { - onAction(ListAction.OnListFilterCriteriaChanged(listFilterCriteria.copy(filterStatus = null))) + onListFilterCriteriaChanged(listFilterCriteria.copy(filterStatus = null)) }, modifier = Modifier.size(28.dp) ) { @@ -148,7 +148,7 @@ fun ListFilterRow( DropdownMenuItem( text = { Text(stringResource(status.stringRes)) }, onClick = { - onAction(ListAction.OnListFilterCriteriaChanged(listFilterCriteria.copy(filterStatus = status))) + onListFilterCriteriaChanged(listFilterCriteria.copy(filterStatus = status)) statusDropdownExpanded = false } ) @@ -169,7 +169,7 @@ fun ListFilterRow_Preview() { listFilterCriteria = ListFilterCriteria(), allCategories = emptyList(), calendarComponent = CalendarComponent.VJOURNAL, - onAction = { } + onListFilterCriteriaChanged = { } ) } @@ -181,6 +181,6 @@ fun ListFilterRow_search_and_category_Preview() { listFilterCriteria = ListFilterCriteria(searchQuery = "preview", searchCategory = "my category", filterStatus = Status.FINAL), allCategories = emptyList(), calendarComponent = CalendarComponent.VJOURNAL, - onAction = { } + onListFilterCriteriaChanged = { } ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/datastructures/ListFilterCriteria.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/datastructures/ListFilterCriteria.kt index 54f64321..82f7098a 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/datastructures/ListFilterCriteria.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/datastructures/ListFilterCriteria.kt @@ -1,5 +1,6 @@ package at.techbee.spectacled.screens.list.presentation.datastructures +import at.techbee.spectacled.screens.core.domain.IcalEntry import at.techbee.spectacled.screens.core.domain.Status data class ListFilterCriteria( @@ -15,4 +16,34 @@ data class ListFilterCriteria( || searchCategory != null || filterStatus != null || hideCompletedTasks -} \ No newline at end of file + + /** + * Whether [icalEntry] passes all currently active criteria. Shared by the list screen and the + * widget so both apply the filters in exactly the same way. + */ + fun matches(icalEntry: IcalEntry): Boolean { + val query = searchQuery + val category = searchCategory + + val matchesQuery = query.isNullOrBlank() + || icalEntry.summary?.contains(query, ignoreCase = true) == true + || icalEntry.description?.contains(query, ignoreCase = true) == true + + if (!matchesQuery) + return false + + val matchesCategory = category.isNullOrBlank() + || icalEntry.categories.any { it.equals(category, ignoreCase = true) } + + if (!matchesCategory) + return false + + if (filterStatus != null && icalEntry.status != filterStatus) + return false + + if (hideCompletedTasks && icalEntry.isDone()) + return false + + return true + } +} From 591e4ac61cc7efa93800035a34b2f07b4b19ad18 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:00:00 +0200 Subject: [PATCH 2/3] Moved matches functions to IcalEntry --- .../spectacled/widget/SpectacledWidget.kt | 12 ------ .../screens/core/domain/IcalEntry.kt | 43 +++++++++++++++++++ .../screens/list/presentation/ListState.kt | 2 +- .../datastructures/ListFilterCriteria.kt | 31 ------------- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt index 48e9b246..743d5981 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt @@ -65,7 +65,6 @@ import at.techbee.spectacled.screens.core.domain.SyncState import at.techbee.spectacled.screens.core.domain.repository.CalendarRepository import at.techbee.spectacled.screens.core.domain.repository.IcalEntryRepository import at.techbee.spectacled.screens.core.getAndroidLogoResId -import at.techbee.spectacled.screens.list.presentation.datastructures.ListFilterCriteria import at.techbee.spectacled.shared.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -313,17 +312,6 @@ class SpectacledWidget : GlanceAppWidget(), KoinComponent { } ?: Intent() } - /** - * Like the list screen, the full criteria only apply to top level entries: a subtask is shown - * with its parent as long as it isn't hidden as completed, even if it carries no category or - * status of its own. - */ - private fun IcalEntry.matchesWidgetFilter(listFilterCriteria: ListFilterCriteria): Boolean = - if (parentUid == null) - listFilterCriteria.matches(this) - else - !(listFilterCriteria.hideCompletedTasks && isDone()) - companion object { const val CALENDAR_ID_KEY = "calendar_id" const val ICAL_ENTRY_ID_KEY = "ical_entry_id" diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt index 8d401330..2b71b766 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/core/domain/IcalEntry.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.text.font.FontWeight import at.techbee.spectacled.screens.core.data.ics.IcsDateTime import at.techbee.spectacled.screens.core.data.ics.RawIcsProperty import at.techbee.spectacled.screens.core.presentation.components.StatusWithProgressIcon +import at.techbee.spectacled.screens.list.presentation.datastructures.ListFilterCriteria import io.ktor.http.Url import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @@ -221,6 +222,48 @@ data class IcalEntry( } } } + + /** + * Whether the entry passes all [listFilterCriteria] criteria. Shared by the list screen and the + * widget so both apply the filters in exactly the same way. + */ + fun matches(listFilterCriteria: ListFilterCriteria): Boolean { + val filterQuery = listFilterCriteria.searchQuery + val filterCategory = listFilterCriteria.searchCategory + val filterStatus = listFilterCriteria.filterStatus + val hideCompletedTasks = listFilterCriteria.hideCompletedTasks + + val matchesQuery = filterQuery.isNullOrBlank() + || summary?.contains(filterQuery, ignoreCase = true) == true + || description?.contains(filterQuery, ignoreCase = true) == true + + if (!matchesQuery) + return false + + val matchesCategory = filterCategory.isNullOrBlank() || categories.any { it.equals(filterCategory, ignoreCase = true) } + if (!matchesCategory) + return false + + val matchesStatus = filterStatus == null || status == filterStatus + if (!matchesStatus) + return false + + if (hideCompletedTasks && isDone()) + return false + + return true + } + + /** + * Like the list screen, the full criteria only apply to top level entries: a subtask is shown + * with its parent as long as it isn't hidden as completed, even if it carries no category or + * status of its own. + */ + fun matchesWidgetFilter(listFilterCriteria: ListFilterCriteria): Boolean = + if (parentUid == null) + this.matches(listFilterCriteria) + else + !(listFilterCriteria.hideCompletedTasks && isDone()) } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListState.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListState.kt index 0af8cd9b..3197fa63 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListState.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/ListState.kt @@ -161,7 +161,7 @@ data class ListState( // Early exit if no filter is active if (!listFilterCriteria.anyFilterActive()) return icalEntries - return icalEntries.filter { listFilterCriteria.matches(it) } + return icalEntries.filter { it.matches(listFilterCriteria) } } diff --git a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/datastructures/ListFilterCriteria.kt b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/datastructures/ListFilterCriteria.kt index 82f7098a..832cebca 100644 --- a/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/datastructures/ListFilterCriteria.kt +++ b/shared/src/commonMain/kotlin/at/techbee/spectacled/screens/list/presentation/datastructures/ListFilterCriteria.kt @@ -1,6 +1,5 @@ package at.techbee.spectacled.screens.list.presentation.datastructures -import at.techbee.spectacled.screens.core.domain.IcalEntry import at.techbee.spectacled.screens.core.domain.Status data class ListFilterCriteria( @@ -16,34 +15,4 @@ data class ListFilterCriteria( || searchCategory != null || filterStatus != null || hideCompletedTasks - - /** - * Whether [icalEntry] passes all currently active criteria. Shared by the list screen and the - * widget so both apply the filters in exactly the same way. - */ - fun matches(icalEntry: IcalEntry): Boolean { - val query = searchQuery - val category = searchCategory - - val matchesQuery = query.isNullOrBlank() - || icalEntry.summary?.contains(query, ignoreCase = true) == true - || icalEntry.description?.contains(query, ignoreCase = true) == true - - if (!matchesQuery) - return false - - val matchesCategory = category.isNullOrBlank() - || icalEntry.categories.any { it.equals(category, ignoreCase = true) } - - if (!matchesCategory) - return false - - if (filterStatus != null && icalEntry.status != filterStatus) - return false - - if (hideCompletedTasks && icalEntry.isDone()) - return false - - return true - } } From 4dd77e507afd43d57c55fed75760008400462011 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:24:32 +0200 Subject: [PATCH 3/3] Code cleanup --- .../spectacled/widget/SpectacledWidgetConfigActivity.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt index d1efa047..343b224d 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt @@ -38,7 +38,6 @@ import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition import at.techbee.spectacled.SpectacledVariant import at.techbee.spectacled.screens.core.domain.Calendar -import at.techbee.spectacled.screens.core.domain.CalendarComponent import at.techbee.spectacled.screens.core.domain.HomeCollection import at.techbee.spectacled.screens.core.domain.IcalEntry import at.techbee.spectacled.screens.core.domain.Principal @@ -114,7 +113,7 @@ class SpectacledWidgetConfigActivity : ComponentActivity(), KoinComponent { } // The categories offered as filter are the ones actually used in the selected calendar. - val allCategories by produceState(emptyList(), selectedCalendarId) { + val allCategories by produceState(emptyList(), selectedCalendarId) { val calendarId = selectedCalendarId if (calendarId == null) { value = emptyList() @@ -138,7 +137,6 @@ class SpectacledWidgetConfigActivity : ComponentActivity(), KoinComponent { listFilterCriteria = listFilterCriteria, onListFilterCriteriaChanged = { listFilterCriteria = it }, allCategories = allCategories, - calendarComponent = spectacledVariant.mainCalendarComponent, spectacledVariant = spectacledVariant, onConfirm = { val calendarId = selectedCalendarId ?: return@WidgetConfigContent @@ -184,7 +182,6 @@ fun WidgetConfigContent( listFilterCriteria: ListFilterCriteria, onListFilterCriteriaChanged: (ListFilterCriteria) -> Unit, allCategories: List, - calendarComponent: CalendarComponent, spectacledVariant: SpectacledVariant, onConfirm: () -> Unit ) { @@ -234,7 +231,7 @@ fun WidgetConfigContent( ListFilterRow( listFilterCriteria = listFilterCriteria, allCategories = allCategories, - calendarComponent = calendarComponent, + calendarComponent = spectacledVariant.mainCalendarComponent, onListFilterCriteriaChanged = onListFilterCriteriaChanged, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) ) @@ -267,7 +264,6 @@ private fun WidgetConfigContent_Preview() { listFilterCriteria = ListFilterCriteria(), onListFilterCriteriaChanged = { }, allCategories = listOf("Category 1", "Category 2"), - calendarComponent = CalendarComponent.VJOURNAL, spectacledVariant = SpectacledVariant.JOURNALS, onConfirm = {} ) @@ -285,7 +281,6 @@ private fun WidgetConfigContent_tasks_filtered_Preview() { listFilterCriteria = ListFilterCriteria(searchCategory = "Category 1", hideCompletedTasks = true), onListFilterCriteriaChanged = { }, allCategories = listOf("Category 1", "Category 2"), - calendarComponent = CalendarComponent.VTODO, spectacledVariant = SpectacledVariant.TASKS, onConfirm = {} )