diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3c4e9f7e6d..b0a1c1f356 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -93,4 +93,13 @@ jobs: run: bundle install - name: Build apk with fastlane - run: bundle exec fastlane testing \ No newline at end of file + run: bundle exec fastlane testing + + - name: set apk name env + run: echo "APK_NAME=$(basename app/build/outputs/apk/free/ci/*.apk .apk)" >> $GITHUB_ENV + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: ${{ env.APK_NAME }} + path: app/build/outputs/apk/free/ci/${{ env.APK_NAME }}.apk diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt index b0c16cb0de..af47b01f6c 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt @@ -56,6 +56,8 @@ private fun AdvancedTriggersBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, + // Hide drag handle because other bottom sheets don't have it + dragHandle = {}, ) { Text( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt index a75720a4ac..5d9532b209 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -23,6 +23,8 @@ import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCas import io.github.sds100.keymapper.onboarding.OnboardingUseCaseImpl import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsUseCaseImpl import io.github.sds100.keymapper.shizuku.ShizukuInputEventInjector +import io.github.sds100.keymapper.sorting.SortKeyMapsUseCase +import io.github.sds100.keymapper.sorting.SortKeyMapsUseCaseImpl import io.github.sds100.keymapper.system.Shell import io.github.sds100.keymapper.system.accessibility.ControlAccessibilityServiceUseCase import io.github.sds100.keymapper.system.accessibility.ControlAccessibilityServiceUseCaseImpl @@ -220,4 +222,9 @@ object UseCases { keyEventRelayService, ServiceLocator.inputMethodAdapter(ctx), ) + fun sortKeyMapsUseCase(ctx: Context): SortKeyMapsUseCase = SortKeyMapsUseCaseImpl( + ServiceLocator.settingsRepository(ctx), + displaySimpleMapping(ctx), + ServiceLocator.resourceProvider(ctx), + ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt index e12dc24722..3d468a154f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt @@ -11,14 +11,21 @@ import io.github.sds100.keymapper.system.volume.VolumeStream import kotlinx.serialization.Serializable @Serializable -sealed class ActionData { +sealed class ActionData : Comparable { abstract val id: ActionId + override fun compareTo(other: ActionData) = id.compareTo(other.id) + @Serializable data class App( val packageName: String, ) : ActionData() { override val id: ActionId = ActionId.APP + + override fun compareTo(other: ActionData) = when (other) { + is App -> packageName.compareTo(other.packageName) + else -> super.compareTo(other) + } } @Serializable @@ -28,6 +35,11 @@ sealed class ActionData { val uri: String, ) : ActionData() { override val id: ActionId = ActionId.APP_SHORTCUT + + override fun compareTo(other: ActionData) = when (other) { + is AppShortcut -> shortcutTitle.compareTo(other.shortcutTitle) + else -> super.compareTo(other) + } } @Serializable @@ -45,6 +57,11 @@ sealed class ActionData { val descriptor: String, val name: String, ) + + override fun compareTo(other: ActionData) = when (other) { + is InputKeyEvent -> keyCode.compareTo(other.keyCode) + else -> super.compareTo(other) + } } @Serializable @@ -53,6 +70,11 @@ sealed class ActionData { val soundDescription: String, ) : ActionData() { override val id = ActionId.SOUND + + override fun compareTo(other: ActionData) = when (other) { + is Sound -> soundUid.compareTo(other.soundUid) + else -> super.compareTo(other) + } } @Serializable @@ -61,6 +83,17 @@ sealed class ActionData { abstract val volumeStream: VolumeStream abstract val showVolumeUi: Boolean + override fun compareTo(other: ActionData) = when (other) { + is Stream -> compareValuesBy( + this, + other, + { it.id }, + { it.volumeStream }, + ) + + else -> super.compareTo(other) + } + @Serializable data class Increase( override val showVolumeUi: Boolean, @@ -108,6 +141,11 @@ sealed class ActionData { val ringerMode: RingerMode, ) : Volume() { override val id: ActionId = ActionId.CHANGE_RINGER_MODE + + override fun compareTo(other: ActionData) = when (other) { + is SetRingerMode -> ringerMode.compareTo(other.ringerMode) + else -> super.compareTo(other) + } } @Serializable @@ -130,6 +168,17 @@ sealed class ActionData { sealed class Flashlight : ActionData() { abstract val lens: CameraLens + override fun compareTo(other: ActionData) = when (other) { + is Flashlight -> compareValuesBy( + this, + other, + { it.id }, + { it.lens }, + ) + + else -> super.compareTo(other) + } + @Serializable data class Toggle(override val lens: CameraLens) : Flashlight() { override val id = ActionId.TOGGLE_FLASHLIGHT @@ -152,6 +201,11 @@ sealed class ActionData { val savedImeName: String, ) : ActionData() { override val id = ActionId.SWITCH_KEYBOARD + + override fun compareTo(other: ActionData) = when (other) { + is SwitchKeyboard -> savedImeName.compareTo(other.savedImeName) + else -> super.compareTo(other) + } } @Serializable @@ -160,11 +214,21 @@ sealed class ActionData { @Serializable data class Toggle(val dndMode: DndMode) : DoNotDisturb() { override val id: ActionId = ActionId.TOGGLE_DND_MODE + + override fun compareTo(other: ActionData) = when (other) { + is Toggle -> dndMode.compareTo(other.dndMode) + else -> super.compareTo(other) + } } @Serializable data class Enable(val dndMode: DndMode) : DoNotDisturb() { override val id: ActionId = ActionId.ENABLE_DND_MODE + + override fun compareTo(other: ActionData) = when (other) { + is Enable -> dndMode.compareTo(other.dndMode) + else -> super.compareTo(other) + } } @Serializable @@ -210,6 +274,16 @@ sealed class ActionData { val orientations: List, ) : Rotation() { override val id = ActionId.CYCLE_ROTATIONS + + override fun compareTo(other: ActionData) = when (other) { + // Compare orientations one by one until a difference is found otherwise compare the size + is CycleRotations -> orientations.zip(other.orientations) + .map { (a, b) -> a.compareTo(b) } + .firstOrNull { it != 0 } + ?: orientations.size.compareTo(other.orientations.size) + + else -> super.compareTo(other) + } } } @@ -217,6 +291,17 @@ sealed class ActionData { sealed class ControlMediaForApp : ActionData() { abstract val packageName: String + override fun compareTo(other: ActionData) = when (other) { + is ControlMediaForApp -> compareValuesBy( + this, + other, + { it.id }, + { it.packageName }, + ) + + else -> super.compareTo(other) + } + @Serializable data class Pause(override val packageName: String) : ControlMediaForApp() { override val id = ActionId.PAUSE_MEDIA_PACKAGE @@ -299,6 +384,11 @@ sealed class ActionData { val extras: List, ) : ActionData() { override val id = ActionId.INTENT + + override fun compareTo(other: ActionData) = when (other) { + is Intent -> description.compareTo(other.description) + else -> super.compareTo(other) + } } @Serializable @@ -308,6 +398,18 @@ sealed class ActionData { val description: String?, ) : ActionData() { override val id = ActionId.TAP_SCREEN + + override fun compareTo(other: ActionData) = when (other) { + is TapScreen -> compareValuesBy( + this, + other, + { it.description }, + { it.x }, + { it.y }, + ) + + else -> super.compareTo(other) + } } @Serializable @@ -321,6 +423,22 @@ sealed class ActionData { val description: String?, ) : ActionData() { override val id = ActionId.SWIPE_SCREEN + + override fun compareTo(other: ActionData) = when (other) { + is SwipeScreen -> compareValuesBy( + this, + other, + { it.description }, + { it.fingerCount }, + { it.xStart }, + { it.yStart }, + { it.xEnd }, + { it.yEnd }, + { it.duration }, + ) + + else -> super.compareTo(other) + } } @Serializable @@ -334,6 +452,22 @@ sealed class ActionData { val description: String?, ) : ActionData() { override val id = ActionId.PINCH_SCREEN + + override fun compareTo(other: ActionData) = when (other) { + is PinchScreen -> compareValuesBy( + this, + other, + { it.description }, + { it.pinchType }, + { it.fingerCount }, + { it.x }, + { it.y }, + { it.distance }, + { it.duration }, + ) + + else -> super.compareTo(other) + } } @Serializable @@ -341,6 +475,11 @@ sealed class ActionData { val number: String, ) : ActionData() { override val id = ActionId.PHONE_CALL + + override fun compareTo(other: ActionData) = when (other) { + is PhoneCall -> number.compareTo(other.number) + else -> super.compareTo(other) + } } @Serializable @@ -348,6 +487,11 @@ sealed class ActionData { val url: String, ) : ActionData() { override val id = ActionId.URL + + override fun compareTo(other: ActionData) = when (other) { + is Url -> url.compareTo(other.url) + else -> super.compareTo(other) + } } @Serializable @@ -355,6 +499,11 @@ sealed class ActionData { val text: String, ) : ActionData() { override val id = ActionId.TEXT + + override fun compareTo(other: ActionData) = when (other) { + is Text -> text.compareTo(other.text) + else -> super.compareTo(other) + } } @Serializable diff --git a/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DragDropState.kt b/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DragDropState.kt new file mode 100644 index 0000000000..4c1b7dc814 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DragDropState.kt @@ -0,0 +1,163 @@ +package io.github.sds100.keymapper.compose.draggable + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.setValue +import androidx.compose.ui.geometry.Offset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +@Composable +fun rememberDragDropState( + lazyListState: LazyListState, + onMove: (Int, Int) -> Unit, + onStart: () -> Unit = {}, + onEnd: () -> Unit = {}, +): DragDropState { + val scope = rememberCoroutineScope() + val state = remember(lazyListState) { + DragDropState( + state = lazyListState, + onStart = onStart, + onMove = onMove, + onEnd = onEnd, + scope = scope, + ) + } + + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + + return state +} + +class DragDropState internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onStart: () -> Unit, + private val onMove: (Int, Int) -> Unit, + private val onEnd: () -> Unit, +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + // check if the touch position is on drag handle + state.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + offset.y.toInt() in item.offset..(item.offset + item.size) + }?.also { + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + + onStart.invoke() + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = 1f, + ), + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + + onEnd.invoke() + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + if (targetItem != null) { + val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) { + draggingItem.index + } else if (draggingItem.index == state.firstVisibleItemIndex) { + targetItem.index + } else { + null + } + 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 + } else { + val overscroll = when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} diff --git a/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DraggableItem.kt b/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DraggableItem.kt new file mode 100644 index 0000000000..3bdf4f3fd4 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DraggableItem.kt @@ -0,0 +1,40 @@ +package io.github.sds100.keymapper.compose.draggable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.zIndex + +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable BoxScope.(isDragging: Boolean) -> Unit, +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = if (dragging) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationY = dragDropState.draggingItemOffset + } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationY = dragDropState.previousItemOffset.value + } + } else { + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ) + } + Box(modifier.then(draggingModifier)) { + content(dragging) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt index a87c1fd419..f39ebc8e43 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt @@ -42,47 +42,47 @@ class ChooseConstraintViewModel( NavigationViewModel by NavigationViewModelImpl() { companion object { - private val ALL_CONSTRAINTS_ORDERED: Array = arrayOf( - ChooseConstraintType.APP_IN_FOREGROUND, - ChooseConstraintType.APP_NOT_IN_FOREGROUND, - ChooseConstraintType.APP_PLAYING_MEDIA, - ChooseConstraintType.APP_NOT_PLAYING_MEDIA, - ChooseConstraintType.MEDIA_PLAYING, - ChooseConstraintType.MEDIA_NOT_PLAYING, - - ChooseConstraintType.BT_DEVICE_CONNECTED, - ChooseConstraintType.BT_DEVICE_DISCONNECTED, - - ChooseConstraintType.SCREEN_ON, - ChooseConstraintType.SCREEN_OFF, - - ChooseConstraintType.ORIENTATION_PORTRAIT, - ChooseConstraintType.ORIENTATION_LANDSCAPE, - ChooseConstraintType.ORIENTATION_0, - ChooseConstraintType.ORIENTATION_90, - ChooseConstraintType.ORIENTATION_180, - ChooseConstraintType.ORIENTATION_270, - - ChooseConstraintType.FLASHLIGHT_ON, - ChooseConstraintType.FLASHLIGHT_OFF, - - ChooseConstraintType.WIFI_ON, - ChooseConstraintType.WIFI_OFF, - ChooseConstraintType.WIFI_CONNECTED, - ChooseConstraintType.WIFI_DISCONNECTED, - - ChooseConstraintType.IME_CHOSEN, - ChooseConstraintType.IME_NOT_CHOSEN, - - ChooseConstraintType.DEVICE_IS_LOCKED, - ChooseConstraintType.DEVICE_IS_UNLOCKED, - - ChooseConstraintType.IN_PHONE_CALL, - ChooseConstraintType.NOT_IN_PHONE_CALL, - ChooseConstraintType.PHONE_RINGING, - - ChooseConstraintType.CHARGING, - ChooseConstraintType.DISCHARGING, + private val ALL_CONSTRAINTS_ORDERED: Array = arrayOf( + ConstraintId.APP_IN_FOREGROUND, + ConstraintId.APP_NOT_IN_FOREGROUND, + ConstraintId.APP_PLAYING_MEDIA, + ConstraintId.APP_NOT_PLAYING_MEDIA, + ConstraintId.MEDIA_PLAYING, + ConstraintId.MEDIA_NOT_PLAYING, + + ConstraintId.BT_DEVICE_CONNECTED, + ConstraintId.BT_DEVICE_DISCONNECTED, + + ConstraintId.SCREEN_ON, + ConstraintId.SCREEN_OFF, + + ConstraintId.ORIENTATION_PORTRAIT, + ConstraintId.ORIENTATION_LANDSCAPE, + ConstraintId.ORIENTATION_0, + ConstraintId.ORIENTATION_90, + ConstraintId.ORIENTATION_180, + ConstraintId.ORIENTATION_270, + + ConstraintId.FLASHLIGHT_ON, + ConstraintId.FLASHLIGHT_OFF, + + ConstraintId.WIFI_ON, + ConstraintId.WIFI_OFF, + ConstraintId.WIFI_CONNECTED, + ConstraintId.WIFI_DISCONNECTED, + + ConstraintId.IME_CHOSEN, + ConstraintId.IME_NOT_CHOSEN, + + ConstraintId.DEVICE_IS_LOCKED, + ConstraintId.DEVICE_IS_UNLOCKED, + + ConstraintId.IN_PHONE_CALL, + ConstraintId.NOT_IN_PHONE_CALL, + ConstraintId.PHONE_RINGING, + + ConstraintId.CHARGING, + ConstraintId.DISCHARGING, ) } @@ -92,7 +92,7 @@ class ChooseConstraintViewModel( private val _returnResult = MutableSharedFlow() val returnResult = _returnResult.asSharedFlow() - private var supportedConstraints = MutableStateFlow>(emptyArray()) + private var supportedConstraints = MutableStateFlow>(emptyArray()) init { viewModelScope.launch(Dispatchers.Default) { @@ -102,91 +102,91 @@ class ChooseConstraintViewModel( } } - fun setSupportedConstraints(supportedConstraints: Array) { + fun setSupportedConstraints(supportedConstraints: Array) { this.supportedConstraints.value = supportedConstraints } fun onListItemClick(id: String) { viewModelScope.launch { - when (val constraintType = ChooseConstraintType.valueOf(id)) { - ChooseConstraintType.APP_IN_FOREGROUND, - ChooseConstraintType.APP_NOT_IN_FOREGROUND, - ChooseConstraintType.APP_PLAYING_MEDIA, - ChooseConstraintType.APP_NOT_PLAYING_MEDIA, + when (val constraintType = ConstraintId.valueOf(id)) { + ConstraintId.APP_IN_FOREGROUND, + ConstraintId.APP_NOT_IN_FOREGROUND, + ConstraintId.APP_PLAYING_MEDIA, + ConstraintId.APP_NOT_PLAYING_MEDIA, -> onSelectAppConstraint(constraintType) - ChooseConstraintType.MEDIA_PLAYING -> _returnResult.emit(Constraint.MediaPlaying) - ChooseConstraintType.MEDIA_NOT_PLAYING -> _returnResult.emit(Constraint.NoMediaPlaying) + ConstraintId.MEDIA_PLAYING -> _returnResult.emit(Constraint.MediaPlaying) + ConstraintId.MEDIA_NOT_PLAYING -> _returnResult.emit(Constraint.NoMediaPlaying) - ChooseConstraintType.BT_DEVICE_CONNECTED, - ChooseConstraintType.BT_DEVICE_DISCONNECTED, + ConstraintId.BT_DEVICE_CONNECTED, + ConstraintId.BT_DEVICE_DISCONNECTED, -> onSelectBluetoothConstraint( constraintType, ) - ChooseConstraintType.SCREEN_ON -> onSelectScreenOnConstraint() - ChooseConstraintType.SCREEN_OFF -> onSelectScreenOffConstraint() + ConstraintId.SCREEN_ON -> onSelectScreenOnConstraint() + ConstraintId.SCREEN_OFF -> onSelectScreenOffConstraint() - ChooseConstraintType.ORIENTATION_PORTRAIT -> + ConstraintId.ORIENTATION_PORTRAIT -> _returnResult.emit(Constraint.OrientationPortrait) - ChooseConstraintType.ORIENTATION_LANDSCAPE -> + ConstraintId.ORIENTATION_LANDSCAPE -> _returnResult.emit(Constraint.OrientationLandscape) - ChooseConstraintType.ORIENTATION_0 -> + ConstraintId.ORIENTATION_0 -> _returnResult.emit(Constraint.OrientationCustom(Orientation.ORIENTATION_0)) - ChooseConstraintType.ORIENTATION_90 -> + ConstraintId.ORIENTATION_90 -> _returnResult.emit(Constraint.OrientationCustom(Orientation.ORIENTATION_90)) - ChooseConstraintType.ORIENTATION_180 -> + ConstraintId.ORIENTATION_180 -> _returnResult.emit(Constraint.OrientationCustom(Orientation.ORIENTATION_180)) - ChooseConstraintType.ORIENTATION_270 -> + ConstraintId.ORIENTATION_270 -> _returnResult.emit(Constraint.OrientationCustom(Orientation.ORIENTATION_270)) - ChooseConstraintType.FLASHLIGHT_ON -> { + ConstraintId.FLASHLIGHT_ON -> { val lens = chooseFlashlightLens() ?: return@launch _returnResult.emit(Constraint.FlashlightOn(lens)) } - ChooseConstraintType.FLASHLIGHT_OFF -> { + ConstraintId.FLASHLIGHT_OFF -> { val lens = chooseFlashlightLens() ?: return@launch _returnResult.emit(Constraint.FlashlightOff(lens)) } - ChooseConstraintType.WIFI_ON -> _returnResult.emit(Constraint.WifiOn) - ChooseConstraintType.WIFI_OFF -> _returnResult.emit(Constraint.WifiOff) + ConstraintId.WIFI_ON -> _returnResult.emit(Constraint.WifiOn) + ConstraintId.WIFI_OFF -> _returnResult.emit(Constraint.WifiOff) - ChooseConstraintType.WIFI_CONNECTED, - ChooseConstraintType.WIFI_DISCONNECTED, + ConstraintId.WIFI_CONNECTED, + ConstraintId.WIFI_DISCONNECTED, -> onSelectWifiConnectedConstraint( constraintType, ) - ChooseConstraintType.IME_CHOSEN, - ChooseConstraintType.IME_NOT_CHOSEN, + ConstraintId.IME_CHOSEN, + ConstraintId.IME_NOT_CHOSEN, -> onSelectImeChosenConstraint(constraintType) - ChooseConstraintType.DEVICE_IS_LOCKED -> + ConstraintId.DEVICE_IS_LOCKED -> _returnResult.emit(Constraint.DeviceIsLocked) - ChooseConstraintType.DEVICE_IS_UNLOCKED -> + ConstraintId.DEVICE_IS_UNLOCKED -> _returnResult.emit(Constraint.DeviceIsUnlocked) - ChooseConstraintType.IN_PHONE_CALL -> + ConstraintId.IN_PHONE_CALL -> _returnResult.emit(Constraint.InPhoneCall) - ChooseConstraintType.NOT_IN_PHONE_CALL -> + ConstraintId.NOT_IN_PHONE_CALL -> _returnResult.emit(Constraint.NotInPhoneCall) - ChooseConstraintType.PHONE_RINGING -> + ConstraintId.PHONE_RINGING -> _returnResult.emit(Constraint.PhoneRinging) - ChooseConstraintType.CHARGING -> + ConstraintId.CHARGING -> _returnResult.emit(Constraint.Charging) - ChooseConstraintType.DISCHARGING -> + ConstraintId.DISCHARGING -> _returnResult.emit(Constraint.Discharging) } } @@ -210,37 +210,37 @@ class ChooseConstraintViewModel( if (!supportedConstraints.value.contains(type)) return@forEach val title: String = when (type) { - ChooseConstraintType.APP_IN_FOREGROUND -> getString(R.string.constraint_choose_app_foreground) - ChooseConstraintType.APP_NOT_IN_FOREGROUND -> getString(R.string.constraint_choose_app_not_foreground) - ChooseConstraintType.APP_PLAYING_MEDIA -> getString(R.string.constraint_choose_app_playing_media) - ChooseConstraintType.APP_NOT_PLAYING_MEDIA -> getString(R.string.constraint_choose_app_not_playing_media) - ChooseConstraintType.MEDIA_NOT_PLAYING -> getString(R.string.constraint_choose_media_not_playing) - ChooseConstraintType.MEDIA_PLAYING -> getString(R.string.constraint_choose_media_playing) - ChooseConstraintType.BT_DEVICE_CONNECTED -> getString(R.string.constraint_choose_bluetooth_device_connected) - ChooseConstraintType.BT_DEVICE_DISCONNECTED -> getString(R.string.constraint_choose_bluetooth_device_disconnected) - ChooseConstraintType.SCREEN_ON -> getString(R.string.constraint_choose_screen_on_description) - ChooseConstraintType.SCREEN_OFF -> getString(R.string.constraint_choose_screen_off_description) - ChooseConstraintType.ORIENTATION_PORTRAIT -> getString(R.string.constraint_choose_orientation_portrait) - ChooseConstraintType.ORIENTATION_LANDSCAPE -> getString(R.string.constraint_choose_orientation_landscape) - ChooseConstraintType.ORIENTATION_0 -> getString(R.string.constraint_choose_orientation_0) - ChooseConstraintType.ORIENTATION_90 -> getString(R.string.constraint_choose_orientation_90) - ChooseConstraintType.ORIENTATION_180 -> getString(R.string.constraint_choose_orientation_180) - ChooseConstraintType.ORIENTATION_270 -> getString(R.string.constraint_choose_orientation_270) - ChooseConstraintType.FLASHLIGHT_ON -> getString(R.string.constraint_flashlight_on) - ChooseConstraintType.FLASHLIGHT_OFF -> getString(R.string.constraint_flashlight_off) - ChooseConstraintType.WIFI_ON -> getString(R.string.constraint_wifi_on) - ChooseConstraintType.WIFI_OFF -> getString(R.string.constraint_wifi_off) - ChooseConstraintType.WIFI_CONNECTED -> getString(R.string.constraint_wifi_connected) - ChooseConstraintType.WIFI_DISCONNECTED -> getString(R.string.constraint_wifi_disconnected) - ChooseConstraintType.IME_CHOSEN -> getString(R.string.constraint_ime_chosen) - ChooseConstraintType.IME_NOT_CHOSEN -> getString(R.string.constraint_ime_not_chosen) - ChooseConstraintType.DEVICE_IS_LOCKED -> getString(R.string.constraint_device_is_locked) - ChooseConstraintType.DEVICE_IS_UNLOCKED -> getString(R.string.constraint_device_is_unlocked) - ChooseConstraintType.IN_PHONE_CALL -> getString(R.string.constraint_in_phone_call) - ChooseConstraintType.NOT_IN_PHONE_CALL -> getString(R.string.constraint_not_in_phone_call) - ChooseConstraintType.PHONE_RINGING -> getString(R.string.constraint_phone_ringing) - ChooseConstraintType.CHARGING -> getString(R.string.constraint_charging) - ChooseConstraintType.DISCHARGING -> getString(R.string.constraint_discharging) + ConstraintId.APP_IN_FOREGROUND -> getString(R.string.constraint_choose_app_foreground) + ConstraintId.APP_NOT_IN_FOREGROUND -> getString(R.string.constraint_choose_app_not_foreground) + ConstraintId.APP_PLAYING_MEDIA -> getString(R.string.constraint_choose_app_playing_media) + ConstraintId.APP_NOT_PLAYING_MEDIA -> getString(R.string.constraint_choose_app_not_playing_media) + ConstraintId.MEDIA_NOT_PLAYING -> getString(R.string.constraint_choose_media_not_playing) + ConstraintId.MEDIA_PLAYING -> getString(R.string.constraint_choose_media_playing) + ConstraintId.BT_DEVICE_CONNECTED -> getString(R.string.constraint_choose_bluetooth_device_connected) + ConstraintId.BT_DEVICE_DISCONNECTED -> getString(R.string.constraint_choose_bluetooth_device_disconnected) + ConstraintId.SCREEN_ON -> getString(R.string.constraint_choose_screen_on_description) + ConstraintId.SCREEN_OFF -> getString(R.string.constraint_choose_screen_off_description) + ConstraintId.ORIENTATION_PORTRAIT -> getString(R.string.constraint_choose_orientation_portrait) + ConstraintId.ORIENTATION_LANDSCAPE -> getString(R.string.constraint_choose_orientation_landscape) + ConstraintId.ORIENTATION_0 -> getString(R.string.constraint_choose_orientation_0) + ConstraintId.ORIENTATION_90 -> getString(R.string.constraint_choose_orientation_90) + ConstraintId.ORIENTATION_180 -> getString(R.string.constraint_choose_orientation_180) + ConstraintId.ORIENTATION_270 -> getString(R.string.constraint_choose_orientation_270) + ConstraintId.FLASHLIGHT_ON -> getString(R.string.constraint_flashlight_on) + ConstraintId.FLASHLIGHT_OFF -> getString(R.string.constraint_flashlight_off) + ConstraintId.WIFI_ON -> getString(R.string.constraint_wifi_on) + ConstraintId.WIFI_OFF -> getString(R.string.constraint_wifi_off) + ConstraintId.WIFI_CONNECTED -> getString(R.string.constraint_wifi_connected) + ConstraintId.WIFI_DISCONNECTED -> getString(R.string.constraint_wifi_disconnected) + ConstraintId.IME_CHOSEN -> getString(R.string.constraint_ime_chosen) + ConstraintId.IME_NOT_CHOSEN -> getString(R.string.constraint_ime_not_chosen) + ConstraintId.DEVICE_IS_LOCKED -> getString(R.string.constraint_device_is_locked) + ConstraintId.DEVICE_IS_UNLOCKED -> getString(R.string.constraint_device_is_unlocked) + ConstraintId.IN_PHONE_CALL -> getString(R.string.constraint_in_phone_call) + ConstraintId.NOT_IN_PHONE_CALL -> getString(R.string.constraint_not_in_phone_call) + ConstraintId.PHONE_RINGING -> getString(R.string.constraint_phone_ringing) + ConstraintId.CHARGING -> getString(R.string.constraint_charging) + ConstraintId.DISCHARGING -> getString(R.string.constraint_discharging) } val error = useCase.isSupported(type) @@ -258,7 +258,7 @@ class ChooseConstraintViewModel( } }.toList() - private suspend fun onSelectWifiConnectedConstraint(type: ChooseConstraintType) { + private suspend fun onSelectWifiConnectedConstraint(type: ConstraintId) { val knownSSIDs = useCase.getKnownWiFiSSIDs() val chosenSSID: String? @@ -301,17 +301,17 @@ class ChooseConstraintViewModel( } when (type) { - ChooseConstraintType.WIFI_CONNECTED -> + ConstraintId.WIFI_CONNECTED -> _returnResult.emit(Constraint.WifiConnected(chosenSSID)) - ChooseConstraintType.WIFI_DISCONNECTED -> + ConstraintId.WIFI_DISCONNECTED -> _returnResult.emit(Constraint.WifiDisconnected(chosenSSID)) else -> Unit } } - private suspend fun onSelectImeChosenConstraint(type: ChooseConstraintType) { + private suspend fun onSelectImeChosenConstraint(type: ConstraintId) { val inputMethods = useCase.getEnabledInputMethods() val items = inputMethods.map { it.id to it.label } val dialog = PopupUi.SingleChoice(items = items) @@ -321,10 +321,10 @@ class ChooseConstraintViewModel( val imeInfo = inputMethods.single { it.id == result } when (type) { - ChooseConstraintType.IME_CHOSEN -> + ConstraintId.IME_CHOSEN -> _returnResult.emit(Constraint.ImeChosen(imeInfo.id, imeInfo.label)) - ChooseConstraintType.IME_NOT_CHOSEN -> + ConstraintId.IME_NOT_CHOSEN -> _returnResult.emit(Constraint.ImeNotChosen(imeInfo.id, imeInfo.label)) else -> Unit @@ -353,7 +353,7 @@ class ChooseConstraintViewModel( _returnResult.emit(Constraint.ScreenOff) } - private suspend fun onSelectBluetoothConstraint(type: ChooseConstraintType) { + private suspend fun onSelectBluetoothConstraint(type: ConstraintId) { val response = showPopup( "bluetooth_device_constraint_limitation", PopupUi.Ok(getString(R.string.dialog_message_bt_constraint_limitation)), @@ -367,12 +367,12 @@ class ChooseConstraintViewModel( ) ?: return val constraint = when (type) { - ChooseConstraintType.BT_DEVICE_CONNECTED -> Constraint.BtDeviceConnected( + ConstraintId.BT_DEVICE_CONNECTED -> Constraint.BtDeviceConnected( device.address, device.name, ) - ChooseConstraintType.BT_DEVICE_DISCONNECTED -> Constraint.BtDeviceDisconnected( + ConstraintId.BT_DEVICE_DISCONNECTED -> Constraint.BtDeviceDisconnected( device.address, device.name, ) @@ -383,7 +383,7 @@ class ChooseConstraintViewModel( _returnResult.emit(constraint) } - private suspend fun onSelectAppConstraint(type: ChooseConstraintType) { + private suspend fun onSelectAppConstraint(type: ConstraintId) { val packageName = navigate( "choose_package_for_constraint", @@ -392,19 +392,19 @@ class ChooseConstraintViewModel( ?: return val constraint = when (type) { - ChooseConstraintType.APP_IN_FOREGROUND -> Constraint.AppInForeground( + ConstraintId.APP_IN_FOREGROUND -> Constraint.AppInForeground( packageName, ) - ChooseConstraintType.APP_NOT_IN_FOREGROUND -> Constraint.AppNotInForeground( + ConstraintId.APP_NOT_IN_FOREGROUND -> Constraint.AppNotInForeground( packageName, ) - ChooseConstraintType.APP_PLAYING_MEDIA -> Constraint.AppPlayingMedia( + ConstraintId.APP_PLAYING_MEDIA -> Constraint.AppPlayingMedia( packageName, ) - ChooseConstraintType.APP_NOT_PLAYING_MEDIA -> Constraint.AppNotPlayingMedia( + ConstraintId.APP_NOT_PLAYING_MEDIA -> Constraint.AppNotPlayingMedia( packageName, ) @@ -420,7 +420,6 @@ class ChooseConstraintViewModel( private val resourceProvider: ResourceProvider, ) : ViewModelProvider.NewInstanceFactory() { - override fun create(modelClass: Class): T = - ChooseConstraintViewModel(isSupported, resourceProvider) as T + override fun create(modelClass: Class): T = ChooseConstraintViewModel(isSupported, resourceProvider) as T } } diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConfigConstraintsViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConfigConstraintsViewModel.kt index 5f32f164fa..ca13d7e8cd 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConfigConstraintsViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConfigConstraintsViewModel.kt @@ -40,7 +40,7 @@ class ConfigConstraintsViewModel( private val coroutineScope: CoroutineScope, private val displayUseCase: DisplayConstraintUseCase, private val configMappingUseCase: ConfigMappingUseCase<*, *>, - private val allowedConstraints: List, + private val allowedConstraints: List, resourceProvider: ResourceProvider, ) : ResourceProvider by resourceProvider, PopupViewModel by PopupViewModelImpl(), diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt index 2bb7fe93b6..4b096019ab 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt @@ -17,106 +17,168 @@ import java.util.UUID @Serializable sealed class Constraint { val uid: String = UUID.randomUUID().toString() + abstract val id: ConstraintId @Serializable - data class AppInForeground(val packageName: String) : Constraint() + data class AppInForeground(val packageName: String) : Constraint() { + override val id: ConstraintId = ConstraintId.APP_IN_FOREGROUND + } @Serializable - data class AppNotInForeground(val packageName: String) : Constraint() + data class AppNotInForeground(val packageName: String) : Constraint() { + override val id: ConstraintId = ConstraintId.APP_NOT_IN_FOREGROUND + } @Serializable - data class AppPlayingMedia(val packageName: String) : Constraint() + data class AppPlayingMedia(val packageName: String) : Constraint() { + override val id: ConstraintId = ConstraintId.APP_PLAYING_MEDIA + } @Serializable - data class AppNotPlayingMedia(val packageName: String) : Constraint() + data class AppNotPlayingMedia(val packageName: String) : Constraint() { + override val id: ConstraintId = ConstraintId.APP_NOT_PLAYING_MEDIA + } @Serializable - object MediaPlaying : Constraint() + data object MediaPlaying : Constraint() { + override val id: ConstraintId = ConstraintId.MEDIA_PLAYING + } @Serializable - object NoMediaPlaying : Constraint() + data object NoMediaPlaying : Constraint() { + override val id: ConstraintId = ConstraintId.MEDIA_NOT_PLAYING + } @Serializable - data class BtDeviceConnected(val bluetoothAddress: String, val deviceName: String) : Constraint() + data class BtDeviceConnected( + val bluetoothAddress: String, + val deviceName: String, + ) : Constraint() { + override val id: ConstraintId = ConstraintId.BT_DEVICE_CONNECTED + } @Serializable - data class BtDeviceDisconnected(val bluetoothAddress: String, val deviceName: String) : Constraint() + data class BtDeviceDisconnected( + val bluetoothAddress: String, + val deviceName: String, + ) : Constraint() { + override val id: ConstraintId = ConstraintId.BT_DEVICE_DISCONNECTED + } @Serializable - object ScreenOn : Constraint() + data object ScreenOn : Constraint() { + override val id: ConstraintId = ConstraintId.SCREEN_ON + } @Serializable - object ScreenOff : Constraint() + data object ScreenOff : Constraint() { + override val id: ConstraintId = ConstraintId.SCREEN_OFF + } @Serializable - object OrientationPortrait : Constraint() + data object OrientationPortrait : Constraint() { + override val id: ConstraintId = ConstraintId.ORIENTATION_PORTRAIT + } @Serializable - object OrientationLandscape : Constraint() + data object OrientationLandscape : Constraint() { + override val id: ConstraintId = ConstraintId.ORIENTATION_LANDSCAPE + } @Serializable - data class OrientationCustom(val orientation: Orientation) : Constraint() + data class OrientationCustom(val orientation: Orientation) : Constraint() { + override val id: ConstraintId = when (orientation) { + Orientation.ORIENTATION_0 -> ConstraintId.ORIENTATION_0 + Orientation.ORIENTATION_90 -> ConstraintId.ORIENTATION_90 + Orientation.ORIENTATION_180 -> ConstraintId.ORIENTATION_180 + Orientation.ORIENTATION_270 -> ConstraintId.ORIENTATION_270 + } + } @Serializable - data class FlashlightOn(val lens: CameraLens) : Constraint() + data class FlashlightOn(val lens: CameraLens) : Constraint() { + override val id: ConstraintId = ConstraintId.FLASHLIGHT_ON + } @Serializable - data class FlashlightOff(val lens: CameraLens) : Constraint() + data class FlashlightOff(val lens: CameraLens) : Constraint() { + override val id: ConstraintId = ConstraintId.FLASHLIGHT_OFF + } @Serializable - object WifiOn : Constraint() + data object WifiOn : Constraint() { + override val id: ConstraintId = ConstraintId.WIFI_ON + } @Serializable - object WifiOff : Constraint() + data object WifiOff : Constraint() { + override val id: ConstraintId = ConstraintId.WIFI_OFF + } @Serializable data class WifiConnected( - /** - * Null if connected to any wifi network. - */ val ssid: String?, - ) : Constraint() + ) : Constraint() { + override val id: ConstraintId = ConstraintId.WIFI_CONNECTED + } @Serializable data class WifiDisconnected( - /** - * Null if disconnected from any wifi network. - */ val ssid: String?, - ) : Constraint() + ) : Constraint() { + override val id: ConstraintId = ConstraintId.WIFI_DISCONNECTED + } @Serializable data class ImeChosen( val imeId: String, val imeLabel: String, - ) : Constraint() + ) : Constraint() { + override val id: ConstraintId = ConstraintId.IME_CHOSEN + } @Serializable data class ImeNotChosen( val imeId: String, val imeLabel: String, - ) : Constraint() + ) : Constraint() { + override val id: ConstraintId = ConstraintId.IME_NOT_CHOSEN + } @Serializable - object DeviceIsLocked : Constraint() + data object DeviceIsLocked : Constraint() { + override val id: ConstraintId = ConstraintId.DEVICE_IS_LOCKED + } @Serializable - object DeviceIsUnlocked : Constraint() + data object DeviceIsUnlocked : Constraint() { + override val id: ConstraintId = ConstraintId.DEVICE_IS_UNLOCKED + } @Serializable - object InPhoneCall : Constraint() + data object InPhoneCall : Constraint() { + override val id: ConstraintId = ConstraintId.IN_PHONE_CALL + } @Serializable - object NotInPhoneCall : Constraint() + data object NotInPhoneCall : Constraint() { + override val id: ConstraintId = ConstraintId.NOT_IN_PHONE_CALL + } @Serializable - object PhoneRinging : Constraint() + data object PhoneRinging : Constraint() { + override val id: ConstraintId = ConstraintId.PHONE_RINGING + } @Serializable - object Charging : Constraint() + data object Charging : Constraint() { + override val id: ConstraintId = ConstraintId.CHARGING + } @Serializable - object Discharging : Constraint() + data object Discharging : Constraint() { + override val id: ConstraintId = ConstraintId.DISCHARGING + } } object ConstraintModeEntityMapper { @@ -140,14 +202,11 @@ object ConstraintEntityMapper { ) fun fromEntity(entity: ConstraintEntity): Constraint { - fun getPackageName(): String = - entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull()!! + fun getPackageName(): String = entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull()!! - fun getBluetoothAddress(): String = - entity.extras.getData(ConstraintEntity.EXTRA_BT_ADDRESS).valueOrNull()!! + fun getBluetoothAddress(): String = entity.extras.getData(ConstraintEntity.EXTRA_BT_ADDRESS).valueOrNull()!! - fun getBluetoothDeviceName(): String = - entity.extras.getData(ConstraintEntity.EXTRA_BT_NAME).valueOrNull()!! + fun getBluetoothDeviceName(): String = entity.extras.getData(ConstraintEntity.EXTRA_BT_NAME).valueOrNull()!! fun getCameraLens(): CameraLens { val extraValue = diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintType.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintId.kt similarity index 90% rename from app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintType.kt rename to app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintId.kt index b3c159a853..590623ec81 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintType.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintId.kt @@ -7,7 +7,9 @@ import kotlinx.serialization.Serializable */ @Serializable -enum class ChooseConstraintType { +enum class ConstraintId { + // THESE MUST BE ORDERED IN HOW THEY WANT TO BE SORTED + APP_IN_FOREGROUND, APP_NOT_IN_FOREGROUND, APP_PLAYING_MEDIA, diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt index 6d6ebb60c2..1d41cbf4b7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt @@ -277,6 +277,48 @@ class ConstraintUiHelper( ) } + /** + * Get a title for a constraint that is not specific to a particular instance. + */ + fun getGenericTitle(constraint: Constraint): String = when (constraint) { + is Constraint.AppInForeground -> getString( + R.string.constraint_app_foreground_description, + "", + ) + + is Constraint.AppNotInForeground -> getString( + R.string.constraint_app_not_foreground_description, + "", + ) + + is Constraint.AppNotPlayingMedia -> TODO() + is Constraint.AppPlayingMedia -> TODO() + is Constraint.BtDeviceConnected -> TODO() + is Constraint.BtDeviceDisconnected -> TODO() + Constraint.Charging -> TODO() + Constraint.DeviceIsLocked -> TODO() + Constraint.DeviceIsUnlocked -> TODO() + Constraint.Discharging -> TODO() + is Constraint.FlashlightOff -> TODO() + is Constraint.FlashlightOn -> TODO() + is Constraint.ImeChosen -> TODO() + is Constraint.ImeNotChosen -> TODO() + Constraint.InPhoneCall -> TODO() + Constraint.MediaPlaying -> TODO() + Constraint.NoMediaPlaying -> TODO() + Constraint.NotInPhoneCall -> TODO() + is Constraint.OrientationCustom -> TODO() + Constraint.OrientationLandscape -> TODO() + Constraint.OrientationPortrait -> TODO() + Constraint.PhoneRinging -> TODO() + Constraint.ScreenOff -> TODO() + Constraint.ScreenOn -> TODO() + is Constraint.WifiConnected -> TODO() + is Constraint.WifiDisconnected -> TODO() + Constraint.WifiOff -> TODO() + Constraint.WifiOn -> TODO() + } + private fun getAppIconInfo(packageName: String): IconInfo? = getAppIcon(packageName).handle( onSuccess = { IconInfo(it, TintType.None) }, onError = { null }, diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUtils.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUtils.kt index 0f9750c405..77e4603734 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUtils.kt @@ -5,40 +5,40 @@ package io.github.sds100.keymapper.constraints */ object ConstraintUtils { private val COMMON_SUPPORTED_CONSTRAINTS = listOf( - ChooseConstraintType.APP_IN_FOREGROUND, - ChooseConstraintType.APP_NOT_IN_FOREGROUND, - ChooseConstraintType.APP_PLAYING_MEDIA, - ChooseConstraintType.APP_NOT_PLAYING_MEDIA, - ChooseConstraintType.MEDIA_PLAYING, - ChooseConstraintType.MEDIA_NOT_PLAYING, - ChooseConstraintType.BT_DEVICE_CONNECTED, - ChooseConstraintType.BT_DEVICE_DISCONNECTED, - ChooseConstraintType.ORIENTATION_PORTRAIT, - ChooseConstraintType.ORIENTATION_LANDSCAPE, - ChooseConstraintType.ORIENTATION_0, - ChooseConstraintType.ORIENTATION_90, - ChooseConstraintType.ORIENTATION_180, - ChooseConstraintType.ORIENTATION_270, - ChooseConstraintType.FLASHLIGHT_ON, - ChooseConstraintType.FLASHLIGHT_OFF, - ChooseConstraintType.WIFI_ON, - ChooseConstraintType.WIFI_OFF, - ChooseConstraintType.WIFI_CONNECTED, - ChooseConstraintType.WIFI_DISCONNECTED, - ChooseConstraintType.IME_CHOSEN, - ChooseConstraintType.IME_NOT_CHOSEN, - ChooseConstraintType.DEVICE_IS_LOCKED, - ChooseConstraintType.DEVICE_IS_UNLOCKED, - ChooseConstraintType.IN_PHONE_CALL, - ChooseConstraintType.NOT_IN_PHONE_CALL, - ChooseConstraintType.PHONE_RINGING, - ChooseConstraintType.CHARGING, - ChooseConstraintType.DISCHARGING, + ConstraintId.APP_IN_FOREGROUND, + ConstraintId.APP_NOT_IN_FOREGROUND, + ConstraintId.APP_PLAYING_MEDIA, + ConstraintId.APP_NOT_PLAYING_MEDIA, + ConstraintId.MEDIA_PLAYING, + ConstraintId.MEDIA_NOT_PLAYING, + ConstraintId.BT_DEVICE_CONNECTED, + ConstraintId.BT_DEVICE_DISCONNECTED, + ConstraintId.ORIENTATION_PORTRAIT, + ConstraintId.ORIENTATION_LANDSCAPE, + ConstraintId.ORIENTATION_0, + ConstraintId.ORIENTATION_90, + ConstraintId.ORIENTATION_180, + ConstraintId.ORIENTATION_270, + ConstraintId.FLASHLIGHT_ON, + ConstraintId.FLASHLIGHT_OFF, + ConstraintId.WIFI_ON, + ConstraintId.WIFI_OFF, + ConstraintId.WIFI_CONNECTED, + ConstraintId.WIFI_DISCONNECTED, + ConstraintId.IME_CHOSEN, + ConstraintId.IME_NOT_CHOSEN, + ConstraintId.DEVICE_IS_LOCKED, + ConstraintId.DEVICE_IS_UNLOCKED, + ConstraintId.IN_PHONE_CALL, + ConstraintId.NOT_IN_PHONE_CALL, + ConstraintId.PHONE_RINGING, + ConstraintId.CHARGING, + ConstraintId.DISCHARGING, ) val KEY_MAP_ALLOWED_CONSTRAINTS = listOf( - ChooseConstraintType.SCREEN_ON, - ChooseConstraintType.SCREEN_OFF, + ConstraintId.SCREEN_ON, + ConstraintId.SCREEN_OFF, ).plus(COMMON_SUPPORTED_CONSTRAINTS) val FINGERPRINT_MAP_ALLOWED_CONSTRAINTS = COMMON_SUPPORTED_CONSTRAINTS diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt index 9a89a2c7af..a994c98936 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/CreateConstraintUseCase.kt @@ -21,14 +21,14 @@ class CreateConstraintUseCaseImpl( private val preferenceRepository: PreferenceRepository, ) : CreateConstraintUseCase { - override fun isSupported(constraint: ChooseConstraintType): Error? { + override fun isSupported(constraint: ConstraintId): Error? { when (constraint) { - ChooseConstraintType.FLASHLIGHT_ON, ChooseConstraintType.FLASHLIGHT_OFF -> + ConstraintId.FLASHLIGHT_ON, ConstraintId.FLASHLIGHT_OFF -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) } - ChooseConstraintType.DEVICE_IS_LOCKED, ChooseConstraintType.DEVICE_IS_UNLOCKED -> + ConstraintId.DEVICE_IS_LOCKED, ConstraintId.DEVICE_IS_UNLOCKED -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.LOLLIPOP_MR1) } @@ -64,13 +64,12 @@ class CreateConstraintUseCaseImpl( ) } - override fun getSavedWifiSSIDs(): Flow> = - preferenceRepository.get(Keys.savedWifiSSIDs) - .map { it?.toList() ?: emptyList() } + override fun getSavedWifiSSIDs(): Flow> = preferenceRepository.get(Keys.savedWifiSSIDs) + .map { it?.toList() ?: emptyList() } } interface CreateConstraintUseCase { - fun isSupported(constraint: ChooseConstraintType): Error? + fun isSupported(constraint: ConstraintId): Error? fun getKnownWiFiSSIDs(): List? fun getEnabledInputMethods(): List diff --git a/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt index 3e63ee4ed1..4462b9d288 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt @@ -75,4 +75,6 @@ object Keys { booleanPreferencesKey("key_never_show_dpad_ime_trigger_error") val neverShowNoKeysRecordedError = booleanPreferencesKey("key_never_show_no_keys_recorded_error") + val sortOrderJson = stringPreferencesKey("key_keymaps_sort_order_json") + val sortShowHelp = booleanPreferencesKey("key_keymaps_sort_show_help") } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt index cbefb978c6..efe1c5e880 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt @@ -206,6 +206,11 @@ class HomeFragment : Fragment() { true } + R.id.action_sort -> { + findNavController().navigate(R.id.action_global_sortingFragment) + true + } + else -> false } } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt index 9004fb00f4..fcd911efec 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt @@ -12,6 +12,7 @@ import io.github.sds100.keymapper.mappings.keymaps.KeyMapListViewModel import io.github.sds100.keymapper.mappings.keymaps.ListKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.trigger.SetupGuiKeyboardUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.sorting.SortKeyMapsUseCase import io.github.sds100.keymapper.system.accessibility.ServiceState import io.github.sds100.keymapper.system.inputmethod.ShowInputMethodPickerUseCase import io.github.sds100.keymapper.util.Error @@ -62,6 +63,7 @@ class HomeViewModel( private val onboarding: OnboardingUseCase, resourceProvider: ResourceProvider, private val setupGuiKeyboard: SetupGuiKeyboardUseCase, + private val sortKeyMaps: SortKeyMapsUseCase, ) : ViewModel(), ResourceProvider by resourceProvider, PopupViewModel by PopupViewModelImpl(), @@ -95,6 +97,7 @@ class HomeViewModel( resourceProvider, multiSelectProvider, setupGuiKeyboard, + sortKeyMaps, ) } @@ -549,6 +552,7 @@ class HomeViewModel( private val onboarding: OnboardingUseCase, private val resourceProvider: ResourceProvider, private val setupGuiKeyboard: SetupGuiKeyboardUseCase, + private val sortKeyMaps: SortKeyMapsUseCase, ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class): T = HomeViewModel( @@ -561,6 +565,7 @@ class HomeViewModel( onboarding, resourceProvider, setupGuiKeyboard, + sortKeyMaps, ) as T } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt index 723bfd1c23..dbc1654a59 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListViewModel.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import io.github.sds100.keymapper.mappings.keymaps.trigger.SetupGuiKeyboardState import io.github.sds100.keymapper.mappings.keymaps.trigger.SetupGuiKeyboardUseCase +import io.github.sds100.keymapper.sorting.SortKeyMapsUseCase import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.State @@ -38,15 +39,16 @@ import kotlinx.coroutines.launch open class KeyMapListViewModel( private val coroutineScope: CoroutineScope, - private val useCase: ListKeyMapsUseCase, + private val listKeyMaps: ListKeyMapsUseCase, resourceProvider: ResourceProvider, private val multiSelectProvider: MultiSelectProvider, private val setupGuiKeyboard: SetupGuiKeyboardUseCase, + private val sortKeyMaps: SortKeyMapsUseCase, ) : PopupViewModel by PopupViewModelImpl(), ResourceProvider by resourceProvider, NavigationViewModel by NavigationViewModelImpl() { - private val listItemCreator = KeyMapListItemCreator(useCase, resourceProvider) + private val listItemCreator = KeyMapListItemCreator(listKeyMaps, resourceProvider) private val _state = MutableStateFlow>>(State.Loading) val state = _state.asStateFlow() @@ -70,9 +72,18 @@ open class KeyMapListViewModel( val rebuildUiState = MutableSharedFlow>>(replay = 1) + combine( + listKeyMaps.keyMapList, + sortKeyMaps.observeKeyMapsSorter(), + ) { keyMapList, sorter -> + keyMapList + .mapData { list -> list.sortedWith(sorter) } + .also { rebuildUiState.emit(it) } + }.flowOn(Dispatchers.Default).launchIn(coroutineScope) + combine( rebuildUiState, - useCase.showDeviceDescriptors, + listKeyMaps.showDeviceDescriptors, ) { keyMapListState, showDeviceDescriptors -> keyMapStateListFlow.value = State.Loading @@ -84,13 +95,7 @@ open class KeyMapListViewModel( }.flowOn(Dispatchers.Default).launchIn(coroutineScope) coroutineScope.launch { - useCase.keyMapList.collectLatest { - rebuildUiState.emit(it) - } - } - - coroutineScope.launch { - useCase.invalidateActionErrors.drop(1).collectLatest { + listKeyMaps.invalidateActionErrors.drop(1).collectLatest { /* Don't get the key maps from the repository because there can be a race condition when restoring key maps. This happens because when the activity is resumed the @@ -102,7 +107,7 @@ open class KeyMapListViewModel( } coroutineScope.launch { - useCase.invalidateTriggerErrors.drop(1).collectLatest { + listKeyMaps.invalidateTriggerErrors.drop(1).collectLatest { /* Don't get the key maps from the repository because there can be a race condition when restoring key maps. This happens because when the activity is resumed the @@ -114,7 +119,7 @@ open class KeyMapListViewModel( } coroutineScope.launch { - useCase.invalidateConstraintErrors.drop(1).collectLatest { + listKeyMaps.invalidateConstraintErrors.drop(1).collectLatest { /* Don't get the key maps from the repository because there can be a race condition when restoring key maps. This happens because when the activity is resumed the @@ -211,7 +216,7 @@ open class KeyMapListViewModel( } fun onNeverShowSetupDpadClick() { - useCase.neverShowDpadImeSetupError() + listKeyMaps.neverShowDpadImeSetupError() } private fun onFixError(error: Error) { @@ -222,8 +227,8 @@ open class KeyMapListViewModel( ViewModelHelper.showDialogExplainingDndAccessBeingUnavailable( resourceProvider = this@KeyMapListViewModel, popupViewModel = this@KeyMapListViewModel, - neverShowDndTriggerErrorAgain = { useCase.neverShowDndTriggerError() }, - fixError = { useCase.fixError(it) }, + neverShowDndTriggerErrorAgain = { listKeyMaps.neverShowDndTriggerError() }, + fixError = { listKeyMaps.fixError(it) }, ) } } @@ -238,7 +243,7 @@ open class KeyMapListViewModel( popupViewModel = this@KeyMapListViewModel, error, ) { - useCase.fixError(error) + listKeyMaps.fixError(error) } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt index 345d60d137..25a8fed77e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ListKeyMapsUseCase.kt @@ -54,8 +54,7 @@ class ListKeyMapsUseCaseImpl( keyMapRepository.duplicate(*uid) } - override suspend fun backupKeyMaps(vararg uid: String, uri: String): Result = - backupManager.backupKeyMaps(uri, uid.asList()) + override suspend fun backupKeyMaps(vararg uid: String, uri: String): Result = backupManager.backupKeyMaps(uri, uid.asList()) } interface ListKeyMapsUseCase : DisplayKeyMapUseCase { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt index fad2475b4c..4cef585d04 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt @@ -29,6 +29,17 @@ data class AssistantTriggerKey( return type == AssistantTriggerType.DEVICE || type == AssistantTriggerType.ANY } + override fun compareTo(other: TriggerKey) = when (other) { + is AssistantTriggerKey -> compareValuesBy( + this, + other, + { it.type }, + { it.clickType }, + ) + + else -> super.compareTo(other) + } + companion object { fun fromEntity( entity: AssistantTriggerKeyEntity, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt index 78e15ab996..5027b2e89a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt @@ -27,6 +27,20 @@ data class KeyCodeTriggerKey( return "KeyCodeTriggerKey(uid=${uid.substring(0..5)}, keyCode=$keyCode, device=$deviceString, clickType=$clickType, consume=$consumeEvent) " } + // key code -> click type -> device -> consume key event + override fun compareTo(other: TriggerKey) = when (other) { + is KeyCodeTriggerKey -> compareValuesBy( + this, + other, + { it.keyCode }, + { it.clickType }, + { it.device }, + { it.consumeEvent }, + ) + + else -> super.compareTo(other) + } + companion object { fun fromEntity(entity: KeyCodeTriggerKeyEntity): TriggerKey { val device = when (entity.deviceId) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt index 27a5d49b51..6343d4cb35 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt @@ -7,7 +7,7 @@ import io.github.sds100.keymapper.mappings.ClickType import kotlinx.serialization.Serializable @Serializable -sealed class TriggerKey { +sealed class TriggerKey : Comparable { abstract val clickType: ClickType /** @@ -34,4 +34,6 @@ sealed class TriggerKey { is AssistantTriggerKey -> copy(clickType = clickType) is KeyCodeTriggerKey -> copy(clickType = clickType) } + + override fun compareTo(other: TriggerKey) = this.javaClass.name.compareTo(other.javaClass.name) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyDevice.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyDevice.kt index 815f65e9bd..05efc579b6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyDevice.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyDevice.kt @@ -3,8 +3,15 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger import io.github.sds100.keymapper.system.devices.InputDeviceInfo import kotlinx.serialization.Serializable +/** + * Created by sds100 on 21/02/2021. + */ + @Serializable -sealed class TriggerKeyDevice { +sealed class TriggerKeyDevice : Comparable { + override fun compareTo(other: TriggerKeyDevice) = + this.javaClass.name.compareTo(other.javaClass.name) + @Serializable object Internal : TriggerKeyDevice() @@ -12,7 +19,20 @@ sealed class TriggerKeyDevice { object Any : TriggerKeyDevice() @Serializable - data class External(val descriptor: String, val name: String) : TriggerKeyDevice() + data class External(val descriptor: String, val name: String) : TriggerKeyDevice() { + override fun compareTo(other: TriggerKeyDevice): Int { + if (other !is External) { + return super.compareTo(other) + } + + return compareValuesBy( + this, + other, + { it.name }, + { it.descriptor }, + ) + } + } fun isSameDevice(other: TriggerKeyDevice): Boolean { if (other is External && this is External) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerMode.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerMode.kt index cd4b156eb7..2da4ef2551 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerMode.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerMode.kt @@ -8,9 +8,19 @@ import kotlinx.serialization.Serializable */ @Serializable -sealed class TriggerMode { +sealed class TriggerMode : Comparable { + override fun compareTo(other: TriggerMode) = this.javaClass.name.compareTo(other.javaClass.name) + @Serializable - data class Parallel(val clickType: ClickType) : TriggerMode() + data class Parallel(val clickType: ClickType) : TriggerMode() { + override fun compareTo(other: TriggerMode): Int { + if (other !is Parallel) { + return super.compareTo(other) + } + + return clickType.compareTo(other.clickType) + } + } @Serializable object Sequence : TriggerMode() diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/SortBottomSheetContent.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/SortBottomSheetContent.kt new file mode 100644 index 0000000000..176644c96b --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/SortBottomSheetContent.kt @@ -0,0 +1,563 @@ +package io.github.sds100.keymapper.sorting + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +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.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.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.compose.draggable.DraggableItem +import io.github.sds100.keymapper.compose.draggable.rememberDragDropState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun SortBottomSheet( + onDismissRequest: () -> Unit, + viewModel: SortViewModel, +) { + val sortFieldOrderList by viewModel.sortFieldOrder.collectAsStateWithLifecycle() + val showHelp by viewModel.showHelp.collectAsStateWithLifecycle() + + SortBottomSheet( + modifier = Modifier.statusBarsPadding(), + sortFieldOrderList = sortFieldOrderList, + showHelp = showHelp, + onDismissRequest = onDismissRequest, + onApply = viewModel::applySortPriority, + onMove = viewModel::swapSortPriority, + onToggle = viewModel::toggleSortOrder, + onReset = viewModel::resetSortPriority, + onHideHelpClick = { viewModel.setShowHelp(false) }, + onShowHelpClick = { viewModel.setShowHelp(true) }, + onShowExample = viewModel::showExample, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SortBottomSheet( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + sortFieldOrderList: List, + showHelp: Boolean, + onApply: () -> Unit, + onMove: (fromIndex: Int, toIndex: Int) -> Unit, + onToggle: (SortField) -> Unit, + onReset: () -> Unit, + onHideHelpClick: () -> Unit, + onShowHelpClick: () -> Unit, + onShowExample: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + ModalBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + // Hide drag handle because other bottom sheets don't have it + dragHandle = {}, + ) { + SortBottomSheetContent( + onCancel = { + coroutineScope.launch { + sheetState.hide() + onDismissRequest() + } + }, + onApply = { + coroutineScope.launch { + onApply() + sheetState.hide() + onDismissRequest() + } + }, + sortFieldOrderList = sortFieldOrderList, + onMove = onMove, + onToggle = onToggle, + onReset = onReset, + showHelp = showHelp, + onHideHelpClick = onHideHelpClick, + onShowHelpClick = onShowHelpClick, + onShowExample = onShowExample, + ) + } +} + +@Composable +private fun SortBottomSheetContent( + modifier: Modifier = Modifier, + sortFieldOrderList: List, + showHelp: Boolean, + onCancel: () -> Unit, + onApply: () -> Unit, + onMove: (fromIndex: Int, toIndex: Int) -> Unit, + onToggle: (SortField) -> Unit, + onReset: () -> Unit, + onHideHelpClick: () -> Unit, + onShowHelpClick: () -> Unit, + onShowExample: () -> Unit, +) { + var isHelpExpanded by rememberSaveable { mutableStateOf(false) } + val scrollableState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + + Column( + modifier = modifier.verticalScroll(scrollableState), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + // Use fully qualified name due to quirky overload resolution. The compiler will + // otherwise tell you to use it in a column or row scope. + androidx.compose.animation.AnimatedVisibility( + modifier = Modifier.align(Alignment.CenterStart), + visible = !showHelp, + enter = fadeIn(), + exit = fadeOut(), + ) { + HelpButton { + onShowHelpClick() + isHelpExpanded = true + } + } + + Text( + modifier = Modifier.align(Alignment.Center), + text = stringResource(R.string.dialog_message_sort_sort_by), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineMedium, + ) + + TextButton( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = onReset, + enabled = sortFieldOrderList != SortKeyMapsUseCaseImpl.defaultOrder, + ) { + Text(stringResource(R.string.reset)) + } + } + + SortDraggableList( + modifier = Modifier.heightIn(max = 400.dp), + sortFieldOrderList = sortFieldOrderList, + onMove = onMove, + onSortFieldClick = onToggle, + ) + + AnimatedVisibility(showHelp) { + SortHelpCard( + modifier = Modifier.padding(8.dp), + onHideHelpClick = { + onHideHelpClick() + isHelpExpanded = false + }, + onShowExampleClick = { + coroutineScope.launch { + scrollableState.animateScrollTo(0) + onShowExample() + } + }, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton( + onClick = onCancel, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Button( + onClick = onApply, + ) { + Text(stringResource(R.string.pos_apply)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun HelpButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + IconButton( + modifier = modifier, + onClick = onClick, + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_help_outline_24), + contentDescription = stringResource(R.string.button_help), + ) + } +} + +@Composable +private fun SortDraggableList( + modifier: Modifier = Modifier, + sortFieldOrderList: List, + onMove: (fromIndex: Int, toIndex: Int) -> Unit, + onSortFieldClick: (SortField) -> Unit, +) { + val lazyListState = rememberLazyListState() + val dragDropState = rememberDragDropState( + lazyListState = lazyListState, + onMove = onMove, + ) + + LazyColumn( + modifier = modifier, + state = lazyListState, + ) { + itemsIndexed( + items = sortFieldOrderList, + key = { _, item -> item.field }, + ) { index, item -> + + DraggableItem( + dragDropState = dragDropState, + index = index, + ) { isDragging -> + SortFieldListItem( + index = index + 1, + sortField = item.field, + sortOrder = item.order, + 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) + }, + onDragStopped = { dragDropState.onDragInterrupted() }, + ) + } + } + } +} + +@Composable +private fun SortFieldListItem( + index: Int, + sortField: SortField, + sortOrder: SortOrder, + onToggle: () -> Unit, + isDragging: Boolean, + onDrag: (Offset) -> Unit, + onDragStarted: suspend CoroutineScope.(Offset) -> Unit, + onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit, + modifier: Modifier = Modifier, +) { + val draggableState = rememberDraggableState { onDrag(Offset(0f, it)) } + val draggableColor = MaterialTheme.colorScheme.surfaceVariant + + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable { onToggle() } + .drawBehind { + if (isDragging) { + drawRect(draggableColor) + } + } + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier.padding(8.dp), + text = index.toString(), + ) + Text( + text = sortFieldText(sortField), + style = if (sortOrder == SortOrder.NONE) { + MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) + } else { + MaterialTheme.typography.titleMedium + }, + ) + AnimatedContent( + targetState = sortOrder, + transitionSpec = { + when (targetState) { + SortOrder.ASCENDING -> + slideInVertically { it } togetherWith slideOutVertically { -it } + + SortOrder.DESCENDING -> + slideInVertically { -it } togetherWith slideOutVertically { it } + + SortOrder.NONE -> + fadeIn() togetherWith fadeOut() + } + }, + label = "$sortField Sort Order", + ) { sortOrder -> + if (sortOrder == SortOrder.NONE) { + Spacer(Modifier.size(24.dp)) + return@AnimatedContent + } + + val imageVector = when (sortOrder) { + SortOrder.NONE -> return@AnimatedContent + SortOrder.ASCENDING -> Icons.Default.ArrowUpward + SortOrder.DESCENDING -> Icons.Default.ArrowDownward + } + + Icon( + imageVector = imageVector, + contentDescription = null, + ) + } + } + + Box( + modifier = Modifier + .size(40.dp) + .draggable( + state = draggableState, + orientation = Orientation.Vertical, + startDragImmediately = true, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, + ), + ) { + Icon( + modifier = Modifier.align(Alignment.Center), + imageVector = Icons.Default.DragHandle, + contentDescription = stringResource( + R.string.drag_handle_for, + sortFieldText(sortField), + ), + ) + } + } +} + +@Composable +private fun SortHelpCard( + modifier: Modifier = Modifier, + onHideHelpClick: () -> Unit, + onShowExampleClick: () -> Unit, +) { + Card( + modifier = modifier, + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_help_outline_24), + contentDescription = null, + ) + + Text( + text = stringResource(R.string.button_help), + style = MaterialTheme.typography.titleMedium, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.sorting_drag_and_drop_list_help), + textAlign = TextAlign.Justify, + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.sorting_drag_and_drop_list_help_example), + textAlign = TextAlign.Justify, + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton( + onClick = onHideHelpClick, + ) { + Text(stringResource(R.string.neutral_hide)) + } + TextButton( + onClick = onShowExampleClick, + ) { + Text(stringResource(R.string.show_example)) + } + } + } + } +} + +@Preview +@Composable +private fun SortBottomSheetContentPreview() { + val list = listOf( + SortFieldOrder(SortField.TRIGGER, SortOrder.NONE), + SortFieldOrder(SortField.ACTIONS, SortOrder.ASCENDING), + SortFieldOrder(SortField.CONSTRAINTS, SortOrder.DESCENDING), + SortFieldOrder(SortField.OPTIONS, SortOrder.NONE), + ) + + KeyMapperTheme { + Surface { + SortBottomSheetContent( + onApply = {}, + onCancel = {}, + sortFieldOrderList = list, + onMove = { _, _ -> }, + onToggle = {}, + onReset = {}, + onHideHelpClick = {}, + showHelp = true, + onShowHelpClick = {}, + onShowExample = {}, + ) + } + } +} + +@Preview +@Composable +private fun SortBottomSheetPreview() { + val list = listOf( + SortFieldOrder(SortField.TRIGGER, SortOrder.NONE), + SortFieldOrder(SortField.ACTIONS, SortOrder.ASCENDING), + SortFieldOrder(SortField.CONSTRAINTS, SortOrder.DESCENDING), + SortFieldOrder(SortField.OPTIONS, SortOrder.NONE), + ) + + var size by remember { mutableIntStateOf(0) } + + KeyMapperTheme { + Surface { + SortBottomSheet( + // Preview hack, breaks if you run it + modifier = Modifier + .offset { IntOffset(0, -size) } + .onSizeChanged { size = it.height }, + onDismissRequest = {}, + onApply = {}, + sortFieldOrderList = list, + onMove = { _, _ -> }, + onToggle = {}, + onReset = {}, + showHelp = true, + onHideHelpClick = {}, + onShowHelpClick = {}, + onShowExample = {}, + ) + } + } +} + +@Composable +private fun sortFieldText(sortField: SortField): String { + return when (sortField) { + SortField.TRIGGER -> stringResource(R.string.trigger_header) + SortField.ACTIONS -> stringResource(R.string.action_list_header) + SortField.CONSTRAINTS -> stringResource(R.string.constraint_list_header) + SortField.OPTIONS -> stringResource(R.string.option_list_header) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/SortField.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/SortField.kt new file mode 100644 index 0000000000..f7831ad790 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/SortField.kt @@ -0,0 +1,11 @@ +package io.github.sds100.keymapper.sorting + +import kotlinx.serialization.Serializable + +@Serializable +enum class SortField { + TRIGGER, + ACTIONS, + CONSTRAINTS, + OPTIONS, +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/SortFieldOrder.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/SortFieldOrder.kt new file mode 100644 index 0000000000..d006a8b385 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/SortFieldOrder.kt @@ -0,0 +1,9 @@ +package io.github.sds100.keymapper.sorting + +import kotlinx.serialization.Serializable + +@Serializable +data class SortFieldOrder( + val field: SortField, + val order: SortOrder = SortOrder.NONE, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/SortKeyMapsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/SortKeyMapsUseCase.kt new file mode 100644 index 0000000000..0cc700115b --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/SortKeyMapsUseCase.kt @@ -0,0 +1,115 @@ +package io.github.sds100.keymapper.sorting + +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.mappings.DisplaySimpleMappingUseCase +import io.github.sds100.keymapper.mappings.keymaps.KeyMap +import io.github.sds100.keymapper.sorting.comparators.KeyMapActionsComparator +import io.github.sds100.keymapper.sorting.comparators.KeyMapConstraintsComparator +import io.github.sds100.keymapper.sorting.comparators.KeyMapOptionsComparator +import io.github.sds100.keymapper.sorting.comparators.KeyMapTriggerComparator +import io.github.sds100.keymapper.util.ui.ResourceProviderImpl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class SortKeyMapsUseCaseImpl( + private val preferenceRepository: PreferenceRepository, + private val displaySimpleMappingUseCase: DisplaySimpleMappingUseCase, + private val resourceProvider: ResourceProviderImpl, +) : SortKeyMapsUseCase { + + /** + * Observes the order in which key map fields should be sorted, prioritizing specific fields. + * For example, if the order is [TRIGGER, ACTIONS, CONSTRAINTS, OPTIONS], + * it means the key maps should be sorted first by trigger, then by actions, followed by constraints, + * and finally by options. + */ + override fun observeSortFieldOrder(): Flow> { + return preferenceRepository + .get(Keys.sortOrderJson) + .map { + if (it == null) { + return@map Companion.defaultOrder + } + + val result = runCatching { + Json.decodeFromString>(it) + }.getOrDefault(Companion.defaultOrder).distinct() + + // If the result is not the expected size it means that the preference is corrupted + // or there are missing fields (e.g. a new field was added). In this case, return + // the default order. + + if (result.size != SortField.entries.size) { + return@map Companion.defaultOrder + } + + result + } + } + + override fun setSortFieldOrder(sortFieldOrders: List) { + val json = Json.encodeToString(sortFieldOrders) + preferenceRepository.set(Keys.sortOrderJson, json) + } + + override fun observeKeyMapsSorter(): Flow> { + return observeSortFieldOrder() + .map { list -> + list.filter { it.order != SortOrder.NONE } + .map(::getComparator) + } + .map { Sorter(it) } + } + + private fun getComparator(sortFieldOrder: SortFieldOrder): Comparator { + val reverseOrder = sortFieldOrder.order == SortOrder.DESCENDING + + return when (sortFieldOrder.field) { + SortField.TRIGGER -> KeyMapTriggerComparator(reverseOrder) + SortField.ACTIONS -> KeyMapActionsComparator(displaySimpleMappingUseCase, reverseOrder) + SortField.CONSTRAINTS -> KeyMapConstraintsComparator( + displaySimpleMappingUseCase, + reverseOrder, + ) + SortField.OPTIONS -> KeyMapOptionsComparator(reverseOrder) + } + } + + companion object { + val defaultOrder = listOf( + SortFieldOrder(SortField.TRIGGER), + SortFieldOrder(SortField.ACTIONS), + SortFieldOrder(SortField.CONSTRAINTS), + SortFieldOrder(SortField.OPTIONS), + ) + } +} + +interface SortKeyMapsUseCase { + fun observeSortFieldOrder(): Flow> + fun setSortFieldOrder(sortFieldOrders: List) + fun observeKeyMapsSorter(): Flow> +} + +private class Sorter( + private val comparatorsOrder: List>, +) : Comparator { + override fun compare( + keyMap: KeyMap?, + otherKeyMap: KeyMap?, + ): Int { + if (keyMap == null || otherKeyMap == null) { + return 0 + } + + // Take the result of the first comparator that returns a non-zero value. + return comparatorsOrder + .asSequence() + .map { it.compare(keyMap, otherKeyMap) } + .firstOrNull { it != 0 } + ?: 0 + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/SortMenuFragment.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/SortMenuFragment.kt new file mode 100644 index 0000000000..3d4211caa5 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/SortMenuFragment.kt @@ -0,0 +1,35 @@ +package io.github.sds100.keymapper.sorting + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.activityViewModels +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.util.Inject + +class SortMenuFragment : BottomSheetDialogFragment() { + + private val sortViewModel: SortViewModel by activityViewModels { + Inject.sortViewModel(requireContext()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(requireContext()).apply { + setContent { + KeyMapperTheme { + SortBottomSheet( + onDismissRequest = ::dismiss, + viewModel = sortViewModel, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/SortOrder.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/SortOrder.kt new file mode 100644 index 0000000000..60130d549d --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/SortOrder.kt @@ -0,0 +1,19 @@ +package io.github.sds100.keymapper.sorting + +import kotlinx.serialization.Serializable + +@Serializable +enum class SortOrder { + NONE, + ASCENDING, + DESCENDING, + ; + + fun toggle(): SortOrder { + return when (this) { + NONE -> ASCENDING + ASCENDING -> DESCENDING + DESCENDING -> NONE + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/SortViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/SortViewModel.kt new file mode 100644 index 0000000000..792af84615 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/SortViewModel.kt @@ -0,0 +1,95 @@ +package io.github.sds100.keymapper.sorting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +class SortViewModel( + private val sortKeyMapsUseCase: SortKeyMapsUseCase, + private val preferenceRepository: PreferenceRepository, +) : ViewModel() { + val showHelp = preferenceRepository.get(Keys.sortShowHelp) + .map { it ?: true } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = runBlocking { + preferenceRepository.get(Keys.sortShowHelp).first() + } ?: true, + ) + + val sortFieldOrder: MutableStateFlow> = MutableStateFlow(emptyList()) + + init { + // Set the initial value of the sort field order to whatever is saved. + // The modified value will be saved when they click Apply. + viewModelScope.launch { + sortFieldOrder.value = sortKeyMapsUseCase.observeSortFieldOrder().first() + } + } + + fun swapSortPriority(fromIndex: Int, toIndex: Int) { + sortFieldOrder.update { + val newList = it.toMutableList() + newList.add(toIndex, newList.removeAt(fromIndex)) + newList + } + } + + fun toggleSortOrder(field: SortField) { + sortFieldOrder.update { sortFieldOrder -> + val index = sortFieldOrder.indexOfFirst { it.field == field } + + sortFieldOrder.mapIndexed { i, sortFieldOrder -> + if (i != index) { + return@mapIndexed sortFieldOrder + } + + val newOrder = sortFieldOrder.order.toggle() + sortFieldOrder.copy(order = newOrder) + } + } + } + + fun resetSortPriority() { + sortFieldOrder.value = SortKeyMapsUseCaseImpl.defaultOrder + } + + fun applySortPriority() { + sortKeyMapsUseCase.setSortFieldOrder(sortFieldOrder.value) + } + + fun setShowHelp(show: Boolean) { + preferenceRepository.set(Keys.sortShowHelp, show) + } + + fun showExample() { + sortFieldOrder.value = listOf( + SortFieldOrder(SortField.ACTIONS, SortOrder.ASCENDING), + SortFieldOrder(SortField.TRIGGER, SortOrder.DESCENDING), + SortFieldOrder(SortField.CONSTRAINTS), + SortFieldOrder(SortField.OPTIONS), + ) + } + + class Factory( + private val sortKeyMapsUseCase: SortKeyMapsUseCase, + private val preferenceRepository: PreferenceRepository, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class) = SortViewModel( + sortKeyMapsUseCase, + preferenceRepository, + ) as T + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapActionsComparator.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapActionsComparator.kt new file mode 100644 index 0000000000..96d6f7a42e --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapActionsComparator.kt @@ -0,0 +1,90 @@ +package io.github.sds100.keymapper.sorting.comparators + +import io.github.sds100.keymapper.actions.ActionData +import io.github.sds100.keymapper.mappings.DisplayActionUseCase +import io.github.sds100.keymapper.mappings.keymaps.KeyMap +import io.github.sds100.keymapper.util.Result +import io.github.sds100.keymapper.util.Success +import io.github.sds100.keymapper.util.valueOrNull + +class KeyMapActionsComparator( + private val displayActions: DisplayActionUseCase, + /** + * Each comparator is reversed separately instead of the entire key map list + * and Comparator.reversed() requires API level 24 so use a custom reverse field. + */ + private val reverse: Boolean = false, +) : Comparator { + override fun compare( + keyMap: KeyMap?, + otherKeyMap: KeyMap?, + ): Int { + if (keyMap == null || otherKeyMap == null) { + return 0 + } + + val keyMapActionsLength = keyMap.actionList.size + val otherKeyMapActionsLength = otherKeyMap.actionList.size + val maxLength = keyMapActionsLength.coerceAtMost(otherKeyMapActionsLength) + + // compare actions one by one + for (i in 0 until maxLength) { + val action1 = keyMap.actionList[i] + val action2 = otherKeyMap.actionList[i] + + val result = compareValuesBy( + action1, + action2, + { it.data.id }, + { getSecondarySortField(it.data).valueOrNull() ?: it.data.id }, + { it.repeat }, + { it.multiplier }, + { it.repeatLimit }, + { it.repeatRate }, + { it.repeatDelay }, + { it.repeatMode }, + { it.delayBeforeNextAction }, + ) + + if (result != 0) { + return invertIfReverse(result) + } + } + + // if actions are equal compare length + val comparison = keyMap.actionList.size.compareTo(otherKeyMap.actionList.size) + + return invertIfReverse(comparison) + } + + private fun invertIfReverse(result: Int) = if (reverse) { + result * -1 + } else { + result + } + + private fun getSecondarySortField(action: ActionData): Result { + return when (action) { + is ActionData.App -> displayActions.getAppName(action.packageName) + is ActionData.AppShortcut -> Success(action.shortcutTitle) + is ActionData.InputKeyEvent -> Success(action.keyCode.toString()) + is ActionData.Sound -> Success(action.soundDescription) + is ActionData.Volume.Stream -> Success(action.volumeStream.toString()) + is ActionData.Volume.SetRingerMode -> Success(action.ringerMode.toString()) + is ActionData.Flashlight -> Success(action.lens.toString()) + is ActionData.SwitchKeyboard -> Success(action.savedImeName) + is ActionData.DoNotDisturb.Toggle -> Success(action.dndMode.toString()) + is ActionData.DoNotDisturb.Enable -> Success(action.dndMode.toString()) + is ActionData.ControlMediaForApp -> Success(action.packageName) + is ActionData.Intent -> Success(action.description) + is ActionData.TapScreen -> Success(action.description ?: "") + is ActionData.SwipeScreen -> Success(action.description ?: "") + is ActionData.PinchScreen -> Success(action.description ?: "") + is ActionData.PhoneCall -> Success(action.number) + is ActionData.Url -> Success(action.url) + is ActionData.Text -> Success(action.text) + is ActionData.Rotation.CycleRotations -> Success(action.orientations.joinToString()) + else -> Success("") + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt new file mode 100644 index 0000000000..43d60fdc1e --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt @@ -0,0 +1,128 @@ +package io.github.sds100.keymapper.sorting.comparators + +import io.github.sds100.keymapper.constraints.Constraint +import io.github.sds100.keymapper.mappings.DisplayConstraintUseCase +import io.github.sds100.keymapper.mappings.keymaps.KeyMap +import io.github.sds100.keymapper.util.Result +import io.github.sds100.keymapper.util.Success +import io.github.sds100.keymapper.util.then +import io.github.sds100.keymapper.util.valueOrNull + +class KeyMapConstraintsComparator( + private val displayConstraints: DisplayConstraintUseCase, + /** + * Each comparator is reversed separately instead of the entire key map list + * and Comparator.reversed() requires API level 24 so use a custom reverse field. + */ + private val reverse: Boolean = false, +) : Comparator { + override fun compare( + keyMap: KeyMap?, + otherKeyMap: KeyMap?, + ): Int { + if (keyMap == null || otherKeyMap == null) { + return 0 + } + + val keyMapConstraintsLength = keyMap.constraintState.constraints.size + val otherKeyMapConstraintsLength = otherKeyMap.constraintState.constraints.size + val maxLength = keyMapConstraintsLength.coerceAtMost(otherKeyMapConstraintsLength) + + // Compare constraints one by one + for (i in 0 until maxLength) { + val constraint = keyMap.constraintState.constraints.elementAt(i) + val otherConstraint = otherKeyMap.constraintState.constraints.elementAt(i) + + val result = compareConstraints(constraint, otherConstraint) + + if (result != 0) { + return invertIfReverse(result) + } + } + + // If constraints are equal, compare the length + val comparison = keyMapConstraintsLength.compareTo(otherKeyMapConstraintsLength) + + return invertIfReverse(comparison) + } + + private fun invertIfReverse(result: Int) = if (reverse) { + result * -1 + } else { + result + } + + private fun compareConstraints( + constraint: Constraint, + otherConstraint: Constraint, + ): Int { + // If constraints are different, compare their types so they are ordered + // by their type. + // + // This ensures that there won't be a case like this: + // 1. "A" is in foreground + // 2. "A" is not in foreground + // 3. "B" is in foreground + // + // Instead, it will be like this: + // 1. "A" is in foreground + // 2. "B" is in foreground + // 3. "A" is not in foreground + + if (constraint.id == otherConstraint.id) { + // If constraints are the same then sort by a secondary field. + val comparison = getSecondarySortField(constraint).then { sortData -> + return@then getSecondarySortField(otherConstraint).then { otherSortData -> + Success(sortData.compareTo(otherSortData)) + } + } + + return comparison.valueOrNull() ?: 0 + } + + return constraint.id.ordinal.compareTo(otherConstraint.id.ordinal) + } + + private fun getSecondarySortField(constraint: Constraint): Result { + return when (constraint) { + is Constraint.AppInForeground -> displayConstraints.getAppName(constraint.packageName) + is Constraint.AppNotInForeground -> displayConstraints.getAppName(constraint.packageName) + is Constraint.AppNotPlayingMedia -> displayConstraints.getAppName(constraint.packageName) + is Constraint.AppPlayingMedia -> displayConstraints.getAppName(constraint.packageName) + is Constraint.BtDeviceConnected -> Success(constraint.deviceName) + is Constraint.BtDeviceDisconnected -> Success(constraint.deviceName) + Constraint.Charging -> Success("") + Constraint.DeviceIsLocked -> Success("") + Constraint.DeviceIsUnlocked -> Success("") + Constraint.Discharging -> Success("") + is Constraint.FlashlightOff -> Success(constraint.lens.toString()) + is Constraint.FlashlightOn -> Success(constraint.lens.toString()) + is Constraint.ImeChosen -> Success(constraint.imeLabel) + is Constraint.ImeNotChosen -> Success(constraint.imeLabel) + Constraint.InPhoneCall -> Success("") + Constraint.MediaPlaying -> Success("") + Constraint.NoMediaPlaying -> Success("") + Constraint.NotInPhoneCall -> Success("") + is Constraint.OrientationCustom -> Success(constraint.orientation.toString()) + Constraint.OrientationLandscape -> Success("") + Constraint.OrientationPortrait -> Success("") + Constraint.PhoneRinging -> Success("") + Constraint.ScreenOff -> Success("") + Constraint.ScreenOn -> Success("") + is Constraint.WifiConnected -> if (constraint.ssid == null) { + Success("") + } else { + Success(constraint.ssid) + } + + is Constraint.WifiDisconnected -> if (constraint.ssid == null) { + Success("") + } else { + Success(constraint.ssid) + } + + Constraint.WifiOff -> Success("") + Constraint.WifiOn -> Success("") + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapOptionsComparator.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapOptionsComparator.kt new file mode 100644 index 0000000000..a891a23393 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapOptionsComparator.kt @@ -0,0 +1,37 @@ +package io.github.sds100.keymapper.sorting.comparators + +import io.github.sds100.keymapper.mappings.keymaps.KeyMap + +class KeyMapOptionsComparator( + /** + * Each comparator is reversed separately instead of the entire key map list + * and Comparator.reversed() requires API level 24 so use a custom reverse field. + */ + private val reverse: Boolean = false, +) : Comparator { + override fun compare( + keyMap: KeyMap?, + otherKeyMap: KeyMap?, + ): Int { + if (keyMap == null || otherKeyMap == null) { + return 0 + } + + val result = compareValuesBy( + keyMap, + otherKeyMap, + { it.vibrate }, + { it.trigger.screenOffTrigger }, + { it.trigger.triggerFromOtherApps }, + { it.showToast }, + ) + + return invertIfReverse(result) + } + + private fun invertIfReverse(result: Int) = if (reverse) { + result * -1 + } else { + result + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapTriggerComparator.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapTriggerComparator.kt new file mode 100644 index 0000000000..b5425499c7 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapTriggerComparator.kt @@ -0,0 +1,59 @@ +package io.github.sds100.keymapper.sorting.comparators + +import io.github.sds100.keymapper.mappings.keymaps.KeyMap + +class KeyMapTriggerComparator( + /** + * Each comparator is reversed separately instead of the entire key map list + * and Comparator.reversed() requires API level 24 so use a custom reverse field. + */ + private val reverse: Boolean = false, +) : Comparator { + /** + * Compare trigger keys -> keys length -> trigger mode + */ + override fun compare( + keyMap: KeyMap?, + otherKeyMap: KeyMap?, + ): Int { + if (keyMap == null || otherKeyMap == null) { + return 0 + } + + val trigger = keyMap.trigger + val otherTrigger = otherKeyMap.trigger + + val keyMapTriggerKeysLength = trigger.keys.size + val otherKeyMapTriggerKeysLength = otherTrigger.keys.size + val maxLength = keyMapTriggerKeysLength.coerceAtMost(otherKeyMapTriggerKeysLength) + + // Compare keys one by one + for (i in 0 until maxLength) { + val key = trigger.keys[i] + val otherKey = otherTrigger.keys[i] + + val result = key.compareTo(otherKey) + + if (result != 0) { + return invertIfReverse(result) + } + } + + // If keys are equal compare length + // Otherwise compare mode + val result = compareValuesBy( + trigger, + otherTrigger, + { it.keys.size }, + { it.mode }, + ) + + return invertIfReverse(result) + } + + private fun invertIfReverse(result: Int) = if (reverse) { + result * -1 + } else { + result + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index 8372d15652..99def2f305 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt @@ -37,6 +37,8 @@ import io.github.sds100.keymapper.reportbug.ReportBugUseCaseImpl import io.github.sds100.keymapper.reportbug.ReportBugViewModel import io.github.sds100.keymapper.settings.ConfigSettingsUseCaseImpl import io.github.sds100.keymapper.settings.SettingsViewModel +import io.github.sds100.keymapper.sorting.SortKeyMapsUseCaseImpl +import io.github.sds100.keymapper.sorting.SortViewModel import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceController import io.github.sds100.keymapper.system.accessibility.MyAccessibilityService import io.github.sds100.keymapper.system.apps.ChooseActivityViewModel @@ -189,6 +191,11 @@ object Inject { ServiceLocator.inputMethodAdapter(ctx), ServiceLocator.packageManagerAdapter(ctx), ), + SortKeyMapsUseCaseImpl( + ServiceLocator.settingsRepository(ctx), + UseCases.displayKeyMap(ctx), + ServiceLocator.resourceProvider(ctx), + ), ) fun settingsViewModel(context: Context): SettingsViewModel.Factory = SettingsViewModel.Factory( @@ -287,4 +294,9 @@ object Inject { ), ServiceLocator.resourceProvider(ctx), ) + + fun sortViewModel(ctx: Context): SortViewModel.Factory = SortViewModel.Factory( + UseCases.sortKeyMapsUseCase(ctx), + ServiceLocator.settingsRepository(ctx), + ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt index d6efa8c902..e5aa82a632 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt @@ -5,8 +5,8 @@ import io.github.sds100.keymapper.actions.pinchscreen.PinchPickCoordinateResult import io.github.sds100.keymapper.actions.sound.ChooseSoundFileResult import io.github.sds100.keymapper.actions.swipescreen.SwipePickCoordinateResult import io.github.sds100.keymapper.actions.tapscreen.PickCoordinateResult -import io.github.sds100.keymapper.constraints.ChooseConstraintType import io.github.sds100.keymapper.constraints.Constraint +import io.github.sds100.keymapper.constraints.ConstraintId import io.github.sds100.keymapper.mappings.fingerprintmaps.FingerprintMapId import io.github.sds100.keymapper.system.apps.ActivityInfo import io.github.sds100.keymapper.system.apps.ChooseAppShortcutResult @@ -87,7 +87,7 @@ sealed class NavDestination { object ChooseActivity : NavDestination() object ChooseSound : NavDestination() object ChooseAction : NavDestination() - data class ChooseConstraint(val supportedConstraints: List) : NavDestination() + data class ChooseConstraint(val supportedConstraints: List) : NavDestination() object ChooseBluetoothDevice : NavDestination() object ReportBug : NavDestination() diff --git a/app/src/main/res/drawable/ic_sort_24.xml b/app/src/main/res/drawable/ic_sort_24.xml new file mode 100644 index 0000000000..1f06ff754e --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/menu/menu_home.xml index e084e17d38..3c4b4b2fd3 100644 --- a/app/src/main/res/menu/menu_home.xml +++ b/app/src/main/res/menu/menu_home.xml @@ -7,4 +7,10 @@ android:icon="@drawable/ic_baseline_help_outline_24" android:title="@string/action_help_home" app:showAsAction="ifRoom" /> + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_app.xml b/app/src/main/res/navigation/nav_app.xml index c1d17bd810..2cb1d20fcf 100644 --- a/app/src/main/res/navigation/nav_app.xml +++ b/app/src/main/res/navigation/nav_app.xml @@ -106,6 +106,17 @@ app:destination="@id/menuFragment" app:enterAnim="@anim/slide_in_bottom" /> + + + + - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 335d690412..928327e471 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -446,6 +446,7 @@ Toggle short messages Copy Clear + Sort @@ -589,6 +590,13 @@ Update now Ignore + Sort + Sort by + Drag the handles to adjust priorities. The item at the top is the most important. You can also tap any item to reverse its sort order. + Example: To sort mappings primarily by their Actions in ascending order and secondarily by their Triggers in descending order, move Actions to the first position and Triggers to the second. + Drag handle for %1$s + Show example + Yes Confirm Done @@ -606,6 +614,7 @@ Restart Never show again Open online guide + Apply Turn off Stay out @@ -613,6 +622,7 @@ Cancel Don\'t show again + Hide Online guide Settings Docs