diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 189549c..e7c561f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ coroutines = "1.10.1" mockk = "1.13.12" turbine = "1.2.0" detekt = "1.23.7" +paparazzi = "1.3.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -31,6 +32,9 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" } +androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } @@ -49,3 +53,4 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" } diff --git a/scanner-ui/build.gradle.kts b/scanner-ui/build.gradle.kts index 4f2f05e..f4ace5c 100644 --- a/scanner-ui/build.gradle.kts +++ b/scanner-ui/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.android) + alias(libs.plugins.paparazzi) } android { @@ -42,6 +43,9 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.ui.unit) implementation(libs.androidx.compose.ui.util) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt new file mode 100644 index 0000000..dc2f2da --- /dev/null +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt @@ -0,0 +1,193 @@ +package com.composea11yscanner.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.DpSize +import com.composea11yscanner.core.model.Rect +import com.composea11yscanner.core.model.ScanResult +import com.composea11yscanner.core.model.ScannerConfig +import com.composea11yscanner.core.model.ScannerState + +/** + * Root scaffold that wires the full accessibility scanner UI around [content]. + * + * Layer order (back to front): + * 1. [content] — the host screen being inspected + * 2. [A11yIssueOverlay] — colored highlight boxes over flagged nodes + * 3. [ScanSummaryBar] — pinned at the top; slides in once scanning begins + * 4. [IssueDetailPanel] — slides up from the bottom when an overlay box is tapped + * + * [ScanReportSheet] is opened by tapping the score chip inside [ScanSummaryBar]. + * + * A new scan starts automatically when the scaffold enters composition and again + * whenever [config] changes. The in-flight scan is stopped when the scaffold + * leaves composition. + */ +@Composable +fun A11yScannerScaffold( + scannerController: A11yScannerController, + config: ScannerConfig, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + var scannerState by remember { mutableStateOf(ScannerState.Idle) } + var selectedIssue by remember { mutableStateOf(null) } + + // Cancel any in-flight scan when the scaffold leaves composition. + DisposableEffect(Unit) { + onDispose { scannerController.stopScan() } + } + + // Apply config and (re)start the scan whenever config changes. + // Clearing selectedIssue at scan start prevents the detail panel from + // showing stale data while the new result is being produced. + LaunchedEffect(config) { + scannerController.configure(config).startScan().collect { state -> + scannerState = state + if (state is ScannerState.Scanning) selectedIssue = null + } + } + + val scanResult = (scannerState as? ScannerState.Complete)?.result + + Box(modifier = modifier.fillMaxSize()) { + // ── 1. Host content ────────────────────────────────────────────────── + content() + + // ── 2. Issue highlight overlay ─────────────────────────────────────── + A11yIssueOverlay( + scanResult = scanResult, + onIssueSelected = { selectedIssue = it }, + modifier = Modifier.fillMaxSize(), + ) + + // ── 3. Summary bar — slides down from the top once scanning starts ─── + AnimatedVisibility( + visible = scannerState !is ScannerState.Idle, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) { + ScanSummaryBar( + state = scannerState, + modifier = Modifier.fillMaxWidth(), + ) + } + + // ── 4. Issue detail panel — slides up when an overlay box is tapped ── + IssueDetailPanel( + issue = selectedIssue, + onDismiss = { selectedIssue = null }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Preview +// ───────────────────────────────────────────────────────────────────────────── + +@Preview(showBackground = true, name = "Scaffold – Idle (bar hidden)") +@Composable +private fun A11yScannerScaffoldIdlePreview() { + MaterialTheme { + A11yScannerScaffold( + scannerController = remember { + A11yScannerController( + nodeProvider = { emptyList() }, + screenDensity = 2f, + ) + }, + config = ScannerConfig(enabledRules = emptySet()), + ) { + SampleHostContent() + } + } +} + +@Preview(showBackground = true, name = "Scaffold – Complete (bar + overlay)") +@Composable +private fun A11yScannerScaffoldCompletePreview() { + MaterialTheme { + // Render the inner layout directly so the preview shows a populated state + // without needing a live coroutine. + Box(modifier = Modifier.fillMaxSize()) { + SampleHostContent() + ScanSummaryBar( + state = ScannerState.Complete(result = previewScanResult()), + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + } + } +} + +@Composable +private fun SampleHostContent() { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Screen Title", style = MaterialTheme.typography.titleLarge) + Text("This is the host content being scanned.", style = MaterialTheme.typography.bodyMedium) + } +} + +private fun previewScanResult() = ScanResult( + scanId = "preview", + timestamp = 0L, + totalNodes = 12, + issues = listOf( + A11yIssue( + issueId = "err-1", + severity = A11ySeverity.Error, + ruleId = "missing-content-description", + ruleName = "Missing Content Description", + affectedNode = 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, + ), + message = "Interactive element has no content description.", + howToFix = "Add Modifier.semantics { contentDescription = … }", + wcagReference = "WCAG 1.1.1 Non-text Content (Level A)", + ), + ), + passedRules = 9, + failedRules = 1, +) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt new file mode 100644 index 0000000..87b2c45 --- /dev/null +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt @@ -0,0 +1,340 @@ +package com.composea11yscanner.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.DpSize +import com.composea11yscanner.core.model.Rect +import java.util.Locale + +private val ErrorChipColor = Color(0xFFD32F2F) +private val WarningChipColor = Color(0xFFFFA000) +private val InfoChipColor = Color(0xFF1976D2) + +private fun A11ySeverity.toChipColor(): Color = when (this) { + A11ySeverity.Error -> ErrorChipColor + A11ySeverity.Warning -> WarningChipColor + A11ySeverity.Info -> InfoChipColor +} + +private fun A11ySeverity.toIcon(): ImageVector = when (this) { + A11ySeverity.Error -> Icons.Filled.Error + A11ySeverity.Warning -> Icons.Filled.Warning + A11ySeverity.Info -> Icons.Filled.Info +} + +private fun A11ySeverity.label(): String = when (this) { + A11ySeverity.Error -> "Error" + A11ySeverity.Warning -> "Warning" + A11ySeverity.Info -> "Info" +} + +// "WCAG 1.1.1 Non-text Content (Level A)" → "https://www.w3.org/WAI/WCAG22/Understanding/non-text-content" +// "WCAG 4.1.2 Name, Role, Value (Level A)" → "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value" +private fun String.toWcagUrl(): String { + val title = Regex("""WCAG \d+\.\d+\.\d+ (.+?) \(Level""").find(this) + ?.groupValues?.getOrNull(1) + ?: return "https://www.w3.org/TR/WCAG22/" + val slug = title.lowercase(Locale.ROOT) + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + return "https://www.w3.org/WAI/WCAG22/Understanding/$slug" +} + +/** + * Slides up from the bottom when [issue] becomes non-null; slides back down on dismissal. + * Content is retained during the exit transition so the panel doesn't flash empty. + */ +@Composable +fun IssueDetailPanel( + issue: A11yIssue?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + // Keep the last non-null issue so the content stays visible during the exit slide. + var panelIssue by remember { mutableStateOf(issue) } + LaunchedEffect(issue) { if (issue != null) panelIssue = issue } + + AnimatedVisibility( + visible = issue != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = modifier, + ) { + panelIssue?.let { PanelContent(issue = it, onDismiss = onDismiss) } + } +} + +@Composable +private fun PanelContent( + issue: A11yIssue, + onDismiss: () -> Unit, +) { + val uriHandler = LocalUriHandler.current + + Surface( + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + tonalElevation = 3.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + DragHandle() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = issue.ruleName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Dismiss panel", + ) + } + } + + SeverityChip( + severity = issue.severity, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 16.dp), + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + SectionBlock( + label = "Issue", + body = issue.message, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + SectionBlock( + label = "How to fix", + body = issue.howToFix, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + + issue.wcagReference?.let { ref -> + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + WcagLink( + reference = ref, + onClick = { uriHandler.openUri(ref.toWcagUrl()) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + } + } +} + +@Composable +private fun DragHandle() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + ) { + Box( + modifier = Modifier + .size(width = 32.dp, height = 4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), + ) + } +} + +@Composable +private fun SeverityChip(severity: A11ySeverity, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(severity.toChipColor()) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = severity.toIcon(), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(14.dp), + ) + Text( + text = severity.label(), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) + } +} + +@Composable +private fun SectionBlock( + label: String, + body: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label.uppercase(Locale.ROOT), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun WcagLink( + reference: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Text( + text = reference, + style = MaterialTheme.typography.bodySmall.copy( + textDecoration = TextDecoration.Underline, + ), + color = MaterialTheme.colorScheme.primary, + modifier = modifier.clickable(onClick = onClick), + ) +} + +// — Previews —————————————————————————————————————————————————————————————————— + +@Preview(showBackground = true) +@Composable +private fun IssueDetailPanelErrorPreview() { + MaterialTheme { + IssueDetailPanel( + issue = previewIssue( + severity = A11ySeverity.Error, + ruleName = "Missing Content Description", + message = "Interactive element has no content description. " + + "Screen readers cannot announce this.", + howToFix = "Add a meaningful contentDescription via semantics: " + + "Modifier.semantics { contentDescription = \"Describe action here\" }", + wcagReference = "WCAG 1.1.1 Non-text Content (Level A)", + ), + onDismiss = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun IssueDetailPanelWarningPreview() { + MaterialTheme { + IssueDetailPanel( + issue = previewIssue( + severity = A11ySeverity.Warning, + ruleName = "Focus Order", + message = "Focus jumps upward from 200dp to 80dp.", + howToFix = "Reorder composables so focus flows top-to-bottom.", + wcagReference = "WCAG 2.4.3 Focus Order (Level A)", + ), + onDismiss = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun IssueDetailPanelNoWcagPreview() { + MaterialTheme { + IssueDetailPanel( + issue = previewIssue( + severity = A11ySeverity.Info, + ruleName = "Text Scaling", + message = "Text does not scale with system font size.", + howToFix = "Use sp units for all text sizes.", + wcagReference = null, + ), + onDismiss = {}, + ) + } +} + +private fun previewIssue( + severity: A11ySeverity, + ruleName: String, + message: String, + howToFix: String, + wcagReference: String?, +) = A11yIssue( + issueId = "preview-1", + severity = severity, + ruleId = "preview-rule", + ruleName = ruleName, + affectedNode = 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, + ), + message = message, + howToFix = howToFix, + wcagReference = wcagReference, +) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt new file mode 100644 index 0000000..0fe3903 --- /dev/null +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt @@ -0,0 +1,108 @@ +package com.composea11yscanner.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +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.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.DpSize +import com.composea11yscanner.core.model.Rect + +private val ErrorBorderColor = Color(0xFFD32F2F) +private val WarningBorderColor = Color(0xFFFFA000) +private val InfoBorderColor = Color(0xFF1976D2) + +private val BorderStrokeWidth = 2.dp +private val BorderCornerRadius = 4.dp + +private fun A11ySeverity.toBorderColor(): Color = when (this) { + A11ySeverity.Error -> ErrorBorderColor + A11ySeverity.Warning -> WarningBorderColor + A11ySeverity.Info -> InfoBorderColor +} + +@Composable +fun IssueHighlightBox( + issue: A11yIssue, + onIssueSelected: (A11yIssue) -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val bounds = issue.affectedNode.bounds + val width = with(density) { bounds.width.toDp() } + val height = with(density) { bounds.height.toDp() } + val borderColor = issue.severity.toBorderColor() + + Spacer( + modifier = modifier + .size(width, height) + .clickable { onIssueSelected(issue) } + .drawBehind { + drawRoundRect( + color = borderColor, + style = Stroke(width = BorderStrokeWidth.toPx()), + cornerRadius = CornerRadius(BorderCornerRadius.toPx()), + ) + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun IssueHighlightBoxErrorPreview() { + IssueHighlightBox( + issue = previewIssue(severity = A11ySeverity.Error), + onIssueSelected = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun IssueHighlightBoxWarningPreview() { + IssueHighlightBox( + issue = previewIssue(severity = A11ySeverity.Warning), + onIssueSelected = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun IssueHighlightBoxInfoPreview() { + IssueHighlightBox( + issue = previewIssue(severity = A11ySeverity.Info), + onIssueSelected = {}, + ) +} + +private fun previewIssue(severity: A11ySeverity) = A11yIssue( + issueId = "preview-1", + severity = severity, + ruleId = "preview-rule", + ruleName = "Preview Rule", + affectedNode = A11yNode( + nodeId = "node-1", + composableName = "Button", + bounds = Rect(0, 0, 300, 150), + contentDescription = null, + isTouchTarget = true, + touchTargetSize = DpSize(100f, 50f), + textColor = null, + backgroundColors = emptyList(), + isFocusable = true, + isMergedDescendant = false, + depth = 1, + ), + message = "Missing content description", + howToFix = "Add contentDescription to the Modifier.", + wcagReference = "WCAG 1.1.1", +) diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueSeverityBadge.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueSeverityBadge.kt new file mode 100644 index 0000000..6bd3b7f --- /dev/null +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueSeverityBadge.kt @@ -0,0 +1,133 @@ +package com.composea11yscanner.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +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 com.composea11yscanner.core.model.A11ySeverity + +private val ErrorBadgeColor = Color(0xFFD32F2F) +private val WarningBadgeColor = Color(0xFFFFA000) +private val InfoBadgeColor = Color(0xFF1976D2) + +private fun A11ySeverity.toBadgeColor(): Color = when (this) { + A11ySeverity.Error -> ErrorBadgeColor + A11ySeverity.Warning -> WarningBadgeColor + A11ySeverity.Info -> InfoBadgeColor +} + +private fun A11ySeverity.toIcon(): ImageVector = when (this) { + A11ySeverity.Error -> Icons.Filled.Error + A11ySeverity.Warning -> Icons.Filled.Warning + A11ySeverity.Info -> Icons.Filled.Info +} + +private fun A11ySeverity.label(): String = when (this) { + A11ySeverity.Error -> "Error" + A11ySeverity.Warning -> "Warning" + A11ySeverity.Info -> "Info" +} + +@Composable +fun IssueSeverityBadge( + severity: A11ySeverity, + count: Int = 1, + modifier: Modifier = Modifier, +) { + // Skip the entrance animation in the layout inspector / preview tool so + // the badge renders at full scale rather than being invisible. + val isInspecting = LocalInspectionMode.current + var appeared by remember { mutableStateOf(isInspecting) } + val scale by animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "badge-scale", + ) + + LaunchedEffect(Unit) { appeared = true } + + Row( + modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + // Grow from the top-left corner so the badge pops out + // in place rather than expanding from its own centre. + transformOrigin = TransformOrigin(0f, 0f) + } + .clip(RoundedCornerShape(4.dp)) + .background(severity.toBadgeColor()) + .padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = severity.toIcon(), + contentDescription = severity.label(), + tint = Color.White, + modifier = Modifier.size(12.dp), + ) + if (count > 1) { + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun IssueSeverityBadgeErrorPreview() { + IssueSeverityBadge(severity = A11ySeverity.Error) +} + +@Preview(showBackground = true) +@Composable +private fun IssueSeverityBadgeWarningPreview() { + IssueSeverityBadge(severity = A11ySeverity.Warning) +} + +@Preview(showBackground = true) +@Composable +private fun IssueSeverityBadgeInfoPreview() { + IssueSeverityBadge(severity = A11ySeverity.Info) +} + +@Preview(showBackground = true) +@Composable +private fun IssueSeverityBadgeCountPreview() { + IssueSeverityBadge(severity = A11ySeverity.Warning, count = 3) +} diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt new file mode 100644 index 0000000..86c5f7b --- /dev/null +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt @@ -0,0 +1,447 @@ +package com.composea11yscanner.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.DpSize +import com.composea11yscanner.core.model.Rect +import com.composea11yscanner.core.model.ScanResult +import java.util.Locale + +// ───────────────────────────────────────────────────────────────────────────── +// Colour constants + severity helpers (file-private) +// ───────────────────────────────────────────────────────────────────────────── + +private val ErrorColor = Color(0xFFD32F2F) +private val WarningColor = Color(0xFFFFA000) +private val InfoColor = Color(0xFF1976D2) +private val ScoreGoodColor = Color(0xFF2E7D32) +private val ScoreFairColor = Color(0xFFF57C00) +private val ScorePoorColor = Color(0xFFC62828) + +private val SeverityOrder = listOf(A11ySeverity.Error, A11ySeverity.Warning, A11ySeverity.Info) + +private fun A11ySeverity.toColor(): Color = when (this) { + A11ySeverity.Error -> ErrorColor + A11ySeverity.Warning -> WarningColor + A11ySeverity.Info -> InfoColor +} + +private fun A11ySeverity.toIcon(): ImageVector = when (this) { + A11ySeverity.Error -> Icons.Filled.Error + A11ySeverity.Warning -> Icons.Filled.Warning + A11ySeverity.Info -> Icons.Filled.Info +} + +private fun A11ySeverity.label(): String = when (this) { + A11ySeverity.Error -> "Error" + A11ySeverity.Warning -> "Warning" + A11ySeverity.Info -> "Info" +} + +private fun Float.toScoreColor(): Color = when { + this >= 90f -> ScoreGoodColor + this >= 70f -> ScoreFairColor + else -> ScorePoorColor +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public composable +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Full-screen report sheet. Pressing back/dismiss while a detail panel is open + * closes the panel first; a second press dismisses the sheet. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScanReportSheet( + result: ScanResult, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var activeFilter by remember { mutableStateOf(null) } + var selectedIssue by remember { mutableStateOf(null) } + + val grouped = remember(result) { result.issues.groupBy { it.severity } } + val displayIssues = result.issues.let { all -> + val f = activeFilter + if (f == null) all else all.filter { it.severity == f } + } + + ModalBottomSheet( + onDismissRequest = { + if (selectedIssue != null) selectedIssue = null else onDismiss() + }, + sheetState = sheetState, + modifier = modifier, + ) { + // Box lets IssueDetailPanel overlay the list at BottomCenter when a row is tapped. + Box(Modifier.fillMaxWidth()) { + LazyColumn(contentPadding = PaddingValues(bottom = 32.dp)) { + item { + ReportScoreHeader(result = result) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + + item { + FilterChipRow( + result = result, + activeFilter = activeFilter, + onFilterChange = { activeFilter = it }, + ) + HorizontalDivider() + } + + if (displayIssues.isEmpty()) { + item { + Text( + text = if (result.issues.isEmpty()) { + "No issues found — all checks passed." + } else { + // activeFilter is guaranteed non-null here: if it were null, + // displayIssues == result.issues which would also be empty, + // handled by the outer branch. + "No ${activeFilter!!.label().lowercase(Locale.ROOT)} issues." + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + } + } else if (activeFilter == null) { + // Grouped view: Errors → Warnings → Info, each with a section header. + SeverityOrder.forEach { severity -> + val issues = grouped[severity] ?: return@forEach + if (issues.isEmpty()) return@forEach + item(key = "header-${severity.label()}") { + SeverityGroupHeader(severity = severity, count = issues.size) + } + items(issues, key = { it.issueId }) { issue -> + IssueListRow( + issue = issue, + onClick = { selectedIssue = issue }, + ) + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + thickness = 0.5.dp, + ) + } + } + } else { + // Flat filtered view: no section header (filter chip signals the severity). + items(displayIssues, key = { it.issueId }) { issue -> + IssueListRow( + issue = issue, + onClick = { selectedIssue = issue }, + ) + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + thickness = 0.5.dp, + ) + } + } + } + + // Slides in over the list when a row chevron is tapped; slides back on dismiss. + IssueDetailPanel( + issue = selectedIssue, + onDismiss = { selectedIssue = null }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sheet sub-composables +// ───────────────────────────────────────────────────────────────────────────── + +@Composable +private fun ReportScoreHeader(result: ScanResult) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "Accessibility Score", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "${result.totalNodes} nodes scanned · ${result.passedRules} rules passed", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "${result.overallScore.toInt()}%", + style = MaterialTheme.typography.displaySmall, + color = result.overallScore.toScoreColor(), + ) + } + } +} + +@Composable +private fun FilterChipRow( + result: ScanResult, + activeFilter: A11ySeverity?, + onFilterChange: (A11ySeverity?) -> Unit, +) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = activeFilter == null, + onClick = { onFilterChange(null) }, + label = { Text("All (${result.issues.size})") }, + ) + // Only render a filter chip for severities that have at least one issue. + if (result.errorCount > 0) { + FilterChip( + selected = activeFilter == A11ySeverity.Error, + onClick = { + onFilterChange( + if (activeFilter == A11ySeverity.Error) null else A11ySeverity.Error, + ) + }, + label = { Text("Errors (${result.errorCount})") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Error, + contentDescription = null, + tint = ErrorColor, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + if (result.warningCount > 0) { + FilterChip( + selected = activeFilter == A11ySeverity.Warning, + onClick = { + onFilterChange( + if (activeFilter == A11ySeverity.Warning) null else A11ySeverity.Warning, + ) + }, + label = { Text("Warnings (${result.warningCount})") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = WarningColor, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + if (result.infoCount > 0) { + FilterChip( + selected = activeFilter == A11ySeverity.Info, + onClick = { + onFilterChange( + if (activeFilter == A11ySeverity.Info) null else A11ySeverity.Info, + ) + }, + label = { Text("Info (${result.infoCount})") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = InfoColor, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } +} + +@Composable +private fun SeverityGroupHeader(severity: A11ySeverity, count: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = severity.toIcon(), + contentDescription = null, + tint = severity.toColor(), + modifier = Modifier.size(16.dp), + ) + Text( + text = severity.label(), + style = MaterialTheme.typography.labelMedium, + color = severity.toColor(), + ) + } + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun IssueListRow(issue: A11yIssue, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + IssueSeverityBadge(severity = issue.severity) + Column(modifier = Modifier.weight(1f)) { + Text( + text = issue.affectedNode.composableName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = issue.ruleName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = "View issue detail", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Previews +// ───────────────────────────────────────────────────────────────────────────── + +@Preview(showBackground = true) +@Composable +private fun ScanReportSheetPreview() { + MaterialTheme { + ScanReportSheet( + result = previewResult(errors = 2, warnings = 3, info = 1), + onDismiss = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ScanReportSheetAllClearPreview() { + MaterialTheme { + ScanReportSheet( + result = previewResult(errors = 0, warnings = 0, info = 0), + onDismiss = {}, + ) + } +} + +private fun previewResult(errors: Int, warnings: Int, info: 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, + ) + 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)", + ) + ) + } + 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)", + ) + ) + } + 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, + ) + ) + } + } + return ScanResult( + scanId = "preview", timestamp = 0L, + totalNodes = 18, issues = issues, + passedRules = 9, failedRules = errors + warnings + info, + ) +} diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt new file mode 100644 index 0000000..4f32d1f --- /dev/null +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt @@ -0,0 +1,378 @@ +package com.composea11yscanner.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.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.Error +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.DpSize +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 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 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 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, +) { + 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(), + ) { + // 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" + } + }, + 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 + } + } + } + + if (showReport) { + lastResult?.let { result -> + ScanReportSheet( + result = result, + onDismiss = { showReport = false }, + ) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Bar content slots +// ───────────────────────────────────────────────────────────────────────────── + +@Composable +private fun ScanningContent(progress: Float) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Scanning accessibility…", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "${(progress * 100).toInt()}%", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ScanCompleteContent( + result: ScanResult, + onScoreClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + 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)) + + ScoreChip(score = result.overallScore, onClick = onScoreClick) + } +} + +@Composable +private fun ErrorContent(message: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + Text( + text = "Scan failed: $message", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Chip atoms used in the bar +// ───────────────────────────────────────────────────────────────────────────── + +@Composable +private fun CountChip( + count: Int, + color: Color, + icon: ImageVector, + contentDescription: String, +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.size(12.dp), + ) + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) + } +} + +@Composable +private fun ScoreChip(score: Float, onClick: () -> Unit) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(score.toScoreColor()) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${score.toInt()}%", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + ) + Icon( + imageVector = Icons.Filled.ExpandMore, + contentDescription = "View full report", + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Previews +// ───────────────────────────────────────────────────────────────────────────── + +@Preview(showBackground = true) +@Composable +private fun ScanSummaryBarScanningPreview() { + MaterialTheme { + ScanSummaryBar(state = ScannerState.Scanning(progress = 0.45f)) + } +} + +@Preview(showBackground = true) +@Composable +private fun ScanSummaryBarCompletePreview() { + MaterialTheme { + ScanSummaryBar( + state = ScannerState.Complete( + result = previewResult( + errors = 2, warnings = 5, info = 1, passed = 9, failed = 3, + ), + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ScanSummaryBarAllClearPreview() { + MaterialTheme { + ScanSummaryBar( + state = ScannerState.Complete( + result = previewResult(errors = 0, warnings = 0, info = 0, passed = 12, failed = 0), + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ScanSummaryBarErrorPreview() { + MaterialTheme { + ScanSummaryBar(state = ScannerState.Error(message = "Semantics owner unavailable")) + } +} + +private fun previewResult( + errors: Int, + warnings: Int, + info: Int, + passed: Int, + 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, + ) + 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)", + ) + ) + } + 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)", + ) + ) + } + 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, + ) + ) + } + } + return ScanResult( + scanId = "preview", timestamp = 0L, + totalNodes = 24, issues = issues, + passedRules = passed, failedRules = failed, + ) +} diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt index cfb7216..2f8cec7 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt @@ -1,7 +1,19 @@ package com.composea11yscanner.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.DpSize +import com.composea11yscanner.core.model.Rect +import com.composea11yscanner.core.model.ScanResult object ScannerUi { const val VERSION = "0.1.0" @@ -11,3 +23,58 @@ object ScannerUi { fun ScannerOverlay(modifier: Modifier = Modifier) { // TODO: Implement scanner overlay } + +@Composable +fun A11yIssueOverlay( + scanResult: ScanResult?, + onIssueSelected: (A11yIssue) -> Unit = {}, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = scanResult != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + // TODO: Render issue markers over host content + } + } +} + +@Preview(showBackground = true) +@Composable +private fun A11yIssueOverlayPreview() { + val node = A11yNode( + nodeId = "node-1", + composableName = "Button", + bounds = Rect(0, 0, 120, 48), + contentDescription = null, + isTouchTarget = true, + touchTargetSize = DpSize(120f, 48f), + textColor = null, + backgroundColors = emptyList(), + isFocusable = true, + isMergedDescendant = false, + depth = 1, + ) + val result = ScanResult( + scanId = "preview", + timestamp = 0L, + totalNodes = 1, + issues = listOf( + A11yIssue( + issueId = "issue-1", + severity = A11ySeverity.Warning, + ruleId = "missing-content-description", + ruleName = "Missing Content Description", + affectedNode = node, + message = "Interactive element has no content description.", + howToFix = "Add contentDescription to the Modifier.", + wcagReference = "WCAG 1.1.1", + ) + ), + passedRules = 9, + failedRules = 1, + ) + A11yIssueOverlay(scanResult = result) +} diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt new file mode 100644 index 0000000..bd92b63 --- /dev/null +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt @@ -0,0 +1,70 @@ +package com.composea11yscanner.ui + +import androidx.compose.material3.MaterialTheme +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import com.composea11yscanner.core.model.A11ySeverity +import org.junit.Rule +import org.junit.Test + +class IssueDetailPanelSnapshotTest { + + @get:Rule + val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5) + + @Test + fun errorIssue_withWcagReference() { + paparazzi.snapshot { + MaterialTheme { + IssueDetailPanel( + issue = issueFixture( + severity = A11ySeverity.Error, + ruleName = "Missing Content Description", + message = "Interactive element has no content description. " + + "Screen readers cannot announce this.", + howToFix = "Add a meaningful contentDescription via semantics: " + + "Modifier.semantics { contentDescription = \"Describe action here\" }", + wcagReference = "WCAG 1.1.1 Non-text Content (Level A)", + ), + onDismiss = {}, + ) + } + } + } + + @Test + fun warningIssue_withWcagReference() { + paparazzi.snapshot { + MaterialTheme { + IssueDetailPanel( + issue = issueFixture( + severity = A11ySeverity.Warning, + ruleName = "Focus Order", + message = "Focus jumps upward from 200dp to 80dp.", + howToFix = "Reorder composables so focus flows top-to-bottom.", + wcagReference = "WCAG 2.4.3 Focus Order (Level A)", + ), + onDismiss = {}, + ) + } + } + } + + @Test + fun infoIssue_noWcagReference() { + paparazzi.snapshot { + MaterialTheme { + IssueDetailPanel( + issue = issueFixture( + severity = A11ySeverity.Info, + ruleName = "Text Scaling", + message = "Text does not scale with system font size.", + howToFix = "Use sp units for all text sizes.", + wcagReference = null, + ), + onDismiss = {}, + ) + } + } + } +} diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueHighlightBoxSnapshotTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueHighlightBoxSnapshotTest.kt new file mode 100644 index 0000000..4164b89 --- /dev/null +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueHighlightBoxSnapshotTest.kt @@ -0,0 +1,43 @@ +package com.composea11yscanner.ui + +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import com.composea11yscanner.core.model.A11ySeverity +import org.junit.Rule +import org.junit.Test + +class IssueHighlightBoxSnapshotTest { + + @get:Rule + val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5) + + @Test + fun errorSeverityBorderIsRed() { + paparazzi.snapshot { + IssueHighlightBox( + issue = issueFixture(A11ySeverity.Error), + onIssueSelected = {}, + ) + } + } + + @Test + fun warningSeverityBorderIsOrange() { + paparazzi.snapshot { + IssueHighlightBox( + issue = issueFixture(A11ySeverity.Warning), + onIssueSelected = {}, + ) + } + } + + @Test + fun infoSeverityBorderIsBlue() { + paparazzi.snapshot { + IssueHighlightBox( + issue = issueFixture(A11ySeverity.Info), + onIssueSelected = {}, + ) + } + } +} diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueSeverityBadgeSnapshotTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueSeverityBadgeSnapshotTest.kt new file mode 100644 index 0000000..0e5b045 --- /dev/null +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueSeverityBadgeSnapshotTest.kt @@ -0,0 +1,59 @@ +package com.composea11yscanner.ui + +import androidx.compose.material3.MaterialTheme +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import com.composea11yscanner.core.model.A11ySeverity +import org.junit.Rule +import org.junit.Test + +class IssueSeverityBadgeSnapshotTest { + + @get:Rule + val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5) + + @Test + fun errorBadge_count1() { + paparazzi.snapshot { + MaterialTheme { + IssueSeverityBadge(severity = A11ySeverity.Error, count = 1) + } + } + } + + @Test + fun warningBadge_count1() { + paparazzi.snapshot { + MaterialTheme { + IssueSeverityBadge(severity = A11ySeverity.Warning, count = 1) + } + } + } + + @Test + fun infoBadge_count1() { + paparazzi.snapshot { + MaterialTheme { + IssueSeverityBadge(severity = A11ySeverity.Info, count = 1) + } + } + } + + @Test + fun errorBadge_count3() { + paparazzi.snapshot { + MaterialTheme { + IssueSeverityBadge(severity = A11ySeverity.Error, count = 3) + } + } + } + + @Test + fun warningBadge_count9plus() { + paparazzi.snapshot { + MaterialTheme { + IssueSeverityBadge(severity = A11ySeverity.Warning, count = 9) + } + } + } +} diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/ScanSummaryBarSnapshotTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/ScanSummaryBarSnapshotTest.kt new file mode 100644 index 0000000..1e6b0e6 --- /dev/null +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/ScanSummaryBarSnapshotTest.kt @@ -0,0 +1,58 @@ +package com.composea11yscanner.ui + +import androidx.compose.material3.MaterialTheme +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import com.composea11yscanner.core.model.ScannerState +import org.junit.Rule +import org.junit.Test + +class ScanSummaryBarSnapshotTest { + + @get:Rule + val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5) + + @Test + fun scanningAt45Percent() { + paparazzi.snapshot { + MaterialTheme { + ScanSummaryBar(state = ScannerState.Scanning(progress = 0.45f)) + } + } + } + + @Test + fun completeWithIssues() { + paparazzi.snapshot { + MaterialTheme { + ScanSummaryBar( + state = ScannerState.Complete( + result = scanResultFixture(errors = 2, warnings = 5, info = 1), + ), + ) + } + } + } + + @Test + fun completeAllClear() { + paparazzi.snapshot { + MaterialTheme { + ScanSummaryBar( + state = ScannerState.Complete( + result = scanResultFixture(errors = 0, warnings = 0, info = 0), + ), + ) + } + } + } + + @Test + fun errorState() { + paparazzi.snapshot { + MaterialTheme { + ScanSummaryBar(state = ScannerState.Error(message = "Semantics owner unavailable")) + } + } + } +} diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/SnapshotFixtures.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/SnapshotFixtures.kt new file mode 100644 index 0000000..5218ac8 --- /dev/null +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/SnapshotFixtures.kt @@ -0,0 +1,88 @@ +package com.composea11yscanner.ui + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.DpSize +import com.composea11yscanner.core.model.Rect +import com.composea11yscanner.core.model.ScanResult + +internal fun issueFixture( + severity: A11ySeverity, + issueId: String = "snapshot-1", + ruleName: String = "Missing Content Description", + message: String = "Interactive element has no content description.", + howToFix: String = "Add Modifier.semantics { contentDescription = \"Describe action\" }", + wcagReference: String? = "WCAG 1.1.1 Non-text Content (Level A)", +): A11yIssue = A11yIssue( + issueId = issueId, + severity = severity, + ruleId = "snapshot-rule", + ruleName = ruleName, + affectedNode = nodeFixture(), + message = message, + howToFix = howToFix, + wcagReference = wcagReference, +) + +internal fun nodeFixture() = A11yNode( + nodeId = "node-1", + composableName = "Button", + bounds = Rect(left = 0, top = 0, right = 300, bottom = 120), + contentDescription = null, + isTouchTarget = true, + touchTargetSize = DpSize(width = 100f, height = 40f), + textColor = null, + backgroundColors = emptyList(), + isFocusable = true, + isMergedDescendant = false, + depth = 1, +) + +internal fun scanResultFixture(errors: Int, warnings: Int, info: Int): ScanResult { + val issues = buildList { + repeat(errors) { i -> + add( + issueFixture( + severity = A11ySeverity.Error, + issueId = "err-$i", + ruleName = "Missing Content Description", + wcagReference = "WCAG 1.1.1 Non-text Content (Level A)", + ) + ) + } + repeat(warnings) { i -> + add( + issueFixture( + severity = A11ySeverity.Warning, + issueId = "warn-$i", + ruleName = "Focus Order", + message = "Focus jumps upward unexpectedly.", + howToFix = "Reorder composables so focus flows top-to-bottom.", + wcagReference = "WCAG 2.4.3 Focus Order (Level A)", + ) + ) + } + repeat(info) { i -> + add( + issueFixture( + severity = A11ySeverity.Info, + issueId = "info-$i", + ruleName = "Text Scaling", + message = "Text does not scale with system font size.", + howToFix = "Use sp units for all text sizes.", + wcagReference = null, + ) + ) + } + } + val totalRules = errors + warnings + info + 12 + return ScanResult( + scanId = "snapshot", + timestamp = 0L, + totalNodes = 24, + issues = issues, + passedRules = totalRules - (errors + warnings + info), + failedRules = errors + warnings + info, + ) +}