diff --git a/.gitignore b/.gitignore index 0e9511a..10f5ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ local.properties /.idea/misc.xml .claude/ .idea/ -.kotlin/errors/ \ No newline at end of file +.kotlin/errors/ +/.idea/deploymentTargetSelector.xml \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..5b63c6a 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -5,6 +5,9 @@ + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c25bca8..c6a3f1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" } androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit" } androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index c935f61..ecf79f2 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.text.google.fonts) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) testImplementation(libs.junit) diff --git a/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt b/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt index 3876358..dd43e96 100644 --- a/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt +++ b/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt @@ -1,12 +1,12 @@ package com.composea11yscanner.sample -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.unit.Density import com.composea11yscanner.core.model.ScannerConfig import com.composea11yscanner.rules.ScannerRules +import com.composea11yscanner.sample.ui.theme.ScannerTheme import com.composea11yscanner.ui.A11yScanner import org.junit.Assert.assertEquals import org.junit.Rule @@ -40,7 +40,7 @@ class FixedScreenAccessibilityTest { private fun assertNoScannerIssues(content: @Composable () -> Unit) { composeRule.setContent { - MaterialTheme { + ScannerTheme { content() } } diff --git a/sample/src/main/java/com/composea11yscanner/sample/FeedScreens.kt b/sample/src/main/java/com/composea11yscanner/sample/FeedScreens.kt new file mode 100644 index 0000000..dd4af22 --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/FeedScreens.kt @@ -0,0 +1,300 @@ +package com.composea11yscanner.sample + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun BrokenFeedScreen(onViewFixed: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Market Pulse", + subtitle = "Live financial news and price movers", + ) { + MarketFilters() + + FinancialNewsCard( + monogram = "A", + logoColor = Color(0xFF6C63FF), + headline = "Apex Bank rallies as mobile deposits hit quarterly record", + priceChange = "+4.8%", + isPositive = true, + timestamp = "2m ago", + chartStart = Color(0xFF26385F), + chartEnd = Color(0xFF121826), + description = "Open market story", + ) + FinancialNewsCard( + monogram = "N", + logoColor = Color(0xFFFF4D6A), + headline = "NovaPay slips after analysts flag rising card loss reserves", + priceChange = "-2.1%", + isPositive = false, + timestamp = "11m ago", + chartStart = Color(0xFF4B2332), + chartEnd = Color(0xFF161923), + description = "Open market story", + ) + onViewFixed?.let { + Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("View Fixed Version") + } + } + } +} + +@Composable +fun FixedFeedScreen(onViewBroken: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Market Pulse", + subtitle = "Accessible financial news and price movers", + ) { + MarketFilters() + + FixedFinancialNewsCard( + monogram = "A", + logoColor = Color(0xFF6C63FF), + headline = "Apex Bank rallies as mobile deposits hit quarterly record", + priceChange = "+4.8%", + isPositive = true, + timestamp = "2m ago", + chartStart = Color(0xFF26385F), + chartEnd = Color(0xFF121826), + description = "Open Apex Bank market story", + ) + FixedFinancialNewsCard( + monogram = "N", + logoColor = Color(0xFFFF4D6A), + headline = "NovaPay slips after analysts flag rising card loss reserves", + priceChange = "-2.1%", + isPositive = false, + timestamp = "11m ago", + chartStart = Color(0xFF4B2332), + chartEnd = Color(0xFF161923), + description = "Open NovaPay market story", + ) + + onViewBroken?.let { + Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("View Broken Version") + } + } + } +} + +@Composable +private fun MarketFilters() { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MarketFilterChip("All", selected = true) + MarketFilterChip("Stocks", selected = false) + MarketFilterChip("Crypto", selected = false) + } +} + +@Composable +private fun FinancialNewsCard( + monogram: String, + logoColor: Color, + headline: String, + priceChange: String, + isPositive: Boolean, + timestamp: String, + chartStart: Color, + chartEnd: Color, + description: String, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(146.dp) + .clip(RoundedCornerShape(8.dp)) + .semantics { contentDescription = description } + .clickable { }, + ) { + Image( + painter = ColorPainter(chartStart), + contentDescription = description, + modifier = Modifier.fillMaxSize(), + ) + ChartOverlay(chartStart = chartStart, chartEnd = chartEnd, isFixed = false) + FinancialNewsContent( + monogram = monogram, + logoColor = logoColor, + headline = headline, + priceChange = priceChange, + isPositive = isPositive, + timestamp = timestamp, + useScrim = false, + ) + } +} + +@Composable +private fun FixedFinancialNewsCard( + monogram: String, + logoColor: Color, + headline: String, + priceChange: String, + isPositive: Boolean, + timestamp: String, + chartStart: Color, + chartEnd: Color, + description: String, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(146.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF121826)) + .drawBehind { drawChart(chartStart, chartEnd, isFixed = true) } + .semantics { contentDescription = description } + .clickable( + role = Role.Button, + onClick = {}, + ), + ) { + FinancialNewsContent( + monogram = monogram, + logoColor = logoColor, + headline = headline, + priceChange = priceChange, + isPositive = isPositive, + timestamp = timestamp, + useScrim = true, + ) + } +} + +@Composable +private fun ChartOverlay(chartStart: Color, chartEnd: Color, isFixed: Boolean) { + Box( + modifier = Modifier + .fillMaxSize() + .drawBehind { drawChart(chartStart, chartEnd, isFixed) }, + ) +} + +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawChart( + chartStart: Color, + chartEnd: Color, + isFixed: Boolean, +) { + drawRect( + brush = Brush.linearGradient( + colors = listOf(chartStart.copy(alpha = if (isFixed) 0.45f else 0.35f), chartEnd), + start = Offset.Zero, + end = Offset(size.width, size.height), + ), + ) + repeat(5) { index -> + val y = size.height * (0.24f + index * 0.13f) + drawLine( + color = Color.White.copy(alpha = if (isFixed) 0.08f else 0.09f), + start = Offset(0f, y + index * 12f), + end = Offset(size.width, y - 48f), + strokeWidth = 2.5f, + ) + } + drawLine( + color = Color(0xFF00D4AA).copy(alpha = if (isFixed) 0.28f else 0.34f), + start = Offset(0f, size.height * 0.78f), + end = Offset(size.width, size.height * 0.34f), + strokeWidth = 5f, + ) +} + +@Composable +private fun FinancialNewsContent( + monogram: String, + logoColor: Color, + headline: String, + priceChange: String, + isPositive: Boolean, + timestamp: String, + useScrim: Boolean, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (useScrim) Modifier.background(Color.Black.copy(alpha = 0.54f)) else Modifier) + .padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(42.dp) + .background(logoColor, RoundedCornerShape(21.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = monogram, + color = Color.White, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = headline, + color = if (useScrim) Color.White else Color(0xFF8B949E), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = timestamp, + color = if (useScrim) Color(0xFFD0D7DE) else Color(0xFF6E7681), + style = MaterialTheme.typography.bodySmall, + ) + } + } + Box( + modifier = Modifier + .background( + color = if (isPositive) Color(0xFF164E3F) else Color(0xFF5A1D2A), + shape = RoundedCornerShape(14.dp), + ) + .padding(horizontal = 9.dp, vertical = 5.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = priceChange, + color = if (isPositive) Color(0xFF00D4AA) else Color(0xFFFF4D6A), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } + } +} diff --git a/sample/src/main/java/com/composea11yscanner/sample/FormScreens.kt b/sample/src/main/java/com/composea11yscanner/sample/FormScreens.kt new file mode 100644 index 0000000..62b7f73 --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/FormScreens.kt @@ -0,0 +1,228 @@ +package com.composea11yscanner.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun BrokenFormScreen(onViewFixed: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Payment form", + subtitle = "Transfer details with intentionally broken focus order", + ) { + var amount by remember { mutableStateOf("") } + var recipient by remember { mutableStateOf("") } + var reference by remember { mutableStateOf("") } + var paymentDate by remember { mutableStateOf("") } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(460.dp) + .background(Color(0xFF0D1117), RoundedCornerShape(8.dp)) + .padding(16.dp), + ) { + PaymentSubmitAction( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth(), + ) + + PaymentAmountField( + amount = amount, + onAmountChange = { amount = it }, + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth(), + ) + + RecipientRow( + recipient = recipient, + onRecipientChange = { recipient = it }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 116.dp) + .fillMaxWidth(), + isFixed = false, + ) + + OutlinedTextField( + value = paymentDate, + onValueChange = { paymentDate = it }, + label = { Text("Payment Date") }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 210.dp) + .fillMaxWidth(), + ) + + OutlinedTextField( + value = reference, + onValueChange = { reference = it }, + label = { Text("Reference Note") }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 294.dp) + .fillMaxWidth(), + ) + } + + onViewFixed?.let { + Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("View Fixed Version") + } + } + } +} + +@Composable +fun FixedFormScreen(onViewBroken: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Payment form", + subtitle = "Accessible transfer details with top-to-bottom focus order", + ) { + var amount by remember { mutableStateOf("") } + var recipient by remember { mutableStateOf("") } + var reference by remember { mutableStateOf("") } + var paymentDate by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF0D1117), RoundedCornerShape(8.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + PaymentAmountField( + amount = amount, + onAmountChange = { amount = it }, + modifier = Modifier.fillMaxWidth(), + ) + + RecipientRow( + recipient = recipient, + onRecipientChange = { recipient = it }, + modifier = Modifier.fillMaxWidth(), + isFixed = true, + ) + + OutlinedTextField( + value = paymentDate, + onValueChange = { paymentDate = it }, + label = { Text("Payment Date") }, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = reference, + onValueChange = { reference = it }, + label = { Text("Reference Note") }, + modifier = Modifier.fillMaxWidth(), + ) + + PaymentSubmitAction(modifier = Modifier.fillMaxWidth()) + } + + onViewBroken?.let { + Button( + onClick = it, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + ) { + Text("View Broken Version") + } + } + } +} + +@Composable +private fun PaymentAmountField( + amount: String, + onAmountChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = amount, + onValueChange = onAmountChange, + label = { Text("Amount") }, + prefix = { + Text( + text = "$", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + }, + textStyle = MaterialTheme.typography.headlineMedium.copy( + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + ), + modifier = modifier, + ) +} + +@Composable +private fun RecipientRow( + recipient: String, + onRecipientChange: (String) -> Unit, + modifier: Modifier = Modifier, + isFixed: Boolean, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(56.dp) + .background(Color(0xFF6C63FF), RoundedCornerShape(28.dp)) + .then( + if (isFixed) { + Modifier.semantics { contentDescription = "Recipient avatar for Maya Johnson" } + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "MJ", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + OutlinedTextField( + value = recipient, + onValueChange = onRecipientChange, + label = { Text("Recipient Account") }, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/sample/src/main/java/com/composea11yscanner/sample/LoginScreens.kt b/sample/src/main/java/com/composea11yscanner/sample/LoginScreens.kt new file mode 100644 index 0000000..3a0ef1b --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/LoginScreens.kt @@ -0,0 +1,361 @@ +package com.composea11yscanner.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.semantics.Role +import androidx.compose.ui.semantics.contentDescription +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.input.VisualTransformation +import androidx.compose.ui.unit.dp + +@Composable +fun BrokenLoginScreen(onViewFixed: (() -> Unit)? = null) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF0D1117)) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + SecureBankHeader() + LoginIntro() + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + trailingIcon = { + Text( + text = if (passwordVisible) "Hide" else "Show", + color = Color(0xFF6C63FF), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .size(width = 44.dp, height = 30.dp) + .clickable { passwordVisible = !passwordVisible }, + ) + }, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + GradientSignInButton(isFixed = false) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Forgot password?", + color = Color(0xFF4DA6FF), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .size(width = 108.dp, height = 30.dp) + .clickable { }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TinyClickableLabel("?", Color(0xFF1C2B3A)) + TinyClickableLabel("!", Color(0xFF2E2A1C)) + TinyClickableLabel("x", Color(0xFF281C2E)) + } + } + + DeviceTrustCard(isFixed = false) + + onViewFixed?.let { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFF1C1C28)) + .clickable { it() }, + contentAlignment = Alignment.Center, + ) { + Text("View Fixed Version") + } + } + } +} + +@Composable +fun FixedLoginScreen(onViewBroken: (() -> Unit)? = null) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF0D1117)) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + SecureBankHeader() + LoginIntro() + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + trailingIcon = { + Box( + modifier = Modifier + .size(56.dp) + .semantics { + contentDescription = if (passwordVisible) { + "Hide password" + } else { + "Show password" + } + } + .clickable( + role = Role.Button, + onClick = { passwordVisible = !passwordVisible }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (passwordVisible) "Hide" else "Show", + color = Color(0xFF6C63FF), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + } + }, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + GradientSignInButton(isFixed = true) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .semantics { contentDescription = "Reset password" } + .clickable( + role = Role.Button, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Forgot password?", + color = Color(0xFF4DA6FF), + style = MaterialTheme.typography.bodySmall, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FixedIconAction("?", "Open login help", Color(0xFF1C2B3A)) + FixedIconAction("!", "Show login requirements", Color(0xFF2E2A1C)) + FixedIconAction("x", "Dismiss login form", Color(0xFF281C2E)) + } + } + + DeviceTrustCard(isFixed = true) + + onViewBroken?.let { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFF1C1C28)) + .semantics { contentDescription = "View broken version" } + .clickable( + role = Role.Button, + onClick = { it() }, + ), + contentAlignment = Alignment.Center, + ) { + Text("View Broken Version") + } + } + } +} + +@Composable +private fun SecureBankHeader() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + BankLogoMark() + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "SecureBank", + color = Color.White, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Private client access", + color = Color(0xFF8B949E), + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun LoginIntro() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Welcome back", + color = Color.White, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Sign in to view balances, transfers, and card activity.", + color = Color(0xFF8B949E), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun GradientSignInButton(isFixed: Boolean) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(54.dp) + .clip(RoundedCornerShape(16.dp)) + .background( + Brush.horizontalGradient( + colors = listOf(Color(0xFF6C63FF), Color(0xFF00D4AA)), + ), + ) + .then( + if (isFixed) { + Modifier + .semantics { contentDescription = "Sign in" } + .clickable(role = Role.Button, onClick = {}) + } else { + Modifier.clickable { } + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Sign In", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun DeviceTrustCard(isFixed: Boolean) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFF121826)) + .padding(14.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "Device trust", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Review this session", + color = Color(0xFF8B949E), + style = MaterialTheme.typography.bodySmall, + ) + } + Box( + modifier = Modifier + .size(if (isFixed) 56.dp else 32.dp) + .clip(RoundedCornerShape(if (isFixed) 16.dp else 10.dp)) + .background(Color(0xFF00D4AA)) + .then( + if (isFixed) { + Modifier + .semantics { contentDescription = "Review trusted device session" } + .clickable(role = Role.Button, onClick = {}) + } else { + Modifier.clickable { } + }, + ), + contentAlignment = Alignment.Center, + ) { + Text("Go", color = Color(0xFF0D1117), fontWeight = FontWeight.Bold) + } + } + } +} diff --git a/sample/src/main/java/com/composea11yscanner/sample/SampleActivity.kt b/sample/src/main/java/com/composea11yscanner/sample/SampleActivity.kt index 50c4ee2..cb8a4cc 100644 --- a/sample/src/main/java/com/composea11yscanner/sample/SampleActivity.kt +++ b/sample/src/main/java/com/composea11yscanner/sample/SampleActivity.kt @@ -1,35 +1,29 @@ package com.composea11yscanner.sample import android.os.Bundle -import android.view.View -import android.view.ViewGroup import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ElevatedCard @@ -37,10 +31,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -55,28 +48,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.ColorPainter -import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.SemanticsNode -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.getOrNull -import androidx.compose.ui.semantics.SemanticsOwner -import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp -import com.composea11yscanner.core.model.A11yNode import com.composea11yscanner.core.model.ScannerConfig import com.composea11yscanner.rules.ScannerRules +import com.composea11yscanner.sample.ui.theme.ScannerTheme import com.composea11yscanner.triggers.scanOnShake -import com.composea11yscanner.ui.A11yNodeExtractor import com.composea11yscanner.ui.A11yScannerController import com.composea11yscanner.ui.A11yScannerScaffold @@ -85,7 +66,7 @@ class SampleActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - MaterialTheme { + ScannerTheme { BrokenAccessibilitySampleApp() } } @@ -100,12 +81,13 @@ fun BrokenAccessibilitySampleApp(modifier: Modifier = Modifier) { var viewingFixed by remember { mutableStateOf(false) } var scanScrollY by remember { mutableIntStateOf(0) } val scrollState = rememberScrollState() - val screens = listOf("Login", "Feed", "Form") - val screenSubtitles = listOf( - "Credentials and compact actions", - "Stories, captions, and repeated labels", - "Form controls and focus order", - ) + val screens = remember { + listOf( + SampleScreen("Login", "Credentials and compact actions", Icons.Filled.Person), + SampleScreen("Feed", "Stories, captions, and repeated labels", Icons.AutoMirrored.Filled.List), + SampleScreen("Form", "Form controls and focus order", Icons.Filled.Edit), + ) + } val issueOffsetY by remember { derivedStateOf { scanScrollY - scrollState.value } } @@ -124,6 +106,7 @@ fun BrokenAccessibilitySampleApp(modifier: Modifier = Modifier) { autoScan = false, ) } + fun startSampleScan() { scanScrollY = scrollState.value scannerController.startScan() @@ -145,647 +128,230 @@ fun BrokenAccessibilitySampleApp(modifier: Modifier = Modifier) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - CenterAlignedTopAppBar( - title = { - Text( - text = "Compose A11y Scanner", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - IconButton(onClick = { scannerController.clearState() }) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Clear scan results", - ) - } - IconButton(onClick = { startSampleScan() }) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = "Scan selected sample", - ) - } - }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + SampleTopBar( + onClear = { scannerController.clearState() }, + onScan = { startSampleScan() }, ) }, bottomBar = { - NavigationBar { - screens.forEachIndexed { index, label -> - NavigationBarItem( - selected = selectedScreen == index, - onClick = { - selectedScreen = index - viewingFixed = false - }, - icon = { - Icon( - imageVector = if (selectedScreen == index) { - Icons.Filled.CheckCircle - } else { - Icons.Filled.Search - }, - contentDescription = null, - ) - }, - label = { Text(label) }, - ) - } - } + SampleBottomBar( + screens = screens, + selectedScreen = selectedScreen, + onScreenSelected = { + selectedScreen = it + viewingFixed = false + }, + ) }, ) { innerPadding -> - Column( + SampleContent( modifier = Modifier .padding(innerPadding) - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainerLowest) - .verticalScroll(scrollState) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = screens[selectedScreen], - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = screenSubtitles[selectedScreen], - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - FilterChip( - selected = !viewingFixed, - onClick = { viewingFixed = false }, - label = { Text("Broken") }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - }, - ) - FilterChip( - selected = viewingFixed, - onClick = { viewingFixed = true }, - label = { Text("Fixed") }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - }, - ) - } - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 1.dp, - ) { - Box( - modifier = Modifier - .padding(8.dp) - .semantics { testTag = BrokenSampleContentTag }, - ) - { - if (viewingFixed) { - when (selectedScreen) { - 0 -> FixedLoginScreen() - 1 -> FixedFeedScreen() - else -> FixedFormScreen() - } - } else { - when (selectedScreen) { - 0 -> BrokenLoginScreen() - 1 -> BrokenFeedScreen() - else -> BrokenFormScreen() - } - } - } - } - } + .fillMaxSize(), + screens = screens, + selectedScreen = selectedScreen, + viewingFixed = viewingFixed, + onViewingFixedChange = { viewingFixed = it }, + scrollModifier = Modifier.verticalScroll(scrollState), + ) } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun BrokenLoginScreen(onViewFixed: (() -> Unit)? = null) { - BrokenScreenCard( - title = "Screen 1: Broken Login", - subtitle = "Missing content descriptions and small touch targets", - ) { - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - - OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text("Email") }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - modifier = Modifier.fillMaxWidth(), - ) - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - TinyClickableLabel("?", Color(0xFF006D77)) - TinyClickableLabel("!", Color(0xFFE29578)) - TinyClickableLabel("x", Color(0xFF8D99AE)) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(32.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color(0xFF2A9D8F)) - .clickable { }, - contentAlignment = Alignment.Center, - ) { - Text("Go", color = Color.White) - } +private fun SampleTopBar( + onClear: () -> Unit, + onScan: () -> Unit, +) { + CenterAlignedTopAppBar( + title = { Text( - text = "Forgot password", - modifier = Modifier - .size(width = 92.dp, height = 30.dp) - .clickable { }, - color = Color(0xFF1565C0), + text = "Compose A11y Scanner", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, ) - } - onViewFixed?.let { - Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { - Text("View Fixed Version") + }, + actions = { + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear scan results", + ) } - } - } -} - -@Composable -private fun BrokenFeedScreen(onViewFixed: (() -> Unit)? = null) { - BrokenScreenCard( - title = "Screen 2: Broken Feed", - subtitle = "Poor contrast text over images and duplicate descriptions", - ) { - FeedHero( - imageColor = Color(0xFF9E9E9E), - title = "Morning trail update", - description = "Open story", - ) - FeedHero( - imageColor = Color(0xFF78909C), - title = "City lights tonight", - description = "Open story", - ) - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - DuplicateAction("Save", "Save item") - DuplicateAction("Bookmark", "Save item") - } - onViewFixed?.let { - Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { - Text("View Fixed Version") + IconButton(onClick = onScan) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Scan selected sample", + ) } - } - } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) } @Composable -private fun BrokenFormScreen(onViewFixed: (() -> Unit)? = null) { - BrokenScreenCard( - title = "Screen 3: Broken Form", - subtitle = "Clickable elements lack roles and source order breaks focus flow", - ) { - Text("Preference form", style = MaterialTheme.typography.titleMedium) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .background(Color(0xFFECEFF1), RoundedCornerShape(8.dp)), - ) { - FormAction( - text = "Submit preferences", - color = Color(0xFF00796B), - modifier = Modifier - .align(Alignment.BottomStart) - .padding(16.dp), - ) - FormAction( - text = "Choose plan", - color = Color(0xFF5E35B1), - modifier = Modifier - .align(Alignment.TopStart) - .padding(16.dp), - ) - FormAction( - text = "Reset fields", - color = Color(0xFFC62828), - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(16.dp), +private fun SampleBottomBar( + screens: List, + selectedScreen: Int, + onScreenSelected: (Int) -> Unit, +) { + NavigationBar { + screens.forEachIndexed { index, screen -> + NavigationBarItem( + selected = selectedScreen == index, + onClick = { onScreenSelected(index) }, + icon = { + Icon( + imageVector = screen.icon, + contentDescription = null, + ) + }, + label = { Text(screen.label) }, ) } - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - FormAction("Monthly", Color(0xFF455A64), Modifier.weight(1f)) - FormAction("Yearly", Color(0xFF455A64), Modifier.weight(1f)) - } - onViewFixed?.let { - Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { - Text("View Fixed Version") - } - } } } @Composable -fun FixedLoginScreen(onViewBroken: (() -> Unit)? = null) { - BrokenScreenCard( - title = "Fixed Login", - subtitle = "Described controls with touch targets at least 48dp", - ) { - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - - OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text("Email") }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - modifier = Modifier.fillMaxWidth(), - ) - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - FixedIconAction("?", "Open login help", Color(0xFF006D77)) - FixedIconAction("!", "Show login requirements", Color(0xFFE29578)) - FixedIconAction("x", "Dismiss login form", Color(0xFF8D99AE)) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(width = 56.dp, height = 48.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color(0xFF2A9D8F)) - .semantics { contentDescription = "Sign in" } - .clickable( - role = Role.Button, - onClick = {}, - ), - contentAlignment = Alignment.Center, - ) { - Text("Go", color = Color.White) - } - Box( - modifier = Modifier - .size(width = 144.dp, height = 48.dp) - .semantics { contentDescription = "Reset password" } - .clickable( - role = Role.Button, - onClick = {}, - ), - contentAlignment = Alignment.Center, - ) { - Text("Forgot password", color = Color(0xFF1565C0)) - } - } - onViewBroken?.let { - Button( - onClick = it, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp), - ) { - Text("View Broken Version") - } - } - } -} - -@Composable -fun FixedFeedScreen(onViewBroken: (() -> Unit)? = null) { - BrokenScreenCard( - title = "Fixed Feed", - subtitle = "Readable captions and unique descriptions", +private fun SampleContent( + modifier: Modifier = Modifier, + screens: List, + selectedScreen: Int, + viewingFixed: Boolean, + onViewingFixedChange: (Boolean) -> Unit, + scrollModifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .then(scrollModifier) + .semantics { testTag = SampleViewportTag } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - FixedFeedItem( - imageColor = Color(0xFF607D8B), - title = "Morning trail update", - description = "Open morning trail story", + SampleModeCard( + screen = screens[selectedScreen], + viewingFixed = viewingFixed, + onViewingFixedChange = onViewingFixedChange, ) - FixedFeedItem( - imageColor = Color(0xFF546E7A), - title = "City lights tonight", - description = "Open city lights story", + SampleScreenHost( + selectedScreen = selectedScreen, + viewingFixed = viewingFixed, ) - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - FixedLabeledAction("Save", "Save feed item") - FixedLabeledAction("Bookmark", "Bookmark feed item") - } - onViewBroken?.let { - Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { - Text("View Broken Version") - } - } - } -} - -@Composable -fun FixedFormScreen(onViewBroken: (() -> Unit)? = null) { - BrokenScreenCard( - title = "Fixed Form", - subtitle = "Button roles and top-to-bottom focus order", - ) { - Text("Preference form", style = MaterialTheme.typography.titleMedium) - Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { - Text("Choose plan") - } - Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { - Text("Reset fields") - } - Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { - Text("Submit preferences") - } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(onClick = {}, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { - Text("Monthly") - } - Button(onClick = {}, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { - Text("Yearly") - } - } - onViewBroken?.let { - Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { - Text("View Broken Version") - } - } } } @Composable -private fun BrokenScreenCard( - title: String, - subtitle: String, - content: @Composable ColumnScope.() -> Unit, +private fun SampleModeCard( + screen: SampleScreen, + viewingFixed: Boolean, + onViewingFixedChange: (Boolean) -> Unit, ) { - Card( + ElevatedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text(subtitle, style = MaterialTheme.typography.bodyMedium) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = screen.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = screen.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = !viewingFixed, + onClick = { onViewingFixedChange(false) }, + label = { Text("Broken") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + ) + FilterChip( + selected = viewingFixed, + onClick = { onViewingFixedChange(true) }, + label = { Text("Fixed") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + ) } - content() } } } @Composable -private fun TinyClickableLabel(text: String, color: Color) { - Box( - modifier = Modifier - .size(28.dp) - .background(color, RoundedCornerShape(6.dp)) - .clickable { }, - contentAlignment = Alignment.Center, - ) { - Text(text, color = Color.White, fontWeight = FontWeight.Bold) - } -} - -@Composable -private fun FeedHero( - imageColor: Color, - title: String, - description: String, -) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(150.dp) - .clip(RoundedCornerShape(8.dp)) - .semantics { contentDescription = description } - .clickable { }, - ) { - Image( - painter = ColorPainter(imageColor), - contentDescription = description, - modifier = Modifier.fillMaxSize(), - ) - Text( - text = title, - color = Color(0xFFBDBDBD), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(14.dp), - ) - } -} - -@Composable -private fun DuplicateAction(label: String, description: String) { - Box( - modifier = Modifier - .size(width = 132.dp, height = 52.dp) - .background(Color(0xFF283593), RoundedCornerShape(8.dp)) - .semantics { contentDescription = description } - .clickable { }, - contentAlignment = Alignment.Center, - ) { - Text(label, color = Color.White) - } -} - -@Composable -private fun FormAction( - text: String, - color: Color, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .height(56.dp) - .background(color, RoundedCornerShape(8.dp)) - .clickable { } - .padding(horizontal = 16.dp), - contentAlignment = Alignment.Center, - ) { - Text(text, color = Color.White, fontWeight = FontWeight.SemiBold) - } -} - -@Composable -private fun FixedIconAction( - label: String, - description: String, - color: Color, -) { - Box( - modifier = Modifier - .size(56.dp) - .background(color, RoundedCornerShape(8.dp)) - .semantics { contentDescription = description } - .clickable( - role = Role.Button, - onClick = {}, - ), - contentAlignment = Alignment.Center, - ) { - Text(label, color = Color.White, fontWeight = FontWeight.Bold) - } -} - -@Composable -private fun FixedFeedItem( - imageColor: Color, - title: String, - description: String, +private fun SampleScreenHost( + selectedScreen: Int, + viewingFixed: Boolean, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = description } - .clickable( - role = Role.Button, - onClick = {}, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 1.dp, ) { - Image( - painter = ColorPainter(imageColor), - contentDescription = null, + Box( modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .clip(RoundedCornerShape(8.dp)), - ) - Text(title, style = MaterialTheme.typography.titleMedium) - } -} - -@Composable -private fun FixedLabeledAction(label: String, description: String) { - Box( - modifier = Modifier - .size(width = 132.dp, height = 56.dp) - .background(Color(0xFF283593), RoundedCornerShape(8.dp)) - .semantics { contentDescription = description } - .clickable( - role = Role.Button, - onClick = {}, - ), - contentAlignment = Alignment.Center, - ) { - Text(label, color = Color.White) + .padding(8.dp) + .semantics { testTag = BrokenSampleContentTag }, + ) { + if (viewingFixed) { + when (selectedScreen) { + 0 -> FixedLoginScreen() + 1 -> FixedFeedScreen() + else -> FixedFormScreen() + } + } else { + when (selectedScreen) { + 0 -> BrokenLoginScreen() + 1 -> BrokenFeedScreen() + else -> BrokenFormScreen() + } + } + } } } @Preview(showBackground = true) @Composable fun BrokenAccessibilitySampleAppPreview() { - MaterialTheme { + ScannerTheme { BrokenAccessibilitySampleApp() } } -private fun ComponentActivity.extractBrokenSampleNodes(): List = - runCatching { - val hostView = (window.decorView as? ViewGroup) - ?.findFirstAbstractComposeView() - ?: return emptyList() - val semanticsOwner = hostView.findSemanticsOwner() ?: return emptyList() - val sampleRoot = semanticsOwner.unmergedRootSemanticsNode - .findNodeByTestTag(BrokenSampleContentTag) - ?: return emptyList() - A11yNodeExtractor(Density(this)).extract(sampleRoot) - }.getOrDefault(emptyList()) - -private const val BrokenSampleContentTag = "broken-sample-content" - -private fun SemanticsNode.findNodeByTestTag(tag: String): SemanticsNode? { - if (config.getOrNull(SemanticsProperties.TestTag) == tag) return this - children.forEach { child -> - child.findNodeByTestTag(tag)?.let { return it } - } - return null -} - -private fun ViewGroup.findFirstAbstractComposeView(): AbstractComposeView? { - for (i in 0 until childCount) { - val child = getChildAt(i) - if (child is AbstractComposeView) return child - if (child is ViewGroup) { - child.findFirstAbstractComposeView()?.let { return it } - } - } - return null -} - -private fun AbstractComposeView.findSemanticsOwner(): SemanticsOwner? { - val composeOwnerView: View = getChildAt(0) ?: return null - return runCatching { - composeOwnerView.javaClass - .getMethod("getSemanticsOwner") - .invoke(composeOwnerView) as? SemanticsOwner - }.getOrNull() -} +private data class SampleScreen( + val label: String, + val subtitle: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, +) diff --git a/sample/src/main/java/com/composea11yscanner/sample/SampleComponents.kt b/sample/src/main/java/com/composea11yscanner/sample/SampleComponents.kt new file mode 100644 index 0000000..4252950 --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/SampleComponents.kt @@ -0,0 +1,176 @@ +package com.composea11yscanner.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun BrokenScreenCard( + title: String, + subtitle: String, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(subtitle, style = MaterialTheme.typography.bodyMedium) + } + content() + } + } +} + +@Composable +fun BankLogoMark() { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFF6C63FF)), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(width = 24.dp, height = 10.dp) + .clip(RoundedCornerShape(3.dp)) + .background(Color.White.copy(alpha = 0.92f)), + ) + Box( + modifier = Modifier + .size(width = 10.dp, height = 24.dp) + .clip(RoundedCornerShape(3.dp)) + .background(Color.White.copy(alpha = 0.72f)), + ) + } +} + +@Composable +fun TinyClickableLabel(text: String, color: Color) { + Box( + modifier = Modifier + .size(28.dp) + .background(color, RoundedCornerShape(6.dp)) + .clickable { }, + contentAlignment = Alignment.Center, + ) { + Text(text, color = Color.White, fontWeight = FontWeight.Bold) + } +} + +@Composable +fun FixedIconAction( + label: String, + description: String, + color: Color, +) { + Box( + modifier = Modifier + .size(56.dp) + .background(color, RoundedCornerShape(8.dp)) + .semantics { contentDescription = description } + .clickable( + role = Role.Button, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Text(label, color = Color.White, fontWeight = FontWeight.Bold) + } +} + +@Composable +fun MarketFilterChip(label: String, selected: Boolean) { + Box( + modifier = Modifier + .height(48.dp) + .background( + color = if (selected) Color(0xFF6C63FF) else Color(0xFF1C1C28), + shape = RoundedCornerShape(24.dp), + ) + .clickable( + role = Role.Button, + onClick = {}, + ) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (selected) Color.White else Color(0xFF8B949E), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +fun PaymentSubmitAction(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .height(56.dp) + .background( + brush = Brush.horizontalGradient( + colors = listOf(Color(0xFF6C63FF), Color(0xFF00D4AA)), + ), + shape = RoundedCornerShape(16.dp), + ) + .clickable( + role = Role.Button, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Text( + text = "Send Payment", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } +} diff --git a/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt b/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt new file mode 100644 index 0000000..f68eb59 --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt @@ -0,0 +1,90 @@ +package com.composea11yscanner.sample + +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsOwner +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.unit.Density +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.Rect +import com.composea11yscanner.ui.A11yNodeExtractor +import kotlin.math.roundToInt + +internal const val BrokenSampleContentTag = "broken-sample-content" +internal const val SampleViewportTag = "sample-viewport" + +internal fun ComponentActivity.extractBrokenSampleNodes(): List = + runCatching { + val hostView = (window.decorView as? ViewGroup) + ?.findFirstAbstractComposeView() + ?: return emptyList() + val semanticsOwner = hostView.findSemanticsOwner() ?: return emptyList() + val sampleRoot = semanticsOwner.unmergedRootSemanticsNode + .findNodeByTestTag(BrokenSampleContentTag) + ?: return emptyList() + val viewport = semanticsOwner.unmergedRootSemanticsNode + .findNodeByTestTag(SampleViewportTag) + ?.boundsInRoot + ?.let { Rect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt()) } + ?: sampleRoot.boundsInRoot.let { + Rect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt()) + } + A11yNodeExtractor(Density(this)) + .extract(sampleRoot) + .filterVisibleIn(viewport) + }.getOrDefault(emptyList()) + +private fun List.filterVisibleIn(viewport: Rect): List = + filter { node -> + if (node.isFocusable) { + node.bounds.centerInside(viewport) + } else { + node.bounds.intersects(viewport) + } + } + +private fun Rect.centerInside(other: Rect): Boolean { + if (isEmpty()) return false + val centerX = (left + right) / 2 + val centerY = (top + bottom) / 2 + return centerX in other.left..other.right && centerY in other.top..other.bottom +} + +private fun Rect.intersects(other: Rect): Boolean = + !isEmpty() && + right > other.left && + left < other.right && + bottom > other.top && + top < other.bottom + +private fun SemanticsNode.findNodeByTestTag(tag: String): SemanticsNode? { + if (config.getOrNull(SemanticsProperties.TestTag) == tag) return this + children.forEach { child -> + child.findNodeByTestTag(tag)?.let { return it } + } + return null +} + +private fun ViewGroup.findFirstAbstractComposeView(): AbstractComposeView? { + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child is AbstractComposeView) return child + if (child is ViewGroup) { + child.findFirstAbstractComposeView()?.let { return it } + } + } + return null +} + +private fun AbstractComposeView.findSemanticsOwner(): SemanticsOwner? { + val composeOwnerView: View = getChildAt(0) ?: return null + return runCatching { + composeOwnerView.javaClass + .getMethod("getSemanticsOwner") + .invoke(composeOwnerView) as? SemanticsOwner + }.getOrNull() +} diff --git a/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Color.kt b/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Color.kt new file mode 100644 index 0000000..b041b64 --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Color.kt @@ -0,0 +1,21 @@ +package com.composea11yscanner.sample.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +val ScannerBackground = Color(0xFF0A0A0F) +val ScannerSurface = Color(0xFF12121A) +val ScannerSurfaceVariant = Color(0xFF1C1C28) +val ScannerPrimary = Color(0xFF6C63FF) +val ScannerSecondary = Color(0xFF00D4AA) +val ScannerError = Color(0xFFFF4D6A) +val ScannerWarning = Color(0xFFFFB547) +val ScannerInfo = Color(0xFF4DA6FF) +val ScannerSuccess = ScannerSecondary + +@Immutable +data class ScannerPalette( + val warning: Color, + val info: Color, + val success: Color, +) diff --git a/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Shape.kt b/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Shape.kt new file mode 100644 index 0000000..e0eefe4 --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Shape.kt @@ -0,0 +1,12 @@ +package com.composea11yscanner.sample.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val ScannerShapes = Shapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp), +) diff --git a/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Theme.kt b/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Theme.kt new file mode 100644 index 0000000..aeb2ceb --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Theme.kt @@ -0,0 +1,66 @@ +package com.composea11yscanner.sample.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +private val ScannerDarkColorScheme = darkColorScheme( + primary = ScannerPrimary, + onPrimary = Color.White, + primaryContainer = ScannerPrimary.copy(alpha = 0.22f), + onPrimaryContainer = Color.White, + secondary = ScannerSecondary, + onSecondary = ScannerBackground, + secondaryContainer = ScannerSecondary.copy(alpha = 0.18f), + onSecondaryContainer = Color.White, + tertiary = ScannerInfo, + onTertiary = ScannerBackground, + error = ScannerError, + onError = Color.White, + errorContainer = ScannerError.copy(alpha = 0.2f), + onErrorContainer = Color.White, + background = ScannerBackground, + onBackground = Color(0xFFF4F4FA), + surface = ScannerSurface, + onSurface = Color(0xFFF4F4FA), + surfaceVariant = ScannerSurfaceVariant, + onSurfaceVariant = Color(0xFFC7C7D6), + outline = Color(0xFF6D6D80), + outlineVariant = Color(0xFF343447), + surfaceContainerLowest = ScannerBackground, + surfaceContainerLow = ScannerSurface, + surfaceContainer = ScannerSurfaceVariant, + surfaceContainerHigh = Color(0xFF242433), + surfaceContainerHighest = Color(0xFF2D2D3D), +) + +private val ScannerSemanticPalette = ScannerPalette( + warning = ScannerWarning, + info = ScannerInfo, + success = ScannerSuccess, +) + +private val LocalScannerPalette = staticCompositionLocalOf { ScannerSemanticPalette } + +object ScannerTheme { + val palette: ScannerPalette + @Composable + @ReadOnlyComposable + get() = LocalScannerPalette.current +} + +@Composable +fun ScannerTheme(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalScannerPalette provides ScannerSemanticPalette) { + MaterialTheme( + colorScheme = ScannerDarkColorScheme, + typography = ScannerTypography, + shapes = ScannerShapes, + content = content, + ) + } +} diff --git a/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Type.kt b/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Type.kt new file mode 100644 index 0000000..acccebb --- /dev/null +++ b/sample/src/main/java/com/composea11yscanner/sample/ui/theme/Type.kt @@ -0,0 +1,45 @@ +package com.composea11yscanner.sample.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont + +private val InterFont = GoogleFont("Inter") + +private val GoogleFontsProvider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = 0, +) + +val InterFontFamily = FontFamily( + Font(googleFont = InterFont, fontProvider = GoogleFontsProvider, weight = FontWeight.Normal), + Font(googleFont = InterFont, fontProvider = GoogleFontsProvider, weight = FontWeight.Medium), + Font(googleFont = InterFont, fontProvider = GoogleFontsProvider, weight = FontWeight.SemiBold), + Font(googleFont = InterFont, fontProvider = GoogleFontsProvider, weight = FontWeight.Bold), +) + +private fun TextStyle.withInter(): TextStyle = copy(fontFamily = InterFontFamily) + +val ScannerTypography = Typography().run { + copy( + displayLarge = displayLarge.withInter(), + displayMedium = displayMedium.withInter(), + displaySmall = displaySmall.withInter(), + headlineLarge = headlineLarge.withInter(), + headlineMedium = headlineMedium.withInter(), + headlineSmall = headlineSmall.withInter(), + titleLarge = titleLarge.withInter(), + titleMedium = titleMedium.withInter(), + titleSmall = titleSmall.withInter(), + bodyLarge = bodyLarge.withInter(), + bodyMedium = bodyMedium.withInter(), + bodySmall = bodySmall.withInter(), + labelLarge = labelLarge.withInter(), + labelMedium = labelMedium.withInter(), + labelSmall = labelSmall.withInter(), + ) +} diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt index 0fe3903..bc6838b 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview @@ -24,6 +26,8 @@ private val InfoBorderColor = Color(0xFF1976D2) private val BorderStrokeWidth = 2.dp private val BorderCornerRadius = 4.dp +private val CornerAccentLength = 12.dp +private val CornerAccentStrokeWidth = 3.dp private fun A11ySeverity.toBorderColor(): Color = when (this) { A11ySeverity.Error -> ErrorBorderColor @@ -48,15 +52,57 @@ fun IssueHighlightBox( .size(width, height) .clickable { onIssueSelected(issue) } .drawBehind { + val strokeWidth = BorderStrokeWidth.toPx() + val cornerRadius = BorderCornerRadius.toPx() + val cornerAccentLength = CornerAccentLength.toPx() + val cornerAccentStrokeWidth = CornerAccentStrokeWidth.toPx() + val cornerInset = cornerAccentStrokeWidth / 2f + + drawRoundRect( + color = borderColor, + alpha = 0.08f, + cornerRadius = CornerRadius(cornerRadius), + ) drawRoundRect( color = borderColor, - style = Stroke(width = BorderStrokeWidth.toPx()), - cornerRadius = CornerRadius(BorderCornerRadius.toPx()), + alpha = 0.92f, + style = Stroke(width = strokeWidth), + cornerRadius = CornerRadius(cornerRadius), + ) + drawCornerAccents( + color = borderColor, + length = cornerAccentLength, + strokeWidth = cornerAccentStrokeWidth, + inset = cornerInset, ) }, ) } +private fun DrawScope.drawCornerAccents( + color: Color, + length: Float, + strokeWidth: Float, + inset: Float, +) { + val left = inset + val top = inset + val right = size.width - inset + val bottom = size.height - inset + + drawLine(color, Offset(left, top), Offset(left + length, top), strokeWidth) + drawLine(color, Offset(left, top), Offset(left, top + length), strokeWidth) + + drawLine(color, Offset(right, top), Offset(right - length, top), strokeWidth) + drawLine(color, Offset(right, top), Offset(right, top + length), strokeWidth) + + drawLine(color, Offset(left, bottom), Offset(left + length, bottom), strokeWidth) + drawLine(color, Offset(left, bottom), Offset(left, bottom - length), strokeWidth) + + drawLine(color, Offset(right, bottom), Offset(right - length, bottom), strokeWidth) + drawLine(color, Offset(right, bottom), Offset(right, bottom - length), strokeWidth) +} + @Preview(showBackground = true) @Composable private fun IssueHighlightBoxErrorPreview() { diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt index 4f32d1f..b99f15f 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt @@ -1,18 +1,28 @@ package com.composea11yscanner.ui import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error @@ -33,9 +43,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode @@ -45,79 +62,84 @@ import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult import com.composea11yscanner.core.model.ScannerState -// ───────────────────────────────────────────────────────────────────────────── -// Colour constants -// ───────────────────────────────────────────────────────────────────────────── - private val ErrorColor = Color(0xFFD32F2F) private val WarningColor = Color(0xFFFFA000) private val InfoColor = Color(0xFF1976D2) +private val ScoreColor = Color(0xFF6C63FF) +private val PrimaryGlow = Color(0x556C63FF) -private val ScoreGoodColor = Color(0xFF2E7D32) // ≥ 90 % -private val ScoreFairColor = Color(0xFFF57C00) // ≥ 70 % -private val ScorePoorColor = Color(0xFFC62828) // < 70 % - -private fun Float.toScoreColor(): Color = when { - this >= 90f -> ScoreGoodColor - this >= 70f -> ScoreFairColor - else -> ScorePoorColor -} - -// ───────────────────────────────────────────────────────────────────────────── -// Public composable -// ───────────────────────────────────────────────────────────────────────────── +private val PillShape = RoundedCornerShape(percent = 50) +private val ScanningPillWidth = 220.dp +private val CompletePillWidth = 280.dp +private val DefaultTopOffset = 72.dp -/** - * Top bar that shows scan progress while a scan is running and a summary of - * findings once it completes. Tapping the score chip opens [ScanReportSheet]. - * - * Callers are responsible for showing/hiding the bar itself; this composable - * renders content for [ScannerState.Scanning] and [ScannerState.Complete] and - * nothing for [ScannerState.Idle] and [ScannerState.Error]. - */ @Composable fun ScanSummaryBar( state: ScannerState, modifier: Modifier = Modifier, + topOffset: Dp = DefaultTopOffset, ) { var showReport by remember { mutableStateOf(false) } - - // Retain last complete result so the sheet stays populated when state rolls - // back to Idle while the sheet is still open. var lastResult by remember { mutableStateOf(null) } + LaunchedEffect(state) { if (state is ScannerState.Complete) lastResult = state.result } - Surface( - tonalElevation = 2.dp, - modifier = modifier.fillMaxWidth(), + val targetWidth = when (state) { + is ScannerState.Complete -> CompletePillWidth + is ScannerState.Scanning -> ScanningPillWidth + is ScannerState.Error -> CompletePillWidth + ScannerState.Idle -> 120.dp + } + val pillWidth by animateDpAsState( + targetValue = targetWidth, + animationSpec = tween(durationMillis = 360), + label = "scan-summary-width", + ) + + Box( + modifier = modifier + .fillMaxWidth() + .padding(top = topOffset, start = 16.dp, end = 16.dp), + contentAlignment = Alignment.TopCenter, ) { - // contentKey groups Scanning(0.1f) and Scanning(0.9f) under the same - // key so the progress bar isn't cross-faded on every progress tick — - // the fade only fires on state-category transitions (e.g. Scanning → - // Complete). - AnimatedContent( - targetState = state, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - contentKey = { s -> - when (s) { - is ScannerState.Scanning -> "scanning" - is ScannerState.Complete -> "complete" - else -> "other" + Surface( + color = MaterialTheme.colorScheme.surface, + shape = PillShape, + tonalElevation = 6.dp, + modifier = Modifier + .width(pillWidth) + .shadow( + elevation = 16.dp, + shape = PillShape, + ambientColor = PrimaryGlow, + spotColor = PrimaryGlow, + ), + ) { + AnimatedContent( + targetState = state, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentKey = { current -> + when (current) { + is ScannerState.Scanning -> "scanning" + is ScannerState.Complete -> "complete" + is ScannerState.Error -> "error" + ScannerState.Idle -> "idle" + } + }, + label = "scan-state", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + ) { currentState -> + when (currentState) { + is ScannerState.Scanning -> ScanningContent(progress = currentState.progress) + is ScannerState.Complete -> ScanCompleteContent( + result = currentState.result, + onScoreClick = { showReport = true }, + ) + is ScannerState.Error -> ErrorContent(message = currentState.message) + ScannerState.Idle -> Unit } - }, - label = "scan-state", - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - ) { currentState -> - when (currentState) { - is ScannerState.Scanning -> ScanningContent(progress = currentState.progress) - is ScannerState.Complete -> ScanCompleteContent( - result = currentState.result, - onScoreClick = { showReport = true }, - ) - is ScannerState.Error -> ErrorContent(message = currentState.message) - ScannerState.Idle -> Unit } } } @@ -132,21 +154,22 @@ fun ScanSummaryBar( } } -// ───────────────────────────────────────────────────────────────────────────── -// Bar content slots -// ───────────────────────────────────────────────────────────────────────────── - @Composable private fun ScanningContent(progress: Float) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + RadarSweepIcon( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) Text( - text = "Scanning accessibility…", - style = MaterialTheme.typography.bodyMedium, + text = "Scanning...", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, ) Text( text = "${(progress * 100).toInt()}%", @@ -156,7 +179,12 @@ private fun ScanningContent(progress: Float) { } LinearProgressIndicator( progress = { progress }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .clip(PillShape), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), ) } } @@ -171,33 +199,24 @@ private fun ScanCompleteContent( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (result.errorCount > 0) { - CountChip( - count = result.errorCount, - color = ErrorColor, - icon = Icons.Filled.Error, - contentDescription = "${result.errorCount} errors", - ) - } - if (result.warningCount > 0) { - CountChip( - count = result.warningCount, - color = WarningColor, - icon = Icons.Filled.Warning, - contentDescription = "${result.warningCount} warnings", - ) - } - if (result.infoCount > 0) { - CountChip( - count = result.infoCount, - color = InfoColor, - icon = Icons.Filled.Info, - contentDescription = "${result.infoCount} info items", - ) - } - - Spacer(modifier = Modifier.weight(1f)) - + CountChip( + count = result.errorCount, + color = ErrorColor, + icon = Icons.Filled.Error, + contentDescription = "${result.errorCount} errors", + ) + CountChip( + count = result.warningCount, + color = WarningColor, + icon = Icons.Filled.Warning, + contentDescription = "${result.warningCount} warnings", + ) + CountChip( + count = result.infoCount, + color = InfoColor, + icon = Icons.Filled.Info, + contentDescription = "${result.infoCount} info items", + ) ScoreChip(score = result.overallScore, onClick = onScoreClick) } } @@ -205,6 +224,7 @@ private fun ScanCompleteContent( @Composable private fun ErrorContent(message: String) { Row( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -218,13 +238,58 @@ private fun ErrorContent(message: String) { text = "Scan failed: $message", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, + maxLines = 1, ) } } -// ───────────────────────────────────────────────────────────────────────────── -// Chip atoms used in the bar -// ───────────────────────────────────────────────────────────────────────────── +@Composable +private fun RadarSweepIcon( + color: Color, + modifier: Modifier = Modifier, +) { + val isInspecting = LocalInspectionMode.current + val infiniteTransition = rememberInfiniteTransition(label = "radar-sweep") + val sweepRotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1100, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "radar-sweep-rotation", + ) + val rotation = if (isInspecting) 35f else sweepRotation + + Canvas(modifier = modifier) { + val strokeWidth = 1.5.dp.toPx() + val radius = size.minDimension / 2f - strokeWidth + val center = Offset(size.width / 2f, size.height / 2f) + + drawCircle( + color = color.copy(alpha = 0.22f), + radius = radius, + center = center, + style = Stroke(width = strokeWidth), + ) + drawCircle( + color = color.copy(alpha = 0.14f), + radius = radius * 0.55f, + center = center, + style = Stroke(width = strokeWidth), + ) + rotate(degrees = rotation, pivot = center) { + drawLine( + color = color, + start = center, + end = Offset(center.x, center.y - radius), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round, + ) + } + drawCircle(color = color, radius = 2.2.dp.toPx(), center = center) + } +} @Composable private fun CountChip( @@ -235,10 +300,10 @@ private fun CountChip( ) { Row( modifier = Modifier - .clip(RoundedCornerShape(4.dp)) + .clip(PillShape) .background(color) - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), + .padding(horizontal = 7.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -259,31 +324,27 @@ private fun CountChip( private fun ScoreChip(score: Float, onClick: () -> Unit) { Row( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(score.toScoreColor()) + .clip(PillShape) + .background(ScoreColor) .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), + .padding(horizontal = 9.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "${score.toInt()}%", color = Color.White, - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.labelMedium, ) Icon( imageVector = Icons.Filled.ExpandMore, contentDescription = "View full report", tint = Color.White, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(14.dp), ) } } -// ───────────────────────────────────────────────────────────────────────────── -// Previews -// ───────────────────────────────────────────────────────────────────────────── - @Preview(showBackground = true) @Composable private fun ScanSummaryBarScanningPreview() { @@ -334,45 +395,68 @@ private fun previewResult( failed: Int, ): ScanResult { val node = A11yNode( - nodeId = "node-1", composableName = "Button", - bounds = Rect(0, 0, 300, 120), contentDescription = null, - isTouchTarget = true, touchTargetSize = DpSize(100f, 40f), - textColor = null, backgroundColors = emptyList(), - isFocusable = true, isMergedDescendant = false, depth = 1, + nodeId = "node-1", + composableName = "Button", + bounds = Rect(0, 0, 300, 120), + contentDescription = null, + isTouchTarget = true, + touchTargetSize = DpSize(100f, 40f), + textColor = null, + backgroundColors = emptyList(), + isFocusable = true, + isMergedDescendant = false, + depth = 1, ) val issues = buildList { repeat(errors) { add( A11yIssue( - "err-$it", A11ySeverity.Error, "missing-content-description", - "Missing Content Description", node, - "Interactive element has no content description.", - "Add Modifier.semantics { contentDescription = … }", "WCAG 1.1.1 Non-text Content (Level A)", + issueId = "err-$it", + severity = A11ySeverity.Error, + ruleId = "missing-content-description", + ruleName = "Missing Content Description", + affectedNode = node, + message = "Interactive element has no content description.", + howToFix = "Add Modifier.semantics { contentDescription = \"Label\" }", + wcagReference = "WCAG 1.1.1 Non-text Content (Level A)", ) ) } repeat(warnings) { add( A11yIssue( - "warn-$it", A11ySeverity.Warning, "focus-order", - "Focus Order", node, "Focus jumps upward unexpectedly.", - "Reorder composables top-to-bottom.", "WCAG 2.4.3 Focus Order (Level A)", + issueId = "warn-$it", + severity = A11ySeverity.Warning, + ruleId = "focus-order", + ruleName = "Focus Order", + affectedNode = node, + message = "Focus jumps upward unexpectedly.", + howToFix = "Reorder composables top-to-bottom.", + wcagReference = "WCAG 2.4.3 Focus Order (Level A)", ) ) } repeat(info) { add( A11yIssue( - "info-$it", A11ySeverity.Info, "text-scaling", - "Text Scaling", node, "Text does not scale with system font size.", - "Use sp units for all text sizes.", null, + issueId = "info-$it", + severity = A11ySeverity.Info, + ruleId = "text-scaling", + ruleName = "Text Scaling", + affectedNode = node, + message = "Text does not scale with system font size.", + howToFix = "Use sp units for all text sizes.", + wcagReference = null, ) ) } } return ScanResult( - scanId = "preview", timestamp = 0L, - totalNodes = 24, issues = issues, - passedRules = passed, failedRules = failed, + scanId = "preview", + timestamp = 0L, + totalNodes = 24, + issues = issues, + passedRules = passed, + failedRules = failed, ) }