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..743d5981 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidget.kt @@ -88,18 +88,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 } } 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..343b224d 100644 --- a/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt +++ b/shared/src/androidMain/kotlin/at/techbee/spectacled/widget/SpectacledWidgetConfigActivity.kt @@ -39,9 +39,13 @@ 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.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 +55,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 +98,65 @@ 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, + 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 +174,18 @@ 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, 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 +193,7 @@ fun WidgetConfigContent( title = { }, actions = { TextButton( - onClick = { - selectedCalendarId?.let { onConfirm(it) } - }, + onClick = onConfirm, enabled = selectedCalendarId != null, ) { Text(stringResource(Res.string.done)) @@ -184,15 +218,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 = spectacledVariant.mainCalendarComponent, + 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 +256,32 @@ 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"), 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"), + 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/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/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..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 @@ -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 { it.matches(listFilterCriteria) } } 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..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 @@ -15,4 +15,4 @@ data class ListFilterCriteria( || searchCategory != null || filterStatus != null || hideCompletedTasks -} \ No newline at end of file +}