Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,19 @@ class SpectacledWidget : GlanceAppWidget(), KoinComponent {
provideContent {
val prefs = currentState<Preferences>()
val calendarId = prefs[longPreferencesKey(CALENDAR_ID_KEY)]
val listFilterCriteria = prefs.getListFilterCriteria()


val calendar by produceState<Calendar?>(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 }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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) {
Expand All @@ -128,38 +174,26 @@ class SpectacledWidgetConfigActivity : ComponentActivity(), KoinComponent {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WidgetConfigContent(
prefs: Preferences?,
calendars: List<Calendar>,
homeCollections: List<HomeCollection>,
principals: List<Principal>,
selectedCalendarId: Long?,
onCalendarIdSelected: (Long) -> Unit,
listFilterCriteria: ListFilterCriteria,
onListFilterCriteriaChanged: (ListFilterCriteria) -> Unit,
allCategories: List<String>,
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 = {
TopAppBar(
title = { },
actions = {
TextButton(
onClick = {
selectedCalendarId?.let { onConfirm(it) }
},
onClick = onConfirm,
enabled = selectedCalendarId != null,
) {
Text(stringResource(Res.string.done))
Expand All @@ -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)
) {
Expand All @@ -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 = {}
)
}
}

@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 = {}
)
}
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions shared/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@

<string name="search">Search…</string>
<string name="filter">Filter…</string>
<string name="hide_completed_tasks">Hide completed tasks</string>
<string name="add_journal">Add journal</string>
<string name="add_note">Add note</string>
<string name="add_task">Add task</string>
Expand Down Expand Up @@ -414,6 +415,7 @@
<string name="proxy_trust_self_host_button">How to host it myself</string>

<string name="widget_configuration">Widget configuration</string>
<string name="widget_filter_criteria">Filter criteria</string>

<!-- Label/value pair, e.g. "Created: 5 Jan 2026". Some locales space the colon differently. -->
<string name="label_value">%1$s: %2$s</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
)
}

Expand Down
Loading
Loading