11package app.pwhs.universalinstaller.presentation.setting
22
3+ import androidx.compose.foundation.BorderStroke
4+ import androidx.compose.foundation.background
35import androidx.compose.foundation.clickable
6+ import androidx.compose.foundation.selection.selectable
47import androidx.compose.foundation.layout.Arrangement
8+ import androidx.compose.foundation.layout.Box
59import androidx.compose.foundation.layout.Column
610import androidx.compose.foundation.layout.PaddingValues
711import androidx.compose.foundation.layout.Row
@@ -24,6 +28,7 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
2428import androidx.compose.material.icons.rounded.AdminPanelSettings
2529import androidx.compose.material.icons.rounded.Badge
2630import androidx.compose.material.icons.rounded.BugReport
31+ import androidx.compose.material.icons.rounded.CheckCircle
2732import androidx.compose.material.icons.rounded.Close
2833import androidx.compose.material.icons.rounded.Fingerprint
2934import androidx.compose.material.icons.automirrored.rounded.HelpOutline
@@ -51,6 +56,7 @@ import androidx.compose.material3.ListItemDefaults
5156import androidx.compose.material3.MaterialTheme
5257import androidx.compose.material3.OutlinedTextField
5358import androidx.compose.material3.Scaffold
59+ import androidx.compose.material3.Surface
5460import androidx.compose.material3.SegmentedButton
5561import androidx.compose.material3.SegmentedButtonDefaults
5662import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@@ -73,13 +79,15 @@ import androidx.compose.ui.focus.FocusRequester
7379import androidx.compose.ui.graphics.Color
7480import androidx.compose.ui.input.nestedscroll.nestedScroll
7581import androidx.compose.ui.res.stringResource
82+ import androidx.compose.ui.semantics.Role
7683import androidx.compose.ui.text.input.ImeAction
7784import androidx.compose.ui.text.input.KeyboardType
7885import coil3.compose.AsyncImage
7986import coil3.request.ImageRequest
8087import androidx.compose.ui.graphics.vector.rememberVectorPainter
8188import androidx.compose.ui.draw.clip
8289import androidx.compose.ui.platform.LocalContext
90+ import androidx.compose.ui.unit.Dp
8391import androidx.compose.ui.unit.dp
8492import app.pwhs.universalinstaller.R
8593import app.pwhs.universalinstaller.domain.model.ExternalOpenMode
@@ -353,6 +361,9 @@ private fun SettingUi(
353361 // ── Installation Section ─────────────────────
354362 if (matchesQuery(q, installLabels)) item {
355363 SettingsSection (title = stringResource(R .string.setting_section_installation), icon = Icons .Rounded .SettingsApplications ) {
364+ // Group headers only while unfiltered: a header whose items were all
365+ // searched away is a label over nothing. Same rule the divider below uses.
366+ if (q.isBlank()) OptionGroupHeader (stringResource(R .string.setting_group_installing))
356367 SearchableItem (q, stringResource(R .string.setting_install_mode_title), " shizuku root default" ) {
357368 InstallModeSelector (
358369 currentMode = InstallMode .from(uiState.useShizuku, uiState.useRoot),
@@ -389,6 +400,7 @@ private fun SettingUi(
389400 }
390401 }
391402
403+ if (q.isBlank()) OptionGroupHeader (stringResource(R .string.setting_group_after_install))
392404 SearchableItem (q, stringResource(R .string.setting_delete_apk_title)) {
393405 SwitchPreference (
394406 title = stringResource(R .string.setting_delete_apk_title),
@@ -404,6 +416,17 @@ private fun SettingUi(
404416 onCheckedChange = onAutoOpenAfterInstallChanged,
405417 )
406418 }
419+
420+ // Auto-confirm moved up next to the external-open modes: it only applies
421+ // to APKs opened from another app, so it belongs to that question rather
422+ // than sitting among the after-install toggles it used to follow.
423+ if (q.isBlank()) OptionGroupHeader (stringResource(R .string.setting_external_open_title))
424+ SearchableItem (q, stringResource(R .string.setting_external_open_title), stringResource(R .string.setting_external_open_notification_sub)) {
425+ ExternalOpenModeSelector (
426+ current = externalOpenMode,
427+ onChange = onExternalOpenModeChanged,
428+ )
429+ }
407430 SearchableItem (q, stringResource(R .string.setting_auto_confirm_title), stringResource(R .string.setting_auto_confirm_subtitle)) {
408431 SwitchPreference (
409432 title = stringResource(R .string.setting_auto_confirm_title),
@@ -412,18 +435,22 @@ private fun SettingUi(
412435 onCheckedChange = onAutoConfirmExternalInstallChanged,
413436 )
414437 }
438+
439+ if (q.isBlank()) OptionGroupHeader (stringResource(R .string.setting_install_ui_style_title))
415440 SearchableItem (q, stringResource(R .string.setting_install_ui_style_title), stringResource(R .string.setting_install_ui_style_sub)) {
416441 InstallUiStyleSelector (
417442 current = installUiStyle,
418443 onChange = onInstallUiStyleChanged,
419444 )
420445 }
421- SearchableItem (q, stringResource(R .string.setting_external_open_title), stringResource(R .string.setting_external_open_notification_sub)) {
422- ExternalOpenModeSelector (
423- current = externalOpenMode,
424- onChange = onExternalOpenModeChanged,
425- )
446+
447+ // Divider only makes sense when the full (unfiltered) list shows.
448+ // Below it sit the two settings that belong to no group above — they
449+ // change what the rest of the system sees, not how a single install runs.
450+ if (q.isBlank()) {
451+ HorizontalDivider (modifier = Modifier .padding(horizontal = 16 .dp), thickness = 0.5 .dp)
426452 }
453+
427454 SearchableItem (q, stringResource(R .string.setting_show_download_tab_title), stringResource(R .string.setting_show_download_tab_subtitle)) {
428455 SwitchPreference (
429456 title = stringResource(R .string.setting_show_download_tab_title),
@@ -432,12 +459,6 @@ private fun SettingUi(
432459 onCheckedChange = onShowDownloadTabChanged,
433460 )
434461 }
435-
436- // Divider only makes sense when the full (unfiltered) list shows.
437- if (q.isBlank()) {
438- HorizontalDivider (modifier = Modifier .padding(horizontal = 16 .dp), thickness = 0.5 .dp)
439- }
440-
441462 SearchableItem (q, stringResource(R .string.setting_default_installer_title), stringResource(R .string.setting_default_installer_subtitle)) {
442463 SwitchPreference (
443464 title = stringResource(R .string.setting_default_installer_title),
@@ -955,10 +976,12 @@ private fun InstallModeSelector(
955976 .fillMaxWidth()
956977 .padding(horizontal = 16 .dp, vertical = 8 .dp),
957978 ) {
979+ // bodyLarge, not titleSmall: this sits among SwitchPreference rows whose headline is
980+ // bodyLarge. Styling it as a caption made the section's most consequential control read
981+ // as a footnote while the minor toggles shouted.
958982 Text (
959983 text = stringResource(R .string.setting_install_mode_title),
960- style = MaterialTheme .typography.titleSmall,
961- color = MaterialTheme .colorScheme.onSurfaceVariant,
984+ style = MaterialTheme .typography.bodyLarge,
962985 modifier = Modifier .padding(bottom = 8 .dp),
963986 )
964987 // Root stays tappable whenever libsu shipped (it's only in the row then) — tapping it
@@ -1038,8 +1061,7 @@ private fun SecurityLevelSelector(
10381061 Column (modifier = Modifier .fillMaxWidth().padding(horizontal = 16 .dp, vertical = 8 .dp)) {
10391062 Text (
10401063 text = stringResource(R .string.setting_security_level_title),
1041- style = MaterialTheme .typography.titleSmall,
1042- color = MaterialTheme .colorScheme.onSurfaceVariant,
1064+ style = MaterialTheme .typography.bodyLarge,
10431065 modifier = Modifier .padding(bottom = 8 .dp),
10441066 )
10451067 SingleChoiceSegmentedButtonRow (modifier = Modifier .fillMaxWidth()) {
@@ -1086,13 +1108,9 @@ private fun ExternalOpenModeSelector(
10861108 current : ExternalOpenMode ,
10871109 onChange : (ExternalOpenMode ) -> Unit ,
10881110) {
1111+ // No title of its own: the group header above already reads "When another app opens an APK",
1112+ // and repeating it here was the second of the two headings this control used to carry.
10891113 Column (modifier = Modifier .fillMaxWidth().padding(horizontal = 16 .dp, vertical = 8 .dp)) {
1090- Text (
1091- text = stringResource(R .string.setting_external_open_title),
1092- style = MaterialTheme .typography.titleSmall,
1093- color = MaterialTheme .colorScheme.onSurfaceVariant,
1094- modifier = Modifier .padding(bottom = 8 .dp),
1095- )
10961114 listOf (
10971115 Triple (ExternalOpenMode .Dialog , R .string.setting_external_open_dialog, R .string.setting_external_open_dialog_sub),
10981116 Triple (ExternalOpenMode .Notification , R .string.setting_external_open_notification, R .string.setting_external_open_notification_sub),
@@ -1125,41 +1143,124 @@ private fun ExternalOpenModeSelector(
11251143
11261144
11271145/* *
1128- * Dialog or bottom sheet. A segmented control, matching the security-level picker — two options
1129- * whose labels say everything, unlike the external-open modes which need a line of warning each.
1146+ * Where the install card sits: centered, or anchored at the bottom.
1147+ *
1148+ * Two thumbnails rather than the segmented control this used to be. The setting decides what the
1149+ * install UI *looks like*, and "Dialog | Bottom sheet" asked the user to picture two Material
1150+ * component names — the only way to find out was to pick one and install something. A drawn
1151+ * mock-up answers the question at a glance, and drops the paragraph of prose that stood in for it.
1152+ *
1153+ * The card idiom (fixed-size tile, primary border and a check when selected) is the one
1154+ * [app.pwhs.universalinstaller.presentation.setting.theme.ThemeScreen] already uses for its theme
1155+ * presets, so the two visual pickers in Settings look like the same control.
11301156 */
1131- @OptIn(ExperimentalMaterial3Api ::class )
11321157@Composable
11331158private fun InstallUiStyleSelector (
11341159 current : InstallUiStyle ,
11351160 onChange : (InstallUiStyle ) -> Unit ,
11361161) {
1137- Column (modifier = Modifier .fillMaxWidth().padding(horizontal = 16 .dp, vertical = 8 .dp)) {
1138- Text (
1139- text = stringResource(R .string.setting_install_ui_style_title),
1140- style = MaterialTheme .typography.titleSmall,
1141- color = MaterialTheme .colorScheme.onSurfaceVariant,
1142- modifier = Modifier .padding(bottom = 8 .dp),
1143- )
1144- val options = listOf (
1162+ Row (
1163+ modifier = Modifier .fillMaxWidth().padding(horizontal = 16 .dp, vertical = 8 .dp),
1164+ horizontalArrangement = Arrangement .spacedBy(12 .dp),
1165+ ) {
1166+ listOf (
11451167 InstallUiStyle .Dialog to R .string.setting_install_ui_style_dialog,
11461168 InstallUiStyle .Sheet to R .string.setting_install_ui_style_sheet,
1147- )
1148- SingleChoiceSegmentedButtonRow (modifier = Modifier .fillMaxWidth()) {
1149- options.forEachIndexed { index, (style, labelRes) ->
1150- SegmentedButton (
1151- selected = style == current,
1152- onClick = { if (style != current) onChange(style) },
1153- shape = SegmentedButtonDefaults .itemShape(index = index, count = options.size),
1154- label = { Text (stringResource(labelRes)) },
1169+ ).forEach { (style, labelRes) ->
1170+ InstallUiStyleCard (
1171+ style = style,
1172+ label = stringResource(labelRes),
1173+ selected = style == current,
1174+ onClick = { if (style != current) onChange(style) },
1175+ )
1176+ }
1177+ }
1178+ }
1179+
1180+ /* *
1181+ * One thumbnail: a phone-shaped frame with a miniature install card drawn where the real one lands.
1182+ */
1183+ @Composable
1184+ private fun InstallUiStyleCard (
1185+ style : InstallUiStyle ,
1186+ label : String ,
1187+ selected : Boolean ,
1188+ onClick : () -> Unit ,
1189+ ) {
1190+ val accent = MaterialTheme .colorScheme.primary
1191+ Column (horizontalAlignment = Alignment .CenterHorizontally ) {
1192+ Surface (
1193+ // selectable(), not clickable(): this is one of a set of mutually exclusive choices,
1194+ // and TalkBack should announce it as such rather than as a plain button.
1195+ modifier = Modifier
1196+ .size(width = 92 .dp, height = 124 .dp)
1197+ .selectable(selected = selected, role = Role .RadioButton , onClick = onClick),
1198+ shape = RoundedCornerShape (12 .dp),
1199+ color = MaterialTheme .colorScheme.surfaceVariant,
1200+ border = BorderStroke (
1201+ width = if (selected) 2 .dp else 1 .dp,
1202+ color = if (selected) accent else MaterialTheme .colorScheme.outlineVariant,
1203+ ),
1204+ ) {
1205+ Box (
1206+ modifier = Modifier .fillMaxSize().padding(10 .dp),
1207+ // The whole point of the thumbnail: the mini card sits where the real one will.
1208+ contentAlignment = if (style == InstallUiStyle .Sheet ) {
1209+ Alignment .BottomCenter
1210+ } else {
1211+ Alignment .Center
1212+ },
1213+ ) {
1214+ Column (
1215+ modifier = Modifier
1216+ // A sheet spans the width; a centered card is inset on both sides.
1217+ .fillMaxWidth(if (style == InstallUiStyle .Sheet ) 1f else 0.82f )
1218+ .clip(RoundedCornerShape (6 .dp))
1219+ .background(MaterialTheme .colorScheme.surface)
1220+ .padding(horizontal = 6 .dp, vertical = 7 .dp),
1221+ verticalArrangement = Arrangement .spacedBy(4 .dp),
1222+ ) {
1223+ MiniCardLine (0.7f , MaterialTheme .colorScheme.onSurface.copy(alpha = 0.55f ))
1224+ MiniCardLine (1f , MaterialTheme .colorScheme.onSurfaceVariant.copy(alpha = 0.35f ))
1225+ Spacer (Modifier .height(1 .dp))
1226+ // Stands in for the Install button, in the accent color so the mock-up reads
1227+ // as this app's card rather than a generic grey box.
1228+ MiniCardLine (0.45f , accent, height = 6 .dp)
1229+ }
1230+ }
1231+ }
1232+ Spacer (Modifier .height(6 .dp))
1233+ Row (verticalAlignment = Alignment .CenterVertically ) {
1234+ if (selected) {
1235+ Icon (
1236+ imageVector = Icons .Rounded .CheckCircle ,
1237+ contentDescription = null ,
1238+ tint = accent,
1239+ modifier = Modifier .size(14 .dp),
11551240 )
1241+ Spacer (Modifier .width(4 .dp))
11561242 }
1243+ Text (
1244+ text = label,
1245+ style = MaterialTheme .typography.bodyMedium,
1246+ color = if (selected) accent else MaterialTheme .colorScheme.onSurfaceVariant,
1247+ )
11571248 }
1158- Spacer (Modifier .height(8 .dp))
1159- Text (
1160- text = stringResource(R .string.setting_install_ui_style_sub),
1161- style = MaterialTheme .typography.bodySmall,
1162- color = MaterialTheme .colorScheme.onSurfaceVariant,
1163- )
11641249 }
11651250}
1251+
1252+ /* * A single bar inside the thumbnail, standing in for a line of text or a button. */
1253+ @Composable
1254+ private fun MiniCardLine (
1255+ widthFraction : Float ,
1256+ color : Color ,
1257+ height : Dp = 4.dp,
1258+ ) {
1259+ Box (
1260+ modifier = Modifier
1261+ .fillMaxWidth(widthFraction)
1262+ .height(height)
1263+ .clip(RoundedCornerShape (2 .dp))
1264+ .background(color),
1265+ )
1266+ }
0 commit comments