diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/settings/KeyboardSettingsScreen.kt b/app/src/main/java/hrln/interactive/sidekit/apps/settings/KeyboardSettingsScreen.kt new file mode 100644 index 00000000..137a216c --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/settings/KeyboardSettingsScreen.kt @@ -0,0 +1,122 @@ +package hrln.interactive.sidekit.apps.settings + +import android.content.ComponentName +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import hrln.interactive.sidekit.apps.everyday.CompactRowHeight +import hrln.interactive.sidekit.design.action.SideAction +import hrln.interactive.sidekit.design.components.* +import hrln.interactive.sidekit.design.input.KeypadModule +import hrln.interactive.sidekit.design.input.LocalKeypad +import hrln.interactive.sidekit.design.input.T9Mode +import hrln.interactive.sidekit.navigation.SubApp +import hrln.interactive.sidekit.onboarding.KeypadDemo + +private val KEYBOARD = "${SubApp.Settings.route}/keyboard" + +internal fun keyboardSettingsRoute() = KEYBOARD + +/** Settings > Keyboard: what the keypad does and what its keys look like. */ +internal fun NavGraphBuilder.keyboardSettingsGraph(nav: NavHostController) { + composable(KEYBOARD) { + KeyboardSettingsScreen( + back = { nav.popBackStack() }, + onKeypadMapping = { nav.navigate(keypadMappingRoute()) } + ) + } +} + +/** + * One page for the keypad preferences that used to be separate rows on the Settings home: + * the module, the input mode, the Back key service and the custom mapping. The diagram below + * describes the keys of the module in use, including a custom mapping's choice. + */ +@Composable +internal fun KeyboardSettingsScreen(back: () -> Unit, onKeypadMapping: () -> Unit) { + val context = LocalContext.current + val prefs = remember { SidekitPreferences(context) } + val keypad = LocalKeypad.current + var revision by remember { mutableIntStateOf(0) } + LifecycleResumeEffect(Unit) { + revision++ + onPauseOrDispose {} + } + val accessibility = remember(revision) { + val enabled = Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES).orEmpty() + enabled.split(':').mapNotNull(ComponentName::unflattenFromString).any { + it.packageName == context.packageName && it.className.endsWith("KeypadAccessibilityService") + } + } + val inputMode = remember(revision) { prefs.inputMode } + val custom = keypad?.mapper?.customModule + val module = keypad?.module ?: KeypadModule.T9 + val list = rememberLazyListState() + SidekitScaffold("Keyboard", actions = listOf(SideAction.back("back", "Settings", back))) { padding -> + SidekitList(padding, state = list) { + // A custom mapping fixes the module: the row opens its editor instead of cycling. + item { + SidekitItem( + "Keypad", + Modifier.height(CompactRowHeight).listFade(list, 0), + secondary = custom?.let { "${it.label} · custom" } ?: module.label, + secondaryToEnd = true, + onClick = { + if (custom != null) { + onKeypadMapping() + } else { + keypad?.useModule(KeypadModule.entries[(module.ordinal + 1) % KeypadModule.entries.size]) + revision++ + } + } + ) + } + item { + SidekitItem( + "Input mode", + Modifier.height(CompactRowHeight).listFade(list, 1), + secondary = if (inputMode == T9Mode.Numeric) "Numbers" else "Letters", + secondaryToEnd = true, + onClick = { + prefs.inputMode = T9Mode.entries[(prefs.inputMode.ordinal + 1) % T9Mode.entries.size] + keypad?.let { prefs.applyInputDefault(it) } + revision++ + } + ) + } + item { + SidekitItem( + "Keypad Back key", + Modifier.listFade(list, 2), + secondary = if (accessibility) "On" else "Off", + showLinkArrow = true, + onClick = { + try { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } catch (_: Exception) { + // Leave the row unchanged if the system page isn't available. + } + } + ) + } + item { + SidekitItem( + "Mapping", + Modifier.listFade(list, 3), + secondary = if (custom != null) "Custom" else "Automatic", + showLinkArrow = true, + onClick = onKeypadMapping + ) + } + item { KeypadDemo(module, idleHint = "Tap a key to learn its action.") } + } + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/settings/SettingsScreen.kt b/app/src/main/java/hrln/interactive/sidekit/apps/settings/SettingsScreen.kt index 515f53e5..f3b6f30a 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/settings/SettingsScreen.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/settings/SettingsScreen.kt @@ -3,7 +3,6 @@ package hrln.interactive.sidekit.apps.settings import android.app.ActivityManager import android.app.role.RoleManager import android.app.usage.StorageStatsManager -import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Build @@ -26,9 +25,6 @@ import hrln.interactive.sidekit.R import hrln.interactive.sidekit.apps.everyday.CompactRowHeight import hrln.interactive.sidekit.design.action.SideAction import hrln.interactive.sidekit.design.components.* -import hrln.interactive.sidekit.design.input.KeypadModule -import hrln.interactive.sidekit.design.input.LocalKeypad -import hrln.interactive.sidekit.design.input.T9Mode import hrln.interactive.sidekit.design.theme.SidekitTheme import hrln.interactive.sidekit.navigation.SubApp import hrln.interactive.sidekit.onboarding.OnboardingScreen @@ -44,10 +40,11 @@ fun NavGraphBuilder.settingsGraph(nav: NavHostController) { onSetup = { nav.navigate("${SubApp.Settings.route}/setup") }, onHiddenApps = { nav.navigate("${SubApp.Settings.route}/hidden-apps") }, onSystem = { nav.navigate("${SubApp.Settings.route}/system") }, - onKeypadMapping = { nav.navigate(keypadMappingRoute()) }, - onArrange = { nav.navigate("${SubApp.Settings.route}/arrange") } + onKeyboard = { nav.navigate(keyboardSettingsRoute()) }, + onArrange = { nav.navigate("${SubApp.Settings.route}/arrange") }, ) } + keyboardSettingsGraph(nav) keypadMappingGraph(nav) composable("${SubApp.Settings.route}/hidden-apps") { HiddenAppsScreen { nav.popBackStack() } } composable("${SubApp.Settings.route}/system") { SystemSettingsScreen { nav.popBackStack() } } @@ -58,21 +55,13 @@ fun NavGraphBuilder.settingsGraph(nav: NavHostController) { } @Composable -internal fun SettingsHome(onHome: () -> Unit, onAbout: () -> Unit, onCaseColour: () -> Unit = {}, onSetup: () -> Unit = {}, onHiddenApps: () -> Unit = {}, onSystem: () -> Unit = {}, onKeypadMapping: () -> Unit = {}, onArrange: () -> Unit = {}) { +internal fun SettingsHome(onHome: () -> Unit, onAbout: () -> Unit, onCaseColour: () -> Unit = {}, onSetup: () -> Unit = {}, onHiddenApps: () -> Unit = {}, onSystem: () -> Unit = {}, onKeyboard: () -> Unit = {}, onArrange: () -> Unit = {}) { val context = LocalContext.current val prefs = remember { SidekitPreferences(context) } val caseColour = rememberCaseColour(prefs) - val keypad = LocalKeypad.current var revision by remember { mutableIntStateOf(0) } var message by remember { mutableStateOf(null) } - LifecycleResumeEffect(Unit) { - revision++ - onPauseOrDispose {} - } - val accessibility = remember(revision) { - val enabled = Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES).orEmpty() - enabled.split(':').mapNotNull(ComponentName::unflattenFromString).any { it.packageName == context.packageName && it.className.endsWith("KeypadAccessibilityService") } - } + LifecycleResumeEffect(Unit) { revision++; onPauseOrDispose {} } val home = remember(revision) { context.getSystemService(RoleManager::class.java).isRoleHeld(RoleManager.ROLE_HOME) } fun open(action: String) { try { @@ -83,43 +72,28 @@ internal fun SettingsHome(onHome: () -> Unit, onAbout: () -> Unit, onCaseColour: } val listLayout = remember(revision) { prefs.listLayout } val compactLayout = remember(revision) { prefs.compactLayout } - val inputMode = remember(revision) { prefs.inputMode } val list = rememberLazyListState() SidekitScaffold("Settings", actions = listOf(SideAction.back("home", "Home", onHome), SideAction.forward("about", "About", onAbout))) { padding -> SidekitList(padding, state = list) { + // Toggles act in place: compact rows, no arrow. item { - SidekitItem("Launcher default", Modifier.height(CompactRowHeight).listFade(list, 0), secondary = if (listLayout) "List" else "Grid", onClick = { + SidekitItem("Launcher default", Modifier.height(CompactRowHeight).listFade(list, 0), secondary = if (listLayout) "List" else "Grid", secondaryToEnd = true, onClick = { prefs.listLayout = !prefs.listLayout revision++ }) } - item { SidekitItem("Arrange apps", Modifier.height(CompactRowHeight).listFade(list, 1), secondary = "Home order & dock", onClick = onArrange) } - item { SidekitItem("Case colour", Modifier.height(CompactRowHeight).listFade(list, 2), secondary = caseColour?.label ?: "Original blue", onClick = onCaseColour) } // Shorter header and action bar on every screen: more room for the body. - item { SidekitItem("Compact layout", Modifier.height(CompactRowHeight).listFade(list, 3), secondary = if (compactLayout) "On" else "Off", onClick = { prefs.compactLayout = !prefs.compactLayout; revision++ }) } - // Detected from the keys pressed; tapping overrides it until another module's key is seen. - // A custom mapping fixes the module, so then the row opens the mapping instead. - val custom = keypad?.mapper?.customModule - item { - SidekitItem("Keypad", Modifier.height(CompactRowHeight).listFade(list, 4), secondary = custom?.let { "${it.label} · custom" } ?: keypad?.module?.label ?: "None", onClick = { - if (custom != null) onKeypadMapping() else keypad?.let { it.useModule(KeypadModule.entries[(it.module.ordinal + 1) % KeypadModule.entries.size]) } - revision++ - }) - } - item { - SidekitItem("Input mode", Modifier.height(CompactRowHeight).listFade(list, 5), secondary = if (inputMode == T9Mode.Numeric) "Numbers" else "Letters", onClick = { - prefs.inputMode = T9Mode.entries[(prefs.inputMode.ordinal + 1) % T9Mode.entries.size] - keypad?.let { prefs.applyInputDefault(it) } - revision++ - }) - } - item { SidekitItem("Keypad Back key", Modifier.height(CompactRowHeight).listFade(list, 6), secondary = if (accessibility) "Enabled" else "Enable", onClick = { open(Settings.ACTION_ACCESSIBILITY_SETTINGS) }) } - item { SidekitItem("Home app", Modifier.height(CompactRowHeight).listFade(list, 7), secondary = if (home) "Sidekit" else "Choose", onClick = { open(Settings.ACTION_HOME_SETTINGS) }) } + item { SidekitItem("Compact layout", Modifier.height(CompactRowHeight).listFade(list, 1), secondary = if (compactLayout) "On" else "Off", secondaryToEnd = true, onClick = { prefs.compactLayout = !prefs.compactLayout; revision++ }) } + // Opens a floating system dialog rather than a Sidekit page, so it stays a compact row. + item { SidekitItem("Home app", Modifier.height(CompactRowHeight).listFade(list, 2), secondary = if (home) "Sidekit" else "Choose", secondaryToEnd = true, onClick = { open(Settings.ACTION_HOME_SETTINGS) }) } + // Everything below opens another page: full-height rows with a link arrow. + item { SidekitItem("Arrange apps", Modifier.listFade(list, 3), showLinkArrow = true, onClick = onArrange) } + item { SidekitItem("Case colour", Modifier.listFade(list, 4), secondary = caseColour?.label ?: "Blue", showLinkArrow = true, onClick = onCaseColour) } + // All keypad preferences live together on the Keyboard page, with the key diagram. + item { SidekitItem("Keyboard", Modifier.listFade(list, 5), showLinkArrow = true, onClick = onKeyboard) } item { SidekitItem("Hide apps", showLinkArrow = true, onClick = onHiddenApps) } // Device settings live on their own page, so this screen stays Sidekit's own settings. item { SidekitItem("System settings", showLinkArrow = true, onClick = onSystem) } - // Advanced: custom mapping for keypads Sidekit doesn't recognise, or keys moved around. - item { SidekitItem("Keypad mapping", secondary = if (custom != null) "Custom" else null, showLinkArrow = custom == null, onClick = onKeypadMapping) } item { SidekitItem("Setup & keypad tour", showLinkArrow = true, onClick = onSetup) } message?.let { item { SidekitCard { SidekitText(it, style = SidekitTheme.typography.label, maxLines = Int.MAX_VALUE) } } } } diff --git a/app/src/main/java/hrln/interactive/sidekit/onboarding/KeypadDemo.kt b/app/src/main/java/hrln/interactive/sidekit/onboarding/KeypadDemo.kt index d89ad3a6..f9c4c40e 100644 --- a/app/src/main/java/hrln/interactive/sidekit/onboarding/KeypadDemo.kt +++ b/app/src/main/java/hrln/interactive/sidekit/onboarding/KeypadDemo.kt @@ -44,12 +44,12 @@ internal fun demoRows(module: KeypadModule): List> { } @Composable -internal fun KeypadDemo(module: KeypadModule) { +internal fun KeypadDemo(module: KeypadModule, idleHint: String = "Tap a key to learn its action. This demo stays in setup.") { var selected by rememberSaveable(module) { mutableStateOf(null) } val rows = remember(module) { demoRows(module) } val caption = SidekitTheme.typography.label.copy(fontSize = 20.sp, lineHeight = 21.sp) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - SidekitText(rows.flatten().firstOrNull { it.label == selected }?.action ?: "Tap a key to learn its action. This demo stays in setup.", style = caption, maxLines = 3, modifier = Modifier.height(64.dp)) + SidekitText(rows.flatten().firstOrNull { it.label == selected }?.action ?: idleHint, style = caption, maxLines = 3, modifier = Modifier.height(64.dp)) rows.forEach { row -> Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { row.forEach { key -> diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/settings/KeypadMappingUiTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/settings/KeypadMappingUiTest.kt index b6e3c4f4..8091057a 100644 --- a/app/src/test/java/hrln/interactive/sidekit/apps/settings/KeypadMappingUiTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/apps/settings/KeypadMappingUiTest.kt @@ -30,7 +30,9 @@ class KeypadMappingUiTest { private val chrome = ChromeState() private lateinit var keypad: Keypad - @Before fun clear() { prefs.edit().clear().commit() } + @Before fun clear() { + prefs.edit().clear().commit() + } private fun show(module: KeypadModule = KeypadModule.T9, content: @Composable () -> Unit) { compose.setContent { @@ -186,13 +188,36 @@ class KeypadMappingUiTest { compose.onRoot().captureRoboImage("screenshots/keypad_mapping_tester.png") } - @Test fun settingsLeadsToTheMapping() { + @Test fun settingsHasOneKeyboardEntry() { var opened = 0 - show { SettingsHome({}, {}, onKeypadMapping = { opened++ }) } - compose.onNodeWithText("Keypad").assertIsDisplayed() - scrollTo("Keypad mapping") - compose.onRoot().captureRoboImage("screenshots/keypad_mapping_settings.png") - compose.onNodeWithText("Keypad mapping").performClick() + show { SettingsHome({}, {}, onKeyboard = { opened++ }) } + scrollTo("Keyboard") + // The old separate keypad rows are gone: one entry leads to the Keyboard page. + compose.onNodeWithText("Input mode").assertDoesNotExist() + compose.onNodeWithText("Keypad Back key").assertDoesNotExist() + compose.onNodeWithText("Keypad mapping").assertDoesNotExist() + compose.onRoot().captureRoboImage("screenshots/keyboard_settings_home.png") + compose.onNodeWithText("Keyboard").performClick() assertEquals(1, opened) } + + @Test fun keyboardPageShowsPreferencesAndDiagram() { + var mapping = 0 + show(KeypadModule.T9) { KeyboardSettingsScreen({}, onKeypadMapping = { mapping++ }) } + compose.onNodeWithText("Keyboard").assertIsDisplayed() + compose.onNodeWithText("Keypad").assertIsDisplayed() + compose.onNodeWithText("Input mode").assertIsDisplayed() + compose.onNodeWithText("Keypad Back key").assertIsDisplayed() + compose.onNodeWithText("Mapping").assertIsDisplayed() + // Tapping the keypad row cycles the module, and the diagram follows it. + compose.onNodeWithText("Keypad").performClick() + compose.runOnIdle { assertEquals(KeypadModule.Qwerty, keypad.module) } + compose.onNodeWithText("Mini QWERTY").assertIsDisplayed() + // The diagram is below the preferences: scroll to it and check a key and its caption. + scrollTo("Tap a key to learn its action.") + compose.onNodeWithText("QW").assertIsDisplayed() + compose.onRoot().captureRoboImage("screenshots/keyboard_settings_page.png") + scrollTo("Mapping").performClick() + assertEquals(1, mapping) + } } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Item.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Item.kt index f8bdb921..f1d3d5e1 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Item.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Item.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Dp import hrln.interactive.sidekit.design.icons.SidekitIcon @@ -73,8 +74,15 @@ fun SidekitItem( title: String, modifier: Modifier = Modifier, secondary: String? = null, + /** Put the secondary value at the row's trailing edge (toggles and compact state rows). */ + secondaryToEnd: Boolean = false, icon: SidekitIcon? = null, showLinkArrow: Boolean = false, + /** + * Keep [showLinkArrow] when [secondary] is present too. Off by default so rows that show a + * value keep their existing look; settings rows that open another page opt in. + */ + showLinkArrowWithSecondary: Boolean = false, colour: ItemColour = ItemColour.Primary, titleDecoration: TextDecoration? = null, onClick: (() -> Unit)? = null, @@ -95,9 +103,20 @@ fun SidekitItem( if (icon != null) SidekitIconImage(icon) SidekitText(title, textDecoration = titleDecoration) if (secondary != null) { - SidekitText(secondary, Modifier.weight(1f), color = SidekitTheme.colors.surfaceA6) - } else if (showLinkArrow) { - Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + SidekitText( + secondary, + Modifier.weight(1f), + color = SidekitTheme.colors.surfaceA6, + textAlign = if (secondaryToEnd) TextAlign.End else null, + ) + } + // The arrow marks a row that opens another page. With a secondary value it must be + // requested explicitly, so existing rows keep the arrow off. + if (showLinkArrow && (secondary == null || showLinkArrowWithSecondary)) { + Box( + if (secondary == null) Modifier.weight(1f) else Modifier, + contentAlignment = Alignment.CenterEnd, + ) { SidekitIconImage(SidekitIcon.ArrowRightAlt) } }