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

View File

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

View File

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

View File

@@ -155,7 +155,7 @@ fun MainScreen(
composable<JoinResidenceRoute> {
Box(modifier = Modifier.fillMaxSize()) {
com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateBack = { navController.navigateUp() },
onJoined = { residenceId ->
// Pop the join screen and hand off to the
// parent nav graph (ResidenceDetailRoute lives
@@ -242,7 +242,7 @@ fun MainScreen(
AddDocumentScreen(
residenceId = route.residenceId,
initialDocumentType = route.initialDocumentType,
onNavigateBack = { navController.popBackStack() },
onNavigateBack = { navController.navigateUp() },
onDocumentCreated = {
navController.popBackStack()
}
@@ -253,7 +253,7 @@ fun MainScreen(
val route = backStackEntry.toRoute<DocumentDetailRoute>()
DocumentDetailScreen(
documentId = route.documentId,
onNavigateBack = { navController.popBackStack() },
onNavigateBack = { navController.navigateUp() },
onNavigateToEdit = { documentId ->
navController.navigate(EditDocumentRoute(documentId))
}
@@ -264,7 +264,7 @@ fun MainScreen(
val route = backStackEntry.toRoute<EditDocumentRoute>()
EditDocumentScreen(
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.rememberScrollState
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.material.icons.Icons
import androidx.compose.material.icons.filled.*
@@ -11,10 +13,16 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -47,6 +55,12 @@ fun RegisterScreen(
var isLoading by remember { mutableStateOf(false) }
val createState by viewModel.registerState.collectAsStateWithLifecycle()
val focusManager = LocalFocusManager.current
val usernameFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
usernameFocusRequester.requestFocus()
}
// Handle errors for registration
createState.HandleErrors(
@@ -130,9 +144,14 @@ fun RegisterScreen(
},
modifier = Modifier
.fillMaxWidth()
.focusRequester(usernameFocusRequester)
.testTag(AccessibilityIds.Authentication.registerUsernameField),
singleLine = true,
shape = RoundedCornerShape(AppRadius.md)
shape = RoundedCornerShape(AppRadius.md),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
OutlinedTextField(
@@ -146,7 +165,14 @@ fun RegisterScreen(
.fillMaxWidth()
.testTag(AccessibilityIds.Authentication.registerEmailField),
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()
@@ -163,7 +189,14 @@ fun RegisterScreen(
.testTag(AccessibilityIds.Authentication.registerPasswordField),
singleLine = true,
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(
@@ -178,7 +211,14 @@ fun RegisterScreen(
.testTag(AccessibilityIds.Authentication.registerConfirmPasswordField),
singleLine = true,
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.ui.Alignment
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.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
@@ -240,7 +244,12 @@ fun OnboardingCreateAccountContent(
// Error message
if (localErrorMessage != null) {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.semantics {
liveRegion = LiveRegionMode.Polite
error(localErrorMessage ?: "")
},
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {

View File

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