mirror of https://github.com/tasks/tasks
Convert list settings to compose (#3163)
parent
87c5ec9f14
commit
8e5b93fc10
@ -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"
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue