Audit: form-error TalkBack + focus management + navigateUp polish

(a) liveRegion + error semantics on form error surfaces so TalkBack
    announces them when they appear:
    - Shared ErrorCard (used by LoginScreen, RegisterScreen,
      VerifyEmail/ResetCode, ForgotPassword, ResetPassword)
    - OnboardingCreateAccountContent inline error row
    - JoinResidenceScreen inline error row

(b) focusRequester + ImeAction.Next on multi-field forms:
    - LoginScreen: auto-focus username, Next→password, Done→submit
    - RegisterScreen: auto-focus username, Next chain through
      email/password/confirm, Done on last

(c) navigateUp() replaces navController.popBackStack() for simple back
    actions in App.kt (6 screens) and MainScreen.kt (3 screens), where
    the back behavior is purely navigation-controlled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 18:16:22 -05:00
parent a1f366cb30
commit bb4cbd58c3
7 changed files with 110 additions and 18 deletions

View File

@@ -511,7 +511,7 @@ fun App(
composable<JoinResidenceRoute> { composable<JoinResidenceRoute> {
com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen( com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
onJoined = { residenceId -> onJoined = { residenceId ->
navController.popBackStack() navController.popBackStack()
navController.navigate(ResidenceDetailRoute(residenceId)) navController.navigate(ResidenceDetailRoute(residenceId))
@@ -674,7 +674,7 @@ fun App(
composable<FeatureComparisonRoute> { composable<FeatureComparisonRoute> {
// P2 Stream E — full-screen Free vs. Pro comparison. // P2 Stream E — full-screen Free vs. Pro comparison.
FeatureComparisonScreen( FeatureComparisonScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
onNavigateToUpgrade = { onNavigateToUpgrade = {
navController.popBackStack() navController.popBackStack()
navController.navigate(UpgradeRoute) navController.navigate(UpgradeRoute)
@@ -687,7 +687,7 @@ fun App(
val route = backStackEntry.toRoute<TaskSuggestionsRoute>() val route = backStackEntry.toRoute<TaskSuggestionsRoute>()
TaskSuggestionsScreen( TaskSuggestionsScreen(
residenceId = route.residenceId, residenceId = route.residenceId,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
) )
} }
@@ -696,7 +696,7 @@ fun App(
val route = backStackEntry.toRoute<AddTaskWithResidenceRoute>() val route = backStackEntry.toRoute<AddTaskWithResidenceRoute>()
AddTaskWithResidenceScreen( AddTaskWithResidenceScreen(
residenceId = route.residenceId, residenceId = route.residenceId,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
onCreated = { navController.popBackStack() }, onCreated = { navController.popBackStack() },
) )
} }
@@ -707,7 +707,7 @@ fun App(
TaskTemplatesBrowserScreen( TaskTemplatesBrowserScreen(
residenceId = route.residenceId, residenceId = route.residenceId,
fromOnboarding = route.fromOnboarding, fromOnboarding = route.fromOnboarding,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
) )
} }
@@ -734,7 +734,7 @@ fun App(
updatedAt = route.updatedAt, updatedAt = route.updatedAt,
completions = emptyList() completions = emptyList()
), ),
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
onTaskUpdated = { navController.popBackStack() } onTaskUpdated = { navController.popBackStack() }
) )
} }

View File

@@ -6,6 +6,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.error
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tt.honeyDue.ui.haptics.Haptics import com.tt.honeyDue.ui.haptics.Haptics
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
@@ -22,7 +26,12 @@ fun ErrorCard(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer containerColor = MaterialTheme.colorScheme.errorContainer
), ),
modifier = modifier.fillMaxWidth(), modifier = modifier
.fillMaxWidth()
.semantics {
liveRegion = LiveRegionMode.Polite
error(message)
},
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Text( Text(

View File

@@ -8,12 +8,17 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
@@ -51,6 +56,12 @@ fun LoginScreen(
var googleSignInError by remember { mutableStateOf<String?>(null) } var googleSignInError by remember { mutableStateOf<String?>(null) }
val loginState by viewModel.loginState.collectAsStateWithLifecycle() val loginState by viewModel.loginState.collectAsStateWithLifecycle()
val googleSignInState by viewModel.googleSignInState.collectAsStateWithLifecycle() val googleSignInState by viewModel.googleSignInState.collectAsStateWithLifecycle()
val focusManager = LocalFocusManager.current
val usernameFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
usernameFocusRequester.requestFocus()
}
// Handle errors for login // Handle errors for login
loginState.HandleErrors( loginState.HandleErrors(
@@ -139,12 +150,16 @@ fun LoginScreen(
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(usernameFocusRequester)
.testTag(AccessibilityIds.Authentication.usernameField), .testTag(AccessibilityIds.Authentication.usernameField),
singleLine = true, singleLine = true,
shape = RoundedCornerShape(OrganicRadius.md), shape = RoundedCornerShape(OrganicRadius.md),
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
) )
) )
@@ -171,7 +186,19 @@ fun LoginScreen(
.testTag(AccessibilityIds.Authentication.passwordField), .testTag(AccessibilityIds.Authentication.passwordField),
singleLine = true, singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(OrganicRadius.md) shape = RoundedCornerShape(OrganicRadius.md),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (username.isNotEmpty() && password.isNotEmpty()) {
viewModel.login(username, password)
}
}
)
) )
ErrorCard(message = errorMessage) ErrorCard(message = errorMessage)

View File

@@ -155,7 +155,7 @@ fun MainScreen(
composable<JoinResidenceRoute> { composable<JoinResidenceRoute> {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen( com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
onJoined = { residenceId -> onJoined = { residenceId ->
// Pop the join screen and hand off to the // Pop the join screen and hand off to the
// parent nav graph (ResidenceDetailRoute lives // parent nav graph (ResidenceDetailRoute lives
@@ -242,7 +242,7 @@ fun MainScreen(
AddDocumentScreen( AddDocumentScreen(
residenceId = route.residenceId, residenceId = route.residenceId,
initialDocumentType = route.initialDocumentType, initialDocumentType = route.initialDocumentType,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
onDocumentCreated = { onDocumentCreated = {
navController.popBackStack() navController.popBackStack()
} }
@@ -253,7 +253,7 @@ fun MainScreen(
val route = backStackEntry.toRoute<DocumentDetailRoute>() val route = backStackEntry.toRoute<DocumentDetailRoute>()
DocumentDetailScreen( DocumentDetailScreen(
documentId = route.documentId, documentId = route.documentId,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.navigateUp() },
onNavigateToEdit = { documentId -> onNavigateToEdit = { documentId ->
navController.navigate(EditDocumentRoute(documentId)) navController.navigate(EditDocumentRoute(documentId))
} }
@@ -264,7 +264,7 @@ fun MainScreen(
val route = backStackEntry.toRoute<EditDocumentRoute>() val route = backStackEntry.toRoute<EditDocumentRoute>()
EditDocumentScreen( EditDocumentScreen(
documentId = route.documentId, documentId = route.documentId,
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.navigateUp() }
) )
} }

View File

@@ -3,6 +3,8 @@ package com.tt.honeyDue.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -11,10 +13,16 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -47,6 +55,12 @@ fun RegisterScreen(
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
val createState by viewModel.registerState.collectAsStateWithLifecycle() val createState by viewModel.registerState.collectAsStateWithLifecycle()
val focusManager = LocalFocusManager.current
val usernameFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
usernameFocusRequester.requestFocus()
}
// Handle errors for registration // Handle errors for registration
createState.HandleErrors( createState.HandleErrors(
@@ -130,9 +144,14 @@ fun RegisterScreen(
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(usernameFocusRequester)
.testTag(AccessibilityIds.Authentication.registerUsernameField), .testTag(AccessibilityIds.Authentication.registerUsernameField),
singleLine = true, singleLine = true,
shape = RoundedCornerShape(AppRadius.md) shape = RoundedCornerShape(AppRadius.md),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
) )
OutlinedTextField( OutlinedTextField(
@@ -146,7 +165,14 @@ fun RegisterScreen(
.fillMaxWidth() .fillMaxWidth()
.testTag(AccessibilityIds.Authentication.registerEmailField), .testTag(AccessibilityIds.Authentication.registerEmailField),
singleLine = true, singleLine = true,
shape = RoundedCornerShape(AppRadius.md) shape = RoundedCornerShape(AppRadius.md),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
) )
OrganicDivider() OrganicDivider()
@@ -163,7 +189,14 @@ fun RegisterScreen(
.testTag(AccessibilityIds.Authentication.registerPasswordField), .testTag(AccessibilityIds.Authentication.registerPasswordField),
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(AppRadius.md) shape = RoundedCornerShape(AppRadius.md),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
) )
OutlinedTextField( OutlinedTextField(
@@ -178,7 +211,14 @@ fun RegisterScreen(
.testTag(AccessibilityIds.Authentication.registerConfirmPasswordField), .testTag(AccessibilityIds.Authentication.registerConfirmPasswordField),
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(AppRadius.md) shape = RoundedCornerShape(AppRadius.md),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() }
)
) )
} }
} }

View File

@@ -15,6 +15,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.error
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -240,7 +244,12 @@ fun OnboardingCreateAccountContent(
// Error message // Error message
if (localErrorMessage != null) { if (localErrorMessage != null) {
OrganicCard( OrganicCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.semantics {
liveRegion = LiveRegionMode.Polite
error(localErrorMessage ?: "")
},
accentColor = MaterialTheme.colorScheme.error, accentColor = MaterialTheme.colorScheme.error,
showBlob = false showBlob = false
) { ) {

View File

@@ -35,6 +35,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.error
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -160,7 +163,11 @@ fun JoinResidenceScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(AppSpacing.sm), .padding(AppSpacing.sm)
.semantics {
liveRegion = LiveRegionMode.Polite
error(error ?: "")
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
) { ) {