diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3b99839c..83e23a6290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [4.5.0](https://github.com/sds100/KeyMapper/releases/tag/v4.5.0) + +#### TO BE RELEASED + +## Added + +- #701 new action options bottom sheet design. +- #701 you can now give custom names to actions, to make it easier to identify key maps. +- #701 redesigned the action list with expandable action cards that show each option and error. +- #1465 actions can be turned off individually without deleting them. +- #701 the delay before the next action is now set with a button between actions instead of in the action options. + ## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) #### TO BE RELEASED @@ -12,6 +24,7 @@ ## Added +- Redesigned the action options with grouped repeat, burst, hold down and delay sections, and actions can now be given a custom name. - #2238 Add a "Reduce app killing" card to Expert Mode on Xiaomi/Redmi/Poco devices with steps to whitelist the app from battery optimisation, disable MIUI optimization, enable autostart, and adjust battery saver settings. - Target Android 17 SDK. - [#2227](https://github.com/keymapperorg/KeyMapper/issues/2227) Add a step to the Expert Mode setup wizard to grant local network access permission, required for ADB on Android 17+. diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt index 73197e3388..ca467520b1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt @@ -29,6 +29,16 @@ data class Action( val multiplier: Int? = null, val delayBeforeNextAction: Int? = null, + + /** + * A name set by the user that is shown instead of the generated title. + */ + val customName: String? = null, + + /** + * Disabled actions are not performed when the key map is triggered. + */ + val isEnabled: Boolean = true, ) object ActionEntityMapper { @@ -85,6 +95,11 @@ object ActionEntityMapper { .valueOrNull() ?.toIntOrNull() + val customName = entity.extras + .getData(ActionEntity.EXTRA_CUSTOM_NAME) + .valueOrNull() + ?.takeIf { it.isNotBlank() } + return Action( uid = entity.uid, data = data, @@ -98,6 +113,8 @@ object ActionEntityMapper { holdDownDuration = holdDownDuration, delayBeforeNextAction = delayBeforeNextAction, multiplier = multiplier, + customName = customName, + isEnabled = !entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), ) } @@ -118,6 +135,10 @@ object ActionEntityMapper { add(EntityExtra(ActionEntity.EXTRA_MULTIPLIER, action.multiplier.toString())) } + if (!action.customName.isNullOrBlank()) { + add(EntityExtra(ActionEntity.EXTRA_CUSTOM_NAME, action.customName)) + } + if (keyMap.isHoldingDownActionBeforeRepeatingAllowed(action) && action.holdDownDuration != null ) { @@ -185,6 +206,10 @@ object ActionEntityMapper { flags = flags.withFlag(ActionEntity.ACTION_FLAG_HOLD_DOWN) } + if (!action.isEnabled) { + flags = flags.withFlag(ActionEntity.ACTION_FLAG_DISABLED) + } + return@map ActionEntity( type = base.type, data = base.data, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt index c8493d9604..7ef9564460 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt @@ -1,70 +1,117 @@ package io.github.sds100.keymapper.base.actions +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClearAll +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.DragHandle -import androidx.compose.material.icons.rounded.PlayCircleOutline +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowRight +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.TouchApp +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.LinkType +import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState import io.github.sds100.keymapper.base.utils.ui.drawable +/** + * The Material 3 alpha for disabled content. + */ +private const val DISABLED_ALPHA = 0.38f + +private const val EXPAND_ANIMATION_DURATION = 300 + +/** + * The fade and size change share the same duration and easing so the content does not disappear + * before the card has finished changing shape. + */ +private val expandTransition: EnterTransition = + fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + expandVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ) + +private val collapseTransition: ExitTransition = + fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + shrinkVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ) + @Composable fun ActionListItem( modifier: Modifier = Modifier, model: ActionListItemModel, index: Int, + isExpanded: Boolean, isDraggingEnabled: Boolean = false, isDragging: Boolean, isReorderingEnabled: Boolean, dragDropState: DragDropState? = null, + onExpandedChange: (Boolean) -> Unit = {}, onEditClick: () -> Unit = {}, onRemoveClick: () -> Unit = {}, onFixClick: () -> Unit = {}, onTestClick: () -> Unit = {}, + onRenameClick: () -> Unit = {}, + onEnabledChange: (Boolean) -> Unit = {}, onMoveUp: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, ) { @@ -75,45 +122,54 @@ fun ActionListItem( val moveUpLabel = stringResource(R.string.accessibility_action_move_up) val moveDownLabel = stringResource(R.string.accessibility_action_move_down) - Column(modifier = modifier.fillMaxWidth()) { - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .height(IntrinsicSize.Min) - .padding(start = 16.dp, end = 16.dp) - .semantics { - if (isReorderingEnabled) { - customActions = buildList { - onMoveUp?.let { action -> - add( - CustomAccessibilityAction(moveUpLabel) { - action() - true - }, - ) - } - onMoveDown?.let { action -> - add( - CustomAccessibilityAction(moveDownLabel) { - action() - true - }, - ) - } + // Only grey out a disabled action when it is collapsed so the expanded options stay readable. + val contentAlpha by animateFloatAsState( + targetValue = if (model.isEnabled || isExpanded) 1f else DISABLED_ALPHA, + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + ) + + val cardColors = CardDefaults.elevatedCardColors( + containerColor = if (isDragging) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), + ) + + ElevatedCard( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .semantics { + if (isReorderingEnabled) { + customActions = buildList { + onMoveUp?.let { action -> + add( + CustomAccessibilityAction(moveUpLabel) { + action() + true + }, + ) + } + onMoveDown?.let { action -> + add( + CustomAccessibilityAction(moveDownLabel) { + action() + true + }, + ) } } - }, - colors = CardDefaults.elevatedCardColors( - containerColor = if (isDragging) { - MaterialTheme.colorScheme.surfaceContainerHighest - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), - ) { + } + }, + colors = cardColors, + ) { + Column { Row( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxWidth() + .clickable { onExpandedChange(!isExpanded) }, verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.width(8.dp)) @@ -127,216 +183,414 @@ fun ActionListItem( enabled = isDraggingEnabled, orientation = Orientation.Vertical, startDragImmediately = true, - onDragStarted = { offset -> - dragDropState?.onDragStart(index, offset) + onDragStarted = { + dragDropState?.onDragStart(model.id) }, onDragStopped = { dragDropState?.onDragInterrupted() }, ), imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource(R.string.drag_handle_for, model.text), + contentDescription = stringResource( + R.string.drag_handle_for, + model.title, + ), tint = MaterialTheme.colorScheme.onSurface, ) } Spacer(Modifier.width(8.dp)) - if (model.error == null) { - when (model.icon) { - is ComposeIconInfo.Vector -> Icon( - modifier = Modifier.size(24.dp), - imageVector = model.icon.imageVector, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) - - is ComposeIconInfo.Drawable -> { - val painter = rememberDrawablePainter(model.icon.drawable) - Icon( - modifier = Modifier.size(24.dp), - painter = painter, - contentDescription = null, - tint = Color.Unspecified, - ) - } - } - } - - val primaryText = model.text + ActionIcon(modifier = Modifier.size(20.dp), icon = model.icon) Spacer(Modifier.width(8.dp)) - TextColumn( + HeaderText( modifier = Modifier .weight(1f) .padding(vertical = 8.dp), - primaryText = primaryText, - secondaryText = model.secondaryText, - errorText = model.error, + model = model, + isExpanded = isExpanded, + onRenameClick = onRenameClick, ) CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, ) { - if (model.error != null && model.isErrorFixable) { - FilledTonalButton( - modifier = Modifier.padding(start = 8.dp, end = 8.dp), - onClick = onFixClick, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text( - text = stringResource(R.string.button_fix), - ) - } - } - - if (model.error == null) { - IconButton(onClick = onTestClick) { - Icon( - imageVector = Icons.Rounded.PlayCircleOutline, - contentDescription = stringResource(R.string.action_list_item_test), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), - ) - } - } - - IconButton(onClick = onEditClick) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(R.string.action_list_item_edit), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), - ) - } - - IconButton(onClick = onRemoveClick) { + IconButton(onClick = { onExpandedChange(!isExpanded) }) { Icon( - imageVector = Icons.Rounded.Clear, - contentDescription = stringResource(R.string.action_list_item_remove), + imageVector = if (isExpanded) { + Icons.Rounded.KeyboardArrowUp + } else { + Icons.Rounded.KeyboardArrowDown + }, + contentDescription = if (isExpanded) { + stringResource(R.string.action_list_item_collapse) + } else { + stringResource(R.string.action_list_item_expand) + }, tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), ) } } + + Spacer(Modifier.width(4.dp)) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandTransition, + exit = collapseTransition, + ) { + ExpandedContent( + modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + model = model, + onEditClick = onEditClick, + onRemoveClick = onRemoveClick, + onFixClick = onFixClick, + onTestClick = onTestClick, + onEnabledChange = onEnabledChange, + ) } } + } +} - if (model.linkType == LinkType.HIDDEN) { - // Important! Show an empty spacer so the height of the card remains constant - // while dragging. If the height changes while dragging it can lead to janky - // behavior. - Spacer(Modifier.height(32.dp)) - } else { - Spacer(Modifier.height(4.dp)) +@Composable +private fun ActionIcon(modifier: Modifier = Modifier, icon: ComposeIconInfo) { + when (icon) { + is ComposeIconInfo.Vector -> Icon( + modifier = modifier, + imageVector = icon.imageVector, + contentDescription = null, + ) + is ComposeIconInfo.Drawable -> { + val painter = rememberDrawablePainter(icon.drawable) Icon( - imageVector = when (model.linkType) { - LinkType.ARROW -> Icons.Rounded.ArrowDownward - LinkType.PLUS -> Icons.Rounded.Add - LinkType.HIDDEN -> Icons.Rounded.Add - }, + modifier = modifier, + painter = painter, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, + tint = Color.Unspecified, + ) + } + } +} + +@Composable +private fun HeaderText( + modifier: Modifier = Modifier, + model: ActionListItemModel, + isExpanded: Boolean, + onRenameClick: () -> Unit, +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( modifier = Modifier - .size(24.dp) - .align(Alignment.CenterHorizontally), + .weight(1f, fill = false), + text = model.title, + style = MaterialTheme.typography.bodyMedium, + fontStyle = if (model.isCustomName) FontStyle.Italic else FontStyle.Normal, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - Spacer(Modifier.height(4.dp)) + + if (isExpanded) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = onRenameClick) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(R.string.action_list_item_rename), + ) + } + } + } + } + + AnimatedVisibility( + visible = !isExpanded, + enter = expandTransition, + exit = collapseTransition, + ) { + Column { + if (model.summary != null) { + Text( + text = model.summary, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (model.error != null && model.isEnabled) { + Text( + text = model.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } } } } @Composable -private fun TextColumn( +private fun ExpandedContent( modifier: Modifier = Modifier, - primaryText: String, - secondaryText: String? = null, - errorText: String? = null, + model: ActionListItemModel, + onEditClick: () -> Unit, + onRemoveClick: () -> Unit, + onFixClick: () -> Unit, + onTestClick: () -> Unit, + onEnabledChange: (Boolean) -> Unit, ) { Column( modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = primaryText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + if (model.error != null) { + Row( + modifier = Modifier.padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = model.error, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + + if (model.isErrorFixable) { + Spacer(Modifier.width(8.dp)) + + Button( + modifier = Modifier.height(28.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 2.dp), + shape = MaterialTheme.shapes.small, + onClick = onFixClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.button_fix)) + } + } + } + } + + if (model.showRepeat) { + OptionRow( + modifier = Modifier + .fillMaxWidth(), + icon = Icons.Rounded.Repeat, + text = model.repeatText ?: stringResource(R.string.action_list_no_repeat), + isSet = model.repeatText != null, + ) + } + + OptionRow( + modifier = Modifier + .fillMaxWidth(), + icon = Icons.Rounded.KeyboardDoubleArrowRight, + text = model.burstText ?: stringResource(R.string.action_list_no_burst), + isSet = model.burstText != null, ) - if (secondaryText != null) { - Text( - text = secondaryText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + + if (model.showHoldDown) { + OptionRow( + modifier = Modifier + .fillMaxWidth(), + icon = Icons.Rounded.TouchApp, + text = model.holdDownText ?: stringResource(R.string.action_list_no_hold_down), + isSet = model.holdDownText != null, ) } - if (errorText != null) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + text = stringResource(R.string.switch_enabled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, ) + + Spacer(Modifier.width(16.dp)) + + Switch(checked = model.isEnabled, onCheckedChange = onEnabledChange) + + Spacer(Modifier.weight(1f)) + + IconButton(onClick = onTestClick) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(R.string.action_list_item_test), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onClick = onEditClick) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.action_list_item_edit), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onClick = onRemoveClick) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.action_list_item_remove), + tint = MaterialTheme.colorScheme.error, + ) + } } } } +@Composable +private fun OptionRow( + modifier: Modifier = Modifier, + icon: ImageVector, + text: String, + isSet: Boolean, +) { + val color = if (isSet) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = icon, + contentDescription = null, + tint = color, + ) + + Spacer(Modifier.width(16.dp)) + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + ) + } +} + @Preview @Composable -private fun NoDragPreview() { - ActionListItem( - model = ActionListItemModel( - id = "id", - text = "Dismiss most recent notification", - secondaryText = "Repeat until released", - error = "Denied notification access permission", - isErrorFixable = true, - icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), - ), - isDragging = false, - isReorderingEnabled = false, - index = 0, - ) +private fun CollapsedErrorPreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Dismiss most recent notification", + summary = "Repeat until released", + error = "Denied notification access permission", + isErrorFixable = true, + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + ), + isExpanded = false, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } } @Preview @Composable -private fun NoDragOneLinePreview() { - ActionListItem( - model = ActionListItemModel( - id = "id", - text = "Clear all", - secondaryText = null, - error = null, - isErrorFixable = true, - icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), - ), - isDragging = false, - isReorderingEnabled = false, - index = 0, - ) +private fun CollapsedErrorDisabledPreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Dismiss most recent notification", + summary = "Repeat until released", + error = "Denied notification access permission", + isErrorFixable = true, + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + isEnabled = false, + ), + isExpanded = false, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } } @Preview @Composable -private fun DragDrawablePreview() { +private fun CollapsedOneLinePreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Clear all", + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + ), + isExpanded = false, + isDragging = false, + isReorderingEnabled = false, + index = 0, + ) + } +} + +@Preview +@Composable +private fun ExpandedPreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Open magnifier", + isCustomName = true, + error = "A Key Mapper keyboard must be enabled!", + isErrorFixable = true, + showRepeat = true, + repeatText = "Repeat 5x after 400ms every 50ms until pressed again", + showHoldDown = true, + burstText = null, + holdDownText = "Hold down for 1000ms", + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + ), + isExpanded = true, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } +} + +@Preview +@Composable +private fun DisabledDrawablePreview() { val drawable = LocalContext.current.drawable(R.mipmap.ic_launcher_round) - ActionListItem( - model = ActionListItemModel( - id = "id", - text = "Dismiss most recent notification", - secondaryText = "Repeat until released", - error = null, - isErrorFixable = true, - icon = ComposeIconInfo.Drawable(drawable), - ), - isDragging = false, - isReorderingEnabled = true, - index = 0, - ) + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Dismiss most recent notification", + summary = "Repeat until released", + isEnabled = false, + icon = ComposeIconInfo.Drawable(drawable), + ), + isExpanded = false, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt new file mode 100644 index 0000000000..cff9fbb8b0 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt @@ -0,0 +1,37 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo + +data class ActionListItemModel( + val id: String, + val icon: ComposeIconInfo, + val title: String, + val isCustomName: Boolean = false, + val isEnabled: Boolean = true, + /** + * A summary of the options that is shown when the item is collapsed. + */ + val summary: String? = null, + val error: String? = null, + val isErrorFixable: Boolean = true, + val showRepeat: Boolean = false, + /** + * Null if the action does not repeat. + */ + val repeatText: String? = null, + /** + * Null if the action is not performed in a burst. + */ + val burstText: String? = null, + val showHoldDown: Boolean = false, + /** + * Null if the action is not held down. + */ + val holdDownText: String? = null, + /** + * Whether to show the chip for setting the delay before the next action. A delay after the + * last action does nothing. + */ + val showDelayChip: Boolean = false, + val delayBeforeNextAction: Int? = null, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt index f156be2ceb..df5d5eb93d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt @@ -9,32 +9,43 @@ import androidx.compose.foundation.layout.Spacer 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.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.HelpOutline +import androidx.compose.material.icons.rounded.Abc +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowRight +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.TouchApp import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource 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 io.github.sds100.keymapper.base.R @@ -42,9 +53,10 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.SliderMaximums import io.github.sds100.keymapper.base.utils.ui.SliderMinimums import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes -import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe import kotlinx.coroutines.launch @@ -68,21 +80,53 @@ fun ActionOptionsBottomSheet( val ctx = LocalContext.current val helpUrl = stringResource(R.string.url_keymap_action_options_guide) val scope = rememberCoroutineScope() - - @Suppress("ktlint:standard:max-line-length") + var showCustomNameDialog by rememberSaveable { mutableStateOf(false) } + + if (showCustomNameDialog) { + TextFieldDialog( + title = stringResource(R.string.action_options_custom_name_dialog_title), + submitButtonText = stringResource(R.string.pos_save), + initialText = state.title, + onSubmitClick = { newText -> + callback.onCustomNameChanged(newText) + null + }, + onDismissRequest = { showCustomNameDialog = false }, + ) + } Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Spacer(modifier = Modifier.height(12.dp)) + Box(modifier = Modifier.fillMaxWidth()) { - Text( - modifier = Modifier.align(Alignment.Center), - textAlign = TextAlign.Center, - text = stringResource(R.string.action_options_title), - style = MaterialTheme.typography.headlineMedium, - ) + Row( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 56.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + textAlign = TextAlign.Center, + text = state.title, + style = MaterialTheme.typography.headlineMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, + ) + + IconButton(onClick = { showCustomNameDialog = true }) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource( + R.string.action_options_custom_name_dialog_title, + ), + ) + } + } IconButton( modifier = Modifier - .align(Alignment.TopEnd) + .align(Alignment.CenterEnd) .padding(horizontal = 8.dp), onClick = { uriHandler.openUriSafe(ctx, helpUrl) }, ) { @@ -93,190 +137,325 @@ fun ActionOptionsBottomSheet( } } + Spacer(modifier = Modifier.height(16.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = state.actionTypeIcon, + text = stringResource(R.string.action_options_type_header, state.actionTypeTitle), + ) + Spacer(modifier = Modifier.height(8.dp)) - Row( + FlowRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { if (state.showEditButton) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = callback::onEditClick, - ) { - Text(stringResource(R.string.button_edit_action)) + FilledTonalButton(onClick = callback::onEditClick) { + Text(stringResource(R.string.action_options_customize)) } - Spacer(Modifier.width(16.dp)) } - OutlinedButton(modifier = Modifier.weight(1f), onClick = callback::onReplaceClick) { - Text(stringResource(R.string.button_replace_action)) + FilledTonalButton(onClick = callback::onReplaceClick) { + Text(stringResource(R.string.action_options_swap)) } } if (state.showRepeat) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) + RepeatOptions(state = state, callback = callback) + } - CheckBoxText( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - text = stringResource(R.string.flag_repeat_actions), - isChecked = state.isRepeatChecked, - onCheckedChange = callback::onRepeatCheckedChange, - ) + Spacer(modifier = Modifier.height(16.dp)) + BurstOptions(state = state, callback = callback) + + if (state.showHoldDown) { + Spacer(modifier = Modifier.height(16.dp)) + HoldDownOptions(state = state, callback = callback) } - if (state.showRepeatRateWarning) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, ) { - Spacer(Modifier.width(16.dp)) Icon( - Icons.Rounded.Warning, + modifier = Modifier.size(ButtonDefaults.IconSize), + imageVector = Icons.Rounded.Check, contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.width(8.dp)) - Text( - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_repeat_rate_warning), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelLarge, ) - Spacer(Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.button_done)) } } - if (state.showRepeatRate) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_repeat_rate), - defaultValue = state.defaultRepeatRate.toFloat(), - value = state.repeatRate.toFloat(), - valueText = { "${it.toInt()} ms" }, - onValueChange = { callback.onRepeatRateChanged(it.toInt()) }, - valueRange = 0f..SliderMaximums.ACTION_REPEAT_RATE.toFloat(), - stepSize = SliderStepSizes.ACTION_REPEAT_RATE, - ) - } +@Composable +private fun RepeatOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) { + Column { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.Repeat, + text = stringResource(R.string.action_options_repeat_header), + ) - if (state.showRepeatLimit) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - val noLimitString = stringResource(R.string.button_slider_repeat_no_limit) + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.action_options_repeat_description), + style = MaterialTheme.typography.bodyMedium, + ) - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_repeat_limit), - defaultValue = state.defaultRepeatLimit.toFloat(), - value = state.repeatLimit.toFloat(), - valueText = { value -> - if (value.toInt() == Int.MAX_VALUE) { - noLimitString - } else { - "${value.toInt()}x" - } - }, - onValueChange = { callback.onRepeatLimitChanged(it.toInt()) }, - valueRange = 1f..SliderMaximums.ACTION_REPEAT_LIMIT.toFloat(), - stepSize = SliderStepSizes.ACTION_REPEAT_LIMIT, - ) - } + val selectedMode: RepeatMode? = if (state.isRepeatChecked) { + state.repeatMode + } else { + null + } - if (state.showRepeatDelay) { - Spacer(Modifier.height(8.dp)) + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_dont_repeat), + isSelected = selectedMode == null, + onSelected = { callback.onSelectRepeatMode(null) }, + ) - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_repeat_delay), - defaultValue = state.defaultRepeatDelay.toFloat(), - value = state.repeatDelay.toFloat(), - valueText = { "${it.toInt()} ms" }, - onValueChange = { callback.onRepeatDelayChanged(it.toInt()) }, - valueRange = 0f..SliderMaximums.ACTION_REPEAT_DELAY.toFloat(), - stepSize = SliderStepSizes.ACTION_REPEAT_DELAY, - ) - } + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_repeat_limit_reached), + isSelected = selectedMode == RepeatMode.LIMIT_REACHED, + isEnabled = state.allowedRepeatModes.contains(RepeatMode.LIMIT_REACHED), + onSelected = { callback.onSelectRepeatMode(RepeatMode.LIMIT_REACHED) }, + ) + + val isUntilReleasedAllowed = + state.allowedRepeatModes.contains(RepeatMode.TRIGGER_RELEASED) + + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_repeat_until_released), + isSelected = selectedMode == RepeatMode.TRIGGER_RELEASED, + isEnabled = isUntilReleasedAllowed, + onSelected = { callback.onSelectRepeatMode(RepeatMode.TRIGGER_RELEASED) }, + ) + + if (!isUntilReleasedAllowed) { + Text( + // Align with the text of the radio button. + modifier = Modifier.padding(start = 48.dp, end = 16.dp), + text = stringResource(R.string.action_options_repeat_until_released_unavailable), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + ) + } + + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_repeat_until_pressed_again), + isSelected = selectedMode == RepeatMode.TRIGGER_PRESSED_AGAIN, + isEnabled = state.allowedRepeatModes.contains(RepeatMode.TRIGGER_PRESSED_AGAIN), + onSelected = { callback.onSelectRepeatMode(RepeatMode.TRIGGER_PRESSED_AGAIN) }, + ) - if (state.allowedRepeatModes.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) + if (state.showRepeatRateWarning) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Rounded.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(8.dp)) Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.stop_repeating_dot_dot_dot), - style = MaterialTheme.typography.titleSmall, + text = stringResource(R.string.action_repeat_rate_warning), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, ) + } + } - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (state.allowedRepeatModes.contains(RepeatMode.TRIGGER_RELEASED)) { - RadioButtonText( - isSelected = state.repeatMode == RepeatMode.TRIGGER_RELEASED, - text = stringResource(R.string.stop_repeating_when_trigger_released), - onSelected = { - callback.onSelectRepeatMode(RepeatMode.TRIGGER_RELEASED) - }, - ) - } + if (state.showRepeatDelay) { + Spacer(modifier = Modifier.height(8.dp)) - if (state.allowedRepeatModes.contains(RepeatMode.TRIGGER_PRESSED_AGAIN)) { - RadioButtonText( - isSelected = state.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN, - text = stringResource(R.string.stop_repeating_trigger_pressed_again), - onSelected = { - callback.onSelectRepeatMode(RepeatMode.TRIGGER_PRESSED_AGAIN) - }, - ) - } + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.action_options_repeat_delay), + defaultValue = state.defaultRepeatDelay.toFloat(), + value = state.repeatDelay.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onRepeatDelayChanged(it.toInt()) }, + valueRange = 0f..SliderMaximums.ACTION_REPEAT_DELAY.toFloat(), + stepSize = SliderStepSizes.ACTION_REPEAT_DELAY, + ) + } - if (state.allowedRepeatModes.contains(RepeatMode.LIMIT_REACHED)) { - RadioButtonText( - isSelected = state.repeatMode == RepeatMode.LIMIT_REACHED, - text = stringResource(R.string.stop_repeating_limit_reached), - onSelected = { callback.onSelectRepeatMode(RepeatMode.LIMIT_REACHED) }, - ) + if (state.showRepeatRate) { + Spacer(modifier = Modifier.height(8.dp)) + + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.action_options_repeat_rate), + defaultValue = state.defaultRepeatRate.toFloat(), + value = state.repeatRate.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onRepeatRateChanged(it.toInt()) }, + valueRange = 0f..SliderMaximums.ACTION_REPEAT_RATE.toFloat(), + stepSize = SliderStepSizes.ACTION_REPEAT_RATE, + ) + } + + if (state.showRepeatLimit) { + Spacer(modifier = Modifier.height(8.dp)) + + val noLimitString = stringResource(R.string.button_slider_repeat_no_limit) + + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.action_options_repeat_limit), + defaultValue = state.defaultRepeatLimit.toFloat(), + value = state.repeatLimit.toFloat(), + valueText = { value -> + if (value.toInt() == Int.MAX_VALUE) { + noLimitString + } else { + "${value.toInt()}x" } + }, + onValueChange = { callback.onRepeatLimitChanged(it.toInt()) }, + valueRange = 1f..SliderMaximums.ACTION_REPEAT_LIMIT.toFloat(), + stepSize = SliderStepSizes.ACTION_REPEAT_LIMIT, + ) + } + } +} - Spacer(Modifier.width(8.dp)) - } - } +@Composable +private fun BurstOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) { + Column { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.KeyboardDoubleArrowRight, + text = stringResource(R.string.action_options_burst_header), + ) - if (state.showRepeat) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - } + Spacer(modifier = Modifier.height(8.dp)) - if (state.showHoldDown) { - Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = if (state.showRepeat && state.isRepeatChecked) { + stringResource(R.string.action_options_burst_description_repeat) + } else { + stringResource(R.string.action_options_burst_description) + }, + style = MaterialTheme.typography.bodyMedium, + ) - CheckBoxText( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - text = stringResource(R.string.flag_hold_down), - isChecked = state.isHoldDownChecked, - onCheckedChange = callback::onHoldDownCheckedChange, - ) - } + Spacer(modifier = Modifier.height(8.dp)) + + val multiplierMin = SliderMinimums.ACTION_MULTIPLIER.toFloat() + val multiplierMax = SliderMaximums.ACTION_MULTIPLIER.toFloat() + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.action_options_burst_size), + defaultValue = state.defaultMultiplier.toFloat(), + value = state.multiplier.toFloat(), + valueText = { "${it.toInt()}x" }, + onValueChange = { callback.onMultiplierChanged(it.toInt()) }, + valueRange = multiplierMin..multiplierMax, + stepSize = SliderStepSizes.ACTION_MULTIPLIER, + ) + } +} + +@Composable +private fun HoldDownOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) { + val isRepeating = state.showRepeat && state.isRepeatChecked + + Column { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.TouchApp, + text = stringResource(R.string.action_options_hold_down_header), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = if (isRepeating) { + stringResource(R.string.action_options_hold_down_description_repeat) + } else { + stringResource(R.string.action_options_hold_down_description) + }, + style = MaterialTheme.typography.bodyMedium, + ) + + val selectedMode: HoldDownMode? = if (state.isHoldDownChecked) { + state.holdDownMode + } else { + null + } + + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_dont_hold_down), + isSelected = selectedMode == null, + onSelected = { callback.onSelectHoldDownMode(null) }, + ) + + if (isRepeating) { + // When repeating, the action is held down for a period of time before each repeat. + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_hold_down_period), + isSelected = selectedMode != null, + onSelected = { callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) }, + ) if (state.showHoldDownDuration) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) val holdDownDurationMin = SliderMinimums.ACTION_HOLD_DOWN_DURATION.toFloat() val holdDownDurationMax = SliderMaximums.ACTION_HOLD_DOWN_DURATION.toFloat() @@ -293,123 +472,26 @@ fun ActionOptionsBottomSheet( stepSize = SliderStepSizes.ACTION_HOLD_DOWN_DURATION, ) } - - if (state.showHoldDownMode) { - Spacer(Modifier.height(8.dp)) - - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.hold_down_until_trigger_is_dot_dot_dot), - style = MaterialTheme.typography.titleSmall, - ) - - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - RadioButtonText( - isSelected = state.holdDownMode == HoldDownMode.TRIGGER_RELEASED, - text = stringResource(R.string.stop_holding_down_when_trigger_released), - onSelected = { - callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) - }, - ) - - RadioButtonText( - isSelected = state.holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, - text = stringResource(R.string.stop_holding_down_trigger_pressed_again), - onSelected = { - callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_PRESSED_AGAIN) - }, - ) - - Spacer(Modifier.width(8.dp)) - } - } - - if (state.showHoldDown) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - } - - if (state.showDelayBeforeNextAction) { - Spacer(Modifier.height(8.dp)) - - val delayBeforeNextActionMin = - SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat() - val delayBeforeNextActionMax = - SliderMaximums.DELAY_BEFORE_NEXT_ACTION.toFloat() - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_delay_before_next_action), - defaultValue = state.defaultDelayBeforeNextAction.toFloat(), - value = state.delayBeforeNextAction.toFloat(), - valueText = { "${it.toInt()} ms" }, - onValueChange = { callback.onDelayBeforeNextActionChanged(it.toInt()) }, - valueRange = delayBeforeNextActionMin..delayBeforeNextActionMax, - stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION, - ) - } - - Spacer(Modifier.height(8.dp)) - - val actionMultiplierMin = SliderMinimums.ACTION_MULTIPLIER.toFloat() - val actionMultiplierMax = SliderMaximums.ACTION_MULTIPLIER.toFloat() - SliderOptionText( + } else { + RadioButtonText( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = if (state.showRepeat && state.isRepeatChecked) { - stringResource(R.string.extra_label_action_multiplier_with_repeat) - } else { - stringResource(R.string.extra_label_action_multiplier) - }, - defaultValue = state.defaultMultiplier.toFloat(), - value = state.multiplier.toFloat(), - valueText = { "${it.toInt()}x" }, - onValueChange = { callback.onMultiplierChanged(it.toInt()) }, - valueRange = actionMultiplierMin..actionMultiplierMax, - stepSize = SliderStepSizes.ACTION_MULTIPLIER, + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_hold_down_until_released), + isSelected = selectedMode == HoldDownMode.TRIGGER_RELEASED, + onSelected = { callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) }, ) - Spacer(Modifier.height(8.dp)) - - HorizontalDivider() - - Spacer(Modifier.height(8.dp)) - - Row( + RadioButtonText( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Spacer(Modifier.weight(0.5f)) - Spacer(Modifier.width(16.dp)) - - FilledTonalButton( - modifier = Modifier.weight(0.5f), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Spacer(Modifier.width(16.dp)) - Text(stringResource(R.string.button_done)) - Spacer(Modifier.width(16.dp)) - } - } - - Spacer(Modifier.height(16.dp)) + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_hold_down_until_pressed_again), + isSelected = selectedMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, + onSelected = { + callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_PRESSED_AGAIN) + }, + ) } } } @@ -417,20 +499,68 @@ fun ActionOptionsBottomSheet( interface ActionOptionsBottomSheetCallback { fun onEditClick() = run { } fun onReplaceClick() = run { } - fun onRepeatCheckedChange(checked: Boolean) = run { } - fun onSelectRepeatMode(repeatMode: RepeatMode) = run { } + fun onCustomNameChanged(name: String) = run { } + + /** + * @param repeatMode null if the action should not repeat. + */ + fun onSelectRepeatMode(repeatMode: RepeatMode?) = run { } fun onRepeatRateChanged(rate: Int) = run { } fun onRepeatLimitChanged(limit: Int) = run { } fun onRepeatDelayChanged(delay: Int) = run { } - fun onHoldDownCheckedChange(checked: Boolean) = run { } + + /** + * @param holdDownMode null if the action should not be held down. + */ + fun onSelectHoldDownMode(holdDownMode: HoldDownMode?) = run { } fun onHoldDownDurationChanged(duration: Int) = run { } - fun onSelectHoldDownMode(holdDownMode: HoldDownMode) = run { } - fun onDelayBeforeNextActionChanged(delay: Int) = run { } fun onMultiplierChanged(multiplier: Int) = run { } } +private val previewState = ActionOptionsState( + title = "Input KEYCODE_0", + actionTypeTitle = "Input key event", + actionTypeIcon = Icons.Rounded.Abc, + + showEditButton = true, + showRepeat = true, + isRepeatChecked = true, + showRepeatRateWarning = false, + + showRepeatRate = true, + repeatRate = 400, + defaultRepeatRate = 500, + + showRepeatDelay = true, + repeatDelay = 400, + defaultRepeatDelay = 400, + + showRepeatLimit = true, + repeatLimit = Int.MAX_VALUE, + defaultRepeatLimit = Int.MAX_VALUE, + + allowedRepeatModes = setOf( + RepeatMode.TRIGGER_RELEASED, + RepeatMode.LIMIT_REACHED, + RepeatMode.TRIGGER_PRESSED_AGAIN, + ), + repeatMode = RepeatMode.TRIGGER_PRESSED_AGAIN, + + showHoldDown = true, + isHoldDownChecked = true, + + showHoldDownDuration = true, + holdDownDuration = 400, + defaultHoldDownDuration = 400, + + holdDownMode = HoldDownMode.TRIGGER_RELEASED, + + multiplier = 4, + defaultMultiplier = 1, +) + @OptIn(ExperimentalMaterial3Api::class) -@Preview(heightDp = 1000, showSystemUi = true) +@Preview(heightDp = 1600, showSystemUi = true) @Composable private fun Preview() { KeyMapperTheme { @@ -443,57 +573,16 @@ private fun Preview() { ActionOptionsBottomSheet( sheetState = sheetState, - state = ActionOptionsState( - showEditButton = true, - showRepeat = true, - isRepeatChecked = true, - showRepeatRateWarning = true, - - showRepeatRate = true, - repeatRate = 400, - defaultRepeatRate = 500, - - showRepeatDelay = true, - repeatDelay = 400, - defaultRepeatDelay = 400, - - showRepeatLimit = true, - repeatLimit = Int.MAX_VALUE, - defaultRepeatLimit = Int.MAX_VALUE, - - allowedRepeatModes = setOf( - RepeatMode.TRIGGER_RELEASED, - RepeatMode.LIMIT_REACHED, - RepeatMode.TRIGGER_PRESSED_AGAIN, - ), - repeatMode = RepeatMode.TRIGGER_RELEASED, - - showHoldDown = true, - isHoldDownChecked = false, - - showHoldDownDuration = true, - holdDownDuration = 400, - defaultHoldDownDuration = 400, - - showHoldDownMode = true, - holdDownMode = HoldDownMode.TRIGGER_PRESSED_AGAIN, - - showDelayBeforeNextAction = true, - delayBeforeNextAction = 10000, - defaultDelayBeforeNextAction = 5000, - - multiplier = 4, - defaultMultiplier = 1, - ), + state = previewState, callback = object : ActionOptionsBottomSheetCallback {}, ) } } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview(heightDp = 1600, showSystemUi = true) @Composable -private fun PreviewNoEditButton() { +private fun PreviewUntilReleasedUnavailable() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, @@ -504,47 +593,42 @@ private fun PreviewNoEditButton() { ActionOptionsBottomSheet( sheetState = sheetState, - state = ActionOptionsState( + state = previewState.copy( showEditButton = false, - showRepeat = true, - isRepeatChecked = true, showRepeatRateWarning = true, - - showRepeatRate = true, - repeatRate = 400, - defaultRepeatRate = 500, - - showRepeatDelay = true, - repeatDelay = 400, - defaultRepeatDelay = 400, - - showRepeatLimit = true, - repeatLimit = 10, - defaultRepeatLimit = Int.MAX_VALUE, - allowedRepeatModes = setOf( - RepeatMode.TRIGGER_RELEASED, RepeatMode.LIMIT_REACHED, RepeatMode.TRIGGER_PRESSED_AGAIN, ), - repeatMode = RepeatMode.TRIGGER_RELEASED, - - showHoldDown = true, isHoldDownChecked = false, + ), + callback = object : ActionOptionsBottomSheetCallback {}, + ) + } +} - showHoldDownDuration = true, - holdDownDuration = 400, - defaultHoldDownDuration = 400, +@OptIn(ExperimentalMaterial3Api::class) +@Preview(heightDp = 1600, showSystemUi = true) +@Composable +private fun PreviewNotRepeating() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + skipHiddenState = true, + ) - showHoldDownMode = true, + ActionOptionsBottomSheet( + sheetState = sheetState, + state = previewState.copy( + isRepeatChecked = false, + showRepeatRate = false, + showRepeatDelay = false, + showRepeatLimit = false, + showHoldDownDuration = false, holdDownMode = HoldDownMode.TRIGGER_PRESSED_AGAIN, - - showDelayBeforeNextAction = true, - delayBeforeNextAction = 10000, - defaultDelayBeforeNextAction = 5000, - - multiplier = 4, - defaultMultiplier = 1, ), callback = object : ActionOptionsBottomSheetCallback {}, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt new file mode 100644 index 0000000000..525005f0ef --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt @@ -0,0 +1,42 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.ui.graphics.vector.ImageVector + +data class ActionOptionsState( + val title: String, + val actionTypeTitle: String, + val actionTypeIcon: ImageVector, + + val showEditButton: Boolean, + + val showRepeat: Boolean, + val isRepeatChecked: Boolean, + + val showRepeatRate: Boolean, + val showRepeatRateWarning: Boolean, + val repeatRate: Int, + val defaultRepeatRate: Int, + + val showRepeatDelay: Boolean, + val repeatDelay: Int, + val defaultRepeatDelay: Int, + + val showRepeatLimit: Boolean, + val repeatLimit: Int, + val defaultRepeatLimit: Int, + + val allowedRepeatModes: Set, + val repeatMode: RepeatMode, + + val showHoldDown: Boolean, + val isHoldDownChecked: Boolean, + + val showHoldDownDuration: Boolean, + val holdDownDuration: Int, + val defaultHoldDownDuration: Int, + + val holdDownMode: HoldDownMode, + + val multiplier: Int, + val defaultMultiplier: Int, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index cebf056bd6..1296d5b774 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -31,6 +31,14 @@ class ActionUiHelper( ) : ResourceProvider by resourceProvider, DisplayActionUseCase by displayActionUseCase { + /** + * @return the custom name of the action if it has one, otherwise the generated title. + */ + fun getTitle(action: Action, showDeviceDescriptors: Boolean): String { + return action.customName?.takeIf { it.isNotBlank() } + ?: getTitle(action.data, showDeviceDescriptors) + } + fun getTitle(action: ActionData, showDeviceDescriptors: Boolean): String = when (action) { is ActionData.App -> getAppName(action.packageName).handle( @@ -712,13 +720,17 @@ class ActionUiHelper( R.string.action_expand_notification_drawer, ) - ActionData.StatusBar.ExpandQuickSettings -> getString(R.string.action_expand_quick_settings) + ActionData.StatusBar.ExpandQuickSettings -> getString( + R.string.action_expand_quick_settings, + ) ActionData.StatusBar.ToggleNotifications -> getString( R.string.action_toggle_notification_drawer, ) - ActionData.StatusBar.ToggleQuickSettings -> getString(R.string.action_toggle_quick_settings) + ActionData.StatusBar.ToggleQuickSettings -> getString( + R.string.action_toggle_quick_settings, + ) ActionData.ToggleKeyboard -> getString(R.string.action_toggle_keyboard) @@ -734,7 +746,9 @@ class ActionUiHelper( ActionData.Wifi.Toggle -> getString(R.string.action_toggle_wifi) - ActionData.DismissAllNotifications -> getString(R.string.action_dismiss_all_notifications) + ActionData.DismissAllNotifications -> getString( + R.string.action_dismiss_all_notifications, + ) ActionData.DismissLastNotification -> getString( R.string.action_dismiss_most_recent_notification, @@ -891,76 +905,116 @@ class ActionUiHelper( } fun getOptionLabels(keyMap: KeyMap, action: Action) = buildList { - if (keyMap.isRepeatingActionsAllowed() && action.repeat) { - val repeatDescription = buildString { - append(getString(R.string.flag_repeat_build_description_start)) + getRepeatDescription(keyMap, action)?.let { add(it) } + + if (keyMap.isHoldingDownActionAllowed(action) && + action.holdDown && + !action.stopHoldDownWhenTriggerPressedAgain + ) { + add(getString(R.string.flag_hold_down)) + } + + if (keyMap.isHoldingDownActionAllowed(action) && + action.holdDown && + action.stopHoldDownWhenTriggerPressedAgain + ) { + add(getString(R.string.flag_hold_down_until_pressed_again)) + } + } - val repeatLimit = when { - action.repeatLimit != null -> action.repeatLimit + /** + * @return null if the action does not repeat. + */ + fun getRepeatDescription(keyMap: KeyMap, action: Action): String? { + if (!keyMap.isRepeatingActionsAllowed() || !action.repeat) { + return null + } - action.repeatMode == RepeatMode.LIMIT_REACHED -> 1 + return buildString { + append(getString(R.string.flag_repeat_build_description_start)) - // and is null - else -> null - } + val repeatLimit = when { + action.repeatLimit != null -> action.repeatLimit - if (repeatLimit != null) { - append(" ") - append(getString(R.string.flag_repeat_build_description_limit, repeatLimit)) - } + action.repeatMode == RepeatMode.LIMIT_REACHED -> 1 - if (action.repeatRate != null) { - append(" ") - append( - getString( - R.string.flag_repeat_build_description_repeat_rate, - action.repeatRate, - ), - ) - } + // and is null + else -> null + } - if (action.repeatDelay != null) { - append(" ") - append( - getString( - R.string.flag_repeat_build_description_repeat_delay, - action.repeatDelay, - ), - ) - } + if (repeatLimit != null) { + append(" ") + append(getString(R.string.flag_repeat_build_description_limit, repeatLimit)) + } + if (action.repeatRate != null) { append(" ") + append( + getString( + R.string.flag_repeat_build_description_repeat_rate, + action.repeatRate, + ), + ) + } - when (action.repeatMode) { - RepeatMode.TRIGGER_RELEASED -> { - append(getString(R.string.flag_repeat_build_description_until_released)) - } + if (action.repeatDelay != null) { + append(" ") + append( + getString( + R.string.flag_repeat_build_description_repeat_delay, + action.repeatDelay, + ), + ) + } - RepeatMode.TRIGGER_PRESSED_AGAIN -> { - append( - getString(R.string.flag_repeat_build_description_until_pressed_again), - ) - } + append(" ") + + when (action.repeatMode) { + RepeatMode.TRIGGER_RELEASED -> { + append(getString(R.string.flag_repeat_build_description_until_released)) + } - else -> Unit + RepeatMode.TRIGGER_PRESSED_AGAIN -> { + append( + getString(R.string.flag_repeat_build_description_until_pressed_again), + ) } + + else -> Unit } + } + } + + /** + * @return null if the action is not performed in a burst. + */ + fun getBurstDescription(action: Action): String? { + val multiplier = action.multiplier ?: return null - add(repeatDescription) + if (multiplier <= 1) { + return null } - if (keyMap.isHoldingDownActionAllowed(action) && - action.holdDown && - !action.stopHoldDownWhenTriggerPressedAgain - ) { - add(getString(R.string.flag_hold_down)) + return getString(R.string.action_list_burst, multiplier) + } + + /** + * @return null if the action is not held down. + */ + fun getHoldDownDescription(keyMap: KeyMap, action: Action): String? { + if (!keyMap.isHoldingDownActionAllowed(action) || !action.holdDown) { + return null } - if (keyMap.isHoldingDownActionAllowed(action) && - action.holdDown && - action.stopHoldDownWhenTriggerPressedAgain - ) { - add(getString(R.string.flag_hold_down_until_pressed_again)) + return when { + keyMap.isHoldingDownActionBeforeRepeatingAllowed(action) && + action.holdDownDuration != null -> + getString(R.string.action_list_hold_down_duration, action.holdDownDuration) + + action.stopHoldDownWhenTriggerPressedAgain -> + getString(R.string.flag_hold_down_until_pressed_again) + + else -> getString(R.string.flag_hold_down) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt index 9781d71951..08d9e71fe6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt @@ -8,30 +8,41 @@ import androidx.compose.foundation.layout.Spacer 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.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDownward import androidx.compose.material.icons.rounded.FlashlightOn +import androidx.compose.material.icons.rounded.MoreTime import androidx.compose.material.icons.rounded.Pinch +import androidx.compose.material.icons.rounded.Timelapse import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -44,14 +55,25 @@ import io.github.sds100.keymapper.base.keymaps.ShortcutModel import io.github.sds100.keymapper.base.keymaps.ShortcutRow import io.github.sds100.keymapper.base.onboarding.OnboardingTipModel import io.github.sds100.keymapper.base.onboarding.TipCard -import io.github.sds100.keymapper.base.utils.ui.LinkType +import io.github.sds100.keymapper.base.utils.ui.SliderMaximums +import io.github.sds100.keymapper.base.utils.ui.SliderMinimums +import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.CustomDialog import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog import io.github.sds100.keymapper.base.utils.ui.compose.rememberDragDropState import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.camera.CameraLens import kotlinx.coroutines.flow.update +/** + * The height of the row between action cards. The last card has a spacer of the same height so + * the height of the items stays constant while dragging. + */ +private val linkRowHeight = 48.dp + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewModel) { @@ -95,15 +117,11 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod modifier = modifier, state = state, tipModel = actionTipModel, + callback = viewModel, onRemoveClick = viewModel::onRemoveClick, - onEditClick = viewModel::onEditClick, - onMoveAction = viewModel::onMoveAction, - onFixErrorClick = viewModel::onFixError, - onClickShortcut = viewModel::onClickShortcut, - onTestClick = viewModel::onTestClick, onAddClick = viewModel::onAddActionClick, - onActionTipDismiss = viewModel::onActionTipDismissClick, - onTipButtonClick = viewModel::onTipButtonClick, + onDelayChange = viewModel::onDelayChanged, + onRenameAction = viewModel::onRenameAction, ) } @@ -112,22 +130,21 @@ private fun ActionsScreen( modifier: Modifier = Modifier, state: State, tipModel: OnboardingTipModel? = null, + callback: ActionListCallback = object : ActionListCallback {}, onAddClick: () -> Unit = {}, onRemoveClick: (String) -> Unit = {}, - onEditClick: (String) -> Unit = {}, - onMoveAction: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, - onFixErrorClick: (String) -> Unit = {}, - onTestClick: (String) -> Unit = {}, - onClickShortcut: (ActionData) -> Unit = {}, - onActionTipDismiss: () -> Unit = {}, - onTipButtonClick: (String) -> Unit = {}, + onDelayChange: (String, Int) -> Unit = { _, _ -> }, + onRenameAction: (String, String) -> Unit = { _, _ -> }, ) { - var showDeleteDialog by rememberSaveable { mutableStateOf(false) } var actionToDelete by rememberSaveable { mutableStateOf(null) } + var actionToRename by rememberSaveable { mutableStateOf(null) } + var actionToSetDelay by rememberSaveable { mutableStateOf(null) } + + val actions = ((state as? State.Data)?.data as? ConfigActionsState.Loaded)?.actions.orEmpty() - if (showDeleteDialog && actionToDelete != null) { + if (actionToDelete != null) { AlertDialog( - onDismissRequest = { showDeleteDialog = false }, + onDismissRequest = { actionToDelete = null }, title = { Text(stringResource(R.string.action_list_delete_dialog_title)) }, @@ -136,20 +153,48 @@ private fun ActionsScreen( TextButton( onClick = { onRemoveClick(actionToDelete!!) - showDeleteDialog = false + actionToDelete = null }, ) { Text(stringResource(R.string.action_list_delete_yes)) } }, dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { + TextButton(onClick = { actionToDelete = null }) { Text(stringResource(R.string.action_list_delete_cancel)) } }, ) } + val renameModel = actions.find { it.id == actionToRename } + + if (renameModel != null) { + TextFieldDialog( + title = stringResource(R.string.action_options_custom_name_dialog_title), + submitButtonText = stringResource(R.string.pos_save), + initialText = renameModel.title, + onSubmitClick = { newText -> + onRenameAction(renameModel.id, newText) + null + }, + onDismissRequest = { actionToRename = null }, + ) + } + + val delayModel = actions.find { it.id == actionToSetDelay } + + if (delayModel != null) { + DelayBeforeNextActionDialog( + initialDelay = delayModel.delayBeforeNextAction, + onSaveClick = { delay -> + onDelayChange(delayModel.id, delay) + actionToSetDelay = null + }, + onDismissRequest = { actionToSetDelay = null }, + ) + } + when (state) { State.Loading -> Loading() @@ -159,17 +204,10 @@ private fun ActionsScreen( modifier = Modifier.weight(1f), state = state.data, tipModel = tipModel, - onRemoveClick = { - actionToDelete = it - showDeleteDialog = true - }, - onEditClick = onEditClick, - onFixErrorClick = onFixErrorClick, - onMove = onMoveAction, - onClickShortcut = onClickShortcut, - onTestClick = onTestClick, - onActionTipDismiss, - onTipButtonClick, + callback = callback, + onRemoveClick = { actionToDelete = it }, + onDelayClick = { actionToSetDelay = it }, + onRenameClick = { actionToRename = it }, ) FilledTonalButton( @@ -196,36 +234,41 @@ private fun Loading(modifier: Modifier = Modifier) { } } +interface ActionListCallback { + fun onEditClick(id: String) = run { } + fun onFixErrorClick(id: String) = run { } + fun onMove(fromIndex: Int, toIndex: Int) = run { } + fun onClickShortcut(data: ActionData) = run { } + fun onTestClick(id: String) = run { } + fun onActionTipDismiss() = run { } + fun onTipButtonClick(id: String) = run { } + fun onEnabledChange(id: String, enabled: Boolean) = run { } +} + @Composable private fun ActionList( modifier: Modifier = Modifier, state: ConfigActionsState, tipModel: OnboardingTipModel?, + callback: ActionListCallback, onRemoveClick: (String) -> Unit, - onEditClick: (String) -> Unit, - onFixErrorClick: (String) -> Unit, - onMove: (fromIndex: Int, toIndex: Int) -> Unit, - onClickShortcut: (ActionData) -> Unit, - onTestClick: (String) -> Unit, - onActionTipDismiss: () -> Unit, - onTipButtonClick: (String) -> Unit, + onDelayClick: (String) -> Unit, + onRenameClick: (String) -> Unit, ) { val lazyListState = rememberLazyListState() - val dragDropState = if (state is ConfigActionsState.Loaded) { - rememberDragDropState( - lazyListState = lazyListState, - onMove = onMove, - // Do not drag and drop the row of shortcuts - ignoreLastItems = if (state.shortcuts.isEmpty()) { - 0 - } else { - 1 - }, - ) - } else { - null - } + // A list rather than a set so it can be saved in a Bundle. + var expandedIds by rememberSaveable { mutableStateOf(listOf()) } + + val actions = (state as? ConfigActionsState.Loaded)?.actions.orEmpty() + val actionIds = remember(actions) { actions.map { it.id } } + + // Only the actions can be dragged. Not the tip or the row of shortcuts. + val dragDropState = rememberDragDropState( + lazyListState = lazyListState, + keys = actionIds, + onMove = callback::onMove, + ) // Use dragContainer rather than .draggable() modifier because that causes // dragging the first item to be always be dropped in the next position. @@ -248,9 +291,9 @@ private fun ActionList( title = tip.title, message = tip.message, isDismissable = tip.isDismissable, - onDismiss = onActionTipDismiss, + onDismiss = callback::onActionTipDismiss, buttonText = tip.buttonText, - onButtonClick = { onTipButtonClick(tip.id) }, + onButtonClick = { callback.onTipButtonClick(tip.id) }, ) Spacer(Modifier.height(8.dp)) @@ -271,40 +314,73 @@ private fun ActionList( } is ConfigActionsState.Loaded -> { + val orderedActions = dragDropState.ordered(state.actions) { it.id } + itemsIndexed( - state.actions, + orderedActions, key = { _, item -> item.id }, contentType = { _, _ -> "action" }, ) { index, model -> + // Automatically expand the item when an error appears so the user can fix it. + LaunchedEffect(model.id, model.error) { + if (model.isEnabled && model.error != null && model.id !in expandedIds) { + expandedIds = expandedIds + model.id + } + } + DraggableItem( - dragDropState = dragDropState!!, - index = index, + dragDropState = dragDropState, + key = model.id, ) { isDragging -> - ActionListItem( - modifier = Modifier.fillMaxWidth(), - model = model, - index = index, - isDraggingEnabled = state.actions.size > 1, - isDragging = isDragging, - isReorderingEnabled = state.isReorderingEnabled, - dragDropState = dragDropState, - onEditClick = { onEditClick(model.id) }, - onRemoveClick = { onRemoveClick(model.id) }, - onFixClick = { onFixErrorClick(model.id) }, - onTestClick = { onTestClick(model.id) }, - onMoveUp = if (state.isReorderingEnabled && index > 0) { - { onMove(index, index - 1) } - } else { - null - }, - onMoveDown = if (state.isReorderingEnabled && - index < state.actions.size - 1 - ) { - { onMove(index, index + 1) } + Column { + ActionListItem( + modifier = Modifier.fillMaxWidth(), + model = model, + index = index, + isExpanded = model.id in expandedIds, + isDraggingEnabled = orderedActions.size > 1, + isDragging = isDragging, + isReorderingEnabled = state.isReorderingEnabled, + dragDropState = dragDropState, + onExpandedChange = { expanded -> + expandedIds = if (expanded) { + expandedIds + model.id + } else { + expandedIds - model.id + } + }, + onEditClick = { callback.onEditClick(model.id) }, + onRemoveClick = { onRemoveClick(model.id) }, + onFixClick = { callback.onFixErrorClick(model.id) }, + onTestClick = { callback.onTestClick(model.id) }, + onRenameClick = { onRenameClick(model.id) }, + onEnabledChange = { callback.onEnabledChange(model.id, it) }, + onMoveUp = if (state.isReorderingEnabled && index > 0) { + { callback.onMove(index, index - 1) } + } else { + null + }, + onMoveDown = if (state.isReorderingEnabled && + index < orderedActions.size - 1 + ) { + { callback.onMove(index, index + 1) } + } else { + null + }, + ) + + if (model.showDelayChip) { + ActionLinkRow( + delayBeforeNextAction = model.delayBeforeNextAction, + onDelayClick = { onDelayClick(model.id) }, + ) } else { - null - }, - ) + // Important! Keep the height of the item constant while dragging. + // If the height changes while dragging it can lead to janky + // behavior. + Spacer(Modifier.height(linkRowHeight)) + } + } } } } @@ -325,7 +401,7 @@ private fun ActionList( .fillMaxWidth() .padding(horizontal = 32.dp), shortcuts = state.shortcuts, - onClick = { onClickShortcut(it) }, + onClick = { callback.onClickShortcut(it) }, ) } } @@ -333,6 +409,121 @@ private fun ActionList( } } +@Composable +private fun ActionLinkRow( + modifier: Modifier = Modifier, + delayBeforeNextAction: Int?, + onDelayClick: () -> Unit, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(linkRowHeight) + .padding(horizontal = 16.dp), + ) { + Icon( + modifier = Modifier + .size(24.dp) + .align(Alignment.Center), + imageVector = Icons.Rounded.ArrowDownward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + + AssistChip( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = onDelayClick, + label = { + if (delayBeforeNextAction == null) { + Text(stringResource(R.string.action_list_add_delay)) + } else { + if (delayBeforeNextAction < 1000) { + Text(stringResource(R.string.action_title_wait_ms, delayBeforeNextAction)) + } else { + val seconds = delayBeforeNextAction / 1000f + val secondsText = if (seconds % 1f == 0f) { + seconds.toInt().toString() + } else { + String.format(LocalLocale.current.platformLocale, "%.1f", seconds) + } + + Text( + stringResource( + R.string.action_title_wait_secs, + secondsText, + ), + ) + } + } + }, + leadingIcon = { + Icon( + modifier = Modifier.size(AssistChipDefaults.IconSize), + imageVector = if (delayBeforeNextAction == null) { + Icons.Rounded.MoreTime + } else { + Icons.Rounded.Timelapse + }, + contentDescription = null, + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + leadingIconContentColor = MaterialTheme.colorScheme.onSurface, + ), + border = null, + ) + } +} + +@Composable +private fun DelayBeforeNextActionDialog( + initialDelay: Int?, + onSaveClick: (Int) -> Unit, + onDismissRequest: () -> Unit, +) { + var delay by rememberSaveable { + mutableIntStateOf(initialDelay ?: SliderStepSizes.DELAY_BEFORE_NEXT_ACTION) + } + + CustomDialog( + title = stringResource(R.string.action_options_delay_header), + confirmButton = { + TextButton(onClick = { onSaveClick(delay) }) { + Text(stringResource(R.string.pos_save)) + } + }, + dismissButton = { + if (initialDelay == null) { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) + } + } else { + TextButton(onClick = { onSaveClick(0) }) { + Text(stringResource(R.string.action_list_delay_remove)) + } + } + }, + onDismissRequest = onDismissRequest, + ) { + val delayMin = SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat() + val delayMax = SliderMaximums.DELAY_BEFORE_NEXT_ACTION.toFloat() + + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + title = null, + defaultValue = delayMin, + value = delay.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { delay = it.toInt() }, + valueRange = delayMin..delayMax, + stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION, + ) + } +} + @Preview @Composable private fun EmptyPreview() { @@ -391,19 +582,25 @@ private fun LoadedPreview() { ActionListItemModel( id = "1", icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), - text = "Toggle Back flashlight", - secondaryText = "Repeat until released", + title = "Toggle Back flashlight", + summary = "Repeat 5x until pressed again • Hold down", error = "Flashlight not found", isErrorFixable = true, - linkType = LinkType.ARROW, + showDelayChip = true, ), ActionListItemModel( id = "2", + icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch), + title = "Open magnifier", + isCustomName = true, + showDelayChip = true, + delayBeforeNextAction = 100, + ), + ActionListItemModel( + id = "3", icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), - text = "Toggle Back flashlight", - secondaryText = "Repeat until released", - error = null, - isErrorFixable = true, + title = "Toggle Back flashlight", + isEnabled = false, ), ), shortcuts = setOf( @@ -427,3 +624,15 @@ private fun LoadedPreview() { ) } } + +@Preview(showSystemUi = true) +@Composable +private fun DelayDialogPreview() { + KeyMapperTheme { + DelayBeforeNextActionDialog( + initialDelay = 400, + onSaveClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt index 96aee5ec15..e383a5267c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt @@ -183,6 +183,16 @@ class ConfigActionsUseCaseImpl @Inject constructor( } } + override fun setActionCustomName(uid: String, customName: String?) { + setActionOption(uid) { action -> + action.copy(customName = customName?.trim()?.takeIf { it.isNotEmpty() }) + } + } + + override fun setActionEnabled(uid: String, enabled: Boolean) { + setActionOption(uid) { action -> action.copy(isEnabled = enabled) } + } + private suspend fun getActionShortcuts(json: String?): List { if (json == null) { return emptyList() @@ -222,6 +232,12 @@ class ConfigActionsUseCaseImpl @Inject constructor( repeat = true } + // Actions repeat until the trigger is released by default so do not repeat if + // the release of the trigger can not be detected. + if (!keyMap.isRepeatUntilReleasedAllowed()) { + repeat = false + } + if (data is ActionData.AnswerCall) { configConstraints.addConstraint(ConstraintData.PhoneRinging) } @@ -269,6 +285,8 @@ interface ConfigActionsUseCase : GetDefaultKeyMapOptionsUseCase { fun setActionData(uid: String, data: ActionData) fun setActionMultiplier(uid: String, multiplier: Int) fun setDelayBeforeNextAction(uid: String, delay: Int) + fun setActionCustomName(uid: String, customName: String?) + fun setActionEnabled(uid: String, enabled: Boolean) fun setActionRepeatRate(uid: String, repeatRate: Int) fun setActionRepeatLimit(uid: String, repeatLimit: Int) fun setActionStopRepeatingWhenTriggerPressedAgain(uid: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt index b606478b92..2e56831119 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt @@ -18,7 +18,6 @@ import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogProvider -import io.github.sds100.keymapper.base.utils.ui.LinkType import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo @@ -53,12 +52,13 @@ class ConfigActionsViewModel @Inject constructor( private val onboardingUseCase: OnboardingUseCase, setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, fixKeyEventActionDelegate: FixKeyEventActionDelegate, - onboardingTipDelegate: OnboardingTipDelegate, + private val onboardingTipDelegate: OnboardingTipDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, ) : ViewModel(), ActionOptionsBottomSheetCallback, + ActionListCallback, SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate, ResourceProvider by resourceProvider, DialogProvider by dialogProvider, @@ -113,13 +113,13 @@ class ConfigActionsViewModel @Inject constructor( return config.keyMap.first().dataOrNull()?.actionList?.singleOrNull { it.uid == uid }?.data } - fun onClickShortcut(action: ActionData) { + override fun onClickShortcut(action: ActionData) { viewModelScope.launch { config.addAction(action) } } - fun onFixError(actionUid: String) { + override fun onFixErrorClick(actionUid: String) { viewModelScope.launch { val actionData = getActionData(actionUid) ?: return@launch val error = @@ -167,7 +167,7 @@ class ConfigActionsViewModel @Inject constructor( } } - fun onMoveAction(fromIndex: Int, toIndex: Int) { + override fun onMove(fromIndex: Int, toIndex: Int) { config.moveAction(fromIndex, toIndex) } @@ -175,11 +175,11 @@ class ConfigActionsViewModel @Inject constructor( config.removeAction(actionUid) } - fun onEditClick(actionUid: String) { + override fun onEditClick(actionUid: String) { actionOptionsUid.value = actionUid } - fun onTestClick(actionUid: String) { + override fun onTestClick(actionUid: String) { viewModelScope.launch { val actionData = getActionData(actionUid) ?: return@launch attemptTestAction(actionData) @@ -213,10 +213,6 @@ class ConfigActionsViewModel @Inject constructor( } } - override fun onRepeatCheckedChange(checked: Boolean) { - actionOptionsUid.value?.let { uid -> config.setActionRepeatEnabled(uid, checked) } - } - override fun onRepeatLimitChanged(limit: Int) { actionOptionsUid.value?.let { uid -> config.setActionRepeatLimit(uid, limit) } } @@ -229,43 +225,79 @@ class ConfigActionsViewModel @Inject constructor( actionOptionsUid.value?.let { uid -> config.setActionRepeatDelay(uid, delay) } } - override fun onHoldDownCheckedChange(checked: Boolean) { - actionOptionsUid.value?.let { uid -> config.setActionHoldDownEnabled(uid, checked) } - } - override fun onHoldDownDurationChanged(duration: Int) { actionOptionsUid.value?.let { uid -> config.setActionHoldDownDuration(uid, duration) } } - override fun onSelectHoldDownMode(holdDownMode: HoldDownMode) { - actionOptionsUid.value?.let { uid -> - config.setActionStopHoldingDownWhenTriggerPressedAgain( - uid, - holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, - ) + override fun onSelectHoldDownMode(holdDownMode: HoldDownMode?) { + val uid = actionOptionsUid.value ?: return + + if (holdDownMode == null) { + config.setActionHoldDownEnabled(uid, false) + return } + + config.setActionHoldDownEnabled(uid, true) + config.setActionStopHoldingDownWhenTriggerPressedAgain( + uid, + holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, + ) } - override fun onDelayBeforeNextActionChanged(delay: Int) { - actionOptionsUid.value?.let { uid -> config.setDelayBeforeNextAction(uid, delay) } + fun onDelayChanged(actionUid: String, delay: Int) { + config.setDelayBeforeNextAction(actionUid, delay) + } + + override fun onEnabledChange(actionUid: String, enabled: Boolean) { + config.setActionEnabled(actionUid, enabled) + } + + override fun onActionTipDismiss() { + onActionTipDismissClick() + } + + override fun onTipButtonClick(id: String) { + onboardingTipDelegate.onTipButtonClick(id) } override fun onMultiplierChanged(multiplier: Int) { actionOptionsUid.value?.let { uid -> config.setActionMultiplier(uid, multiplier) } } - override fun onSelectRepeatMode(repeatMode: RepeatMode) { - actionOptionsUid.value?.let { uid -> - when (repeatMode) { - RepeatMode.TRIGGER_RELEASED -> config.setActionStopRepeatingWhenTriggerReleased( - uid, - ) + override fun onSelectRepeatMode(repeatMode: RepeatMode?) { + val uid = actionOptionsUid.value ?: return - RepeatMode.LIMIT_REACHED -> config.setActionStopRepeatingWhenLimitReached(uid) + if (repeatMode == null) { + config.setActionRepeatEnabled(uid, false) + return + } - RepeatMode.TRIGGER_PRESSED_AGAIN -> - config.setActionStopRepeatingWhenTriggerPressedAgain(uid) - } + config.setActionRepeatEnabled(uid, true) + + when (repeatMode) { + RepeatMode.TRIGGER_RELEASED -> config.setActionStopRepeatingWhenTriggerReleased(uid) + + RepeatMode.LIMIT_REACHED -> config.setActionStopRepeatingWhenLimitReached(uid) + + RepeatMode.TRIGGER_PRESSED_AGAIN -> + config.setActionStopRepeatingWhenTriggerPressedAgain(uid) + } + } + + override fun onCustomNameChanged(name: String) { + val uid = actionOptionsUid.value ?: return + onRenameAction(uid, name) + } + + fun onRenameAction(uid: String, name: String) { + viewModelScope.launch { + val actionData = getActionData(uid) ?: return@launch + val showDeviceDescriptors = displayAction.showDeviceDescriptors.first() + val generatedTitle = uiHelper.getTitle(actionData, showDeviceDescriptors) + + // Submitting the generated title removes the custom name. + val customName = name.trim().takeIf { it.isNotEmpty() && it != generatedTitle } + config.setActionCustomName(uid, customName) } } @@ -312,60 +344,36 @@ class ConfigActionsViewModel @Inject constructor( ): List { val actionErrors = errorSnapshot.getErrors(keyMap.actionList.map { it.data }) - return keyMap.actionList.mapIndexed { index, action -> - - val title: String = if (action.multiplier != null && action.multiplier > 1) { - val multiplier = action.multiplier - "${multiplier}x ${uiHelper.getTitle(action.data, showDeviceDescriptors)}" - } else { - uiHelper.getTitle(action.data, showDeviceDescriptors) - } + val midDot = getString(R.string.middot) + return keyMap.actionList.mapIndexed { index, action -> val icon: ComposeIconInfo = uiHelper.getIcon(action.data) val error: KMError? = actionErrors[action.data] - val extraInfo = buildString { - val midDot = getString(R.string.middot) - - uiHelper.getOptionLabels(keyMap, action).forEachIndexed { index, label -> - if (index != 0) { - append(" $midDot ") - } + val repeatText = uiHelper.getRepeatDescription(keyMap, action) + val burstText = uiHelper.getBurstDescription(action) + val holdDownText = uiHelper.getHoldDownDescription(keyMap, action) - append(label) - } - - action.delayBeforeNextAction.apply { - if (keyMap.isDelayBeforeNextActionAllowed() && - action.delayBeforeNextAction != null - ) { - if (this@buildString.isNotBlank()) { - append(" $midDot ") - } - - append( - getString( - R.string.action_title_wait, - action.delayBeforeNextAction, - ), - ) - } - } - }.takeIf { it.isNotBlank() } - - val linkType = when { - index < keyMap.actionList.lastIndex -> LinkType.ARROW - else -> LinkType.HIDDEN - } + val summary = listOfNotNull(repeatText, burstText, holdDownText) + .joinToString(" $midDot ") + .takeIf { it.isNotBlank() } ActionListItemModel( id = action.uid, icon = icon, - text = title, - secondaryText = extraInfo, + title = uiHelper.getTitle(action, showDeviceDescriptors), + isCustomName = !action.customName.isNullOrBlank(), + isEnabled = action.isEnabled, + summary = summary, error = error?.getFullMessage(this), isErrorFixable = error?.isFixable ?: true, - linkType = linkType, + showRepeat = keyMap.isRepeatingActionsAllowed(), + repeatText = repeatText, + burstText = burstText, + showHoldDown = keyMap.isHoldingDownActionAllowed(action), + holdDownText = holdDownText, + showDelayChip = index < keyMap.actionList.lastIndex, + delayBeforeNextAction = action.delayBeforeNextAction, ) } } @@ -382,12 +390,17 @@ class ConfigActionsViewModel @Inject constructor( val allowedRepeatModes = mutableSetOf() - if (keyMap.isChangingRepeatModeAllowed(action)) { - allowedRepeatModes.add(RepeatMode.TRIGGER_RELEASED) + if (keyMap.isRepeatingActionsAllowed()) { + if (keyMap.isRepeatUntilReleasedAllowed()) { + allowedRepeatModes.add(RepeatMode.TRIGGER_RELEASED) + } + allowedRepeatModes.add(RepeatMode.TRIGGER_PRESSED_AGAIN) allowedRepeatModes.add(RepeatMode.LIMIT_REACHED) } + val showDeviceDescriptors = displayAction.showDeviceDescriptors.first() + val defaultRepeatRate = config.defaultRepeatRate.first() val defaultRepeatDelay = config.defaultRepeatDelay.first() val defaultHoldDownDuration = config.defaultHoldDownDuration.first() @@ -404,6 +417,10 @@ class ConfigActionsViewModel @Inject constructor( keyMap.trigger.keys.any { it is EvdevTriggerKey } return ActionOptionsState( + title = uiHelper.getTitle(action, showDeviceDescriptors), + actionTypeTitle = getString(ActionUtils.getTitle(action.data.id)), + actionTypeIcon = ActionUtils.getComposeIcon(action.data.id), + showEditButton = action.data.isEditable(), showRepeat = keyMap.isRepeatingActionsAllowed(), @@ -432,19 +449,12 @@ class ConfigActionsViewModel @Inject constructor( holdDownDuration = action.holdDownDuration ?: defaultHoldDownDuration, defaultHoldDownDuration = defaultHoldDownDuration, - showHoldDownMode = keyMap.isStopHoldingDownActionWhenTriggerPressedAgainAllowed( - action, - ), holdDownMode = if (action.stopHoldDownWhenTriggerPressedAgain) { HoldDownMode.TRIGGER_PRESSED_AGAIN } else { HoldDownMode.TRIGGER_RELEASED }, - showDelayBeforeNextAction = keyMap.isDelayBeforeNextActionAllowed(), - delayBeforeNextAction = action.delayBeforeNextAction ?: 0, - defaultDelayBeforeNextAction = 0, - multiplier = action.multiplier ?: 1, defaultMultiplier = 1, ) @@ -463,53 +473,3 @@ sealed class ConfigActionsState { override val shortcuts: Set> = emptySet(), ) : ConfigActionsState() } - -data class ActionListItemModel( - val id: String, - val icon: ComposeIconInfo, - val text: String, - val secondaryText: String?, - val error: String? = null, - val isErrorFixable: Boolean = true, - val linkType: LinkType = LinkType.HIDDEN, -) - -data class ActionOptionsState( - val showEditButton: Boolean, - - val showRepeat: Boolean, - val isRepeatChecked: Boolean, - - val showRepeatRate: Boolean, - val showRepeatRateWarning: Boolean, - val repeatRate: Int, - val defaultRepeatRate: Int, - - val showRepeatDelay: Boolean, - val repeatDelay: Int, - val defaultRepeatDelay: Int, - - val showRepeatLimit: Boolean, - val repeatLimit: Int, - val defaultRepeatLimit: Int, - - val allowedRepeatModes: Set, - val repeatMode: RepeatMode, - - val showHoldDown: Boolean, - val isHoldDownChecked: Boolean, - - val showHoldDownDuration: Boolean, - val holdDownDuration: Int, - val defaultHoldDownDuration: Int, - - val showHoldDownMode: Boolean, - val holdDownMode: HoldDownMode, - - val showDelayBeforeNextAction: Boolean, - val delayBeforeNextAction: Int, - val defaultDelayBeforeNextAction: Int, - - val multiplier: Int, - val defaultMultiplier: Int, -) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt index 01d28b35b1..7754d823ae 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt @@ -81,7 +81,10 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( if (groupUid == null) { add( DetectKeyMapModel( - keyMap = keyMap, + // Disabled actions must never be performed. + keyMap = keyMap.copy( + actionList = keyMap.actionList.filter { it.isEnabled }, + ), groupConstraintStates = constraintStates, ), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt index 929038eb47..f70861d8e3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt @@ -125,14 +125,9 @@ class KeyMapListItemCreator( for (action in keyMap.actionList) { val actionTitle: String = if (action.multiplier != null) { - "${action.multiplier}x ${ - actionUiHelper.getTitle( - action.data, - showDeviceDescriptors, - ) - }" + "${action.multiplier}x ${actionUiHelper.getTitle(action, showDeviceDescriptors)}" } else { - actionUiHelper.getTitle(action.data, showDeviceDescriptors) + actionUiHelper.getTitle(action, showDeviceDescriptors) } val chipText = buildString { @@ -153,7 +148,7 @@ class KeyMapListItemCreator( append( getString( - R.string.action_title_wait, + R.string.action_title_wait_ms, action.delayBeforeNextAction, ), ) @@ -163,8 +158,14 @@ class KeyMapListItemCreator( val icon: ComposeIconInfo = actionUiHelper.getIcon(action.data) val error: KMError? = actionErrors[action.data] - val chip = if (error == null) { - ComposeChipModel.Normal(id = action.uid, text = chipText, icon = icon) + // Disabled actions are never performed so do not show their errors. + val chip = if (error == null || !action.isEnabled) { + ComposeChipModel.Normal( + id = action.uid, + text = chipText, + icon = icon, + isEnabled = action.isEnabled, + ) } else { ComposeChipModel.Error(action.uid, chipText, error, isFixable = error.isFixable) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt index 4688989732..a46e7ef45c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt @@ -473,20 +473,26 @@ private fun OptionsDescription(modifier: Modifier = Modifier, options: List Unit) { when (model) { is ComposeChipModel.Normal -> { + // Grey out disabled chips using the Material 3 alpha for disabled content. + val contentAlpha = if (model.isEnabled) 1f else 0.38f + CompactChip( text = model.text, + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), icon = model.icon?.let { icon -> { when (icon) { is ComposeIconInfo.Drawable -> Icon( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier + .fillMaxHeight(), painter = rememberDrawablePainter(icon.drawable), contentDescription = null, tint = Color.Unspecified, ) is ComposeIconInfo.Vector -> Icon( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier + .fillMaxHeight(), imageVector = icon.imageVector, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, @@ -653,6 +659,7 @@ private fun sampleList(): List { drawable = context.drawable(R.drawable.ic_launcher_web), ), "Open Key Mapper", + isEnabled = false, ), ), constraintMode = ConstraintMode.AND, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt index f75bbf55c6..9ea161792b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt @@ -9,6 +9,8 @@ import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper import io.github.sds100.keymapper.base.constraints.ConstraintState import io.github.sds100.keymapper.base.detection.KeyMapAlgorithm +import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey +import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerEntityMapper import io.github.sds100.keymapper.base.trigger.TriggerKey @@ -57,6 +59,13 @@ data class KeyMap( fun isChangingRepeatLimitAllowed(action: Action): Boolean = action.repeat && isRepeatingActionsAllowed() + /** + * The release of assistant and fingerprint gesture triggers can not be detected. + */ + fun isRepeatUntilReleasedAllowed(): Boolean = trigger.keys.none { + it is AssistantTriggerKey || it is FingerprintTriggerKey + } + fun isStopHoldingDownActionWhenTriggerPressedAgainAllowed(action: Action): Boolean = action.holdDown && !action.repeat && isHoldingDownActionAllowed(action) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt index fa3e7d9a7a..c095902c40 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt @@ -286,8 +286,10 @@ private fun SortDraggableList( onSortFieldClick: (SortField) -> Unit, ) { val lazyListState = rememberLazyListState() + val sortFields = remember(sortFieldOrderList) { sortFieldOrderList.map { it.field } } val dragDropState = rememberDragDropState( lazyListState = lazyListState, + keys = sortFields, onMove = onMove, ) @@ -296,13 +298,13 @@ private fun SortDraggableList( state = lazyListState, ) { itemsIndexed( - items = sortFieldOrderList, + items = dragDropState.ordered(sortFieldOrderList) { it.field }, key = { _, item -> item.field }, ) { index, item -> DraggableItem( dragDropState = dragDropState, - index = index, + key = item.field, ) { isDragging -> SortFieldListItem( index = index + 1, @@ -311,17 +313,7 @@ private fun SortDraggableList( onToggle = { onSortFieldClick(item.field) }, isDragging = isDragging, onDrag = { dragDropState.onDrag(it) }, - onDragStarted = { offset -> - // Calculate the offset of the item in the list - val lazyItem = lazyListState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == index } ?: return@SortFieldListItem - - val initialOffset = lazyItem.offset - - val finalOffset = offset + Offset(0f, initialOffset.toFloat()) - - dragDropState.onDragStart(finalOffset) - }, + onDragStarted = { dragDropState.onDragStart(item.field) }, onDragStopped = { dragDropState.onDragInterrupted() }, ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt index e32b627772..ed8c073118 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -437,16 +438,15 @@ private fun TriggerList( onAddMoreClick: () -> Unit, ) { val lazyListState = rememberLazyListState() + val triggerKeyIds = remember(triggerList) { triggerList.map { it.id } } + + // Only the trigger keys can be dragged. Not the "add more" button. val dragDropState = rememberDragDropState( lazyListState = lazyListState, + keys = triggerKeyIds, onMove = onMove, - // Do not drag and drop the "add more" button - ignoreLastItems = if (triggerList.isEmpty()) { - 0 - } else { - 1 - }, ) + val orderedTriggerList = dragDropState.ordered(triggerList) { it.id } // Use dragContainer rather than .draggable() modifier because that causes // dragging the first item to be always be dropped in the next position. @@ -457,19 +457,19 @@ private fun TriggerList( horizontalAlignment = Alignment.CenterHorizontally, ) { itemsIndexed( - triggerList, + orderedTriggerList, key = { _, item -> item.id }, contentType = { _, _ -> "key" }, ) { index, model -> DraggableItem( dragDropState = dragDropState, - index = index, + key = model.id, ) { isDragging -> TriggerKeyListItem( modifier = Modifier.fillMaxWidth(), model = model, index = index, - isDraggingEnabled = triggerList.size > 1, + isDraggingEnabled = orderedTriggerList.size > 1, isDragging = isDragging, isReorderingEnabled = isReorderingEnabled, dragDropState = dragDropState, @@ -481,7 +481,7 @@ private fun TriggerList( } else { null }, - onMoveDown = if (isReorderingEnabled && index < triggerList.size - 1) { + onMoveDown = if (isReorderingEnabled && index < orderedTriggerList.size - 1) { { onMove(index, index + 1) } } else { null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt index 6370ac90b2..adfb51b65f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt @@ -127,8 +127,8 @@ fun TriggerKeyListItem( enabled = isDraggingEnabled, orientation = Orientation.Vertical, startDragImmediately = true, - onDragStarted = { offset -> - dragDropState?.onDragStart(index, offset) + onDragStarted = { + dragDropState?.onDragStart(model.id) }, onDragStopped = { dragDropState?.onDragInterrupted() }, ), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt index 040b285a01..3eb294318b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt @@ -42,6 +42,7 @@ fun CompactChip( Surface( modifier = modifier.height(chipHeight), color = containerColor, + contentColor = contentColor, border = borderStroke, shape = AssistChipDefaults.shape, ) { @@ -51,6 +52,7 @@ fun CompactChip( Surface( modifier = modifier.height(chipHeight), color = containerColor, + contentColor = contentColor, border = borderStroke, shape = AssistChipDefaults.shape, onClick = onClick, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt index 15f44addaf..992fda2688 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt @@ -8,6 +8,10 @@ sealed class ComposeChipModel { override val id: String, val icon: ComposeIconInfo?, override val text: String, + /** + * Disabled chips are greyed out. + */ + val isEnabled: Boolean = true, ) : ComposeChipModel() data class Error( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt index 4212057655..10a9cf2f08 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt @@ -9,16 +9,130 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue 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.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import kotlinx.coroutines.launch + +@Composable +fun TextFieldDialog( + title: String, + submitButtonText: String, + initialText: String, + /** + * Returns an error message. + */ + onSubmitClick: suspend (newText: String) -> String? = { null }, + onDismissRequest: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + var textFieldValue: TextFieldValue by remember { + mutableStateOf( + TextFieldValue( + text = initialText, + selection = TextRange(initialText.length), + ), + ) + } + var error: String? by remember { mutableStateOf(null) } + val isError by remember { derivedStateOf { textFieldValue.text.isBlank() || error != null } } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + + val submit = { + scope.launch { + error = onSubmitClick(textFieldValue.text) + + if (error == null) { + onDismissRequest() + } + } + Unit + } + + CustomDialog( + title = title, + confirmButton = { + TextButton( + onClick = submit, + enabled = !isError, + ) { + Text(submitButtonText) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) + } + }, + onDismissRequest = onDismissRequest, + ) { + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .focusRequester(focusRequester), + value = textFieldValue, + onValueChange = { + error = null + textFieldValue = it + }, + singleLine = true, + maxLines = 1, + isError = isError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (!isError) { + submit() + } + }, + ), + supportingText = { + when { + error != null -> Text(error!!) + + textFieldValue.text.isBlank() -> { + Text(stringResource(R.string.error_cant_be_empty)) + } + } + }, + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -100,3 +214,27 @@ fun CustomDialogContent( } } } + +@Preview(widthDp = 800, heightDp = 400) +@Composable +private fun TextFieldDialogPreview() { + KeyMapperTheme { + TextFieldDialog( + title = "Title", + submitButtonText = "Submit", + initialText = "Some dialog text", + ) + } +} + +@Preview(widthDp = 800, heightDp = 400) +@Composable +private fun TextFieldDialogEmptyPreview() { + KeyMapperTheme { + TextFieldDialog( + title = "Title", + submitButtonText = "Submit", + initialText = "", + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt index bece2124d7..020f15b5a5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt @@ -6,51 +6,101 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * The distance past the edge of the list at which auto scrolling reaches its maximum speed. + */ +private val autoScrollEdgeDistance = 64.dp + +/** + * Maximum auto scroll speed per second. + */ +private val autoScrollMaxSpeed = 1000.dp + @Composable fun rememberDragDropState( lazyListState: LazyListState, /** - * Ignore the last N items in the list. Do not allow dragging and dropping these items or - * placing other items in these positions. + * The keys of the items that can be dragged, in their current order. Other items in the list, + * such as headers and footers, can not be dragged and items can not be dropped on them. */ - ignoreLastItems: Int = 0, - onMove: (Int, Int) -> Unit, + keys: List, + /** + * Called once when the drag ends with the indices in [keys]. While dragging, the order is + * only changed locally, so use [DragDropState.ordered] to display the items. + */ + onMove: (fromIndex: Int, toIndex: Int) -> Unit, onStart: () -> Unit = {}, onEnd: () -> Unit = {}, ): DragDropState { val scope = rememberCoroutineScope() + val currentOnMove by rememberUpdatedState(onMove) + val currentOnStart by rememberUpdatedState(onStart) + val currentOnEnd by rememberUpdatedState(onEnd) + val state = remember(lazyListState) { DragDropState( state = lazyListState, - ignoreLastItems = ignoreLastItems, - onStart = onStart, - onMove = onMove, - onEnd = onEnd, scope = scope, + onStart = { currentOnStart() }, + onMove = { fromIndex, toIndex -> currentOnMove(fromIndex, toIndex) }, + onEnd = { currentOnEnd() }, ) } - LaunchedEffect(state) { - while (true) { - val diff = state.scrollChannel.receive() - lazyListState.scrollBy(diff) + SideEffect { + state.updateKeys(keys) + } + + val density = LocalDensity.current + val edgeDistancePx = with(density) { autoScrollEdgeDistance.toPx() } + val maxSpeedPx = with(density) { autoScrollMaxSpeed.toPx() } + + LaunchedEffect(state, edgeDistancePx, maxSpeedPx) { + snapshotFlow { state.draggingItemKey != null }.collectLatest { isDragging -> + if (!isDragging) { + return@collectLatest + } + + var lastFrameNanos = withFrameNanos { it } + + while (true) { + val frameNanos = withFrameNanos { it } + val seconds = (frameNanos - lastFrameNanos) / 1_000_000_000f + lastFrameNanos = frameNanos + + val overflow = state.draggingItemOverflow() + if (overflow != 0f) { + val fraction = (overflow / edgeDistancePx).coerceIn(-1f, 1f) + lazyListState.scrollBy(fraction * maxSpeedPx * seconds) + } + + // Check again after scrolling because other items move under the dragged item. + state.moveToTarget() + } } } @@ -58,20 +108,29 @@ fun rememberDragDropState( } /** - * This is copied from an official demo for drag and drop at https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt + * Originally based on the official demo for drag and drop at https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt + * + * Items are tracked by their key rather than their index so the list can contain other items, + * such as headers and footers. The new order is kept locally until the drag ends so the list + * does not depend on the new order propagating back from a ViewModel while dragging. */ class DragDropState internal constructor( private val state: LazyListState, - private val ignoreLastItems: Int, private val scope: CoroutineScope, private val onStart: () -> Unit, private val onMove: (Int, Int) -> Unit, private val onEnd: () -> Unit, ) { - var draggingItemIndex by mutableStateOf(null) + var draggingItemKey by mutableStateOf(null) private set - internal val scrollChannel = Channel() + private var keys: List = emptyList() + + /** + * The order of the keys while dragging, and after the drag ends until the new order is + * received in [updateKeys]. + */ + private var pendingOrder by mutableStateOf?>(null) private var draggingItemDraggedDelta by mutableFloatStateOf(0f) private var draggingItemInitialOffset by mutableIntStateOf(0) @@ -81,44 +140,81 @@ class DragDropState internal constructor( } ?: 0f private val draggingItemLayoutInfo: LazyListItemInfo? - get() = state.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == draggingItemIndex } + get() = draggingItemKey?.let { key -> + state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key } + } + + /** + * The layout info when the order was last changed. Do not change the order again until the + * list has been laid out with the new order, otherwise the item can move back and forth. + */ + private var layoutInfoAtLastMove: LazyListLayoutInfo? = null - internal var previousIndexOfDraggedItem by mutableStateOf(null) + internal var previousKeyOfDraggedItem by mutableStateOf(null) private set internal var previousItemOffset = Animatable(0f) private set - fun onDragStart(index: Int, offset: Offset) { - // Calculate the offset of the item in the list - val lazyItem = state.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == index } - ?: return + /** + * Sort the items in the order they should be displayed while dragging. + */ + fun ordered(items: List, key: (T) -> Any): List { + val order = pendingOrder ?: return items + val positions = order.withIndex().associate { it.value to it.index } + return items.sortedBy { positions[key(it)] ?: Int.MAX_VALUE } + } + + internal fun updateKeys(newKeys: List) { + if (newKeys == keys) { + return + } + + keys = newKeys - val initialOffset = lazyItem.offset + // The moved list has been received so the local order is no longer needed. + if (draggingItemKey == null) { + pendingOrder = null + } + } - val finalOffset = offset + Offset(0f, initialOffset.toFloat()) + /** + * Start dragging the item with this key. Use this when dragging with a drag handle. + */ + fun onDragStart(key: Any) { + if (key !in keys) { + return + } - onDragStart(finalOffset) + val item = state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key } ?: return + startDragging(item) } + /** + * Start dragging the item at this offset in the list. + */ fun onDragStart(offset: Offset) { - // check if the touch position is on drag handle - state.layoutInfo.visibleItemsInfo - .firstOrNull { item -> - item.index < state.layoutInfo.totalItemsCount - ignoreLastItems && - offset.y.toInt() in item.offset..(item.offset + item.size) - }?.also { - draggingItemIndex = it.index - draggingItemInitialOffset = it.offset - } + val item = state.layoutInfo.visibleItemsInfo.firstOrNull { item -> + item.key in keys && offset.y.toInt() in item.offset..item.offsetEnd + } ?: return + + startDragging(item) + } + + private fun startDragging(item: LazyListItemInfo) { + pendingOrder = ordered(keys) { it } + draggingItemKey = item.key + draggingItemInitialOffset = item.offset + draggingItemDraggedDelta = 0f + layoutInfoAtLastMove = null onStart.invoke() } fun onDragInterrupted() { - if (draggingItemIndex != null) { - previousIndexOfDraggedItem = draggingItemIndex + val key = draggingItemKey + + if (key != null) { + previousKeyOfDraggedItem = key val startOffset = draggingItemOffset scope.launch { previousItemOffset.snapTo(startOffset) @@ -129,68 +225,123 @@ class DragDropState internal constructor( visibilityThreshold = 1f, ), ) - previousIndexOfDraggedItem = null + previousKeyOfDraggedItem = null + } + + val fromIndex = keys.indexOf(key) + val toIndex = ordered(keys) { it }.indexOf(key) + + if (fromIndex != -1 && toIndex != -1 && fromIndex != toIndex) { + // Keep the local order until the new keys are received. + onMove.invoke(fromIndex, toIndex) + } else { + pendingOrder = null } } + draggingItemDraggedDelta = 0f - draggingItemIndex = null + draggingItemKey = null draggingItemInitialOffset = 0 + layoutInfoAtLastMove = null onEnd.invoke() } fun onDrag(offset: Offset) { draggingItemDraggedDelta += offset.y + moveToTarget() + } + /** + * Move the dragged item to the position of the item underneath its middle. + */ + internal fun moveToTarget() { val draggingItem = draggingItemLayoutInfo ?: return + val layoutInfo = state.layoutInfo + val order = pendingOrder ?: return + + if (layoutInfo === layoutInfoAtLastMove) { + return + } + val startOffset = draggingItem.offset + draggingItemOffset - val endOffset = startOffset + draggingItem.size - val middleOffset = startOffset + (endOffset - startOffset) / 2f + val middleOffset = startOffset + draggingItem.size / 2f + + val targetItem = layoutInfo.visibleItemsInfo.find { item -> + item.key != draggingItem.key && + item.key in keys && + middleOffset.toInt() in item.offset..item.offsetEnd + } ?: return - val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> - middleOffset.toInt() in item.offset..item.offsetEnd && - draggingItem.index != item.index + // Only move once the middle of the dragged item has passed the middle of the target. + // Otherwise items of different heights swap back and forth. + val targetMiddle = targetItem.offset + targetItem.size / 2f + val hasPassedTarget = if (targetItem.offset > draggingItem.offset) { + middleOffset > targetMiddle + } else { + middleOffset < targetMiddle } - val itemCount = state.layoutInfo.totalItemsCount - if (targetItem != null) { - val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) { - draggingItem.index - } else if (draggingItem.index == state.firstVisibleItemIndex) { - targetItem.index - } else { - null - } + if (!hasPassedTarget) { + return + } - if (draggingItem.index < itemCount - ignoreLastItems && - targetItem.index < itemCount - ignoreLastItems - ) { - if (scrollToIndex != null) { - scope.launch { - // this is needed to neutralize automatic keeping the first item first. - state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) - onMove.invoke(draggingItem.index, targetItem.index) - } - } else { - onMove.invoke(draggingItem.index, targetItem.index) - } - draggingItemIndex = targetItem.index - } + val fromIndex = order.indexOf(draggingItem.key) + val toIndex = order.indexOf(targetItem.key) + + if (fromIndex == -1 || toIndex == -1) { + return + } + + // Where the dragged item will be laid out after moving if the other items stay in place. + val newOffset = if (targetItem.offset > draggingItem.offset) { + targetItem.offsetEnd - draggingItem.size } else { - val overscroll = when { - draggingItemDraggedDelta > 0 -> - (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast( - 0f, - ) + targetItem.offset + } + val minOffset = layoutInfo.viewportStartOffset + val maxOffset = + (layoutInfo.viewportEndOffset - draggingItem.size).coerceAtLeast(minOffset) - draggingItemDraggedDelta < 0 -> - (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + if (newOffset !in minOffset..maxOffset) { + // Moving a small item past a large item can move it out of the list, which stops + // it being laid out so the drag can not continue. Scroll so it stays in the list. + // The dragged item will be at the target's index after moving. + state.requestScrollToItem(targetItem.index, -newOffset.coerceIn(minOffset, maxOffset)) + } else if (draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + // The list keeps the first visible item in place when the order changes, which + // scrolls the list when the first visible item is moved. Keep the scroll position. + state.requestScrollToItem( + state.firstVisibleItemIndex, + state.firstVisibleItemScrollOffset, + ) + } - else -> 0f - } - if (overscroll != 0f) { - scrollChannel.trySend(overscroll) - } + pendingOrder = order.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } + layoutInfoAtLastMove = layoutInfo + } + + /** + * How far the dragged item is past the edge of the list in the direction it is being dragged. + * Positive when past the end and negative when past the start. + */ + internal fun draggingItemOverflow(): Float { + val draggingItem = draggingItemLayoutInfo ?: return 0f + val layoutInfo = state.layoutInfo + + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + + return when { + draggingItemDraggedDelta > 0 -> + (endOffset - layoutInfo.viewportEndOffset).coerceAtLeast(0f) + + draggingItemDraggedDelta < 0 -> + (startOffset - layoutInfo.viewportStartOffset).coerceAtMost(0f) + + else -> 0f } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt index d40e0cb3cd..7d05a62527 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt @@ -11,18 +11,21 @@ import androidx.compose.ui.zIndex @Composable fun LazyItemScope.DraggableItem( dragDropState: DragDropState, - index: Int, + /** + * The same key that is used for this item in the lazy list. + */ + key: Any, modifier: Modifier = Modifier, content: @Composable BoxScope.(isDragging: Boolean) -> Unit, ) { - val dragging = index == dragDropState.draggingItemIndex + val dragging = key == dragDropState.draggingItemKey val draggingModifier = if (dragging) { Modifier .zIndex(1f) .graphicsLayer { translationY = dragDropState.draggingItemOffset } - } else if (index == dragDropState.previousIndexOfDraggedItem) { + } else if (key == dragDropState.previousKeyOfDraggedItem) { Modifier .zIndex(1f) .graphicsLayer { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt index 8bb1949548..9fdfd8433e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt @@ -44,7 +44,7 @@ import kotlin.math.roundToInt @Composable fun SliderOptionText( modifier: Modifier = Modifier, - title: String, + title: String?, value: Float, defaultValue: Float, valueText: (Float) -> String, @@ -77,13 +77,15 @@ fun SliderOptionText( } Column(modifier = modifier) { - Text( - modifier = Modifier, - text = title, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (title != null) { + Text( + modifier = Modifier, + text = title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Row(verticalAlignment = Alignment.CenterVertically) { val interactionSource = remember { MutableInteractionSource() } @@ -138,7 +140,7 @@ fun SliderOptionText( private fun ValueDialog( initialValue: Int?, placeholderValue: String?, - title: String, + title: String?, onDismissRequest: () -> Unit, onSaveClick: (Int?) -> Unit, ) { @@ -218,6 +220,26 @@ private fun Preview() { } } +@Preview +@Composable +private fun PreviewNoTitle() { + KeyMapperTheme { + Surface { + SliderOptionText( + modifier = Modifier.width(400.dp), + title = null, + value = 50f, + defaultValue = 500f, + valueText = { "${it.roundToInt()} ms" }, + isEnabled = true, + onValueChange = {}, + valueRange = 0f..1000f, + stepSize = 50, + ) + } + } +} + @Preview @Composable private fun PreviewDefault() { diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index bc6ba9d952..81b4dd3c53 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -70,7 +70,8 @@ Reset Once you have enabled the device admin, you must DEACTIVATE it if you want to uninstall Key Mapper. - Wait %sms + Wait %sms + Wait %ss Start activity: %s Start service: %s @@ -1802,6 +1803,16 @@ Remove Edit Test + Expand + Collapse + Rename + Add delay + Remove + No repeat + No burst + Burst %dx + No hold down + Hold down for %dms Add actions to set what the key map should do once triggered. Recently used actions Action options @@ -1816,6 +1827,32 @@ Cancel Run these actions when you trigger the key map: Repeating under 20 ms can lag! Adjust for your device and game. + Action name + %s options + Customize + Swap action + Repeat options + This action can be set to repeat multiple times for one trigger. + Don\'t repeat + Repeat a fixed number of times + Repeat continuously until the trigger is released + Not available for side key, assistant or fingerprint gesture triggers! + Repeat continuously until the trigger is pressed again + Before repeating, wait… + Then repeat this action every… + Maximum repeat limit + Burst options + The action can be performed multiple times in a row in a rapid-fire burst. + Every time the action repeats, it can be performed multiple times in a row in a rapid-fire burst. + Burst size + Hold down options + This action can be held down instead of being pressed and released immediately. + This action can be set to be held down for a period of time, regardless of how long you press the trigger. + Don\'t hold down + Hold down for a period of time + Hold down until the trigger is released + Hold down until the trigger is pressed again + Delay options Choose side Brightness diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt new file mode 100644 index 0000000000..7ecd30102e --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt @@ -0,0 +1,57 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.common.utils.hasFlag +import io.github.sds100.keymapper.data.entities.ActionEntity +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Test + +class ActionEntityMapperTest { + + @Test + fun `save and restore the custom name of an action`() { + val action = Action(data = ActionData.ConsumeKeyEvent, customName = "My action") + + val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single() + + assertThat(ActionEntityMapper.fromEntity(entity)?.customName, `is`("My action")) + } + + @Test + fun `do not save a blank custom name`() { + val action = Action(data = ActionData.ConsumeKeyEvent, customName = " ") + + val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single() + + assertThat(ActionEntityMapper.fromEntity(entity)?.customName, nullValue()) + } + + @Test + fun `action is enabled when the disabled flag is not set`() { + val entity = + ActionEntity(type = ActionEntity.Type.SYSTEM_ACTION, data = "consume_key_event") + + assertThat(ActionEntityMapper.fromEntity(entity)?.isEnabled, `is`(true)) + } + + @Test + fun `save and restore a disabled action`() { + val action = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false) + + val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single() + + assertThat(entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), `is`(true)) + assertThat(ActionEntityMapper.fromEntity(entity)?.isEnabled, `is`(false)) + } + + @Test + fun `do not set the disabled flag for an enabled action`() { + val action = Action(data = ActionData.ConsumeKeyEvent, isEnabled = true) + + val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single() + + assertThat(entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), `is`(false)) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt index 96309f4a02..b33fa2cdf9 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt @@ -6,6 +6,8 @@ import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapStateImpl import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey +import io.github.sds100.keymapper.base.trigger.AssistantTriggerType import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey import io.github.sds100.keymapper.base.utils.singleKeyTrigger @@ -17,6 +19,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -73,6 +76,83 @@ class ConfigActionsUseCaseTest { assertThat(actionList[0].repeat, `is`(false)) } + @Test + fun `add key event action with assistant trigger, do not enable repeat`() = + runTest(testDispatcher) { + configKeyMapState.setKeyMap( + KeyMap( + trigger = singleKeyTrigger( + AssistantTriggerKey( + type = AssistantTriggerType.ANY, + clickType = ClickType.SHORT_PRESS, + ), + ), + ), + ) + + useCase.addAction(ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_W)) + + val actionList = useCase.keyMap.value.dataOrNull()!!.actionList + assertThat(actionList[0].repeat, `is`(false)) + } + + @Test + fun `add key event action with key event trigger, repeat until released`() = + runTest(testDispatcher) { + configKeyMapState.setKeyMap( + KeyMap( + trigger = singleKeyTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + ), + ), + ), + ) + + useCase.addAction(ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_W)) + + val action = useCase.keyMap.value.dataOrNull()!!.actionList[0] + assertThat(action.repeat, `is`(true)) + assertThat(action.repeatMode, `is`(RepeatMode.TRIGGER_RELEASED)) + } + + @Test + fun `set custom name, trim the name and remove it when blank`() = runTest(testDispatcher) { + configKeyMapState.setKeyMap(KeyMap()) + useCase.addAction(ActionData.ConsumeKeyEvent) + + val uid = useCase.keyMap.value.dataOrNull()!!.actionList[0].uid + + useCase.setActionCustomName(uid, " My action ") + assertThat( + useCase.keyMap.value.dataOrNull()!!.actionList[0].customName, + `is`("My action"), + ) + + useCase.setActionCustomName(uid, " ") + assertThat(useCase.keyMap.value.dataOrNull()!!.actionList[0].customName, nullValue()) + } + + @Test + fun `disable an action, only that action is disabled`() = runTest(testDispatcher) { + configKeyMapState.setKeyMap(KeyMap()) + useCase.addAction(ActionData.ConsumeKeyEvent) + useCase.addAction(ActionData.GoHome) + + val uid = useCase.keyMap.value.dataOrNull()!!.actionList[0].uid + + useCase.setActionEnabled(uid, false) + + val actionList = useCase.keyMap.value.dataOrNull()!!.actionList + assertThat(actionList[0].isEnabled, `is`(false)) + assertThat(actionList[1].isEnabled, `is`(true)) + + useCase.setActionEnabled(uid, true) + assertThat(useCase.keyMap.value.dataOrNull()!!.actionList[0].isEnabled, `is`(true)) + } + /** * Issue #852. Add a phone ringing constraint when you add an action * to answer a phone call. diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt index a5f20562cf..45cc078e17 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt @@ -1,5 +1,7 @@ package io.github.sds100.keymapper.base.keymaps +import io.github.sds100.keymapper.base.actions.Action +import io.github.sds100.keymapper.base.actions.ActionData import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintMode @@ -211,6 +213,33 @@ class DetectKeyMapsUseCaseTest { assertThat(models, Matchers.empty()) } + @Test + fun `Key map has disabled actions then remove the disabled actions`() { + val enabledAction = Action(data = ActionData.GoHome) + val disabledAction = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false) + val keyMap = KeyMap(actionList = listOf(enabledAction, disabledAction)) + + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = emptyList(), + ) + + assertThat(models.single().keyMap.actionList, Matchers.contains(enabledAction)) + } + + @Test + fun `Key map has only disabled actions then action list is empty`() { + val disabledAction = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false) + val keyMap = KeyMap(actionList = listOf(disabledAction)) + + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = emptyList(), + ) + + assertThat(models.single().keyMap.actionList, Matchers.empty()) + } + @Test fun `One key map in child group then include`() { val keyMap = KeyMap(groupUid = "child") diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 7d20d6dca6..2ec96727b1 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -142,6 +142,12 @@ data class ActionEntity( const val ACTION_FLAG_SHELL_COMMAND_USE_ROOT = 16 const val ACTION_FLAG_SHELL_COMMAND_USE_ADB = 32 + /** + * Stored as disabled rather than enabled so actions saved before this flag existed + * are enabled by default. + */ + const val ACTION_FLAG_DISABLED = 64 + const val EXTRA_CUSTOM_STOP_REPEAT_BEHAVIOUR = "extra_custom_stop_repeat_behaviour" const val EXTRA_CUSTOM_HOLD_DOWN_BEHAVIOUR = "extra_custom_hold_down_behaviour" const val EXTRA_REPEAT_DELAY = "extra_hold_down_until_repeat_delay" @@ -150,6 +156,7 @@ data class ActionEntity( const val EXTRA_DELAY_BEFORE_NEXT_ACTION = "extra_delay_before_next_action" const val EXTRA_HOLD_DOWN_DURATION = "extra_hold_down_duration" const val EXTRA_REPEAT_LIMIT = "extra_repeat_limit" + const val EXTRA_CUSTOM_NAME = "extra_custom_name" const val EXTRA_SETTING_VALUE = "extra_setting_value" const val EXTRA_SETTING_TYPE = "extra_setting_type"