feat(android): redesign onboarding flow UI

- Welcome: replace bullet list with icon+subtitle feature cards
- Gateway: simplify to single instruction line, collapse advanced by default, remove verbose developer text
- Permissions: group into System/Media/Personal Data sections, rewrite subtitles to plain English, style "Not granted" with warning color
- Review: replace plain text fields with icon cards matching Welcome style, add colored status cards for connect/pairing states
- Remove redundant "FIRST RUN" label, "Step X of 4" text, and StepRailWrap dividers
This commit is contained in:
Ayaan Zaidi 2026-03-13 12:25:09 +05:30
parent 80e6701959
commit aae75b5e57
No known key found for this signature in database
1 changed files with 282 additions and 110 deletions

View File

@ -57,8 +57,16 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
@ -514,25 +522,20 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
) {
Column(
modifier = Modifier.padding(top = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"FIRST RUN",
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp),
color = onboardingAccent,
)
Text(
"OpenClaw\nMobile Setup",
style = onboardingDisplayStyle.copy(lineHeight = 38.sp),
"OpenClaw",
style = onboardingDisplayStyle,
color = onboardingText,
)
Text(
"Step ${step.index} of 4",
style = onboardingCaption1Style,
color = onboardingAccent,
"Mobile Setup",
style = onboardingTitle1Style,
color = onboardingTextSecondary,
)
}
StepRailWrap(current = step)
StepRail(current = step)
when (step) {
OnboardingStep.Welcome -> WelcomeStep()
@ -893,15 +896,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
}
@Composable
private fun StepRailWrap(current: OnboardingStep) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
HorizontalDivider(color = onboardingBorder)
StepRail(current = current)
HorizontalDivider(color = onboardingBorder)
}
}
@Composable
private fun StepRail(current: OnboardingStep) {
val steps = OnboardingStep.entries
@ -943,11 +937,31 @@ private fun StepRail(current: OnboardingStep) {
@Composable
private fun WelcomeStep() {
StepShell(title = "What You Get") {
Bullet("Control the gateway and operator chat from one mobile surface.")
Bullet("Connect with setup code and recover pairing with CLI commands.")
Bullet("Enable only the permissions and capabilities you want.")
Bullet("Finish with a real connection check before entering the app.")
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
FeatureCard(
icon = Icons.Default.Wifi,
title = "Connect to your gateway",
subtitle = "Scan a QR code or enter your host manually",
accentColor = onboardingAccent,
)
FeatureCard(
icon = Icons.Default.Tune,
title = "Choose your permissions",
subtitle = "Enable only what you need, change anytime",
accentColor = Color(0xFF7C5AC7),
)
FeatureCard(
icon = Icons.Default.ChatBubble,
title = "Chat, voice, and screen",
subtitle = "Full operator control from your phone",
accentColor = onboardingSuccess,
)
FeatureCard(
icon = Icons.Default.CheckCircle,
title = "Verify your connection",
subtitle = "Live check before you enter the app",
accentColor = Color(0xFFC8841A),
)
}
}
@ -976,11 +990,12 @@ private fun GatewayStep(
val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } }
StepShell(title = "Gateway Connection") {
GuideBlock(title = "Scan onboarding QR") {
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
CommandBlock("openclaw qr")
Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
Text(
"Run `openclaw qr` on your gateway host, then scan the code with this device.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
CommandBlock("openclaw qr")
Button(
onClick = onScanQrClick,
modifier = Modifier.fillMaxWidth().height(48.dp),
@ -1024,21 +1039,6 @@ private fun GatewayStep(
AnimatedVisibility(visible = advancedOpen) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
GuideBlock(title = "Manual setup commands") {
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
CommandBlock("openclaw qr --setup-code-only")
CommandBlock("openclaw qr --json")
Text(
"`--json` prints `setupCode` and `gatewayUrl`.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
Text(
"Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
}
GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
if (inputMode == GatewayInputMode.SetupCode) {
@ -1307,13 +1307,9 @@ private fun StepShell(
title: String,
content: @Composable ColumnScope.() -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
HorizontalDivider(color = onboardingBorder)
Column(modifier = Modifier.padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = onboardingTitle1Style, color = onboardingText)
content()
}
HorizontalDivider(color = onboardingBorder)
Column(modifier = Modifier.padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = onboardingTitle1Style, color = onboardingText)
content()
}
}
@ -1379,13 +1375,15 @@ private fun PermissionsStep(
StepShell(title = "Permissions") {
Text(
"Enable only what you need now. You can change everything later in Settings.",
"Enable only what you need. You can change these anytime in Settings.",
style = onboardingCalloutStyle,
color = onboardingTextSecondary,
)
PermissionSectionHeader("System")
PermissionToggleRow(
title = "Gateway discovery",
subtitle = if (Build.VERSION.SDK_INT >= 33) "Nearby devices" else "Location (for NSD)",
subtitle = "Find gateways on your local network",
checked = enableDiscovery,
granted = isPermissionGranted(context, discoveryPermission),
onCheckedChange = onDiscoveryChange,
@ -1393,7 +1391,7 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "Location",
subtitle = "location.get (while app is open)",
subtitle = "Share device location while app is open",
checked = enableLocation,
granted = locationGranted,
onCheckedChange = onLocationChange,
@ -1402,7 +1400,7 @@ private fun PermissionsStep(
if (Build.VERSION.SDK_INT >= 33) {
PermissionToggleRow(
title = "Notifications",
subtitle = "system.notify and foreground alerts",
subtitle = "Alerts and foreground service notices",
checked = enableNotifications,
granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS),
onCheckedChange = onNotificationsChange,
@ -1411,15 +1409,16 @@ private fun PermissionsStep(
}
PermissionToggleRow(
title = "Notification listener",
subtitle = "notifications.list and notifications.actions (opens Android Settings)",
subtitle = "Read and act on your notifications",
checked = enableNotificationListener,
granted = notificationListenerGranted,
onCheckedChange = onNotificationListenerChange,
)
InlineDivider()
PermissionSectionHeader("Media")
PermissionToggleRow(
title = "Microphone",
subtitle = "Foreground Voice tab transcription",
subtitle = "Voice transcription in the Voice tab",
checked = enableMicrophone,
granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO),
onCheckedChange = onMicrophoneChange,
@ -1427,7 +1426,7 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "Camera",
subtitle = "camera.snap and camera.clip",
subtitle = "Take photos and short video clips",
checked = enableCamera,
granted = isPermissionGranted(context, Manifest.permission.CAMERA),
onCheckedChange = onCameraChange,
@ -1435,15 +1434,16 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "Photos",
subtitle = "photos.latest",
subtitle = "Access your recent photos",
checked = enablePhotos,
granted = isPermissionGranted(context, photosPermission),
onCheckedChange = onPhotosChange,
)
InlineDivider()
PermissionSectionHeader("Personal Data")
PermissionToggleRow(
title = "Contacts",
subtitle = "contacts.search and contacts.add",
subtitle = "Search and add contacts",
checked = enableContacts,
granted = contactsGranted,
onCheckedChange = onContactsChange,
@ -1451,7 +1451,7 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "Calendar",
subtitle = "calendar.events and calendar.add",
subtitle = "Read and create calendar events",
checked = enableCalendar,
granted = calendarGranted,
onCheckedChange = onCalendarChange,
@ -1459,7 +1459,7 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "Motion",
subtitle = "motion.activity and motion.pedometer",
subtitle = "Activity and step tracking",
checked = enableMotion,
granted = motionGranted,
onCheckedChange = onMotionChange,
@ -1470,16 +1470,25 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "SMS",
subtitle = "Allow gateway-triggered SMS sending",
subtitle = "Send text messages via the gateway",
checked = enableSms,
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
onCheckedChange = onSmsChange,
)
}
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}
@Composable
private fun PermissionSectionHeader(title: String) {
Text(
title.uppercase(),
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp),
color = onboardingAccent,
modifier = Modifier.padding(top = 8.dp),
)
}
@Composable
private fun PermissionToggleRow(
title: String,
@ -1490,6 +1499,12 @@ private fun PermissionToggleRow(
statusOverride: String? = null,
onCheckedChange: (Boolean) -> Unit,
) {
val statusText = statusOverride ?: if (granted) "Granted" else "Not granted"
val statusColor = when {
statusOverride != null -> onboardingTextTertiary
granted -> onboardingSuccess
else -> onboardingWarning
}
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp),
verticalAlignment = Alignment.CenterVertically,
@ -1498,11 +1513,7 @@ private fun PermissionToggleRow(
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = onboardingHeadlineStyle, color = onboardingText)
Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
Text(
statusOverride ?: if (granted) "Granted" else "Not granted",
style = onboardingCaption1Style,
color = if (granted) onboardingSuccess else onboardingTextSecondary,
)
Text(statusText, style = onboardingCaption1Style, color = statusColor)
}
Switch(
checked = checked,
@ -1530,20 +1541,131 @@ private fun FinalStep(
enabledPermissions: String,
methodLabel: String,
) {
StepShell(title = "Review") {
SummaryField(label = "Method", value = methodLabel)
SummaryField(label = "Gateway", value = parsedGateway?.displayUrl ?: "Invalid gateway URL")
SummaryField(label = "Enabled Permissions", value = enabledPermissions)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Review", style = onboardingTitle1Style, color = onboardingText)
SummaryCard(
icon = Icons.Default.Link,
label = "Method",
value = methodLabel,
accentColor = onboardingAccent,
)
SummaryCard(
icon = Icons.Default.Cloud,
label = "Gateway",
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
accentColor = Color(0xFF7C5AC7),
)
SummaryCard(
icon = Icons.Default.Security,
label = "Permissions",
value = enabledPermissions,
accentColor = onboardingSuccess,
)
if (!attemptedConnect) {
Text("Press Connect to verify gateway reachability and auth.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingAccentSoft,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingAccent.copy(alpha = 0.2f)),
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(onboardingAccent.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
tint = onboardingAccent,
modifier = Modifier.size(22.dp),
)
}
Text(
"Tap Connect to verify your gateway is reachable.",
style = onboardingCalloutStyle,
color = onboardingAccent,
)
}
}
} else if (isConnected) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color(0xFFEEF9F3),
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(onboardingSuccess.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = onboardingSuccess,
modifier = Modifier.size(22.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Connected", style = onboardingHeadlineStyle, color = onboardingSuccess)
Text(
serverName ?: remoteAddress ?: "gateway",
style = onboardingCalloutStyle,
color = onboardingSuccess.copy(alpha = 0.8f),
)
}
}
}
} else {
Text("Status: $statusText", style = onboardingCalloutStyle, color = if (isConnected) onboardingSuccess else onboardingTextSecondary)
if (isConnected) {
Text("Connected to ${serverName ?: remoteAddress ?: "gateway"}", style = onboardingCalloutStyle, color = onboardingSuccess)
} else {
GuideBlock(title = "Pairing Required") {
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color(0xFFFFF8EC),
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(onboardingWarning.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = onboardingWarning,
modifier = Modifier.size(22.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}
CommandBlock("openclaw devices list")
CommandBlock("openclaw devices approve <requestId>")
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
@ -1554,15 +1676,46 @@ private fun FinalStep(
}
@Composable
private fun SummaryField(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
label,
style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
color = onboardingTextSecondary,
)
Text(value, style = onboardingHeadlineStyle, color = onboardingText)
HorizontalDivider(color = onboardingBorder)
private fun SummaryCard(
icon: ImageVector,
label: String,
value: String,
accentColor: Color,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorder),
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(accentColor.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(22.dp),
)
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
label.uppercase(),
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp),
color = onboardingTextSecondary,
)
Text(value, style = onboardingHeadlineStyle, color = onboardingText)
}
}
}
}
@ -1589,23 +1742,42 @@ private fun CommandBlock(command: String) {
}
@Composable
private fun Bullet(text: String) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Box(
modifier =
Modifier
.padding(top = 7.dp)
.size(8.dp)
.background(onboardingAccentSoft, CircleShape),
)
Box(
modifier =
Modifier
.padding(top = 9.dp)
.size(4.dp)
.background(onboardingAccent, CircleShape),
)
Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f))
private fun FeatureCard(
icon: ImageVector,
title: String,
subtitle: String,
accentColor: Color,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = onboardingSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorder),
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(42.dp)
.background(accentColor.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(22.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = onboardingHeadlineStyle, color = onboardingText)
Text(subtitle, style = onboardingCalloutStyle, color = onboardingTextSecondary)
}
}
}
}