Convert list settings to compose (#3163)

pull/3191/head
Hady 12 months ago committed by GitHub
parent 87c5ec9f14
commit 8e5b93fc10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,20 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "org.tasks.ak",
"variantName": "genericRelease",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 130804,
"versionName": "14.0.6",
"outputFile": "app-generic-release.apk"
}
],
"elementType": "File"
}

@ -2,6 +2,7 @@ package org.tasks.location
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.GoogleMap
@ -23,14 +24,18 @@ class GoogleMapFragment @Inject constructor(
private var map: GoogleMap? = null private var map: GoogleMap? = null
private var circle: Circle? = null private var circle: Circle? = null
override fun init(activity: AppCompatActivity, callback: MapFragmentCallback, dark: Boolean) { override fun init(activity: AppCompatActivity, callback: MapFragmentCallback, dark: Boolean, parent: ViewGroup?) {
this.callback = callback this.callback = callback
this.dark = dark this.dark = dark
val fragmentManager = activity.supportFragmentManager val fragmentManager = activity.supportFragmentManager
var mapFragment = fragmentManager.findFragmentByTag(FRAG_TAG_MAP) as SupportMapFragment? var mapFragment = fragmentManager.findFragmentByTag(FRAG_TAG_MAP) as SupportMapFragment?
if (mapFragment == null) { if (mapFragment == null) {
mapFragment = SupportMapFragment() mapFragment = SupportMapFragment()
fragmentManager.beginTransaction().replace(R.id.map, mapFragment).commit() if (parent == null) {
fragmentManager.beginTransaction().replace(R.id.map, mapFragment).commit()
} else {
fragmentManager.beginTransaction().add(parent, mapFragment, null).commit()
}
} }
mapFragment.getMapAsync(this) mapFragment.getMapAsync(this)
} }

@ -1,100 +1,65 @@
package org.tasks.activities package org.tasks.activities
import android.graphics.drawable.LayerDrawable
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import androidx.compose.foundation.layout.Column
import android.view.View import androidx.compose.foundation.layout.ColumnScope
import android.view.ViewGroup import androidx.compose.foundation.layout.fillMaxWidth
import android.widget.TextView import androidx.compose.foundation.layout.padding
import androidx.appcompat.widget.Toolbar import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.Row import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.R import org.tasks.R
import org.tasks.compose.Constants
import org.tasks.compose.DeleteButton
import org.tasks.compose.IconPickerActivity.Companion.launchIconPicker import org.tasks.compose.IconPickerActivity.Companion.launchIconPicker
import org.tasks.compose.IconPickerActivity.Companion.registerForIconPickerResult import org.tasks.compose.IconPickerActivity.Companion.registerForIconPickerResult
import org.tasks.compose.components.TasksIcon import org.tasks.compose.ListSettings.ProgressBar
import org.tasks.compose.ListSettings.SettingsSurface
import org.tasks.compose.ListSettings.TitleInput
import org.tasks.compose.ListSettings.Toolbar
import org.tasks.compose.ListSettings.PromptAction
import org.tasks.compose.ListSettings.SelectColorRow
import org.tasks.compose.ListSettings.SelectIconRow
import org.tasks.dialogs.ColorPalettePicker import org.tasks.dialogs.ColorPalettePicker
import org.tasks.dialogs.ColorPalettePicker.Companion.newColorPalette import org.tasks.dialogs.ColorPalettePicker.Companion.newColorPalette
import org.tasks.dialogs.ColorPickerAdapter.Palette import org.tasks.dialogs.ColorPickerAdapter.Palette
import org.tasks.dialogs.ColorWheelPicker import org.tasks.dialogs.ColorWheelPicker
import org.tasks.dialogs.DialogBuilder
import org.tasks.extensions.addBackPressedCallback import org.tasks.extensions.addBackPressedCallback
import org.tasks.injection.ThemedInjectingAppCompatActivity import org.tasks.injection.ThemedInjectingAppCompatActivity
import org.tasks.themes.ColorProvider import org.tasks.themes.ColorProvider
import org.tasks.themes.DrawableUtil
import org.tasks.themes.TasksTheme
import org.tasks.themes.ThemeColor import org.tasks.themes.ThemeColor
import javax.inject.Inject import javax.inject.Inject
abstract class BaseListSettingsActivity : ThemedInjectingAppCompatActivity(), Toolbar.OnMenuItemClickListener, ColorPalettePicker.ColorPickedCallback, ColorWheelPicker.ColorPickedCallback {
@Inject lateinit var dialogBuilder: DialogBuilder abstract class BaseListSettingsActivity : ThemedInjectingAppCompatActivity(), ColorPalettePicker.ColorPickedCallback, ColorWheelPicker.ColorPickedCallback {
@Inject lateinit var colorProvider: ColorProvider @Inject lateinit var colorProvider: ColorProvider
protected abstract val defaultIcon: String protected abstract val defaultIcon: String
protected var selectedColor = 0 protected var selectedColor = 0
protected var selectedIcon = MutableStateFlow<String?>(null) protected var selectedIcon = mutableStateOf<String?>(null)
private lateinit var clear: View protected val textState = mutableStateOf("")
private lateinit var color: TextView protected val errorState = mutableStateOf("")
protected lateinit var toolbar: Toolbar protected val colorState = mutableStateOf(Color.Unspecified)
protected lateinit var colorRow: ViewGroup protected val showProgress = mutableStateOf(false)
protected val promptDelete = mutableStateOf(false)
protected val promptDiscard = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val view = bind()
setContentView(view) /* defaultIcon is initialized in the descendant's constructor so it can not be used
clear = findViewById<View>(R.id.clear).apply { in constructor of the base class. So valid initial value for iconState is set here */
setOnClickListener { clearColor() } selectedIcon.value = defaultIcon
}
color = findViewById(R.id.color)
colorRow = findViewById<ViewGroup>(R.id.color_row).apply {
setOnClickListener { showThemePicker() }
}
findViewById<ComposeView>(R.id.icon).setContent {
TasksTheme(theme = tasksTheme.themeBase.index) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
TasksIcon(
label = selectedIcon.collectAsStateWithLifecycle().value ?: defaultIcon
)
Spacer(modifier = Modifier.width(34.dp))
Text(
text = "Icon",
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = 18.sp,
),
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
findViewById<View>(R.id.icon_row).setOnClickListener { showIconPicker() }
toolbar = view.findViewById(R.id.toolbar)
if (savedInstanceState != null) { if (savedInstanceState != null) {
selectedColor = savedInstanceState.getInt(EXTRA_SELECTED_THEME) selectedColor = savedInstanceState.getInt(EXTRA_SELECTED_THEME)
selectedIcon.update { savedInstanceState.getString(EXTRA_SELECTED_ICON) } selectedIcon.value = savedInstanceState.getString(EXTRA_SELECTED_ICON) ?: defaultIcon
}
toolbar.title = toolbarTitle
toolbar.navigationIcon = getDrawable(R.drawable.ic_outline_save_24px)
toolbar.setNavigationOnClickListener { lifecycleScope.launch { save() } }
if (!isNew) {
toolbar.inflateMenu(R.menu.menu_tag_settings)
} }
toolbar.setOnMenuItemClickListener(this)
addBackPressedCallback { addBackPressedCallback {
discard() discard()
} }
@ -111,33 +76,25 @@ abstract class BaseListSettingsActivity : ThemedInjectingAppCompatActivity(), To
protected abstract val isNew: Boolean protected abstract val isNew: Boolean
protected abstract val toolbarTitle: String? protected abstract val toolbarTitle: String?
protected abstract suspend fun delete() protected abstract suspend fun delete()
protected abstract fun bind(): View
protected open fun discard() { protected open fun discard() {
if (!hasChanges()) { if (hasChanges()) promptDiscard.value = true
finish() else finish()
} else {
dialogBuilder
.newDialog(R.string.discard_changes)
.setPositiveButton(R.string.discard) { _, _ -> finish() }
.setNegativeButton(R.string.cancel, null)
.show()
}
} }
private fun clearColor() { protected fun clearColor() {
onColorPicked(0) onColorPicked(0)
} }
private fun showThemePicker() { protected fun showThemePicker() {
newColorPalette(null, 0, selectedColor, Palette.COLORS) newColorPalette(null, 0, selectedColor, Palette.COLORS)
.show(supportFragmentManager, FRAG_TAG_COLOR_PICKER) .show(supportFragmentManager, FRAG_TAG_COLOR_PICKER)
} }
val launcher = registerForIconPickerResult { selected -> val launcher = registerForIconPickerResult { selected ->
selectedIcon.update { selected } selectedIcon.value = selected
} }
private fun showIconPicker() { fun showIconPicker() {
launcher.launchIconPicker(this, selectedIcon.value) launcher.launchIconPicker(this, selectedIcon.value)
} }
@ -146,38 +103,64 @@ abstract class BaseListSettingsActivity : ThemedInjectingAppCompatActivity(), To
updateTheme() updateTheme()
} }
override fun onMenuItemClick(item: MenuItem): Boolean { protected open fun promptDelete() { promptDelete.value = true }
if (item.itemId == R.id.delete) {
promptDelete() protected fun updateTheme() {
return true
} val themeColor: ThemeColor =
return onOptionsItemSelected(item) if (selectedColor == 0) this.themeColor
} else colorProvider.getThemeColor(selectedColor, true)
colorState.value =
if (selectedColor == 0) Color.Unspecified
else Color((colorProvider.getThemeColor(selectedColor, true)).primaryColor)
protected open fun promptDelete() { //iconState.intValue = (getIconResId(selectedIcon) ?: getIconResId(defaultIcon))!!
dialogBuilder
.newDialog(R.string.delete_tag_confirmation, toolbarTitle) themeColor.applyToNavigationBar(this)
.setPositiveButton(R.string.delete) { _, _ -> lifecycleScope.launch { delete() } }
.setNegativeButton(R.string.cancel, null)
.show()
} }
protected fun updateTheme() { /** Standard @Compose view content for descendants. Caller must wrap it to TasksTheme{} */
val themeColor: ThemeColor @Composable
if (selectedColor == 0) { protected fun baseSettingsContent(
themeColor = this.themeColor title: String = toolbarTitle ?: "",
DrawableUtil.setLeftDrawable(this, color, R.drawable.ic_outline_not_interested_24px) requestKeyboard: Boolean = isNew,
DrawableUtil.getLeftDrawable(color).setTint(getColor(R.color.icon_tint_with_alpha)) optionButton: @Composable () -> Unit = { if (!isNew) DeleteButton { promptDelete() } },
clear.visibility = View.GONE extensionContent: @Composable ColumnScope.() -> Unit = {}
} else { ) {
themeColor = colorProvider.getThemeColor(selectedColor, true) SettingsSurface {
DrawableUtil.setLeftDrawable(this, color, R.drawable.color_picker) Toolbar(
val leftDrawable = DrawableUtil.getLeftDrawable(color) title = title,
(if (leftDrawable is LayerDrawable) leftDrawable.getDrawable(0) else leftDrawable) save = { lifecycleScope.launch { save() } },
.setTint(themeColor.primaryColor) optionButton = optionButton
clear.visibility = View.VISIBLE )
ProgressBar(showProgress)
TitleInput(
text = textState, error = errorState, requestKeyboard = requestKeyboard,
modifier = Modifier.padding(horizontal = Constants.KEYLINE_FIRST)
)
Column(modifier = Modifier.fillMaxWidth()) {
SelectColorRow(
color = colorState,
selectColor = { showThemePicker() },
clearColor = { clearColor() })
SelectIconRow(
icon = selectedIcon.value ?: defaultIcon,
selectIcon = { showIconPicker() })
extensionContent()
PromptAction(
showDialog = promptDelete,
title = stringResource(id = R.string.delete_tag_confirmation, title),
onAction = { lifecycleScope.launch { delete() } }
)
PromptAction(
showDialog = promptDiscard,
title = stringResource(id = R.string.discard_changes),
onAction = { lifecycleScope.launch { finish() } }
)
}
} }
themeColor.applyToNavigationBar(this)
} }
companion object { companion object {

@ -1,56 +1,60 @@
package org.tasks.activities package org.tasks.activities
import android.app.Activity import android.app.Activity
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import androidx.activity.compose.setContent
import android.widget.EditText import androidx.compose.foundation.layout.Box
import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxSize
import androidx.core.widget.addTextChangedListener import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Help
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import org.tasks.data.sql.Field
import org.tasks.data.sql.Query
import org.tasks.data.sql.UnaryCriterion
import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment import com.todoroo.astrid.activity.TaskListFragment
import com.todoroo.astrid.api.BooleanCriterion import com.todoroo.astrid.api.BooleanCriterion
import org.tasks.filters.CustomFilter
import com.todoroo.astrid.api.CustomFilterCriterion import com.todoroo.astrid.api.CustomFilterCriterion
import com.todoroo.astrid.api.MultipleSelectCriterion import com.todoroo.astrid.api.MultipleSelectCriterion
import com.todoroo.astrid.api.PermaSql import com.todoroo.astrid.api.PermaSql
import com.todoroo.astrid.api.TextInputCriterion import com.todoroo.astrid.api.TextInputCriterion
import com.todoroo.astrid.core.CriterionInstance import com.todoroo.astrid.core.CriterionInstance
import com.todoroo.astrid.core.CustomFilterAdapter
import com.todoroo.astrid.core.CustomFilterItemTouchHelper
import org.tasks.data.db.Database
import org.tasks.data.entity.Task
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.LocalBroadcastManager import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.Strings import org.tasks.Strings
import org.tasks.data.entity.Filter import org.tasks.compose.DeleteButton
import org.tasks.data.dao.FilterDao import org.tasks.compose.FilterCondition.FilterCondition
import org.tasks.compose.FilterCondition.InputTextOption
import org.tasks.compose.FilterCondition.NewCriterionFAB
import org.tasks.compose.FilterCondition.SelectCriterionType
import org.tasks.compose.FilterCondition.SelectFromList
import org.tasks.data.NO_ORDER import org.tasks.data.NO_ORDER
import org.tasks.data.dao.FilterDao
import org.tasks.data.dao.TaskDao.TaskCriteria.activeAndVisible 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.rawQuery
import org.tasks.databinding.FilterSettingsActivityBinding 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.db.QueryUtils
import org.tasks.extensions.Context.hideKeyboard
import org.tasks.extensions.Context.openUri import org.tasks.extensions.Context.openUri
import org.tasks.extensions.hideKeyboard import org.tasks.filters.CustomFilter
import org.tasks.filters.FilterCriteriaProvider import org.tasks.filters.FilterCriteriaProvider
import org.tasks.filters.mapToSerializedString import org.tasks.filters.mapToSerializedString
import org.tasks.themes.TasksIcons import org.tasks.themes.TasksIcons
import org.tasks.themes.TasksTheme
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.max import kotlin.math.max
@ -63,23 +67,23 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var filterCriteriaProvider: FilterCriteriaProvider @Inject lateinit var filterCriteriaProvider: FilterCriteriaProvider
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var localBroadcastManager: LocalBroadcastManager
private lateinit var name: TextInputEditText
private lateinit var nameLayout: TextInputLayout
private lateinit var recyclerView: RecyclerView
private lateinit var fab: ExtendedFloatingActionButton
private var filter: CustomFilter? = null private var filter: CustomFilter? = null
private lateinit var adapter: CustomFilterAdapter
private var criteria: MutableList<CriterionInstance> = ArrayList()
override val defaultIcon = TasksIcons.FILTER_LIST override val defaultIcon = TasksIcons.FILTER_LIST
private var criteria: SnapshotStateList<CriterionInstance> = emptyList<CriterionInstance>().toMutableStateList()
private val fabExtended = mutableStateOf(false)
private val editCriterionType: MutableState<String?> = mutableStateOf(null)
private val newCriterionTypes: MutableState<List<CustomFilterCriterion>?> = mutableStateOf(null)
private val newCriterionOptions: MutableState<CriterionInstance?> = mutableStateOf(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
filter = intent.getParcelableExtra(TOKEN_FILTER) filter = intent.getParcelableExtra(TOKEN_FILTER)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (savedInstanceState == null && filter != null) { if (savedInstanceState == null && filter != null) {
selectedColor = filter!!.tint selectedColor = filter!!.tint
selectedIcon.update { filter!!.icon } selectedIcon.value = filter!!.icon ?: defaultIcon
name.setText(filter!!.title) textState.value = filter!!.title ?: ""
} }
when { when {
savedInstanceState != null -> lifecycleScope.launch { savedInstanceState != null -> lifecycleScope.launch {
@ -93,36 +97,33 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
setCriteria(filterCriteriaProvider.fromString(filter!!.criterion)) setCriteria(filterCriteriaProvider.fromString(filter!!.criterion))
} }
intent.hasExtra(EXTRA_CRITERIA) -> lifecycleScope.launch { intent.hasExtra(EXTRA_CRITERIA) -> lifecycleScope.launch {
name.setText(intent.getStringExtra(EXTRA_TITLE)) textState.value = intent.getStringExtra(EXTRA_TITLE) ?: ""
setCriteria( setCriteria(
filterCriteriaProvider.fromString(intent.getStringExtra(EXTRA_CRITERIA)!!) filterCriteriaProvider.fromString(intent.getStringExtra(EXTRA_CRITERIA)!!)
) )
} }
else -> setCriteria(universe()) else -> setCriteria(universe())
} }
recyclerView.layoutManager = LinearLayoutManager(this)
ItemTouchHelper(
CustomFilterItemTouchHelper(this, this::onMove, this::onDelete, this::updateList))
.attachToRecyclerView(recyclerView)
if (isNew) {
toolbar.inflateMenu(R.menu.menu_help)
}
updateTheme() updateTheme()
}
} /* end onCreate */
private fun universe() = listOf(CriterionInstance().apply { private fun universe() = listOf(CriterionInstance().apply {
criterion = filterCriteriaProvider.startingUniverse criterion = filterCriteriaProvider.startingUniverse
type = CriterionInstance.TYPE_UNIVERSE type = CriterionInstance.TYPE_UNIVERSE
}) })
private fun setCriteria(criteria: List<CriterionInstance>) { private fun setCriteria(criteriaList: List<CriterionInstance>) {
this.criteria = criteria criteria = criteriaList
.ifEmpty { universe() } .ifEmpty { universe() }
.toMutableList() .toMutableStateList()
adapter = CustomFilterAdapter(criteria, locale) { replaceId: String -> onClick(replaceId) } fabExtended.value = isNew || criteria.size <= 1
recyclerView.adapter = adapter
fab.isExtended = isNew || adapter.itemCount <= 1
updateList() updateList()
this.setContent {
TasksTheme { ActivityContent() }
}
} }
private fun onDelete(index: Int) { private fun onDelete(index: Int) {
@ -133,97 +134,15 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
private fun onMove(from: Int, to: Int) { private fun onMove(from: Int, to: Int) {
val criterion = criteria.removeAt(from) val criterion = criteria.removeAt(from)
criteria.add(to, criterion) criteria.add(to, criterion)
adapter.notifyItemMoved(from, to)
} }
private fun onClick(replaceId: String) { private fun newCriterion() {
val criterionInstance = criteria.find { it.id == replaceId }!! fabExtended.value = false // a.k.a. fab.shrink()
val view = layoutInflater.inflate(R.layout.dialog_custom_filter_row_edit, recyclerView, false)
val group: MaterialButtonToggleGroup = view.findViewById(R.id.button_toggle)
val selected = getSelected(criterionInstance)
group.check(selected)
dialogBuilder
.newDialog(criterionInstance.titleFromCriterion)
.setView(view)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
criterionInstance.type = getType(group.checkedButtonId)
updateList()
}
.setNeutralButton(R.string.help) { _, _ -> help() }
.show()
}
private fun getSelected(instance: CriterionInstance): Int =
when (instance.type) {
CriterionInstance.TYPE_ADD -> R.id.button_or
CriterionInstance.TYPE_SUBTRACT -> R.id.button_not
else -> R.id.button_and
}
private fun getType(selected: Int): Int =
when (selected) {
R.id.button_or -> CriterionInstance.TYPE_ADD
R.id.button_not -> CriterionInstance.TYPE_SUBTRACT
else -> CriterionInstance.TYPE_INTERSECT
}
private fun addCriteria() {
hideKeyboard()
fab.shrink()
lifecycleScope.launch { lifecycleScope.launch {
val all = filterCriteriaProvider.all() newCriterionTypes.value = filterCriteriaProvider.all()
val names = all.map(CustomFilterCriterion::getName)
dialogBuilder.newDialog()
.setItems(names) { dialog: DialogInterface, which: Int ->
val instance = CriterionInstance()
instance.criterion = all[which]
showOptionsFor(instance) {
criteria.add(instance)
updateList()
}
dialog.dismiss()
}
.show()
} }
} }
/** Show options menu for the given criterioninstance */
private fun showOptionsFor(item: CriterionInstance, onComplete: Runnable?) {
if (item.criterion is BooleanCriterion) {
onComplete?.run()
return
}
val dialog = dialogBuilder.newDialog(item.criterion.name)
if (item.criterion is MultipleSelectCriterion) {
val multiSelectCriterion = item.criterion as MultipleSelectCriterion
val titles = multiSelectCriterion.entryTitles
val listener = DialogInterface.OnClickListener { _: DialogInterface?, which: Int ->
item.selectedIndex = which
onComplete?.run()
}
dialog.setItems(titles, listener)
} else if (item.criterion is TextInputCriterion) {
val textInCriterion = item.criterion as TextInputCriterion
val frameLayout = FrameLayout(this)
frameLayout.setPadding(10, 0, 10, 0)
val editText = EditText(this)
editText.setText(item.selectedText)
editText.hint = textInCriterion.hint
frameLayout.addView(
editText,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT))
dialog
.setView(frameLayout)
.setPositiveButton(R.string.ok) { _, _ ->
item.selectedText = editText.text.toString()
onComplete?.run()
}
}
dialog.show()
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putString(EXTRA_CRITERIA, CriterionInstance.serialize(criteria)) outState.putString(EXTRA_CRITERIA, CriterionInstance.serialize(criteria))
@ -232,15 +151,16 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
override val isNew: Boolean override val isNew: Boolean
get() = filter == null get() = filter == null
override val toolbarTitle: String? 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 filter?.title ?: ""
override suspend fun save() { override suspend fun save() {
val newName = newName val newName = newName
if (Strings.isNullOrEmpty(newName)) { if (Strings.isNullOrEmpty(newName)) {
nameLayout.error = getString(R.string.name_cannot_be_empty) errorState.value = getString(R.string.name_cannot_be_empty)
return return
} }
if (hasChanges()) { if (hasChanges()) {
var f = Filter( var f = Filter(
id = filter?.id ?: 0L, id = filter?.id ?: 0L,
@ -272,7 +192,7 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
} }
private val newName: String private val newName: String
get() = name.text.toString().trim { it <= ' ' } get() = textState.value.trim { it <= ' ' }
override fun hasChanges(): Boolean { override fun hasChanges(): Boolean {
return if (isNew) { return if (isNew) {
@ -287,24 +207,9 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
} }
override fun finish() { override fun finish() {
hideKeyboard(name)
super.finish() super.finish()
} }
override fun bind() = FilterSettingsActivityBinding.inflate(layoutInflater).let {
name = it.name.apply {
addTextChangedListener(
onTextChanged = { _, _, _, _ -> nameLayout.error = null }
)
}
nameLayout = it.nameLayout
recyclerView = it.recyclerView
fab = it.fab.apply {
setOnClickListener { addCriteria() }
}
it.root
}
override suspend fun delete() { override suspend fun delete() {
filterDao.delete(filter!!.id) filterDao.delete(filter!!.id)
setResult( setResult(
@ -312,21 +217,15 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
finish() finish()
} }
override fun onMenuItemClick(item: MenuItem): Boolean =
if (item.itemId == R.id.menu_help) {
help()
true
} else {
super.onMenuItemClick(item)
}
private fun help() = openUri(R.string.url_filters) private fun help() = openUri(R.string.url_filters)
private fun updateList() = lifecycleScope.launch { private fun updateList() = lifecycleScope.launch {
val newList = emptyList<CriterionInstance>().toMutableList()
var max = 0 var max = 0
var last = -1 var last = -1
val sql = StringBuilder(Query.select(Field.COUNT).from(Task.TABLE).toString()) val sql = StringBuilder(Query.select(Field.COUNT).from(Task.TABLE).toString())
.append(" WHERE ") .append(" WHERE ")
for (instance in criteria) { for (instance in criteria) {
when (instance.type) { when (instance.type) {
CriterionInstance.TYPE_ADD -> sql.append("OR ") CriterionInstance.TYPE_ADD -> sql.append("OR ")
@ -353,13 +252,139 @@ class FilterSettingsActivity : BaseListSettingsActivity() {
last = instance.end last = instance.end
max = max(max, last) max = max(max, last)
} }
newList.add(instance)
} }
for (instance in criteria) { for (instance in newList) {
instance.max = max instance.max = max
} }
adapter.submitList(criteria) criteria.clear()
criteria.addAll(newList)
} }
@Composable
private fun ActivityContent ()
{
TasksTheme {
Box( // to layout FAB over the main content
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopStart
) {
baseSettingsContent(
optionButton = {
if (isNew) {
IconButton(onClick = { help() }) {
Icon(imageVector = Icons.Outlined.Help, contentDescription = "")
}
} else DeleteButton{ promptDelete() }
}
) {
FilterCondition(
items = criteria,
onDelete = { index -> onDelete(index) },
doSwap = { from, to -> onMove(from, to) },
onComplete = { updateList() },
onClick = { id -> editCriterionType.value = id }
)
}
NewCriterionFAB(fabExtended) { newCriterion() }
/** edit given criterion type (AND|OR|NOT) **/
editCriterionType.value?.let { itemId ->
val index = criteria.indexOfFirst { it.id == itemId }
assert(index >= 0)
val criterionInstance = criteria[index]
if (criterionInstance.type != CriterionInstance.TYPE_UNIVERSE) {
SelectCriterionType(
title = criterionInstance.titleFromCriterion,
selected = when (criterionInstance.type) {
CriterionInstance.TYPE_INTERSECT -> 0
CriterionInstance.TYPE_ADD -> 1
else -> 2
},
types = listOf(
stringResource(R.string.custom_filter_and),
stringResource(R.string.custom_filter_or),
stringResource(R.string.custom_filter_not)
),
help = { help() },
onCancel = { editCriterionType.value = null }
) { selected ->
val type = when (selected) {
0 -> CriterionInstance.TYPE_INTERSECT
1 -> CriterionInstance.TYPE_ADD
else -> CriterionInstance.TYPE_SUBTRACT
}
if (criterionInstance.type != type) {
criterionInstance.type = type
updateList()
}
editCriterionType.value = null
}
}
} /* end (AND|OR|NOT) dialog */
/** dialog to select new criterion category **/
newCriterionTypes.value?.let { list ->
SelectFromList(
names = list.map(CustomFilterCriterion::getName),
onCancel = { newCriterionTypes.value = null },
onSelected = { which ->
val instance = CriterionInstance()
instance.criterion = list[which]
newCriterionTypes.value = null
if (instance.criterion is BooleanCriterion) {
criteria.add(instance)
updateList()
} else
newCriterionOptions.value = instance
}
)
} /* end dialog */
/** Show options menu for the given CriterionInstance */
newCriterionOptions.value?.let { instance ->
when (instance.criterion) {
is MultipleSelectCriterion -> {
val multiSelectCriterion = instance.criterion as MultipleSelectCriterion
val list = multiSelectCriterion.entryTitles.toList()
SelectFromList(
names = list,
title = instance.criterion.name,
onCancel = { newCriterionOptions.value = null },
onSelected = { which ->
instance.selectedIndex = which
criteria.add(instance)
updateList()
newCriterionOptions.value = null
}
)
}
is TextInputCriterion -> {
val textInCriterion = instance.criterion as TextInputCriterion
InputTextOption (
title = textInCriterion.name,
onCancel = { newCriterionOptions.value = null },
onDone = { text ->
text.trim().takeIf{ it != "" }?. let { text ->
instance.selectedText = text
criteria.add(instance)
updateList()
}
newCriterionOptions.value = null
}
)
}
else -> assert(false) { "Unexpected Criterion type" }
}
} /* end given criteria options dialog */
}
}
} /* activityContent */
companion object { companion object {
const val TOKEN_FILTER = "token_filter" const val TOKEN_FILTER = "token_filter"
const val EXTRA_TITLE = "extra_title" const val EXTRA_TITLE = "extra_title"

@ -1,35 +1,30 @@
package org.tasks.activities package org.tasks.activities
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import androidx.activity.compose.setContent
import android.view.inputmethod.InputMethodManager
import android.widget.ProgressBar
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.material3.SnackbarHostState
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputEditText
import com.google.api.services.tasks.model.TaskList import com.google.api.services.tasks.model.TaskList
import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment import com.todoroo.astrid.activity.TaskListFragment
import com.todoroo.astrid.service.TaskDeleter import com.todoroo.astrid.service.TaskDeleter
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.tasks.LocalBroadcastManager import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.compose.ListSettings.Toaster
import org.tasks.data.dao.GoogleTaskListDao import org.tasks.data.dao.GoogleTaskListDao
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.databinding.ActivityGoogleTaskListSettingsBinding
import org.tasks.extensions.Context.hideKeyboard
import org.tasks.extensions.Context.toast
import org.tasks.filters.GtasksFilter import org.tasks.filters.GtasksFilter
import org.tasks.themes.TasksIcons import org.tasks.themes.TasksIcons
import org.tasks.themes.TasksTheme
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -39,9 +34,6 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var taskDeleter: TaskDeleter @Inject lateinit var taskDeleter: TaskDeleter
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var localBroadcastManager: LocalBroadcastManager
private lateinit var name: TextInputEditText
private lateinit var progressView: ProgressBar
private var isNewList = false private var isNewList = false
private lateinit var gtasksList: CaldavCalendar private lateinit var gtasksList: CaldavCalendar
private val createListViewModel: CreateListViewModel by viewModels() private val createListViewModel: CreateListViewModel by viewModels()
@ -49,6 +41,8 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
private val deleteListViewModel: DeleteListViewModel by viewModels() private val deleteListViewModel: DeleteListViewModel by viewModels()
override val defaultIcon = TasksIcons.LIST override val defaultIcon = TasksIcons.LIST
val snackbar = SnackbarHostState()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
gtasksList = intent.getParcelableExtra(EXTRA_STORE_DATA) gtasksList = intent.getParcelableExtra(EXTRA_STORE_DATA)
?: CaldavCalendar( ?: CaldavCalendar(
@ -59,23 +53,28 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (savedInstanceState == null) { if (savedInstanceState == null) {
selectedColor = gtasksList.color selectedColor = gtasksList.color
selectedIcon.update { gtasksList.icon } selectedIcon.value = gtasksList.icon ?: defaultIcon
}
if (isNewList) {
name.requestFocus()
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(name, InputMethodManager.SHOW_IMPLICIT)
} else {
name.setText(gtasksList.name)
} }
if (!isNewList) textState.value = gtasksList.name!!
if (createListViewModel.inProgress if (createListViewModel.inProgress
|| renameListViewModel.inProgress || renameListViewModel.inProgress
|| deleteListViewModel.inProgress) { || deleteListViewModel.inProgress) {
showProgressIndicator() showProgressIndicator()
} }
createListViewModel.observe(this, this::onListCreated, this::requestFailed) createListViewModel.observe(this, this::onListCreated, this::requestFailed)
renameListViewModel.observe(this, this::onListRenamed, this::requestFailed) renameListViewModel.observe(this, this::onListRenamed, this::requestFailed)
deleteListViewModel.observe(this, this::onListDeleted, this::requestFailed) deleteListViewModel.observe(this, this::onListDeleted, this::requestFailed)
setContent {
TasksTheme {
baseSettingsContent()
Toaster(state = snackbar)
}
}
updateTheme() updateTheme()
} }
@ -86,14 +85,14 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
get() = if (isNew) getString(R.string.new_list) else gtasksList.name!! get() = if (isNew) getString(R.string.new_list) else gtasksList.name!!
private fun showProgressIndicator() { private fun showProgressIndicator() {
progressView.visibility = View.VISIBLE showProgress.value = true
} }
private fun hideProgressIndicator() { private fun hideProgressIndicator() {
progressView.visibility = View.GONE showProgress.value = false
} }
private fun requestInProgress() = progressView.visibility == View.VISIBLE private fun requestInProgress() = showProgress.value
override suspend fun save() { override suspend fun save() {
if (requestInProgress()) { if (requestInProgress()) {
@ -101,7 +100,7 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
} }
val newName = newName val newName = newName
if (isNullOrEmpty(newName)) { if (isNullOrEmpty(newName)) {
toast(R.string.name_cannot_be_empty) errorState.value = getString(R.string.name_cannot_be_empty)
return return
} }
when { when {
@ -133,16 +132,9 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
} }
override fun finish() { override fun finish() {
hideKeyboard(name)
super.finish() super.finish()
} }
override fun bind() = ActivityGoogleTaskListSettingsBinding.inflate(layoutInflater).let {
name = it.name
progressView = it.progressBar.progressBar
it.root
}
override fun promptDelete() { override fun promptDelete() {
if (!requestInProgress()) { if (!requestInProgress()) {
super.promptDelete() super.promptDelete()
@ -161,7 +153,7 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
} }
private val newName: String private val newName: String
get() = name.text.toString().trim { it <= ' ' } get() = textState.value.trim { it <= ' ' }
override fun hasChanges(): Boolean = override fun hasChanges(): Boolean =
if (isNewList) { if (isNewList) {
@ -219,7 +211,8 @@ class GoogleTaskListSettingsActivity : BaseListSettingsActivity() {
private fun requestFailed(error: Throwable) { private fun requestFailed(error: Throwable) {
Timber.e(error) Timber.e(error)
hideProgressIndicator() hideProgressIndicator()
toast(R.string.gtasks_GLA_errorIOAuth) lifecycleScope.launch { snackbar.showSnackbar(getString(R.string.gtasks_GLA_errorIOAuth)) }
//toast(R.string.gtasks_GLA_errorIOAuth)
return return
} }

@ -3,46 +3,57 @@ package org.tasks.activities
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.core.widget.addTextChangedListener import android.view.ViewGroup
import com.google.android.material.slider.Slider import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import com.google.android.material.textfield.TextInputEditText import android.widget.LinearLayout
import com.google.android.material.textfield.TextInputLayout import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment import com.todoroo.astrid.activity.TaskListFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.update
import org.tasks.LocalBroadcastManager import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.compose.Constants
import org.tasks.data.dao.LocationDao import org.tasks.data.dao.LocationDao
import org.tasks.data.displayName
import org.tasks.data.entity.Place import org.tasks.data.entity.Place
import org.tasks.data.mapPosition import org.tasks.data.mapPosition
import org.tasks.databinding.ActivityLocationSettingsBinding
import org.tasks.extensions.formatNumber import org.tasks.extensions.formatNumber
import org.tasks.filters.PlaceFilter import org.tasks.filters.PlaceFilter
import org.tasks.location.MapFragment import org.tasks.location.MapFragment
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.themes.TasksIcons import org.tasks.themes.TasksIcons
import org.tasks.themes.TasksTheme
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class PlaceSettingsActivity : BaseListSettingsActivity(), MapFragment.MapFragmentCallback, class PlaceSettingsActivity : BaseListSettingsActivity(),
Slider.OnChangeListener { MapFragment.MapFragmentCallback {
companion object { companion object {
const val EXTRA_PLACE = "extra_place" const val EXTRA_PLACE = "extra_place"
private const val MIN_RADIUS = 75 private const val MIN_RADIUS = 75
private const val MAX_RADIUS = 1000 private const val MAX_RADIUS = 1000
private const val STEP = 25.0 private const val STEP = 25
} }
private lateinit var name: TextInputEditText
private lateinit var nameLayout: TextInputLayout
private lateinit var slider: Slider
@Inject lateinit var locationDao: LocationDao @Inject lateinit var locationDao: LocationDao
@Inject lateinit var map: MapFragment @Inject lateinit var map: MapFragment
@Inject lateinit var preferences: Preferences @Inject lateinit var preferences: Preferences
@ -52,6 +63,9 @@ class PlaceSettingsActivity : BaseListSettingsActivity(), MapFragment.MapFragmen
private lateinit var place: Place private lateinit var place: Place
override val defaultIcon = TasksIcons.PLACE override val defaultIcon = TasksIcons.PLACE
private val sliderPos = mutableFloatStateOf(100f)
private lateinit var viewHolder: ViewGroup
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
if (intent?.hasExtra(EXTRA_PLACE) != true) { if (intent?.hasExtra(EXTRA_PLACE) != true) {
finish() finish()
@ -68,52 +82,89 @@ class PlaceSettingsActivity : BaseListSettingsActivity(), MapFragment.MapFragmen
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (savedInstanceState == null) { if (savedInstanceState == null) {
name.setText(place.displayName) textState.value = place.displayName
selectedColor = place.color selectedColor = place.color
selectedIcon.update { place.icon } selectedIcon.value = place.icon ?: defaultIcon
} }
sliderPos.floatValue = (place.radius / STEP * STEP).toFloat()
val dark = preferences.mapTheme == 2 val dark = preferences.mapTheme == 2
|| preferences.mapTheme == 0 && tasksTheme.themeBase.isDarkTheme(this) || preferences.mapTheme == 0 && tasksTheme.themeBase.isDarkTheme(this)
map.init(this, this, dark)
updateTheme() updateTheme()
}
override fun bind() = ActivityLocationSettingsBinding.inflate(layoutInflater).let { setContent {
name = it.name.apply { TasksTheme {
addTextChangedListener( baseSettingsContent {
onTextChanged = { _, _, _, _ -> nameLayout.error = null } Row(
) modifier = Modifier
} .requiredHeight(56.dp)
nameLayout = it.nameLayout .fillMaxWidth()
slider = it.slider.apply { .padding(horizontal = Constants.KEYLINE_FIRST),
setLabelFormatter { value -> verticalAlignment = Alignment.CenterVertically,
getString( horizontalArrangement = Arrangement.SpaceBetween
R.string.location_radius_meters, ) {
locale.formatNumber(value.toInt()) Text(stringResource(id = R.string.geofence_radius))
) Row(horizontalArrangement = Arrangement.End) {
Text(getString(
R.string.location_radius_meters,
locale.formatNumber(sliderPos.floatValue.roundToInt()
)))
}
}
Slider(
modifier = Modifier.fillMaxWidth(),
value = sliderPos.floatValue,
valueRange = (MIN_RADIUS.toFloat()..MAX_RADIUS.toFloat()),
steps = (MAX_RADIUS - MIN_RADIUS) / STEP - 1,
onValueChange = { sliderPos.floatValue = it; updateGeofenceCircle(sliderPos.floatValue) },
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.secondary,
activeTrackColor = MaterialTheme.colorScheme.secondary,
inactiveTrackColor = colorResource(id = R.color.text_tertiary),
activeTickColor = MaterialTheme.colorScheme.secondary,
inactiveTickColor = colorResource(id = R.color.text_tertiary),
disabledActiveTrackColor = MaterialTheme.colorScheme.secondary,
disabledInactiveTrackColor = colorResource(id = R.color.text_tertiary),
disabledActiveTickColor = MaterialTheme.colorScheme.secondary,
disabledInactiveTickColor = colorResource(id = R.color.text_tertiary)
)
)
AndroidView(
factory = { ctx ->
viewHolder = LinearLayout(ctx).apply {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
id = R.id.map
}
map.init(
this@PlaceSettingsActivity,
this@PlaceSettingsActivity,
dark,
viewHolder
)
viewHolder
},
modifier = Modifier
.fillMaxWidth()
.requiredHeight(300.dp)
.padding(horizontal = 8.dp)
)
}
} }
valueTo = MAX_RADIUS.toFloat()
valueFrom = MIN_RADIUS.toFloat()
stepSize = STEP.toFloat()
haloRadius = 0
value = (place.radius / STEP * STEP).roundToInt().toFloat()
} }
slider.addOnChangeListener(this)
it.root
} }
override fun hasChanges() = name.text.toString() != place.displayName override fun hasChanges() = textState.value != place.displayName
|| selectedColor != place.color || selectedColor != place.color
|| selectedIcon.value != place.icon || selectedIcon.value != place.icon
override suspend fun save() { override suspend fun save() {
val newName: String = name.text.toString() val newName: String = textState.value
if (isNullOrEmpty(newName)) { if (isNullOrEmpty(newName)) {
nameLayout.error = getString(R.string.name_cannot_be_empty) errorState.value = getString(R.string.name_cannot_be_empty)
return return
} }
@ -121,7 +172,7 @@ class PlaceSettingsActivity : BaseListSettingsActivity(), MapFragment.MapFragmen
name = newName, name = newName,
color = selectedColor, color = selectedColor,
icon = selectedIcon.value, icon = selectedIcon.value,
radius = slider.value.toInt(), radius = sliderPos.floatValue.roundToInt(),
) )
locationDao.update(place) locationDao.update(place)
localBroadcastManager.broadcastRefresh() localBroadcastManager.broadcastRefresh()
@ -151,16 +202,13 @@ class PlaceSettingsActivity : BaseListSettingsActivity(), MapFragment.MapFragmen
map.setMarkers(listOf(place)) map.setMarkers(listOf(place))
map.disableGestures() map.disableGestures()
map.movePosition(place.mapPosition, false) map.movePosition(place.mapPosition, false)
updateGeofenceCircle() updateGeofenceCircle(sliderPos.floatValue)
} }
override fun onPlaceSelected(place: Place) {} override fun onPlaceSelected(place: Place) {}
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
updateGeofenceCircle()
}
private fun updateGeofenceCircle() { private fun updateGeofenceCircle(radius: Float) {
val radius = slider.value.toDouble() val radius = radius.toDouble()
val zoom = when (radius) { val zoom = when (radius) {
in 0f..300f -> 15f in 0f..300f -> 15f
in 300f..500f -> 14.5f in 300f..500f -> 14.5f

@ -6,27 +6,21 @@
package org.tasks.activities package org.tasks.activities
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.inputmethod.InputMethodManager import androidx.activity.compose.setContent
import androidx.core.widget.addTextChangedListener
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment import com.todoroo.astrid.activity.TaskListFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.update
import org.tasks.LocalBroadcastManager import org.tasks.LocalBroadcastManager
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.data.dao.TagDao import org.tasks.data.dao.TagDao
import org.tasks.data.dao.TagDataDao import org.tasks.data.dao.TagDataDao
import org.tasks.data.entity.TagData import org.tasks.data.entity.TagData
import org.tasks.databinding.ActivityTagSettingsBinding
import org.tasks.extensions.Context.hideKeyboard
import org.tasks.filters.TagFilter import org.tasks.filters.TagFilter
import org.tasks.themes.TasksIcons import org.tasks.themes.TasksIcons
import org.tasks.themes.TasksTheme
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -35,9 +29,6 @@ class TagSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var tagDao: TagDao @Inject lateinit var tagDao: TagDao
@Inject lateinit var localBroadcastManager: LocalBroadcastManager @Inject lateinit var localBroadcastManager: LocalBroadcastManager
private lateinit var name: TextInputEditText
private lateinit var nameLayout: TextInputLayout
private lateinit var tagData: TagData private lateinit var tagData: TagData
private val isNewTag: Boolean private val isNewTag: Boolean
get() = tagData.id == null get() = tagData.id == null
@ -46,16 +37,20 @@ class TagSettingsActivity : BaseListSettingsActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
tagData = intent.getParcelableExtra(EXTRA_TAG_DATA) ?: TagData() tagData = intent.getParcelableExtra(EXTRA_TAG_DATA) ?: TagData()
if (!isNewTag) textState.value = tagData.name!!
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (savedInstanceState == null) { if (savedInstanceState == null) {
selectedColor = tagData.color ?: 0 selectedColor = tagData.color ?: 0
selectedIcon.update { tagData.icon } selectedIcon.value = tagData.icon ?: defaultIcon
} }
name.setText(tagData.name)
if (isNewTag) { setContent {
name.requestFocus() TasksTheme {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager baseSettingsContent()
imm.showSoftInput(name, InputMethodManager.SHOW_IMPLICIT) }
} }
updateTheme() updateTheme()
} }
@ -67,7 +62,7 @@ class TagSettingsActivity : BaseListSettingsActivity() {
get() = if (isNew) getString(R.string.new_tag) else tagData.name!! get() = if (isNew) getString(R.string.new_tag) else tagData.name!!
private val newName: String private val newName: String
get() = name.text.toString().trim { it <= ' ' } get() = textState.value.trim { it <= ' ' }
private suspend fun clashes(newName: String): Boolean { private suspend fun clashes(newName: String): Boolean {
return ((isNewTag || !newName.equals(tagData.name, ignoreCase = true)) return ((isNewTag || !newName.equals(tagData.name, ignoreCase = true))
@ -77,11 +72,11 @@ class TagSettingsActivity : BaseListSettingsActivity() {
override suspend fun save() { override suspend fun save() {
val newName = newName val newName = newName
if (isNullOrEmpty(newName)) { if (isNullOrEmpty(newName)) {
nameLayout.error = getString(R.string.name_cannot_be_empty) errorState.value = getString(R.string.name_cannot_be_empty)
return return
} }
if (clashes(newName)) { if (clashes(newName)) {
nameLayout.error = getString(R.string.tag_already_exists) errorState.value = getString(R.string.tag_already_exists)
return return
} }
if (isNewTag) { if (isNewTag) {
@ -131,20 +126,10 @@ class TagSettingsActivity : BaseListSettingsActivity() {
} }
override fun finish() { override fun finish() {
hideKeyboard(name) //hideKeyboard(name)
super.finish() super.finish()
} }
override fun bind() = ActivityTagSettingsBinding.inflate(layoutInflater).let {
name = it.name.apply {
addTextChangedListener(
onTextChanged = { _, _, _, _ -> nameLayout.error = null }
)
}
nameLayout = it.nameLayout
it.root
}
override suspend fun delete() { override suspend fun delete() {
val uuid = tagData.remoteId val uuid = tagData.remoteId
tagDataDao.delete(tagData) tagDataDao.delete(tagData)
@ -159,3 +144,4 @@ class TagSettingsActivity : BaseListSettingsActivity() {
private const val EXTRA_TAG_UUID = "uuid" // $NON-NLS-1$ private const val EXTRA_TAG_UUID = "uuid" // $NON-NLS-1$
} }
} }

@ -1,32 +1,27 @@
package org.tasks.caldav package org.tasks.caldav
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import androidx.compose.foundation.layout.ColumnScope
import android.view.inputmethod.InputMethodManager import androidx.compose.material3.SnackbarHostState
import android.widget.LinearLayout import androidx.compose.runtime.Composable
import android.widget.ProgressBar import androidx.lifecycle.lifecycleScope
import androidx.core.widget.addTextChangedListener
import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.exception.HttpException
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.MainActivity
import com.todoroo.astrid.activity.TaskListFragment import com.todoroo.astrid.activity.TaskListFragment
import com.todoroo.astrid.service.TaskDeleter import com.todoroo.astrid.service.TaskDeleter
import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.activities.BaseListSettingsActivity import org.tasks.activities.BaseListSettingsActivity
import org.tasks.compose.DeleteButton
import org.tasks.compose.ListSettings.Toaster
import org.tasks.data.UUIDHelper import org.tasks.data.UUIDHelper
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.databinding.ActivityCaldavCalendarSettingsBinding
import org.tasks.extensions.Context.hideKeyboard
import org.tasks.filters.CaldavFilter import org.tasks.filters.CaldavFilter
import org.tasks.themes.TasksIcons import org.tasks.themes.TasksIcons
import org.tasks.ui.DisplayableException import org.tasks.ui.DisplayableException
@ -37,27 +32,12 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
@Inject lateinit var caldavDao: CaldavDao @Inject lateinit var caldavDao: CaldavDao
@Inject lateinit var taskDeleter: TaskDeleter @Inject lateinit var taskDeleter: TaskDeleter
private lateinit var root: LinearLayout
private lateinit var name: TextInputEditText
protected lateinit var nameLayout: TextInputLayout
protected lateinit var progressView: ProgressBar
protected var caldavCalendar: CaldavCalendar? = null protected var caldavCalendar: CaldavCalendar? = null
protected lateinit var caldavAccount: CaldavAccount protected lateinit var caldavAccount: CaldavAccount
override val defaultIcon = TasksIcons.LIST override val defaultIcon = TasksIcons.LIST
override fun bind() = ActivityCaldavCalendarSettingsBinding.inflate(layoutInflater).let { protected val snackbar = SnackbarHostState() // to be used by descendants
root = it.rootLayout
name = it.name.apply {
addTextChangedListener(
onTextChanged = { _, _, _, _ -> nameLayout.error = null }
)
}
nameLayout = it.nameLayout
progressView = it.progressBar.progressBar
it.root
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val intent = intent val intent = intent
@ -70,16 +50,11 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
} }
if (savedInstanceState == null) { if (savedInstanceState == null) {
if (caldavCalendar != null) { if (caldavCalendar != null) {
name.setText(caldavCalendar!!.name) textState.value = caldavCalendar!!.name ?: ""
selectedColor = caldavCalendar!!.color selectedColor = caldavCalendar!!.color
selectedIcon.update { caldavCalendar?.icon } selectedIcon.value = caldavCalendar?.icon ?: defaultIcon
} }
} }
if (caldavCalendar == null) {
name.requestFocus()
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(name, InputMethodManager.SHOW_IMPLICIT)
}
updateTheme() updateTheme()
} }
@ -95,7 +70,7 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
} }
val name = newName val name = newName
if (isNullOrEmpty(name)) { if (isNullOrEmpty(name)) {
nameLayout.error = getString(R.string.name_cannot_be_empty) errorState.value = getString(R.string.name_cannot_be_empty)
return return
} }
when { when {
@ -121,17 +96,11 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar
) )
private fun showProgressIndicator() { private fun showProgressIndicator() { showProgress.value = true }
progressView.visibility = View.VISIBLE
}
private fun hideProgressIndicator() { private fun hideProgressIndicator() { showProgress.value = false }
progressView.visibility = View.GONE
}
protected fun requestInProgress(): Boolean { protected fun requestInProgress(): Boolean = showProgress.value
return progressView.visibility == View.VISIBLE
}
protected fun requestFailed(t: Throwable) { protected fun requestFailed(t: Throwable) {
hideProgressIndicator() hideProgressIndicator()
@ -146,17 +115,11 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
} }
private fun showSnackbar(resId: Int, vararg formatArgs: Any) { private fun showSnackbar(resId: Int, vararg formatArgs: Any) {
showSnackbar(getString(resId, *formatArgs)) lifecycleScope.launch { snackbar.showSnackbar( getString(resId, *formatArgs) ) }
} }
private fun showSnackbar(message: String?) { private fun showSnackbar(message: String?) {
val snackbar = Snackbar.make(root, message!!, 8000) lifecycleScope.launch { snackbar.showSnackbar( message!! ) }
.setTextColor(getColor(R.color.snackbar_text_color))
.setActionTextColor(getColor(R.color.snackbar_action_color))
snackbar
.view
.setBackgroundColor(getColor(R.color.snackbar_background))
snackbar.show()
} }
protected suspend fun createSuccessful(url: String?) { protected suspend fun createSuccessful(url: String?) {
@ -202,10 +165,10 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
private fun iconChanged(): Boolean = selectedIcon.value != caldavCalendar!!.icon private fun iconChanged(): Boolean = selectedIcon.value != caldavCalendar!!.icon
private val newName: String private val newName: String
get() = name.text.toString().trim { it <= ' ' } get() = textState.value.trim { it <= ' '}
override fun finish() { override fun finish() {
hideKeyboard(name) // hideKeyboard(name)
super.finish() super.finish()
} }
@ -234,6 +197,18 @@ abstract class BaseCaldavCalendarSettingsActivity : BaseListSettingsActivity() {
} }
} }
@Composable
fun baseCaldavSettingsContent (
optionButton: @Composable () -> Unit = { if (!isNew) DeleteButton { promptDelete() } },
extensionContent: @Composable ColumnScope.() -> Unit = {}
) {
baseSettingsContent (
optionButton = optionButton,
extensionContent = extensionContent
)
Toaster(state = snackbar)
}
companion object { companion object {
const val EXTRA_CALDAV_CALENDAR = "extra_caldav_calendar" const val EXTRA_CALDAV_CALENDAR = "extra_caldav_calendar"
const val EXTRA_CALDAV_ACCOUNT = "extra_caldav_account" const val EXTRA_CALDAV_ACCOUNT = "extra_caldav_account"

@ -1,20 +1,29 @@
package org.tasks.caldav package org.tasks.caldav
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.core.view.isVisible import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.R import org.tasks.R
import org.tasks.compose.Constants
import org.tasks.compose.ListSettingsComposables.PrincipalList import org.tasks.compose.ListSettingsComposables.PrincipalList
import org.tasks.compose.ShareInvite.ShareInviteDialog import org.tasks.compose.ShareInvite.ShareInviteDialog
import org.tasks.data.PrincipalWithAccess import org.tasks.data.PrincipalWithAccess
@ -36,10 +45,14 @@ class CaldavCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
private val viewModel: CaldavCalendarViewModel by viewModels() private val viewModel: CaldavCalendarViewModel by viewModels()
private var principalsList: MutableState<List<PrincipalWithAccess>> = mutableStateOf( emptyList<PrincipalWithAccess>().toMutableList())
private val removeDialog = mutableStateOf<PrincipalWithAccess?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel.inFlight.observe(this) { progressView.isVisible = it } viewModel.inFlight.observe(this) { showProgress.value = it }
viewModel.error.observe(this) { throwable -> viewModel.error.observe(this) { throwable ->
throwable?.let { throwable?.let {
requestFailed(it) requestFailed(it)
@ -51,22 +64,63 @@ class CaldavCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
finish() finish()
} }
caldavCalendar?.takeIf { it.id > 0 }?.let { setContent {
findViewById<ComposeView>(R.id.people).setContent { TasksTheme {
TasksTheme(theme = tasksTheme.themeBase.index) { Box(contentAlignment = Alignment.TopStart) {// Box to layout FAB over main content
val principals = principalDao.getPrincipals(it.id).collectAsStateWithLifecycle(initialValue = emptyList()).value baseCaldavSettingsContent {
PrincipalList( caldavCalendar?.takeIf { it.id > 0 }?.let { calendar->
principals = principals, val principals = principalDao.getPrincipals(calendar.id).collectAsStateWithLifecycle(initialValue = emptyList()).value
onRemove = if (canRemovePrincipals) { { onRemove(it) } } else null, PrincipalList(
) principals = principals,
onRemove = if (canRemovePrincipals) { { onRemove(it) } } else null,
)
}
if (principalsList.value.isNotEmpty())
PrincipalList(
principalsList.value,
onRemove = if (canRemovePrincipals) ::onRemove else null
)
}
removeDialog.value?.let { principal ->
AlertDialog(
onDismissRequest = { removeDialog.value = null },
confirmButton = {
Constants.TextButton(text = R.string.ok) {
removePrincipal(principal)
removeDialog.value = null
}
},
dismissButton = {
Constants.TextButton(text = R.string.cancel) {
removeDialog.value = null
}
},
title = {
Text(
stringResource(id = R.string.remove_user),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Text(
text = stringResource(
R.string.remove_user_confirmation,
principal.name,
caldavCalendar?.name ?: ""
),
style = MaterialTheme.typography.bodyMedium
)
}
)
}
} }
} Box(
} modifier = Modifier.fillMaxSize(),
if (caldavAccount.canShare && (isNew || caldavCalendar?.access == ACCESS_OWNER)) { contentAlignment = Alignment.BottomEnd
findViewById<ComposeView>(R.id.fab) ) {
.apply { isVisible = true } if (caldavAccount.canShare && (isNew || caldavCalendar?.access == ACCESS_OWNER)) {
.setContent {
TasksTheme(theme = tasksTheme.themeBase.index) {
val openDialog = rememberSaveable { mutableStateOf(false) } val openDialog = rememberSaveable { mutableStateOf(false) }
ShareInviteDialog( ShareInviteDialog(
openDialog, openDialog,
@ -79,16 +133,18 @@ class CaldavCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
} }
FloatingActionButton( FloatingActionButton(
onClick = { openDialog.value = true }, onClick = { openDialog.value = true },
containerColor = MaterialTheme.colorScheme.primary modifier = Modifier.padding(Constants.KEYLINE_FIRST),
containerColor = MaterialTheme.colorScheme.secondary
) { ) {
Icon( Icon(
painter = painterResource(R.drawable.ic_outline_person_add_24), painter = painterResource(R.drawable.ic_outline_person_add_24),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onSecondary,
) )
} }
} }
} }
}
} }
} }
@ -96,15 +152,8 @@ class CaldavCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
get() = caldavCalendar?.access == ACCESS_OWNER && caldavAccount.canRemovePrincipal get() = caldavCalendar?.access == ACCESS_OWNER && caldavAccount.canRemovePrincipal
private fun onRemove(principal: PrincipalWithAccess) { private fun onRemove(principal: PrincipalWithAccess) {
if (requestInProgress()) { if (requestInProgress()) return
return removeDialog.value = principal
}
dialogBuilder
.newDialog(R.string.remove_user)
.setMessage(R.string.remove_user_confirmation, principal.name, caldavCalendar?.name)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ -> removePrincipal(principal) }
.show()
} }
private fun removePrincipal(principal: PrincipalWithAccess) = lifecycleScope.launch { private fun removePrincipal(principal: PrincipalWithAccess) = lifecycleScope.launch {

@ -1,21 +1,32 @@
package org.tasks.caldav package org.tasks.caldav
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.tasks.R import org.tasks.R
import org.tasks.compose.DeleteButton
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.themes.TasksTheme
@AndroidEntryPoint @AndroidEntryPoint
class LocalListSettingsActivity : BaseCaldavCalendarSettingsActivity() { class LocalListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
toolbar.menu.findItem(R.id.delete)?.isVisible = val canDelete = runBlocking { caldavDao.getCalendarsByAccount(CaldavDao.LOCAL).size > 1 }
runBlocking { caldavDao.getCalendarsByAccount(CaldavDao.LOCAL).size > 1 }
setContent {
TasksTheme {
baseCaldavSettingsContent (
optionButton = { if (!isNew && canDelete) DeleteButton { promptDelete() } }
)
}
}
} }
override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) = override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) =

@ -0,0 +1,199 @@
package org.tasks.compose
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.min
/**
* Drag - drop to reorder elements of LazyColumn
*
* Implementation is based on:
* https://github.com/realityexpander/DragDropColumnCompose
*
* Scheme of use:
* 1. Hoist state of the LazyColumn (create your own and set it as LazyColumn parameter)
* 2. Create and remember DragDropState object by call to "rememberDragDropState"
* 3. Use Modifier.doDrag in the LazyColumn
* 4. enclose LazyList items into DraggableItem
* **/
class DragDropState internal constructor(
val state: LazyListState,
private val scope: CoroutineScope,
private val confirmDrag: (Int) -> Boolean,
private val complete: () -> Unit,
private val swap: (Int, Int) -> Unit,
) {
/* primary ID of the item being dragged */
var draggedItemIndex by mutableStateOf<Int?>(null)
private var draggedDistance by mutableFloatStateOf(0f)
private var draggingElementOffset: Int = 0 // cached drugged element offset and size
private var draggingElementSize: Int = -1 // size must not be negative when dragging is in progress
private var overscrollJob by mutableStateOf<Job?>( null )
/* sibling of draggingElementOffset, not cached, for use in animation */
internal val draggingItemOffset: Float
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggedItemIndex }
?.let { item ->
draggingElementOffset + draggedDistance - item.offset
} ?: 0f
fun itemAtOffset(offsetY: Float): LazyListItemInfo? =
state.layoutInfo.visibleItemsInfo.firstOrNull {
item -> offsetY.toInt() in item.offset..(item.offset + item.size)
}
fun startDragging(item: LazyListItemInfo?) {
//Log.d("HADY", "start dragging ${item}")
if (item != null && confirmDrag(item.index)) {
draggedItemIndex = item.index
draggingElementOffset = item.offset
draggingElementSize = item.size
assert(item.size >= 0) { "Invalid size of element ${item.size}" }
}
}
fun stopDragging() {
draggedDistance = 0f
draggedItemIndex = null
draggingElementOffset = 0
draggingElementSize = -1
overscrollJob?.cancel()
complete()
}
fun onDrag(offset: Offset) {
draggedDistance += offset.y
if (draggedItemIndex != null) {
assert(draggingElementSize >= 0) { "FATAL: Invalid dragging element" }
val startOffset = draggingElementOffset + draggedDistance
val endOffset = startOffset + draggingElementSize
val draggedIndex = draggedItemIndex
val dragged = draggedIndex?.let { index ->
state.layoutInfo.visibleItemsInfo.getOrNull(
index - state.layoutInfo.visibleItemsInfo.first().index
)
}
if (dragged != null) {
val up = (startOffset - dragged.offset) > 0
val hovered =
if (up) itemAtOffset(startOffset + 0.1f * draggingElementSize)
else itemAtOffset(endOffset - 0.1f * draggingElementSize)
if (hovered != null) {
scope.launch { swap(draggedIndex, hovered.index) }
draggedItemIndex = hovered.index
}
if (overscrollJob?.isActive != true) {
val overscroll = when {
draggedDistance > 0 -> max(endOffset - state.layoutInfo.viewportEndOffset+50f, 0f)
draggedDistance < 0 -> min(startOffset - state.layoutInfo.viewportStartOffset-50f, 0f)
else -> 0f
}
if (overscroll != 0f) {
overscrollJob = scope.launch {
state.animateScrollBy(
overscroll * 1.3f, tween(easing = FastOutLinearInEasing)
)
}
}
}
}
}
} /* end onDrag */
} /* end DragDropState */
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
confirmDrag: (Int) -> Boolean = { true },
completeDragDrop: () -> Unit,
doSwap: (Int, Int) -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(
state = lazyListState,
swap = doSwap,
complete = completeDragDrop,
scope = scope,
confirmDrag = confirmDrag
)
}
return state
}
fun Modifier.doDrag(dragDropState: DragDropState): Modifier =
this.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
dragDropState.startDragging(dragDropState.itemAtOffset(offset.y))
},
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset)
},
onDragEnd = { dragDropState.stopDragging() },
onDragCancel = { dragDropState.stopDragging() }
)
}
@ExperimentalFoundationApi
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable (isDragging: Boolean) -> Unit
) {
val current: Float by animateFloatAsState(
targetValue = dragDropState.draggingItemOffset * 0.67f
)
val dragging = index == dragDropState.draggedItemIndex
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer { translationY = current }
} else {
Modifier.animateItemPlacement(
tween(easing = FastOutLinearInEasing)
)
}
Box(modifier = modifier.then(draggingModifier)) {
content(dragging)
}
}

@ -0,0 +1,496 @@
package org.tasks.compose
/**
* Composables for FilterSettingActivity
**/
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Abc
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.os.ConfigurationCompat
import com.todoroo.astrid.core.CriterionInstance
import org.tasks.R
import org.tasks.compose.ListSettings.SettingRow
import org.tasks.compose.SwipeOut.SwipeOut
import org.tasks.extensions.formatNumber
import org.tasks.themes.TasksTheme
import java.util.Locale
@Composable
@Preview (showBackground = true)
private fun CriterionTypeSelectPreview () {
TasksTheme {
FilterCondition.SelectCriterionType(
title = "Select criterion type",
selected = 1,
types = listOf("AND", "OR", "NOT"),
onCancel = { /*TODO*/ }) {
}
}
}
@Composable
@Preview (showBackground = true)
private fun InputTextPreview () {
TasksTheme {
FilterCondition.InputTextOption(title = "Task name contains...", onCancel = { /*TODO*/ }
) {
}
}
}
@Composable
@Preview (showBackground = true)
private fun SwipeOutDecorationPreview () {
TasksTheme {
Box(modifier = Modifier
.height(56.dp)
.fillMaxWidth()) {
FilterCondition.SwipeOutDecoration()
}
}
}
@Composable
@Preview (showBackground = true)
private fun FabPreview () {
TasksTheme {
FilterCondition.NewCriterionFAB(
isExtended = remember { mutableStateOf(true) }
) {
}
}
}
object FilterCondition {
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FilterCondition(
items: SnapshotStateList<CriterionInstance>,
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
CriterionInstance.TYPE_SUBTRACT -> R.drawable.ic_outline_not_interested_24px
CriterionInstance.TYPE_INTERSECT -> R.drawable.ic_outline_add_24px
else -> {
0
} /* assert */
}
}
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(
lazyListState = listState,
confirmDrag = { index -> index != 0 },
completeDragDrop = onComplete,
) { fromIndex, toIndex ->
if (fromIndex != 0 && toIndex != 0) doSwap(fromIndex, toIndex)
}
Row {
Text(
text = stringResource(id = R.string.custom_filter_criteria),
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(Constants.KEYLINE_FIRST)
)
}
Row {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.doDrag(dragDropState),
userScrollEnabled = true,
state = listState
) {
itemsIndexed(
items = items,
key = { _, item -> item.id + " " + item.type + " " + item.end}
) { index, criterion ->
if (index == 0) {
FilterConditionRow(criterion, false, getIcon, onClick)
} else {
DraggableItem(
dragDropState = dragDropState, index = index
) { dragging ->
SwipeOut(
decoration = { SwipeOutDecoration() },
onSwipe = { index -> onDelete(index) },
index = index
) {
FilterConditionRow(criterion, dragging, getIcon, onClick)
}
}
}
}
}
}
} /* FilterCondition */
@Composable
private fun FilterConditionRow(
criterion: CriterionInstance,
dragging: Boolean,
getIcon: (CriterionInstance) -> Int,
onClick: (String) -> Unit
) {
HorizontalDivider(
color = when (criterion.type) {
CriterionInstance.TYPE_ADD -> Color.Gray
else -> Color.Transparent
}
)
val modifier =
if (dragging) Modifier.background(Color.LightGray)
else Modifier
SettingRow(
modifier = modifier.clickable { onClick(criterion.id) },
left = {
Box(
modifier = Modifier.size(56.dp),
contentAlignment = Alignment.Center
)
{
if (criterion.type != CriterionInstance.TYPE_UNIVERSE) {
Icon(
modifier = Modifier.padding(Constants.KEYLINE_FIRST),
painter = painterResource(id = getIcon(criterion)),
contentDescription = null
)
}
}
},
center = {
Text(
text = criterion.titleFromCriterion,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp)
)
},
right = {
val context = LocalContext.current
val locale = remember {
ConfigurationCompat
.getLocales(context.resources.configuration)
.get(0)
?: Locale.getDefault()
}
Text(
text = locale.formatNumber(criterion.end),
modifier = Modifier.padding(end = Constants.KEYLINE_FIRST),
color = Color.Gray,
fontSize = 14.sp,
textAlign = TextAlign.End
)
}
)
}
@Composable
fun SwipeOutDecoration() {
Box(
modifier = Modifier
.fillMaxSize()
.background(colorResource(id = org.tasks.kmp.R.color.red_a400))
//.background(MaterialTheme.colorScheme.secondary)
) {
@Composable
fun deleteIcon() {
Icon(
modifier = Modifier.padding(horizontal = Constants.KEYLINE_FIRST),
imageVector = Icons.Outlined.Delete,
contentDescription = "Delete",
tint = Color.White.copy(alpha = 0.6f)
)
}
Row(
modifier = Modifier
.fillMaxSize()
.height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
deleteIcon()
deleteIcon()
}
}
} /* end SwipeOutDecoration */
@Composable
fun NewCriterionFAB(
isExtended: MutableState<Boolean>,
onClick: () -> Unit
) {
Box( // lays out over main content as a space to layout FAB
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
FloatingActionButton(
onClick = onClick,
modifier = Modifier.padding(16.dp),
shape = RoundedCornerShape(50),
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
)
)
if (extended)
Text(
text = LocalContext.current.getString(R.string.CFA_button_add),
modifier = Modifier.padding(end = 16.dp)
)
}
} /* end FloatingActionButton */
}
} /* end NewCriterionFAB */
@Composable
fun SelectCriterionType(
title: String,
selected: Int,
types: List<String>,
onCancel: () -> Unit,
help: () -> Unit = {},
onSelected: (Int) -> Unit
) {
val selected = remember { mutableIntStateOf(selected) }
Dialog(onDismissRequest = onCancel)
{
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background)
) {
Column(modifier = Modifier
.padding(horizontal = Constants.KEYLINE_FIRST)
.padding(top = Constants.HALF_KEYLINE)
) {
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(top = 16.dp)
)
Spacer(modifier = Modifier.height(Constants.HALF_KEYLINE))
ToggleGroup(items = types, selected = selected)
Row(
modifier = Modifier.height(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(contentAlignment = Alignment.CenterStart) {
Constants.TextButton(text = R.string.help, onClick = help)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxWidth()
) {
Row {
Constants.TextButton(text = R.string.cancel, onClick = onCancel)
Constants.TextButton(text = R.string.ok) { onSelected(selected.intValue) }
}
}
}
}
}
}
} /* end SelectCriterionType */
@Composable
fun ToggleGroup(
items: List<String>,
selected: MutableIntState = remember { mutableIntStateOf(0) }
) {
assert(selected.intValue in items.indices)
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
contentAlignment = Alignment.Center
) {
Row {
for (index in items.indices) {
val highlight = (index == selected.intValue)
val color =
if (highlight) MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f)
OutlinedButton(
onClick = { selected.intValue = index },
border = BorderStroke(1.dp, SolidColor(color.copy(alpha = 0.5f))),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = color.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.onBackground),
shape = RoundedCornerShape(Constants.HALF_KEYLINE)
) {
Text(items[index])
}
if (index<items.size-1) Spacer(modifier = Modifier.size(2.dp))
}
}
}
} /* end ToggleGroup */
@Composable
fun SelectFromList(
names: List<String>,
title: String? = null,
onCancel: () -> Unit,
onSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onCancel) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background)
) {
Column(
modifier = Modifier
.padding(horizontal = Constants.KEYLINE_FIRST)
.padding(bottom = Constants.KEYLINE_FIRST)
) {
title?.let { title ->
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Medium,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(top = Constants.KEYLINE_FIRST)
)
}
names.forEachIndexed { index, name ->
Text(
text = name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(top = Constants.KEYLINE_FIRST)
.clickable { onSelected(index) }
)
}
}
}
}
} /* end SelectFromList */
@Composable
fun InputTextOption(
title: String,
onCancel: () -> Unit,
onDone: (String) -> Unit
) {
val text = remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onCancel,
confirmButton = {
Constants.TextButton(
text = R.string.ok,
onClick = { onDone(text.value) })
},
dismissButton = { Constants.TextButton(text = R.string.cancel, onClick = onCancel) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
title,
style = MaterialTheme.typography.headlineSmall,
)
Spacer(Modifier.height(Constants.KEYLINE_FIRST))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = text.value,
label = { Text(title) },
onValueChange = { text.value = it },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Abc,
contentDescription = null
)
},
textStyle = MaterialTheme.typography.bodyMedium,
colors = Constants.textFieldColors(),
)
}
}
)
} /* end InputTextOption */
}

@ -0,0 +1,374 @@
package org.tasks.compose
/**
* Composables for BaseListSettingActivity
*/
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
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.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import org.tasks.R
import org.tasks.compose.components.TasksIcon
import org.tasks.themes.TasksIcons
import org.tasks.themes.TasksTheme
@Composable
@Preview (showBackground = true)
private fun TitleBarPreview() {
TasksTheme {
ListSettings.Toolbar(
title = "Tollbar title",
save = { /*TODO*/ }, optionButton = { DeleteButton {} }
)
}
}
@Composable
@Preview(showBackground = true)
private fun PromptActionPreview() {
TasksTheme {
ListSettings.PromptAction(
showDialog = remember { mutableStateOf(true) },
title = "Delete list?",
onAction = { /*TODO*/ })
}
}
@Composable
@Preview (showBackground = true)
private fun IconSelectPreview () {
TasksTheme {
ListSettings.SelectIconRow(
icon = TasksIcons.FILTER_LIST,
selectIcon = {}
)
}
}
@Composable
@Preview (showBackground = true)
private fun ColorSelectPreview () {
TasksTheme {
ListSettings.SelectColorRow(
color = remember { mutableStateOf(Color.Red) },
selectColor = {},
clearColor = {}
)
}
}
object ListSettings {
@Composable
fun Toolbar(
title: String,
save: () -> Unit,
optionButton: @Composable () -> Unit,
) {
/* Hady: reminder for the future
val activity = LocalView.current.context as Activity
activity.window.statusBarColor = colorResource(id = R.color.drawer_color_selected).toArgb()
*/
Surface(
shadowElevation = 4.dp,
color = colorResource(id = R.color.content_background),
contentColor = colorResource(id = R.color.text_primary),
modifier = Modifier.requiredHeight(56.dp)
)
{
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = save, modifier = Modifier.size(56.dp)) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_outline_save_24px),
contentDescription = stringResource(id = R.string.save),
)
}
Text(
text = title,
fontWeight = FontWeight.Medium,
fontSize = 20.sp,
modifier = Modifier
.weight(0.9f)
.padding(start = Constants.KEYLINE_FIRST)
)
optionButton()
}
}
} /* ToolBar */
@Composable
fun ProgressBar(showProgress: State<Boolean>) {
Box(modifier = Modifier
.fillMaxWidth()
.requiredHeight(3.dp))
{
if (showProgress.value) {
LinearProgressIndicator(
modifier = Modifier.fillMaxSize(),
trackColor = LocalContentColor.current.copy(alpha = 0.3f), //Color.LightGray,
color = colorResource(org.tasks.kmp.R.color.red_a400)
)
}
}
}
@Composable
fun TitleInput(
text: MutableState<String>,
error: MutableState<String>,
requestKeyboard: Boolean,
modifier: Modifier = Modifier,
label: String = stringResource(R.string.display_name),
errorState: Color = MaterialTheme.colorScheme.secondary,
activeState: Color = LocalContentColor.current.copy(alpha = 0.75f),
inactiveState: Color = LocalContentColor.current.copy(alpha = 0.3f),
) {
val keyboardController = LocalSoftwareKeyboardController.current
val requester = remember { FocusRequester() }
val focused = remember { mutableStateOf(false) }
val labelColor = when {
(error.value != "") -> errorState
(focused.value) -> activeState
else -> inactiveState
}
val dividerColor = if (focused.value) errorState else labelColor
val labelText = if (error.value != "") error.value else label
Row (modifier = modifier)
{
Column {
Text(
modifier = Modifier.padding(top = 18.dp, bottom = 4.dp),
text = labelText,
fontSize = 12.sp,
letterSpacing = 0.sp,
fontWeight = FontWeight.Medium,
color = labelColor
)
BasicTextField(
value = text.value,
textStyle = TextStyle(
fontSize = LocalTextStyle.current.fontSize,
color = LocalContentColor.current
),
onValueChange = {
text.value = it
if (error.value != "") error.value = ""
},
cursorBrush = SolidColor(errorState), // SolidColor(LocalContentColor.current),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 3.dp)
.focusRequester(requester)
.onFocusChanged { focused.value = (it.isFocused) }
)
HorizontalDivider(
color = dividerColor,
modifier = Modifier.padding(bottom = 8.dp)
)
}
}
if (requestKeyboard) {
LaunchedEffect(null) {
requester.requestFocus()
delay(30) // Workaround. Otherwise keyboard don't show in 4/5 tries
keyboardController?.show()
}
}
} /* TextInput */
@Composable
fun SelectColorRow(color: State<Color>, selectColor: () -> Unit, clearColor: () -> Unit) =
SettingRow(
modifier = Modifier.clickable(onClick = selectColor),
left = {
IconButton(onClick = { selectColor() }) {
if (color.value == Color.Unspecified) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_outline_not_interested_24px),
tint = colorResource(R.color.icon_tint_with_alpha),
contentDescription = null
)
} else {
val borderColor = colorResource(R.color.icon_tint_with_alpha) // colorResource(R.color.text_tertiary)
Box(
modifier = Modifier.size(56.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.size(24.dp)) {
drawCircle(color = color.value)
drawCircle(color = borderColor, style = Stroke(width = 4.0f)
)
}
}
}
}
},
center = {
Text(
text = LocalContext.current.getString(R.string.color),
modifier = Modifier.padding(start = Constants.KEYLINE_FIRST)
)
},
right = {
if (color.value != Color.Unspecified) {
IconButton(onClick = clearColor) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_outline_clear_24px),
contentDescription = null
)
}
}
}
)
@Composable
fun SelectIconRow(icon: String, selectIcon: () -> Unit) =
SettingRow(
modifier = Modifier.clickable(onClick = selectIcon),
left = {
IconButton(onClick = selectIcon) {
TasksIcon(
label = icon,
tint = colorResource(R.color.icon_tint_with_alpha)
)
}
},
center = {
Text(
text = LocalContext.current.getString(R.string.icon),
modifier = Modifier.padding(start = Constants.KEYLINE_FIRST)
)
}
)
@Composable
fun SettingRow(
left: @Composable () -> Unit,
center: @Composable () -> Unit,
right: @Composable (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Box (modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
left()
}
Box (
modifier = Modifier
.height(56.dp)
.weight(1f), contentAlignment = Alignment.CenterStart
) {
center()
}
right?.let {
Box (modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
it.invoke()
}
}
}
}
@Composable
fun SettingsSurface(content: @Composable ColumnScope.() -> Unit) {
ProvideTextStyle(LocalTextStyle.current.copy(fontSize = 18.sp)) {
Surface(
color = colorResource(id = R.color.window_background),
contentColor = colorResource(id = R.color.text_primary)
) {
Column(modifier = Modifier.fillMaxSize()) { content() }
}
}
}
@Composable
fun Toaster(state: SnackbarHostState) {
SnackbarHost(state) { data ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Snackbar(
modifier = Modifier.padding(horizontal = 24.dp),
shape = RoundedCornerShape(10.dp),
containerColor = colorResource(id = R.color.snackbar_background),
contentColor = colorResource(id = R.color.snackbar_text_color),
) {
Text(text = data.visuals.message, fontSize = 18.sp)
}
}
}
}
@Composable
fun PromptAction(
showDialog: MutableState<Boolean>,
title: String,
onAction: () -> Unit,
onCancel: () -> Unit = { showDialog.value = false }
) {
if (showDialog.value) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(title, style = MaterialTheme.typography.headlineSmall) },
confirmButton = { Constants.TextButton(text = R.string.ok, onClick = onAction) },
dismissButton = { Constants.TextButton(text = R.string.cancel, onClick = onCancel) }
)
}
}
}

@ -0,0 +1,94 @@
package org.tasks.compose
/**
* Simple Swipe-to-delete implementation
*/
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import org.tasks.R
import kotlin.math.roundToInt
object SwipeOut {
private enum class Anchors { Left, Center, Right }
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SwipeOut(
modifier: Modifier = Modifier,
index: Int,
onSwipe: (Int) -> Unit,
decoration: @Composable BoxScope.() -> Unit = {},
content: @Composable BoxScope.() -> Unit
) {
val screenWidthPx =
with(LocalDensity.current) {
LocalConfiguration.current.screenWidthDp.dp.roundToPx().toFloat()
}
val dragState: AnchoredDraggableState<Anchors> = remember {
AnchoredDraggableState(
initialValue = Anchors.Center,
anchors = DraggableAnchors {
Anchors.Left at -screenWidthPx * 3/4
Anchors.Center at 0f
Anchors.Right at screenWidthPx * 3/4
},
positionalThreshold = { _ -> screenWidthPx/3 },
velocityThreshold = { 100f },
snapAnimationSpec = tween(),
decayAnimationSpec = exponentialDecay()
)
}
if (dragState.currentValue == Anchors.Left || dragState.currentValue == Anchors.Right) {
onSwipe(index)
}
Box( /* container for swipeable and it's background decoration */
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.CenterStart
) {
decoration()
Box(
modifier = modifier
.fillMaxWidth()
.offset {
IntOffset(
x = dragState
.requireOffset()
.roundToInt(),
y = 0
)
}
.background(colorResource(id = R.color.content_background)) // MUST BE AFTER .offset modifier (?!?!)
.anchoredDraggable(state = dragState, orientation = Orientation.Horizontal)
) {
content()
}
}
}
}

@ -1,11 +1,13 @@
package org.tasks.etebase package org.tasks.etebase
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.themes.TasksTheme
@AndroidEntryPoint @AndroidEntryPoint
class EtebaseCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() { class EtebaseCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
@ -19,6 +21,12 @@ class EtebaseCalendarSettingsActivity : BaseCaldavCalendarSettingsActivity() {
createCalendarViewModel.observe(this, this::createSuccessful, this::requestFailed) createCalendarViewModel.observe(this, this::createSuccessful, this::requestFailed)
deleteCalendarViewModel.observe(this, this::onDeleted, this::requestFailed) deleteCalendarViewModel.observe(this, this::onDeleted, this::requestFailed)
updateCalendarViewModel.observe(this, { updateCalendar() }, this::requestFailed) updateCalendarViewModel.observe(this, { updateCalendar() }, this::requestFailed)
setContent {
TasksTheme {
baseCaldavSettingsContent()
}
}
} }
override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) = override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) =

@ -1,10 +1,11 @@
package org.tasks.location package org.tasks.location
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import org.tasks.data.entity.Place import org.tasks.data.entity.Place
interface MapFragment { interface MapFragment {
fun init(activity: AppCompatActivity, callback: MapFragmentCallback, dark: Boolean) fun init(activity: AppCompatActivity, callback: MapFragmentCallback, dark: Boolean, parent: ViewGroup? = null)
val mapPosition: MapPosition? val mapPosition: MapPosition?

@ -2,6 +2,7 @@ package org.tasks.location
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -31,7 +32,7 @@ class OsmMapFragment @Inject constructor(
private var locationOverlay: MyLocationNewOverlay? = null private var locationOverlay: MyLocationNewOverlay? = null
private var circle: Polygon? = null private var circle: Polygon? = null
override fun init(activity: AppCompatActivity, callback: MapFragmentCallback, dark: Boolean) { override fun init(activity: AppCompatActivity, callback: MapFragmentCallback, dark: Boolean, parent: ViewGroup?) {
this.callback = callback this.callback = callback
Configuration.getInstance() Configuration.getInstance()
.load(activity, PreferenceManager.getDefaultSharedPreferences(activity)) .load(activity, PreferenceManager.getDefaultSharedPreferences(activity))
@ -46,7 +47,8 @@ class OsmMapFragment @Inject constructor(
val copyright = CopyrightOverlay(activity) val copyright = CopyrightOverlay(activity)
copyright.setTextColor(ContextCompat.getColor(activity, R.color.text_primary)) copyright.setTextColor(ContextCompat.getColor(activity, R.color.text_primary))
overlays.add(copyright) overlays.add(copyright)
activity.findViewById<ViewGroup>(R.id.map).addView(this) if (parent != null) parent.addView(this)
else activity.findViewById<ViewGroup>(R.id.map).addView(this)
} }
callback.onMapReady(this) callback.onMapReady(this)
} }

@ -1,12 +1,19 @@
package org.tasks.opentasks package org.tasks.opentasks
import android.os.Bundle import android.os.Bundle
import android.view.View import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.tasks.R import kotlinx.coroutines.launch
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.compose.ListSettings.ProgressBar
import org.tasks.compose.ListSettings.Toaster
import org.tasks.compose.ListSettings.SettingsSurface
import org.tasks.compose.ListSettings.Toolbar
import org.tasks.compose.ListSettings.SelectIconRow
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.themes.TasksTheme
@AndroidEntryPoint @AndroidEntryPoint
class OpenTasksListSettingsActivity : BaseCaldavCalendarSettingsActivity() { class OpenTasksListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
@ -14,15 +21,26 @@ class OpenTasksListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
toolbar.menu.findItem(R.id.delete).isVisible = false setContent {
nameLayout.visibility = View.GONE TasksTheme {
colorRow.visibility = View.GONE SettingsSurface {
Toolbar(
title = toolbarTitle,
save = { lifecycleScope.launch { save() } },
optionButton = { },
)
ProgressBar(showProgress)
SelectIconRow(icon = selectedIcon.value?: defaultIcon) { showIconPicker() }
}
Toaster(state = snackbar)
}
} /* setContent */
} }
override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) {} override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) {}
override suspend fun updateNameAndColor( override suspend fun updateNameAndColor(
account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) = account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) =
updateCalendar() updateCalendar()
override suspend fun deleteCalendar(caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar) {} override suspend fun deleteCalendar(caldavAccount: CaldavAccount, caldavCalendar: CaldavCalendar) {}

@ -3,6 +3,7 @@ package org.tasks.sync.microsoft
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.todoroo.astrid.activity.MainActivity import com.todoroo.astrid.activity.MainActivity
@ -12,6 +13,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.data.entity.CaldavAccount import org.tasks.data.entity.CaldavAccount
import org.tasks.data.entity.CaldavCalendar import org.tasks.data.entity.CaldavCalendar
import org.tasks.themes.TasksTheme
@AndroidEntryPoint @AndroidEntryPoint
class MicrosoftListSettingsActivity : BaseCaldavCalendarSettingsActivity() { class MicrosoftListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
@ -44,6 +46,12 @@ class MicrosoftListSettingsActivity : BaseCaldavCalendarSettingsActivity() {
} }
} }
} }
setContent {
TasksTheme {
baseCaldavSettingsContent()
}
}
} }
override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) = override suspend fun createCalendar(caldavAccount: CaldavAccount, name: String, color: Int) =

@ -57,6 +57,11 @@
android:clipToPadding="false" android:clipToPadding="false"
android:nestedScrollingEnabled="false"/> android:nestedScrollingEnabled="false"/>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/filter_criteria_list"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>

@ -60,6 +60,7 @@ fun TasksTheme(
colorScheme = colorScheme.copy( colorScheme = colorScheme.copy(
primary = Color(primary), primary = Color(primary),
onPrimary = colorOnPrimary, onPrimary = colorOnPrimary,
secondary = Color.Red, // Hady: Sorry for this hack, I believe the regular solution is planned
), ),
) { ) {
content() content()

Loading…
Cancel
Save