diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e66269720..62b6eedc55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - #1414 constraint for when the keyboard is showing. - #1900 log to logcat if extra logging is enabled. - #1902 add toggle next to record trigger button to use PRO mode. +- #1909 categorise constraints similar to actions. ## Bug fixes diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt index d3a956cd9c..857f30330c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.base.constraints import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding @@ -15,25 +14,19 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Android -import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Bluetooth +import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.BottomAppBar import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection @@ -48,25 +41,28 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.SearchAppBarActions import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemFixedHeight +import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemGroup +import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemHeader import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemModel import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.flow.update @Composable fun ChooseConstraintScreen(modifier: Modifier = Modifier, viewModel: ChooseConstraintViewModel) { - val listItems by viewModel.listItems.collectAsStateWithLifecycle() + val state by viewModel.groups.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() TimeConstraintBottomSheet(viewModel) ChooseConstraintScreen( modifier = modifier, - state = listItems, + state = state, query = query, onQueryChange = { newQuery -> viewModel.searchQuery.update { newQuery } }, onCloseSearch = { viewModel.searchQuery.update { null } }, - onClickAction = viewModel::onListItemClick, + onClickConstraint = viewModel::onListItemClick, onNavigateBack = viewModel::onNavigateBack, ) } @@ -75,78 +71,30 @@ fun ChooseConstraintScreen(modifier: Modifier = Modifier, viewModel: ChooseConst @Composable private fun ChooseConstraintScreen( modifier: Modifier = Modifier, - state: State>, + state: State>, query: String? = null, onQueryChange: (String) -> Unit = {}, onCloseSearch: () -> Unit = {}, - onClickAction: (String) -> Unit = {}, + onClickConstraint: (String) -> Unit = {}, onNavigateBack: () -> Unit = {}, ) { - var isExpanded: Boolean by rememberSaveable { mutableStateOf(false) } - Scaffold( modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.choose_constraint_title)) }, + ) + }, bottomBar = { BottomAppBar( modifier = Modifier.imePadding(), actions = { - IconButton(onClick = { - if (isExpanded) { - onCloseSearch() - isExpanded = false - } else { - onNavigateBack() - } - }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource( - R.string.bottom_app_bar_back_content_description, - ), - ) - } - - DockedSearchBar( - modifier = Modifier.align(Alignment.CenterVertically), - inputField = { - SearchBarDefaults.InputField( - modifier = Modifier.align(Alignment.CenterVertically), - onSearch = { - onQueryChange(it) - isExpanded = false - }, - leadingIcon = { - Icon( - Icons.Rounded.Search, - contentDescription = null, - ) - }, - enabled = state is State.Data, - placeholder = { Text(stringResource(R.string.search_placeholder)) }, - query = query ?: "", - onQueryChange = onQueryChange, - expanded = isExpanded, - onExpandedChange = { expanded -> - if (expanded) { - isExpanded = true - } else { - onCloseSearch() - isExpanded = false - } - }, - ) - }, - // This is false to prevent an empty "content" showing underneath. - expanded = isExpanded, - onExpandedChange = { expanded -> - if (expanded) { - isExpanded = true - } else { - onCloseSearch() - isExpanded = false - } - }, - content = {}, + SearchAppBarActions( + onCloseSearch = onCloseSearch, + onNavigateBack = onNavigateBack, + onQueryChange = onQueryChange, + enabled = state is State.Data, + query = query, ) }, ) @@ -167,33 +115,20 @@ private fun ChooseConstraintScreen( ), ) { - Column { - Text( - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 16.dp, - bottom = 8.dp, - ), - text = stringResource(R.string.choose_constraint_title), - style = MaterialTheme.typography.titleLarge, - ) - - when (state) { - State.Loading -> LoadingScreen(modifier = Modifier.fillMaxSize()) + when (state) { + State.Loading -> LoadingScreen(modifier = Modifier.fillMaxSize()) - is State.Data -> { - if (state.data.isEmpty()) { - EmptyScreen( - modifier = Modifier.fillMaxSize(), - ) - } else { - ListScreen( - modifier = Modifier.fillMaxSize(), - listItems = state.data, - onClickAction = onClickAction, - ) - } + is State.Data -> { + if (state.data.isEmpty()) { + EmptyScreen( + modifier = Modifier.fillMaxSize(), + ) + } else { + ListScreen( + modifier = Modifier.fillMaxSize(), + groups = state.data, + onClickConstraint = onClickConstraint, + ) } } } @@ -233,8 +168,8 @@ private fun EmptyScreen(modifier: Modifier = Modifier) { @Composable private fun ListScreen( modifier: Modifier = Modifier, - listItems: List, - onClickAction: (String) -> Unit, + groups: List, + onClickConstraint: (String) -> Unit, ) { LazyVerticalGrid( modifier = modifier, @@ -243,12 +178,21 @@ private fun ListScreen( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - items(listItems, key = { it.id }) { model -> - SimpleListItemFixedHeight( - modifier = Modifier.fillMaxWidth(), - model = model, - onClick = { onClickAction(model.id) }, - ) + for (group in groups) { + stickyHeader(contentType = "header") { + SimpleListItemHeader(modifier = Modifier.fillMaxWidth(), text = group.header) + } + + items( + group.items, + contentType = { "list_item" }, + ) { model -> + SimpleListItemFixedHeight( + modifier = Modifier.fillMaxWidth(), + model = model, + onClick = { onClickConstraint(model.id) }, + ) + } } } } @@ -261,18 +205,30 @@ private fun PreviewList() { query = "Search query", state = State.Data( listOf( - SimpleListItemModel( - "app", - title = "App in foreground", - icon = ComposeIconInfo.Vector(Icons.Rounded.Android), + SimpleListItemGroup( + header = "Apps", + items = listOf( + SimpleListItemModel( + "app1", + title = "App in foreground", + icon = ComposeIconInfo.Vector(Icons.Rounded.Android), + ), + SimpleListItemModel( + "app2", + title = "App not in foreground", + icon = ComposeIconInfo.Vector(Icons.Rounded.Android), + ), + ), ), - SimpleListItemModel( - "app", - title = "App not in foreground", - icon = ComposeIconInfo.Vector(Icons.Rounded.Android), - subtitle = "Error", - isSubtitleError = true, - isEnabled = false, + SimpleListItemGroup( + header = "Bluetooth", + items = listOf( + SimpleListItemModel( + "bt1", + title = "Bluetooth device connected", + icon = ComposeIconInfo.Vector(Icons.Rounded.Bluetooth), + ), + ), ), ), ), @@ -285,21 +241,42 @@ private fun PreviewList() { private fun PreviewGrid() { KeyMapperTheme { ChooseConstraintScreen( - query = "Search query", state = State.Data( listOf( - SimpleListItemModel( - "app1", - title = "App in foreground", - icon = ComposeIconInfo.Vector(Icons.Rounded.Android), + SimpleListItemGroup( + header = "Apps", + items = listOf( + SimpleListItemModel( + "app1", + title = "App in foreground", + icon = ComposeIconInfo.Vector(Icons.Rounded.Android), + ), + SimpleListItemModel( + "app2", + title = "App not in foreground", + icon = ComposeIconInfo.Vector(Icons.Rounded.Android), + ), + ), ), - SimpleListItemModel( - "app2", - title = "App not in foreground", - icon = ComposeIconInfo.Vector(Icons.Rounded.Android), - subtitle = "Error", - isSubtitleError = true, - isEnabled = false, + SimpleListItemGroup( + header = "WiFi", + items = listOf( + SimpleListItemModel( + "wifi1", + title = "WiFi is on", + icon = ComposeIconInfo.Vector(Icons.Rounded.Wifi), + subtitle = "Requires root", + isSubtitleError = true, + ), + SimpleListItemModel( + "wifi2", + title = "WiFi is off", + icon = ComposeIconInfo.Vector(Icons.Rounded.Wifi), + subtitle = "Requires root", + isSubtitleError = true, + isEnabled = false, + ), + ), ), ), ), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt index c39dacae37..1b24b89c2d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt @@ -15,6 +15,7 @@ import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemGroup import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemModel import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.Orientation @@ -45,70 +46,42 @@ class ChooseConstraintViewModel @Inject constructor( NavigationProvider by navigationProvider { companion object { - 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.KEYBOARD_SHOWING, - ConstraintId.KEYBOARD_NOT_SHOWING, - - ConstraintId.DEVICE_IS_LOCKED, - ConstraintId.DEVICE_IS_UNLOCKED, - ConstraintId.LOCK_SCREEN_SHOWING, - ConstraintId.LOCK_SCREEN_NOT_SHOWING, - - ConstraintId.IN_PHONE_CALL, - ConstraintId.NOT_IN_PHONE_CALL, - ConstraintId.PHONE_RINGING, - - ConstraintId.CHARGING, - ConstraintId.DISCHARGING, - - ConstraintId.HINGE_CLOSED, - ConstraintId.HINGE_OPEN, - - ConstraintId.TIME, + private val CATEGORY_ORDER = arrayOf( + ConstraintCategory.APPS, + ConstraintCategory.MEDIA, + ConstraintCategory.BLUETOOTH, + ConstraintCategory.DISPLAY, + ConstraintCategory.FLASHLIGHT, + ConstraintCategory.WIFI, + ConstraintCategory.KEYBOARD, + ConstraintCategory.LOCK, + ConstraintCategory.PHONE, + ConstraintCategory.POWER, + ConstraintCategory.DEVICE, + ConstraintCategory.TIME, ) } private val returnResult = MutableSharedFlow() - private val allListItems: List by lazy { buildListItems() } + private val allGroupedListItems: List by lazy { buildListGroups() } val searchQuery = MutableStateFlow(null) - val listItems: StateFlow>> = + val groups: StateFlow>> = searchQuery.map { query -> - val filteredItems = allListItems.filter { it.title.containsQuery(query) } - State.Data(filteredItems) + val groups = allGroupedListItems.mapNotNull { group -> + + val filteredItems = group.items.filter { it.title.containsQuery(query) } + + if (filteredItems.isEmpty()) { + return@mapNotNull null + } else { + group.copy(items = filteredItems) + } + } + + State.Data(groups) }.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) var timeConstraintState: ConstraintData.Time? by mutableStateOf(null) @@ -278,25 +251,55 @@ class ChooseConstraintViewModel @Inject constructor( return cameraLens } - private fun buildListItems(): List = buildList { - ALL_CONSTRAINTS_ORDERED.forEach { id -> - val title = getString(ConstraintUtils.getTitleStringId(id)) - val icon = ConstraintUtils.getIcon(id) - val error = useCase.isSupported(id) - - val listItem = SimpleListItemModel( - id = id.toString(), - title = title, - icon = icon, - subtitle = error?.getFullMessage(this@ChooseConstraintViewModel), - isSubtitleError = true, - isEnabled = error == null, + private fun buildListGroups(): List = buildList { + val listItems = buildListItems(ConstraintId.entries) + + for (category in CATEGORY_ORDER) { + val header = getString(ConstraintUtils.getCategoryLabel(category)) + + val group = SimpleListItemGroup( + header, + items = listItems.filter { + it.isEnabled && + ConstraintUtils.getCategory(ConstraintId.valueOf(it.id)) == category + }, ) - add(listItem) + if (group.items.isNotEmpty()) { + add(group) + } + } + + val unsupportedItems = listItems.filter { !it.isEnabled } + if (unsupportedItems.isNotEmpty()) { + val unsupportedGroup = SimpleListItemGroup( + header = getString(R.string.choose_constraint_group_unsupported), + items = unsupportedItems, + ) + add(unsupportedGroup) } } + private fun buildListItems(constraintIds: List): List = + buildList { + for (constraintId in constraintIds) { + val title = getString(ConstraintUtils.getTitleStringId(constraintId)) + val icon = ConstraintUtils.getIcon(constraintId) + val error = useCase.isSupported(constraintId) + + val listItem = SimpleListItemModel( + id = constraintId.toString(), + title = title, + icon = icon, + subtitle = error?.getFullMessage(this@ChooseConstraintViewModel), + isSubtitleError = true, + isEnabled = error == null, + ) + + add(listItem) + } + } + private suspend fun onSelectWifiConnectedConstraint(type: ConstraintId) { val knownSSIDs: List = useCase.getKnownWiFiSSIDs() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt new file mode 100644 index 0000000000..e4937d1529 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.base.constraints + +enum class ConstraintCategory { + APPS, + MEDIA, + BLUETOOTH, + DISPLAY, + FLASHLIGHT, + WIFI, + KEYBOARD, + LOCK, + PHONE, + POWER, + DEVICE, + TIME, +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt index 2d7b1912aa..679a1229e1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.constraints +import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Battery2Bar import androidx.compose.material.icons.outlined.BatteryChargingFull @@ -30,6 +31,85 @@ import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo object ConstraintUtils { + @StringRes + fun getCategoryLabel(category: ConstraintCategory): Int = when (category) { + ConstraintCategory.APPS -> R.string.constraint_cat_apps + ConstraintCategory.MEDIA -> R.string.constraint_cat_media + ConstraintCategory.BLUETOOTH -> R.string.constraint_cat_bluetooth + ConstraintCategory.DISPLAY -> R.string.constraint_cat_display + ConstraintCategory.FLASHLIGHT -> R.string.constraint_cat_flashlight + ConstraintCategory.WIFI -> R.string.constraint_cat_wifi + ConstraintCategory.KEYBOARD -> R.string.constraint_cat_keyboard + ConstraintCategory.LOCK -> R.string.constraint_cat_lock + ConstraintCategory.PHONE -> R.string.constraint_cat_phone + ConstraintCategory.POWER -> R.string.constraint_cat_power + ConstraintCategory.DEVICE -> R.string.constraint_cat_device + ConstraintCategory.TIME -> R.string.constraint_cat_time + } + + fun getCategory(constraintId: ConstraintId): ConstraintCategory = when (constraintId) { + ConstraintId.APP_IN_FOREGROUND, + ConstraintId.APP_NOT_IN_FOREGROUND, + ConstraintId.APP_PLAYING_MEDIA, + ConstraintId.APP_NOT_PLAYING_MEDIA, + -> ConstraintCategory.APPS + + ConstraintId.MEDIA_PLAYING, + ConstraintId.MEDIA_NOT_PLAYING, + -> ConstraintCategory.MEDIA + + ConstraintId.BT_DEVICE_CONNECTED, + ConstraintId.BT_DEVICE_DISCONNECTED, + -> ConstraintCategory.BLUETOOTH + + ConstraintId.SCREEN_ON, + ConstraintId.SCREEN_OFF, + ConstraintId.ORIENTATION_PORTRAIT, + ConstraintId.ORIENTATION_LANDSCAPE, + ConstraintId.ORIENTATION_0, + ConstraintId.ORIENTATION_90, + ConstraintId.ORIENTATION_180, + ConstraintId.ORIENTATION_270, + -> ConstraintCategory.DISPLAY + + ConstraintId.FLASHLIGHT_ON, + ConstraintId.FLASHLIGHT_OFF, + -> ConstraintCategory.FLASHLIGHT + + ConstraintId.WIFI_ON, + ConstraintId.WIFI_OFF, + ConstraintId.WIFI_CONNECTED, + ConstraintId.WIFI_DISCONNECTED, + -> ConstraintCategory.WIFI + + ConstraintId.IME_CHOSEN, + ConstraintId.IME_NOT_CHOSEN, + ConstraintId.KEYBOARD_SHOWING, + ConstraintId.KEYBOARD_NOT_SHOWING, + -> ConstraintCategory.KEYBOARD + + ConstraintId.DEVICE_IS_LOCKED, + ConstraintId.DEVICE_IS_UNLOCKED, + ConstraintId.LOCK_SCREEN_SHOWING, + ConstraintId.LOCK_SCREEN_NOT_SHOWING, + -> ConstraintCategory.LOCK + + ConstraintId.IN_PHONE_CALL, + ConstraintId.NOT_IN_PHONE_CALL, + ConstraintId.PHONE_RINGING, + -> ConstraintCategory.PHONE + + ConstraintId.CHARGING, + ConstraintId.DISCHARGING, + -> ConstraintCategory.POWER + + ConstraintId.HINGE_CLOSED, + ConstraintId.HINGE_OPEN, + -> ConstraintCategory.DEVICE + + ConstraintId.TIME -> ConstraintCategory.TIME + } + fun getIcon(constraintId: ConstraintId): ComposeIconInfo = when (constraintId) { ConstraintId.APP_IN_FOREGROUND, ConstraintId.APP_NOT_IN_FOREGROUND, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 4286af0b29..a99a76348c 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1230,6 +1230,21 @@ Special + + Apps + Media + Bluetooth + Display + Flashlight + WiFi + Keyboard + Lock + Phone + Power + Device + Time + + Boolean Boolean array @@ -1600,6 +1615,7 @@ Options Unsupported + Unsupported Add constraints if you want key maps to only work in some situations.