diff --git a/app/src/main/java/org/tasks/activities/FilterSettingsActivity.kt b/app/src/main/java/org/tasks/activities/FilterSettingsActivity.kt index 1320034af..2dd8e8bf1 100644 --- a/app/src/main/java/org/tasks/activities/FilterSettingsActivity.kt +++ b/app/src/main/java/org/tasks/activities/FilterSettingsActivity.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons @@ -12,19 +13,19 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.toMutableStateList +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.TaskListFragment import com.todoroo.astrid.api.BooleanCriterion import com.todoroo.astrid.api.CustomFilterCriterion import com.todoroo.astrid.api.MultipleSelectCriterion -import com.todoroo.astrid.api.PermaSql import com.todoroo.astrid.api.TextInputCriterion import com.todoroo.astrid.core.CriterionInstance import dagger.hilt.android.AndroidEntryPoint @@ -41,118 +42,69 @@ import org.tasks.compose.FilterCondition.SelectFromList import org.tasks.data.NO_ORDER import org.tasks.data.dao.FilterDao import org.tasks.data.dao.TaskDao.TaskCriteria.activeAndVisible -import org.tasks.data.db.Database import org.tasks.data.entity.Filter import org.tasks.data.entity.Task -import org.tasks.data.rawQuery -import org.tasks.data.sql.Field -import org.tasks.data.sql.Query import org.tasks.data.sql.UnaryCriterion -import org.tasks.db.QueryUtils import org.tasks.extensions.Context.openUri import org.tasks.filters.CustomFilter import org.tasks.filters.FilterCriteriaProvider import org.tasks.filters.mapToSerializedString import org.tasks.themes.TasksIcons import org.tasks.themes.TasksTheme -import java.util.Locale import javax.inject.Inject -import kotlin.math.max @AndroidEntryPoint class FilterSettingsActivity : BaseListSettingsActivity() { @Inject lateinit var filterDao: FilterDao - @Inject lateinit var locale: Locale - @Inject lateinit var database: Database @Inject lateinit var filterCriteriaProvider: FilterCriteriaProvider @Inject lateinit var localBroadcastManager: LocalBroadcastManager + private val viewModel: FilterSettingsViewModel by viewModels() - private var filter: CustomFilter? = null override val defaultIcon = TasksIcons.FILTER_LIST - private var criteria: SnapshotStateList = emptyList().toMutableStateList() - private val fabExtended = mutableStateOf(false) private val editCriterionType: MutableState = mutableStateOf(null) private val newCriterionTypes: MutableState?> = mutableStateOf(null) private val newCriterionOptions: MutableState = mutableStateOf(null) + private val filter: CustomFilter? + get() = viewModel.viewState.value.filter + override fun onCreate(savedInstanceState: Bundle?) { - filter = intent.getParcelableExtra(TOKEN_FILTER) super.onCreate(savedInstanceState) - if (savedInstanceState == null && filter != null) { - selectedColor = filter!!.tint - selectedIcon.value = filter!!.icon ?: defaultIcon - textState.value = filter!!.title ?: "" - } - when { - savedInstanceState != null -> lifecycleScope.launch { - setCriteria( - filterCriteriaProvider.fromString( - savedInstanceState.getString(EXTRA_CRITERIA)!! - ) - ) + if (savedInstanceState == null) { + filter?.let { + selectedColor = it.tint + selectedIcon.value = it.icon + textState.value = it.title ?: "" } - filter != null -> lifecycleScope.launch { - setCriteria(filterCriteriaProvider.fromString(filter!!.criterion)) - } - intent.hasExtra(EXTRA_CRITERIA) -> lifecycleScope.launch { - textState.value = intent.getStringExtra(EXTRA_TITLE) ?: "" - setCriteria( - filterCriteriaProvider.fromString(intent.getStringExtra(EXTRA_CRITERIA)!!) - ) - } - else -> setCriteria(universe()) } - + if (savedInstanceState != null) { + intent.getStringExtra(EXTRA_TITLE)?.let { textState.value = it } + } updateTheme() - } /* end onCreate */ - - private fun universe() = listOf(CriterionInstance().apply { - criterion = filterCriteriaProvider.startingUniverse - type = CriterionInstance.TYPE_UNIVERSE - }) - - private fun setCriteria(criteriaList: List) { - criteria = criteriaList - .ifEmpty { universe() } - .toMutableStateList() - fabExtended.value = isNew || criteria.size <= 1 - updateList() - - this.setContent { + setContent { TasksTheme { ActivityContent() } } } private fun onDelete(index: Int) { - criteria.removeAt(index) - updateList() - } - - private fun onMove(from: Int, to: Int) { - val criterion = criteria.removeAt(from) - criteria.add(to, criterion) + viewModel.removeAt(index) } private fun newCriterion() { - fabExtended.value = false // a.k.a. fab.shrink() + viewModel.setFabExtended(false) lifecycleScope.launch { newCriterionTypes.value = filterCriteriaProvider.all() } } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(EXTRA_CRITERIA, CriterionInstance.serialize(criteria)) - } - override val isNew: Boolean - get() = filter == null + get() = viewModel.viewState.value.filter == null override val toolbarTitle: String - get() = if (isNew) getString(R.string.FLA_new_filter) else filter?.title ?: "" + get() = if (isNew) getString(R.string.FLA_new_filter) else viewModel.viewState.value.filter?.title ?: "" override suspend fun save() { val newName = newName @@ -162,6 +114,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() { } if (hasChanges()) { + val criteria = viewModel.viewState.value.criteria var f = Filter( id = filter?.id ?: 0L, title = newName, @@ -195,6 +148,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() { get() = textState.value.trim { it <= ' ' } override fun hasChanges(): Boolean { + val criteria = viewModel.viewState.value.criteria return if (isNew) { (!Strings.isNullOrEmpty(newName) || selectedColor != 0 || selectedIcon.value?.isBlank() == false || criteria.size > 1) @@ -206,10 +160,6 @@ class FilterSettingsActivity : BaseListSettingsActivity() { || criteria.sql != filter!!.sql } - override fun finish() { - super.finish() - } - override suspend fun delete() { filterDao.delete(filter!!.id) setResult( @@ -219,48 +169,6 @@ class FilterSettingsActivity : BaseListSettingsActivity() { private fun help() = openUri(R.string.url_filters) - private fun updateList() = lifecycleScope.launch { - val newList = emptyList().toMutableList() - var max = 0 - var last = -1 - val sql = StringBuilder(Query.select(Field.COUNT).from(Task.TABLE).toString()) - .append(" WHERE ") - - for (instance in criteria) { - when (instance.type) { - CriterionInstance.TYPE_ADD -> sql.append("OR ") - CriterionInstance.TYPE_SUBTRACT -> sql.append("AND NOT ") - CriterionInstance.TYPE_INTERSECT -> sql.append("AND ") - } - - // special code for all tasks universe - if (instance.type == CriterionInstance.TYPE_UNIVERSE || instance.criterion.sql == null) { - sql.append(activeAndVisible()).append(' ') - } else { - var subSql: String = instance.criterion.sql.replace( - "?", - UnaryCriterion.sanitize(instance.valueFromCriterion!!) - ) - subSql = PermaSql.replacePlaceholdersForQuery(subSql) - sql.append(Task.ID).append(" IN (").append(subSql).append(")") - } - val sqlString = QueryUtils.showHiddenAndCompleted(sql.toString()) - database.rawQuery(sqlString) { cursor -> - cursor.step() - instance.start = if (last == -1) cursor.getInt(0) else last - instance.end = cursor.getInt(0) - last = instance.end - max = max(max, last) - } - newList.add(instance) - } - for (instance in newList) { - instance.max = max - } - criteria.clear() - criteria.addAll(newList) - } - @Composable private fun ActivityContent () { @@ -269,6 +177,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() { modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart ) { + val viewState by viewModel.viewState.collectAsStateWithLifecycle() BaseSettingsContent( optionButton = { if (isNew) { @@ -279,21 +188,22 @@ class FilterSettingsActivity : BaseListSettingsActivity() { } ) { FilterCondition( - items = criteria, + items = viewState.criteria, onDelete = { index -> onDelete(index) }, - doSwap = { from, to -> onMove(from, to) }, - onComplete = { updateList() }, + doSwap = { from, to -> viewModel.move(from, to) }, onClick = { id -> editCriterionType.value = id } ) } - NewCriterionFAB(fabExtended) { newCriterion() } + NewCriterionFAB(viewState.fabExtended) { newCriterion() } /** edit given criterion type (AND|OR|NOT) **/ editCriterionType.value?.let { itemId -> - val index = criteria.indexOfFirst { it.id == itemId } + val index = viewState.criteria.indexOfFirst { it.id == itemId } assert(index >= 0) - val criterionInstance = criteria[index] + val criterionInstance = remember (index) { + CriterionInstance(viewState.criteria[index]) + } if (criterionInstance.type != CriterionInstance.TYPE_UNIVERSE) { SelectCriterionType( title = criterionInstance.titleFromCriterion, @@ -317,7 +227,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() { } if (criterionInstance.type != type) { criterionInstance.type = type - updateList() + viewModel.setCriterion(index, criterionInstance) } editCriterionType.value = null } @@ -334,8 +244,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() { instance.criterion = list[which] newCriterionTypes.value = null if (instance.criterion is BooleanCriterion) { - criteria.add(instance) - updateList() + viewModel.addCriteria(instance) } else newCriterionOptions.value = instance } @@ -355,8 +264,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() { onCancel = { newCriterionOptions.value = null }, onSelected = { which -> instance.selectedIndex = which - criteria.add(instance) - updateList() + viewModel.addCriteria(instance) newCriterionOptions.value = null } ) @@ -370,8 +278,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() { onDone = { text -> text.trim().takeIf{ it != "" }?. let { text -> instance.selectedText = text - criteria.add(instance) - updateList() + viewModel.addCriteria(instance) } newCriterionOptions.value = null } diff --git a/app/src/main/java/org/tasks/activities/FilterSettingsViewModel.kt b/app/src/main/java/org/tasks/activities/FilterSettingsViewModel.kt new file mode 100644 index 000000000..5040f82b6 --- /dev/null +++ b/app/src/main/java/org/tasks/activities/FilterSettingsViewModel.kt @@ -0,0 +1,157 @@ +package org.tasks.activities + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.todoroo.astrid.api.PermaSql +import com.todoroo.astrid.core.CriterionInstance +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.tasks.activities.FilterSettingsActivity.Companion.EXTRA_CRITERIA +import org.tasks.activities.FilterSettingsActivity.Companion.TOKEN_FILTER +import org.tasks.data.dao.TaskDao.TaskCriteria.activeAndVisible +import org.tasks.data.db.Database +import org.tasks.data.entity.Task +import org.tasks.data.rawQuery +import org.tasks.data.sql.Field +import org.tasks.data.sql.Query +import org.tasks.data.sql.UnaryCriterion +import org.tasks.db.QueryUtils +import org.tasks.filters.CustomFilter +import org.tasks.filters.FilterCriteriaProvider +import javax.inject.Inject +import kotlin.math.max + +@HiltViewModel +class FilterSettingsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val filterCriteriaProvider: FilterCriteriaProvider, + private val database: Database +) : ViewModel() { + data class ViewState( + val filter: CustomFilter? = null, + val fabExtended: Boolean = false, + val criteria: ImmutableList = persistentListOf(), + ) + + private val _viewState = MutableStateFlow( + ViewState( + filter = savedStateHandle.get(TOKEN_FILTER), + ) + ) + val viewState: StateFlow = _viewState + + init { + _viewState.value.filter + ?.let { filter -> + viewModelScope.launch { + setCriteria(filterCriteriaProvider.fromString(filter.criterion)) + } + } + ?: savedStateHandle.get(EXTRA_CRITERIA)?.let { criteria -> + viewModelScope.launch { + setCriteria(filterCriteriaProvider.fromString(criteria)) + } + } + ?: setCriteria(emptyList()) + } + + fun setFabExtended(extended: Boolean) { + _viewState.update { it.copy(fabExtended = extended) } + } + + private fun setCriteria(criteria: List) { + _viewState.update { + it.copy( + criteria = criteria.ifEmpty { universe() }.toImmutableList(), + fabExtended = it.filter == null || criteria.size <= 1, + ) + } + viewModelScope.launch { + _viewState.update { + it.copy(criteria = updateCounts(it.criteria).toImmutableList()) + } + } + } + + fun removeAt(index: Int) { + setCriteria(_viewState.value.criteria.toMutableList().apply { removeAt(index) }) + } + + fun move(from: Int, to: Int) { + setCriteria( + _viewState.value.criteria + .toMutableList() + .apply { + val criterion = removeAt(from) + add(to, criterion) + } + ) + } + + fun addCriteria(instance: CriterionInstance) { + setCriteria(_viewState.value.criteria.toMutableList().apply { add(instance) }) + } + + private suspend fun updateCounts(criteria: List): List { + val newList = mutableListOf() + var max = 0 + var last = -1 + val sql = StringBuilder(Query.select(Field.COUNT).from(Task.TABLE).toString()) + .append(" WHERE ") + + for (instance in criteria.map { CriterionInstance(it) }) { + when (instance.type) { + CriterionInstance.TYPE_ADD -> sql.append("OR ") + CriterionInstance.TYPE_SUBTRACT -> sql.append("AND NOT ") + CriterionInstance.TYPE_INTERSECT -> sql.append("AND ") + } + + // special code for all tasks universe + if (instance.type == CriterionInstance.TYPE_UNIVERSE || instance.criterion.sql == null) { + sql.append(activeAndVisible()).append(' ') + } else { + var subSql: String = instance.criterion.sql.replace( + "?", + UnaryCriterion.sanitize(instance.valueFromCriterion!!) + ) + subSql = PermaSql.replacePlaceholdersForQuery(subSql) + sql.append(Task.ID).append(" IN (").append(subSql).append(")") + } + val sqlString = QueryUtils.showHiddenAndCompleted(sql.toString()) + database.rawQuery(sqlString) { cursor -> + cursor.step() + instance.start = if (last == -1) cursor.getInt(0) else last + instance.end = cursor.getInt(0) + last = instance.end + max = max(max, last) + } + newList.add(instance) + } + for (instance in newList) { + instance.max = max + } + return newList + } + + private fun universe() = listOf( + CriterionInstance().apply { + criterion = filterCriteriaProvider.startingUniverse + type = CriterionInstance.TYPE_UNIVERSE + } + ) + + fun setCriterion(index: Int, criterionInstance: CriterionInstance) { + setCriteria( + _viewState.value.criteria.toMutableList().apply { + set(index, criterionInstance) + } + ) + } +} diff --git a/app/src/main/java/org/tasks/compose/FilterCondition.kt b/app/src/main/java/org/tasks/compose/FilterCondition.kt index e57397600..183f5efbf 100644 --- a/app/src/main/java/org/tasks/compose/FilterCondition.kt +++ b/app/src/main/java/org/tasks/compose/FilterCondition.kt @@ -39,11 +39,9 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableIntState -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -61,10 +59,11 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.core.os.ConfigurationCompat import com.todoroo.astrid.core.CriterionInstance +import kotlinx.collections.immutable.ImmutableList import org.tasks.R import org.tasks.compose.SwipeOut.SwipeOut -import org.tasks.kmp.org.tasks.compose.settings.SettingRow import org.tasks.extensions.formatNumber +import org.tasks.kmp.org.tasks.compose.settings.SettingRow import org.tasks.themes.TasksTheme import java.util.Locale @@ -109,7 +108,7 @@ private fun SwipeOutDecorationPreview () { private fun FabPreview () { TasksTheme { FilterCondition.NewCriterionFAB( - isExtended = remember { mutableStateOf(true) } + isExtended = true ) { } @@ -120,13 +119,11 @@ object FilterCondition { @OptIn(ExperimentalFoundationApi::class) @Composable fun FilterCondition( - items: SnapshotStateList, + items: ImmutableList, onDelete: (Int) -> Unit, doSwap: (Int, Int) -> Unit, - onComplete: () -> Unit, onClick: (String) -> Unit ) { - val getIcon: (CriterionInstance) -> Int = { criterion -> when (criterion.type) { CriterionInstance.TYPE_ADD -> R.drawable.ic_call_split_24px @@ -141,9 +138,9 @@ object FilterCondition { val dragDropState = rememberDragDropState( lazyListState = listState, confirmDrag = { index -> index != 0 }, - completeDragDrop = onComplete, + completeDragDrop = {}, ) { fromIndex, toIndex -> - if (fromIndex != 0 && toIndex != 0) doSwap(fromIndex, toIndex) + if (fromIndex != toIndex) doSwap(fromIndex, toIndex) } Row { @@ -282,10 +279,9 @@ object FilterCondition { @Composable fun NewCriterionFAB( - isExtended: MutableState, + isExtended: Boolean, onClick: () -> Unit ) { - Box( // lays out over main content as a space to layout FAB modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd @@ -297,17 +293,15 @@ object FilterCondition { containerColor = MaterialTheme.colorScheme.secondary, contentColor = Color.White, ) { - val extended = isExtended.value - Row(verticalAlignment = Alignment.CenterVertically) { Icon( imageVector = Icons.Outlined.Add, contentDescription = "New Criteria", modifier = Modifier.padding( - start = if (extended) 16.dp else 0.dp + start = if (isExtended) 16.dp else 0.dp ) ) - if (extended) + if (isExtended) Text( text = LocalContext.current.getString(R.string.CFA_button_add), modifier = Modifier.padding(end = 16.dp)