
🍊 lazy_ally
lazy_ally makes your Flutter app accessible with one widget. It handles system-aware theming, color-blind support, reduce-motion, and screen-reader announcements, using only stock Flutter.
Features
- Pure Flutter, zero extra dependencies. lazy_ally uses only stock
ThemeData,ChangeNotifier, andInheritedNotifier. It does not replaceMaterialApp, bundle fonts, or add a state-management package. - Live system theming. lazy_ally syncs system/light/dark mode to the OS continuously, not only at launch.
- Color-blind safe by default. lazy_ally uses the IBM color-blind-safe palette out of the box. The palette is contrast-correct and independent of brightness, so a color-blind user keeps their light/dark preference.
- Named custom themes and color schemes. Two independent, open-ended registries let you swap the
whole
ThemeDataor just theColorScheme. Both resolve live against the active brightness. - Text scale that respects the OS. lazy_ally seeds text scale from the system's own text scaler and composes with it. It does not silently override a user's accessibility setting.
- Reduce-motion aware controls. Under reduce-motion, built-in widgets swap to simple, non-animated alternatives, not a toned-down version of the same animation.
- Screen-reader announcements. Every interactive widget announces its own state changes through
SemanticsService, out of the box. - Ready for translation. You can override every string through one
LazyAllyLabelsobject. lazy_ally does not need anintl/Localedependency. - Pluggable persistence. Bring your own storage through plain
loadLazyAllyPreferences/onLazyAllyPreferencesChangedhooks. A documented pattern avoids a flash on startup. - Well tested. 44 package tests and 16 example/widget tests run in CI on every push.

What "lazy" means here
lazy (adj.)
I choose a lazy person to do a hard job. Because a lazy person will find an easy way to do it.
— Bill Gates… or Steve Jobs… definitely not Mark
A lazy_ally widget is lazy in the way a seatbelt is lazy. We measure that by what you do not have to
build: the harder the accessibility problem, the easier the implementation and developer experience need
to be.
Why
- Most Flutter apps wire the same three things by hand: a light/dark theme switch, a way to keep text size adjustable, and some form of reduced-motion support.
- Accessibility-minded work often takes a back seat to domain-specific features and shipping deadlines.
- Apps solve the same accessibility problems again and again.
- We saw a need for a Flutter-native, low-maintenance interface that uses only
ThemeData,ChangeNotifier,InheritedNotifier, andBuildContext. These are the same primitives you would reach for if you built it yourself and had the time.
Install
dependencies:
lazy_ally: ^1.0.3
How lazy is lazy?
Ranked by how much work each one saves you. The harder the problem, the lazier the fix.
Dynamic brightness, text scale, and color-blind support — not lazy at all 🤧
runApp(LazyAlly(light: myLightTheme, dark: myDarkTheme, child: const MyApp()));
The whole accessible settings surface — extremely lazy 🥱
void main() => runApp(
LazyAlly(
light: myLightTheme,
dark: myDarkTheme,
loadLazyAllyPreferences: _loadCache, // persistence, your choice of storage
onLazyAllyPreferencesChanged: _saveCache,
child: const MyApp(),
),
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: context.lazyAllyTheme,
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => showModalBottomSheet<void>(
context: context,
builder: (_) => const LazyAllyPanel(),
),
child: const Icon(Icons.accessibility_new),
),
// ...
),
);
}
}
This gives you brightness, text scale, color-blind, and reduce-motion support: live, persisted,
announced, and translatable, behind one button. See the full runnable version in
example/.
Usage
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:lazy_ally/lazy_ally.dart';
void main() => runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
// Prefetch before runApp so loadLazyAllyPreferences below returns an
// already-known value instead of a Future. LazyAlly then applies the
// value in initState, before the first frame, with no flash of
// constructor defaults. This is the recommended pattern. A
// Future-returning loadLazyAllyPreferences still works, but it cannot
// avoid that flash.
final LazyAllyPreferences? cachedPrefs = await readCachedLazyAllyPreferences();
runApp(
LazyAlly(
light: myLightTheme,
dark: myDarkTheme,
// Optional — everything below has a reasonable default.
colorBlindScheme: (light: /* ... */, dark: /* ... */),
customColorSchemes: <String, LazyAllyColorSchemePair>{ /* ... */ },
initialCustomScheme: 'pride',
customThemes: <String, LazyAllyThemePair>{ /* ... */ },
initialCustomTheme: 'mango',
textScaleMin: 0.85,
textScaleMax: 1.6,
textScaleDefault: 1.0,
initialMode: kLazyAllySystem,
initialColorBlind: false,
initiallyDisableAnimation: false,
loadLazyAllyPreferences: () => cachedPrefs,
onLazyAllyPreferencesChanged: (LazyAllyPreferences prefs) => /* ... */,
child: const MyApp(),
),
);
},
// runZonedGuarded catches errors thrown asynchronously (for example,
// during the prefetch above) that would otherwise crash silently before
// runApp.
(Object error, StackTrace stackTrace) => log('$error:\n$stackTrace'),
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// Works because MyApp.build's context is already a descendant of the
// LazyAllyProvider that LazyAlly installs above it.
return MaterialApp(
theme: context.lazyAllyTheme,
home: const HomePage(),
);
}
}
Read and change settings anywhere below LazyAlly via context.lazyAlly:
final LazyAllyController ally = context.lazyAlly;
ally.setMode(kLazyAllyDark); // kLazyAllySystem | kLazyAllyLight | kLazyAllyDark
ally.setColorBlind(true);
ally.setTextScaleFactor(1.2); // clamped to [textScaleMin, textScaleMax]
kLazyAllySystem is the default mode. It tracks the OS's live platform brightness continuously. There
is no built-in "extra dark" mode. Register a variant like that as a named customThemes entry instead.
See TROUBLESHOOTING.md.
Control widgets
LazyAllyBrightnessSelector, LazyAllyTextScaleSlider, LazyAllyColorBlindSwitch, and
LazyAllyReduceMotionSwitch read and write context.lazyAlly directly. Drop them anywhere below
LazyAlly. LazyAllyPanel is a pre-composed column of the first four, meant for a
showModalBottomSheet/dialog/settings-page that you build and manage yourself. LazyAlly stays out of
navigation decisions:
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const LazyAllyPanel(
// Defaults to true — set to false if your app is not ready to expose
// reduce motion yet. The rest of the panel is unaffected.
showReduceMotion: false,
),
);
Reduce-motion-aware content
For your own widgets that need to swap to a static-but-still-communicative alternative under
reduce-motion, use LazyAllyReducedMotionBuilder instead of wiring up your own
ValueListenableBuilder<bool>. It needs no extra state to manage and no AnimationController to
start or stop:
LazyAllyReducedMotionBuilder(
motionChild: const CircularProgressIndicator(),
reducedMotionChild: const Text('Loading…'),
)
If you need the current reduceMotion value itself, use a ValueListenableBuilder<bool> on
context.lazyAlly.reduceMotionListenable directly instead. This is most useful for starting or
stopping an AnimationController you own, so it does not keep ticking invisibly behind whichever
child is not shown. See example/lib/widgets/motion_examples.dart for worked examples of both.
Non-English apps
Every LazyAlly widget defaults to English copy but accepts a labels: LazyAllyLabels override: chip
labels, switch titles/subtitles, panel headings, and the accessibility announcements each widget
sends. It is a plain data class, not a localization framework. Build one from your own
AppLocalizations/ARB-generated strings and pass it to LazyAllyPanel (which forwards it to every
control it renders) or to an atomic widget directly. See the LazyAllyLabels
dartdoc for every field.
Accessibility announcements
Every interactive LazyAlly widget calls lazyAllyAnnounce (through SemanticsService.sendAnnouncement)
on state change, since selecting a new chip/switch value does not itself generate a spoken
confirmation. lazyAllyAnnounce is exported so your own custom controls (for example, a bespoke
itemBuilder) can match the same standard.
Common gotchas
This is the short version. See TROUBLESHOOTING.md for the full explanation and fix for each:
- First-frame flash on startup with an
async loadLazyAllyPreferences - Reduce motion does not stop everything from animating
- A
Switch/Slider-based control keeps animating regardless - Settings changed inside a bottom sheet/dialog do not repaint
- Your own
MediaQuery(builder: ...)shadows LazyAlly's
Design decisions
- Color-blind is a flag, not a fourth theme. Brightness (
system/light/dark) and color-blind (on/off) are independent axes, so a color-blind user keeps their light/dark preference. Turning it on swaps in a fullColorScheme, not a hand-indexed color list. Named slots (primary,secondary, and others) are harder to misuse thanpalette[i]. - No built-in brightness variants. Only
system/light/darkare built in. A variant like "extra dark" is just a namedcustomThemesentry, the same open mechanism any other named theme uses. - Theme/scheme overrides are light/dark pairs, resolved by the package. Correctness is yours. LazyAlly always picks the member matching the active brightness, so a selected pair never ends up mismatched. It does not inspect or "fix" either member.
- No
MaterialAppreplacement, no bundled font.LazyAllywraps your app. It does not ask you to swap widgets or pay for typography you did not ask for. - No bundled storage or localization framework.
loadLazyAllyPreferences/onLazyAllyPreferencesChangedandLazyAllyLabelsare plain hooks/data classes. BringSharedPreferences/intl/whatever you already use.
Related
If you also want dev-time accessibility auditing (contrast checks, tap-target size, missing
semantics) during development, pair this with
accessibility_tools. It does a different job, and
the two complement each other rather than compete.
Contact
- Bugs & feature requests: GitHub Issues
- General inquiries: lazyfruit.dev
- Email: lazy.ally.reprise769@passmail.net
Maintained by LazyFruit, a public-interest Flutter studio.
Contributing
See CONTRIBUTING.md. This is the author's first published Dart/Flutter package. Issues, PRs, and "this API is awkward, here is why" feedback are genuinely welcome.
License
MIT — see LICENSE.
Made with ♥️ in North Carolina.
Libraries
- lazy_ally
- Wrap your app in a LazyAlly widget to get system, light, and dark
theming. LazyAlly also supports your own named custom themes and color
schemes, an independent IBM color-blind-safe flag, and text-scale
limits. LazyAlly builds entirely on stock Flutter tooling (
ThemeData,ChangeNotifier,InheritedNotifier,BuildContext).