Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.") }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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() } }
Expand All @@ -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<String?>(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 {
Expand All @@ -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) } } }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ internal fun demoRows(module: KeypadModule): List<List<DemoKey>> {
}

@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<String?>(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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}
Expand Down
Loading