flutter_a11y_lens 0.0.1
flutter_a11y_lens: ^0.0.1 copied to clipboard
Live accessibility auditing for Flutter: inspects the running widget tree and flags WCAG violations in real time, with an on-screen debug overlay.
import 'package:flutter/material.dart';
import 'package:flutter_a11y_lens/a11y_lens.dart';
void main() {
// Wrapping the app in A11yLensOverlay is all it takes: it walks the tree
// after every frame and draws an outline over anything the rules below
// flag. Tap an outline to see the rule, message, and suggested fix.
runApp(const A11yLensOverlay(child: MyApp()));
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
// Flips every card below between "before" (one deliberate violation each,
// for the overlay/rules to catch) and "after" (the same layout, fixed) —
// a live before/after comparison you can record straight from this screen.
bool _fixed = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(title: const Text('flutter_a11y_lens example')),
body: Builder(
builder: (context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
SwitchListTile(
title: const Text('Show fixed version'),
subtitle: Text(
_fixed
? 'After — no violations below'
: 'Before — 4 deliberate violations below, one per rule',
),
value: _fixed,
onChanged: (value) => setState(() => _fixed = value),
),
const SizedBox(height: 8),
// Scoped separately from the switch/button above and below
// (both ordinary Material widgets outside this demo's
// scope) so a violation check can target just the four
// deliberate issues below.
Column(
key: const Key('a11yDemo'),
children: [
_DemoCard(
number: 1,
rule: 'ContrastRule',
description:
'Text color too close to its background — below '
'the 4.5:1 WCAG AA minimum for normal text.',
child: _contrastDemo(_fixed),
),
_DemoCard(
number: 2,
rule: 'TapTargetSizeRule',
description:
'Interactive widget smaller than 48x48dp. Already '
'labeled, so only this rule fires — not '
'SemanticLabelRule too.',
child: _tapTargetSizeDemo(_fixed),
),
_DemoCard(
number: 3,
rule: 'SemanticLabelRule',
description:
'Interactive widget with no label for a screen '
'reader to announce. Already 48x48, so only this '
'rule fires — not TapTargetSizeRule too.',
child: _semanticLabelDemo(_fixed),
),
_DemoCard(
number: 4,
rule: 'FocusOrderRule',
description:
'Built (and thus traversed) right-then-left, but '
'laid out left-then-right — a screen reader or '
'switch control would visit them out of order.',
child: _focusOrderDemo(_fixed),
),
],
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {
const walker = ElementTreeWalker();
final elements = walker.walk(context);
const semanticsWalker = SemanticsTreeWalker();
final semanticsNodes = semanticsWalker.walk(context);
final violations = [
...const ContrastRule().check(elements),
...const TapTargetSizeRule().check(elements),
...const SemanticLabelRule().check(semanticsNodes),
...const FocusOrderRule().check(semanticsNodes),
];
debugPrint(
'flutter_a11y_lens: walked ${elements.length} elements, '
'found ${violations.length} violations',
);
for (final violation in violations) {
debugPrint(' - $violation: ${violation.message}');
}
},
child: const Text('Check for violations'),
),
],
);
},
),
),
);
}
}
/// 1. ContrastRule — only the text color changes; background and size stay
/// fixed so contrast is the sole variable.
Widget _contrastDemo(bool fixed) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(16),
child: Text(
fixed ? 'readable text' : 'low contrast text',
style: TextStyle(
color: fixed ? Colors.black87 : const Color(0xFFDDDDDD),
),
),
),
);
}
/// 2. TapTargetSizeRule — always labeled, so only size varies.
Widget _tapTargetSizeDemo(bool fixed) {
return Align(
alignment: Alignment.centerLeft,
child: Semantics(
label: 'tap target',
button: true,
child: GestureDetector(
onTap: () {},
child: SizedBox(
width: fixed ? 48 : 20,
height: fixed ? 48 : 20,
child: const ColoredBox(color: Colors.red),
),
),
),
);
}
/// 3. SemanticLabelRule — always 48x48, so only the label varies.
Widget _semanticLabelDemo(bool fixed) {
final target = GestureDetector(
onTap: () {},
child: const SizedBox(
width: 48,
height: 48,
child: ColoredBox(color: Colors.deepPurple),
),
);
return Align(
alignment: Alignment.centerLeft,
child: fixed
? Semantics(label: 'favorite', button: true, child: target)
// A semantics boundary, tightly wrapping just the target (not the
// Align above, which would stretch this node's bounds to the
// card's full width): without it, this unlabeled GestureDetector's
// tap action merges upward into the card's title/description text
// above, picking up their text as its own "label" and defeating
// the demo — SemanticLabelRule then sees a non-empty label and
// doesn't flag it.
: Semantics(container: true, child: target),
);
}
/// 4. FocusOrderRule — same two tap targets either way; only their build
/// order relative to their fixed left-to-right visual position changes.
Widget _focusOrderDemo(bool fixed) {
return Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: 116,
height: 48,
child: Stack(
children: fixed
? const [
Positioned(left: 0, child: _NamedTapTarget('A (built first)')),
Positioned(
left: 68,
child: _NamedTapTarget('B (built second)'),
),
]
: const [
Positioned(left: 68, child: _NamedTapTarget('B (built first)')),
Positioned(left: 0, child: _NamedTapTarget('A (built second)')),
],
),
),
);
}
class _NamedTapTarget extends StatelessWidget {
const _NamedTapTarget(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Semantics(
label: label,
button: true,
child: GestureDetector(
onTap: () {},
child: const SizedBox(
width: 48,
height: 48,
child: ColoredBox(color: Colors.blue),
),
),
);
}
}
class _DemoCard extends StatelessWidget {
const _DemoCard({
required this.number,
required this.rule,
required this.description,
required this.child,
});
final int number;
final String rule;
final String description;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$number. $rule',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(description, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 12),
child,
],
),
),
);
}
}