Skip to content
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand All @@ -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" }
4 changes: 4 additions & 0 deletions scanner-ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.paparazzi)
}

android {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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>(ScannerState.Idle) }
var selectedIssue by remember { mutableStateOf<A11yIssue?>(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,
)
Loading
Loading