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,
)
}